549 lines
20 KiB
TypeScript
549 lines
20 KiB
TypeScript
import * as THREE from "three";
|
|
import App from "@/core/app/App";
|
|
import { useAddSignal, useRemoveSignal } from "@/hooks";
|
|
import { Block, FontLibrary } from "@/core/libs/three-mesh-ui/src/three-mesh-ui.js";
|
|
import { getMousePosition } from "@/utils";
|
|
import UIPanelBlock from "./UIPanelBlock";
|
|
import UIPanelText from "./UIPanelText";
|
|
import UIPanelInline from "./UIPanelInline";
|
|
import UIPanelInlineBlock from "./UIPanelInlineBlock";
|
|
import {
|
|
UIPanelElementController,
|
|
UIPanelNode,
|
|
UIPanelStateMap,
|
|
canAcceptUIPanelChild,
|
|
cloneUIPanelNode,
|
|
createUIPanelNodeId,
|
|
extractUIPanelProps,
|
|
normalizeUIPanelNode,
|
|
requestUIPanelRender,
|
|
stripUIPanelNodeChildren,
|
|
} from "./UIPanelElementBase";
|
|
|
|
const DEFAULT_FONT_FAMILY = "Roboto";
|
|
const DEFAULT_FONT_JSON = new URL(`${import.meta.env.BASE_URL}resource/fonts/roboto/regular.json`, import.meta.url).href;
|
|
const DEFAULT_FONT_PNG = new URL(`${import.meta.env.BASE_URL}resource/fonts/roboto/regular.png`, import.meta.url).href;
|
|
const BlockBase = Block as unknown as new (options?: Record<string, any>) => THREE.Object3D;
|
|
|
|
export const getDefaultUIPanelOptions = (): IUIPanel.options => {
|
|
return {
|
|
id: createUIPanelNodeId(),
|
|
type: "block",
|
|
name: "UIPanel",
|
|
position: [0, 0, 0],
|
|
rotation: [0, 0, 0],
|
|
scale: [1, 1, 1],
|
|
font: {
|
|
family: DEFAULT_FONT_FAMILY,
|
|
weight: "400",
|
|
style: "normal",
|
|
},
|
|
props: {
|
|
width: 1.6,
|
|
height: 0.9,
|
|
padding: 0.06,
|
|
backgroundColor: "#1f1f1f",
|
|
backgroundOpacity: 0.8,
|
|
borderRadius: 0.05,
|
|
flexDirection: "column",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
children: [
|
|
{
|
|
id: createUIPanelNodeId(),
|
|
type: "text",
|
|
name: "Title",
|
|
props: {
|
|
textContent: "UIPanel",
|
|
fontSize: 0.08,
|
|
color: "#ffffff",
|
|
textAlign: "center",
|
|
},
|
|
children: [],
|
|
},
|
|
],
|
|
};
|
|
};
|
|
|
|
const toRotationArray = (values?: number[]): [number, number, number] => {
|
|
if (!Array.isArray(values)) return [0, 0, 0];
|
|
return [Number(values[0] ?? 0), Number(values[1] ?? 0), Number(values[2] ?? 0)];
|
|
};
|
|
|
|
const resolveUIPanelOptions = (options: Partial<IUIPanel.options> = {}): IUIPanel.options => {
|
|
const resolved = getDefaultUIPanelOptions();
|
|
if (options.id) resolved.id = options.id;
|
|
if (options.type) resolved.type = options.type;
|
|
if (options.name) resolved.name = options.name;
|
|
if (options.props) resolved.props = { ...(resolved.props || {}), ...options.props };
|
|
if (options.states !== undefined) {
|
|
resolved.states = options.states ? JSON.parse(JSON.stringify(options.states)) : undefined;
|
|
}
|
|
if (Array.isArray(options.children)) {
|
|
resolved.children = options.children.map(child => cloneUIPanelNode(child));
|
|
}
|
|
if (Array.isArray(options.position)) resolved.position = options.position.slice();
|
|
if (Array.isArray(options.rotation)) resolved.rotation = toRotationArray(options.rotation);
|
|
if (Array.isArray(options.scale)) resolved.scale = options.scale.slice();
|
|
if (options.font) resolved.font = { ...resolved.font, ...options.font };
|
|
|
|
if (resolved.type !== "block") resolved.type = "block";
|
|
normalizeUIPanelNode(resolved);
|
|
return resolved;
|
|
};
|
|
|
|
const extractRootOptions = (options: IUIPanel.options): IUIPanel.options => {
|
|
return {
|
|
id: options.id || createUIPanelNodeId(),
|
|
type: "block",
|
|
name: options.name || "UIPanel",
|
|
position: Array.isArray(options.position) ? options.position.slice() : [0, 0, 0],
|
|
rotation: Array.isArray(options.rotation) ? toRotationArray(options.rotation) : [0, 0, 0],
|
|
scale: Array.isArray(options.scale) ? options.scale.slice() : [1, 1, 1],
|
|
font: options.font ? { ...options.font } : undefined,
|
|
props: options.props ? JSON.parse(JSON.stringify(options.props)) : {},
|
|
states: options.states ? JSON.parse(JSON.stringify(options.states)) : undefined,
|
|
children: [],
|
|
};
|
|
};
|
|
|
|
type UIPanelElement = THREE.Object3D & {
|
|
options?: UIPanelNode;
|
|
updateRootId?: (rootId: string) => void;
|
|
hasInteractiveState?: () => boolean;
|
|
applyState?: (state: string | null) => void;
|
|
};
|
|
|
|
export default class UIPanel extends BlockBase {
|
|
type = "UIPanel";
|
|
isUIPanel = true;
|
|
options: IUIPanel.options;
|
|
declare set: (options: Record<string, any>) => void;
|
|
private controller: UIPanelElementController;
|
|
private elementMap = new Map<string, UIPanelElement>();
|
|
private hoveredNodeId: string | null = null;
|
|
private pressedNodeId: string | null = null;
|
|
private static fontReady = false;
|
|
private static interactivePanels = new Set<UIPanel>();
|
|
private static interactionBound = false;
|
|
private static boundViewer: any | null = null;
|
|
private static pointerMoveHandler = (payload: any) => UIPanel.handlePointerMove(payload);
|
|
private static pointerDownHandler = (payload: any) => UIPanel.handlePointerDown(payload);
|
|
private static pointerUpHandler = (payload: any) => UIPanel.handlePointerUp(payload);
|
|
private static viewerInitHandler = (viewer: any) => UIPanel.bindViewer(viewer);
|
|
private handleAdded = () => {
|
|
this.updateInteractionRegistration();
|
|
this.requestUpdate();
|
|
};
|
|
private handleRemoved = () => {
|
|
this.updateInteractionRegistration();
|
|
};
|
|
|
|
constructor(options: Partial<IUIPanel.options> = {}, init: { buildChildren?: boolean } = {}) {
|
|
const resolvedOptions = resolveUIPanelOptions(options);
|
|
const rootOptions = extractRootOptions(resolvedOptions);
|
|
const { props } = extractUIPanelProps(rootOptions as UIPanelNode);
|
|
super(props);
|
|
|
|
this.options = rootOptions;
|
|
this.name = this.options.name;
|
|
this.position.fromArray(this.options.position);
|
|
if (this.options.rotation) this.rotation.fromArray(toRotationArray(this.options.rotation));
|
|
if (this.options.scale) this.scale.fromArray(this.options.scale);
|
|
|
|
this.controller = new UIPanelElementController(this, this.options as UIPanelNode, this.type, {
|
|
rootId: this.uuid,
|
|
});
|
|
|
|
this.ensureDefaultFont();
|
|
this.applyRootFont();
|
|
|
|
this.registerElement(this as UIPanelElement);
|
|
|
|
if (init.buildChildren !== false && Array.isArray(resolvedOptions.children)) {
|
|
resolvedOptions.children.forEach(child => {
|
|
const element = this.createElement(child);
|
|
if (element) this.add(element);
|
|
});
|
|
}
|
|
|
|
this.addEventListener("added", this.handleAdded);
|
|
this.addEventListener("removed", this.handleRemoved);
|
|
this.updateInteractionRegistration();
|
|
}
|
|
|
|
copy(source: this, recursive = true) {
|
|
this.elementMap.clear();
|
|
super.copy(source, recursive);
|
|
if (source?.options) {
|
|
this.options = extractRootOptions(source.options);
|
|
this.controller.updateOptions(this.options as UIPanelNode);
|
|
this.applyRootFont();
|
|
|
|
const registerDirect = (element: UIPanelElement) => {
|
|
if (!element?.options?.id) return;
|
|
this.elementMap.set(element.options.id, element);
|
|
element.updateRootId?.(this.uuid);
|
|
};
|
|
|
|
registerDirect(this as UIPanelElement);
|
|
if (recursive) {
|
|
this.traverse(child => {
|
|
if (child === this) return;
|
|
registerDirect(child as UIPanelElement);
|
|
});
|
|
}
|
|
this.updateInteractionRegistration();
|
|
}
|
|
return this;
|
|
}
|
|
|
|
requestUpdate() {
|
|
requestUIPanelRender();
|
|
}
|
|
|
|
registerElement(element: UIPanelElement) {
|
|
if (!element?.options?.id) return;
|
|
this.elementMap.set(element.options.id, element);
|
|
element.updateRootId?.(this.uuid);
|
|
this.updateInteractionRegistration();
|
|
}
|
|
|
|
refreshInteraction() {
|
|
this.updateInteractionRegistration();
|
|
}
|
|
|
|
unregisterElement(element: UIPanelElement) {
|
|
if (!element?.options?.id) return;
|
|
const nodeId = element.options.id;
|
|
this.elementMap.delete(nodeId);
|
|
if (this.hoveredNodeId === nodeId) this.hoveredNodeId = null;
|
|
if (this.pressedNodeId === nodeId) this.pressedNodeId = null;
|
|
this.updateInteractionRegistration();
|
|
}
|
|
|
|
getElementByNodeId(nodeId: string) {
|
|
return this.elementMap.get(nodeId) || null;
|
|
}
|
|
|
|
updateOptions(options: Partial<IUIPanel.options>) {
|
|
if (options.name !== undefined) {
|
|
this.options.name = options.name;
|
|
this.name = options.name;
|
|
}
|
|
if (Array.isArray(options.position)) {
|
|
this.options.position = options.position.slice();
|
|
this.position.fromArray(this.options.position);
|
|
}
|
|
if (Array.isArray(options.rotation)) {
|
|
const rotation = toRotationArray(options.rotation);
|
|
this.options.rotation = rotation.slice();
|
|
this.rotation.fromArray(rotation);
|
|
}
|
|
if (Array.isArray(options.scale)) {
|
|
this.options.scale = options.scale.slice();
|
|
this.scale.fromArray(this.options.scale);
|
|
}
|
|
if (options.font) {
|
|
this.options.font = { ...this.options.font, ...options.font };
|
|
this.applyRootFont();
|
|
this.requestUpdate();
|
|
}
|
|
if (options.props) {
|
|
this.controller.setProps(options.props);
|
|
}
|
|
if (options.states !== undefined) {
|
|
this.controller.setStates(options.states as UIPanelStateMap);
|
|
this.updateInteractionRegistration();
|
|
}
|
|
}
|
|
|
|
setProps(patch: Record<string, any>) {
|
|
this.controller.setProps(patch);
|
|
}
|
|
|
|
setStates(states?: UIPanelStateMap) {
|
|
this.controller.setStates(states);
|
|
this.updateInteractionRegistration();
|
|
}
|
|
|
|
applyState(state: string | null) {
|
|
this.controller.applyState(state);
|
|
}
|
|
|
|
hasInteractiveState() {
|
|
return this.controller.hasInteractiveState();
|
|
}
|
|
|
|
dispose() {
|
|
this.controller.dispose();
|
|
this.removeEventListener("added", this.handleAdded);
|
|
this.removeEventListener("removed", this.handleRemoved);
|
|
UIPanel.interactivePanels.delete(this);
|
|
|
|
this.children.slice().forEach(child => {
|
|
const anyChild = child as any;
|
|
anyChild.dispose?.();
|
|
});
|
|
this.elementMap.clear();
|
|
this.updateInteractionRegistration();
|
|
}
|
|
|
|
toJSON(meta?: THREE.JSONMeta) {
|
|
const snapshot = this.controller.detachInternalMeshes();
|
|
const data = super.toJSON(meta) as any;
|
|
this.controller.restoreInternalMeshes(snapshot);
|
|
|
|
const options = JSON.parse(JSON.stringify(this.options)) as IUIPanel.options;
|
|
options.position = this.position.toArray();
|
|
options.rotation = [this.rotation.x, this.rotation.y, this.rotation.z];
|
|
options.scale = this.scale.toArray();
|
|
data.object.type = this.type;
|
|
data.object.options = options;
|
|
|
|
return data;
|
|
}
|
|
|
|
static fromJSON(json: { options: IUIPanel.options }, init?: { buildChildren?: boolean }) {
|
|
return new UIPanel(json.options, { buildChildren: false, ...init });
|
|
}
|
|
|
|
private createElement(node: UIPanelNode): UIPanelElement | null {
|
|
normalizeUIPanelNode(node);
|
|
const elementOptions = stripUIPanelNodeChildren(node);
|
|
let element: UIPanelElement | null = null;
|
|
const init = { rootId: this.uuid };
|
|
|
|
switch (node.type) {
|
|
case "text":
|
|
element = new UIPanelText(elementOptions, init) as UIPanelElement;
|
|
break;
|
|
case "inline":
|
|
element = new UIPanelInline(elementOptions, init) as UIPanelElement;
|
|
break;
|
|
case "inlineBlock":
|
|
element = new UIPanelInlineBlock(elementOptions, init) as UIPanelElement;
|
|
break;
|
|
case "block":
|
|
default:
|
|
element = new UIPanelBlock(elementOptions, init) as UIPanelElement;
|
|
break;
|
|
}
|
|
|
|
if (!element) return null;
|
|
|
|
this.registerElement(element);
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
node.children.forEach(child => {
|
|
if (!canAcceptUIPanelChild(node.type, child.type)) return;
|
|
const childElement = this.createElement(child);
|
|
if (childElement) element.add(childElement);
|
|
});
|
|
}
|
|
|
|
return element;
|
|
}
|
|
|
|
private ensureDefaultFont() {
|
|
if (UIPanel.fontReady) return;
|
|
|
|
try {
|
|
const existing = FontLibrary.getFontFamily(DEFAULT_FONT_FAMILY);
|
|
const family = existing || FontLibrary.addFontFamily(DEFAULT_FONT_FAMILY);
|
|
family.addVariant("400", "normal", DEFAULT_FONT_JSON, DEFAULT_FONT_PNG);
|
|
FontLibrary.prepare(family).then(() => {
|
|
UIPanel.fontReady = true;
|
|
this.requestUpdate();
|
|
});
|
|
} catch {
|
|
UIPanel.fontReady = true;
|
|
}
|
|
}
|
|
|
|
private applyRootFont() {
|
|
const font = this.options.font;
|
|
if (!font) return;
|
|
|
|
const fontProps: Record<string, any> = {};
|
|
if (font.family) fontProps.fontFamily = font.family;
|
|
if (font.texture) fontProps.fontTexture = font.texture;
|
|
if (font.weight) fontProps.fontWeight = font.weight;
|
|
if (font.style) fontProps.fontStyle = font.style;
|
|
|
|
if (Object.keys(fontProps).length > 0 && this.set) {
|
|
this.set(fontProps);
|
|
}
|
|
}
|
|
|
|
private hasInteractiveNodes() {
|
|
for (const element of this.elementMap.values()) {
|
|
if (element?.hasInteractiveState?.()) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private updateInteractionRegistration() {
|
|
if (this.hasInteractiveNodes()) {
|
|
UIPanel.interactivePanels.add(this);
|
|
UIPanel.bindInteraction();
|
|
} else {
|
|
UIPanel.interactivePanels.delete(this);
|
|
if (UIPanel.interactivePanels.size === 0) {
|
|
UIPanel.unbindInteraction();
|
|
}
|
|
}
|
|
}
|
|
|
|
private resolveInteractiveNodeId(object: THREE.Object3D) {
|
|
let current: THREE.Object3D | null = object;
|
|
while (current && current !== this) {
|
|
const nodeId = (current as any).metadata?.__uiPanelNodeId;
|
|
if (nodeId) {
|
|
const element = this.elementMap.get(nodeId);
|
|
if (element?.hasInteractiveState?.()) return nodeId as string;
|
|
}
|
|
current = current.parent;
|
|
}
|
|
|
|
const rootId = this.options.id;
|
|
if (rootId) {
|
|
const element = this.elementMap.get(rootId);
|
|
if (element?.hasInteractiveState?.()) return rootId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private applyStateForNode(nodeId: string, state: string | null) {
|
|
const element = this.elementMap.get(nodeId);
|
|
element?.applyState?.(state);
|
|
}
|
|
|
|
private setHoverNode(nodeId: string | null) {
|
|
if (this.hoveredNodeId === nodeId) return;
|
|
const previous = this.hoveredNodeId;
|
|
this.hoveredNodeId = nodeId;
|
|
|
|
if (previous && previous !== this.pressedNodeId) {
|
|
this.applyStateForNode(previous, null);
|
|
}
|
|
if (nodeId && nodeId !== this.pressedNodeId) {
|
|
this.applyStateForNode(nodeId, "hover");
|
|
}
|
|
}
|
|
|
|
private setPressedNode(nodeId: string | null) {
|
|
if (this.pressedNodeId === nodeId) return;
|
|
const previous = this.pressedNodeId;
|
|
this.pressedNodeId = nodeId;
|
|
|
|
if (previous) {
|
|
const fallback = this.hoveredNodeId === previous ? "hover" : null;
|
|
this.applyStateForNode(previous, fallback);
|
|
}
|
|
if (nodeId) {
|
|
this.applyStateForNode(nodeId, "active");
|
|
}
|
|
}
|
|
|
|
private static getPointerEvent(payload: any) {
|
|
if (!payload) return null;
|
|
if (payload.event) return payload.event;
|
|
if (payload.clientX !== undefined) return payload;
|
|
return null;
|
|
}
|
|
|
|
private static handlePointerMove(payload: any) {
|
|
const viewer = UIPanel.boundViewer || App.viewer;
|
|
if (!viewer || UIPanel.interactivePanels.size === 0) return;
|
|
const pointer = UIPanel.getPointerEvent(payload);
|
|
if (!pointer) return;
|
|
|
|
const array = getMousePosition(viewer.container, pointer.clientX, pointer.clientY);
|
|
const mouse = new THREE.Vector2();
|
|
mouse.set(array[0] * 2 - 1, -(array[1] * 2) + 1);
|
|
viewer.raycaster.setFromCamera(mouse, viewer.camera);
|
|
|
|
let best: { panel: UIPanel; nodeId: string; distance: number } | null = null;
|
|
|
|
UIPanel.interactivePanels.forEach(panel => {
|
|
const intersects = viewer.raycaster.intersectObject(panel, true);
|
|
if (!intersects.length) return;
|
|
const nodeId = panel.resolveInteractiveNodeId(intersects[0].object);
|
|
if (!nodeId) return;
|
|
const distance = intersects[0].distance;
|
|
if (!best || distance < best.distance) {
|
|
best = { panel, nodeId, distance };
|
|
}
|
|
});
|
|
|
|
UIPanel.interactivePanels.forEach(panel => {
|
|
if (best && panel === best.panel) {
|
|
panel.setHoverNode(best.nodeId);
|
|
} else {
|
|
panel.setHoverNode(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
private static handlePointerDown(payload: any) {
|
|
const pointer = UIPanel.getPointerEvent(payload);
|
|
if (!pointer || pointer.button !== 0) return;
|
|
UIPanel.interactivePanels.forEach(panel => {
|
|
if (!panel.hoveredNodeId) return;
|
|
const element = panel.getElementByNodeId(panel.hoveredNodeId) as any;
|
|
if (element?.options?.states?.active) {
|
|
panel.setPressedNode(panel.hoveredNodeId);
|
|
}
|
|
});
|
|
}
|
|
|
|
private static handlePointerUp(payload: any) {
|
|
const pointer = UIPanel.getPointerEvent(payload);
|
|
if (!pointer || pointer.button !== 0) return;
|
|
UIPanel.interactivePanels.forEach(panel => {
|
|
if (panel.pressedNodeId) {
|
|
panel.setPressedNode(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
private static bindViewer(viewer: any) {
|
|
if (!viewer || UIPanel.boundViewer === viewer) return;
|
|
if (UIPanel.boundViewer) {
|
|
UIPanel.unbindViewer(UIPanel.boundViewer);
|
|
}
|
|
UIPanel.boundViewer = viewer;
|
|
viewer.addEventListener("onPointerMove", UIPanel.pointerMoveHandler);
|
|
viewer.addEventListener("onPointerDown", UIPanel.pointerDownHandler);
|
|
viewer.addEventListener("onPointerUp", UIPanel.pointerUpHandler);
|
|
}
|
|
|
|
private static unbindViewer(viewer: any) {
|
|
viewer.removeEventListener("onPointerMove", UIPanel.pointerMoveHandler);
|
|
viewer.removeEventListener("onPointerDown", UIPanel.pointerDownHandler);
|
|
viewer.removeEventListener("onPointerUp", UIPanel.pointerUpHandler);
|
|
if (UIPanel.boundViewer === viewer) UIPanel.boundViewer = null;
|
|
}
|
|
|
|
private static bindInteraction() {
|
|
if (UIPanel.interactionBound) return;
|
|
UIPanel.interactionBound = true;
|
|
if (App.viewer) {
|
|
UIPanel.bindViewer(App.viewer);
|
|
} else {
|
|
useAddSignal("viewerInitCompleted", UIPanel.viewerInitHandler);
|
|
}
|
|
}
|
|
|
|
private static unbindInteraction() {
|
|
if (!UIPanel.interactionBound) return;
|
|
UIPanel.interactionBound = false;
|
|
if (UIPanel.boundViewer) {
|
|
UIPanel.unbindViewer(UIPanel.boundViewer);
|
|
}
|
|
useRemoveSignal("viewerInitCompleted", UIPanel.viewerInitHandler);
|
|
}
|
|
}
|