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) => 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 => { 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) => void; private controller: UIPanelElementController; private elementMap = new Map(); private hoveredNodeId: string | null = null; private pressedNodeId: string | null = null; private static fontReady = false; private static interactivePanels = new Set(); 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 = {}, 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) { 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) { 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 = {}; 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); } }