import * as THREE from "three"; import App from "@/core/app/App"; import ThreeMeshUI from "@/core/libs/three-mesh-ui/src/three-mesh-ui.js"; import Frame from "@/core/libs/three-mesh-ui/src/frame/Frame.js"; import { useAddSignal } from "@/hooks"; export type UIPanelNode = IUIPanel.Node; export type UIPanelStateMap = Record>; export type UIPanelElementHost = THREE.Object3D & { set?: (options: Record) => void; setBackgroundMesh?: (mesh: any) => void; setFontMesh?: (mesh: any) => void; _backgroundMesh?: THREE.Object3D; _fontMesh?: THREE.Object3D; }; export type UIPanelElementInit = { rootId?: string; }; export const createUIPanelNodeId = () => THREE.MathUtils.generateUUID(); const UIPANEL_LOCAL_POSITION_KEY = "__localPosition"; const UIPANEL_POSITION_EPSILON = 1e-6; export const UIPANEL_ELEMENT_TYPES: Record = { block: "UIPanelBlock", text: "UIPanelText", inline: "UIPanelInline", inlineBlock: "UIPanelInlineBlock", }; export const canAcceptUIPanelChild = (parentType: IUIPanel.NodeType, childType: IUIPanel.NodeType) => { if (parentType === "block") { return childType === "block" || childType === "text" || childType === "inline" || childType === "inlineBlock"; } return false; }; export const normalizeUIPanelNode = (node: UIPanelNode) => { if (!node.type || !UIPANEL_ELEMENT_TYPES[node.type]) node.type = "block"; if (!node.id) node.id = createUIPanelNodeId(); if (!node.name) node.name = node.type; if (!node.props || typeof node.props !== "object") node.props = {}; if (node.states && typeof node.states !== "object") node.states = undefined; if (!Array.isArray(node.children)) node.children = []; if (node.children.length > 0) { const next: UIPanelNode[] = []; node.children.forEach(child => { if (!child) return; normalizeUIPanelNode(child); if (canAcceptUIPanelChild(node.type, child.type)) { next.push(child); } }); node.children = next; } }; export const cloneUIPanelNode = (node: UIPanelNode): UIPanelNode => { return { id: node.id || createUIPanelNodeId(), type: node.type, name: node.name, props: node.props ? JSON.parse(JSON.stringify(node.props)) : undefined, states: node.states ? JSON.parse(JSON.stringify(node.states)) : undefined, children: Array.isArray(node.children) ? node.children.map(child => cloneUIPanelNode(child)) : [], }; }; export const stripUIPanelNodeChildren = (node: UIPanelNode): UIPanelNode => { const copy = cloneUIPanelNode(node); copy.children = []; return copy; }; export const normalizeUIPanelElementOptions = ( options: Partial, type: IUIPanel.NodeType, fallbackName: string ): UIPanelNode => { const normalized: UIPanelNode = { id: options.id || createUIPanelNodeId(), type, name: options.name || fallbackName, props: options.props ? JSON.parse(JSON.stringify(options.props)) : {}, states: options.states ? JSON.parse(JSON.stringify(options.states)) : undefined, children: [], }; return normalized; }; export const extractUIPanelProps = (node: UIPanelNode) => { const props = node.props ? { ...node.props } : {}; const backgroundImage = props.backgroundImage; if (typeof backgroundImage === "string" || backgroundImage instanceof THREE.Texture) { delete props.backgroundImage; } if (Object.prototype.hasOwnProperty.call(props, UIPANEL_LOCAL_POSITION_KEY)) { delete props[UIPANEL_LOCAL_POSITION_KEY]; } return { props, backgroundImage }; }; const applyUIPanelLocalPositions = () => { const scene = App.scene; if (!scene) return; scene.traverse(child => { const element = child as any; if (!element?.isUIPanelElement) return; const stored = element.options?.props?.[UIPANEL_LOCAL_POSITION_KEY]; if (!Array.isArray(stored) || stored.length < 3) return; if (!element.position?.fromArray) return; element.position.fromArray(stored); }); }; export const requestUIPanelRender = () => { try { ThreeMeshUI.update(); applyUIPanelLocalPositions(); } catch { } (App.viewer as any)?.pluginRequestRender?.(true); }; export const applyUIPanelMetadata = ( target: any, data: { nodeId: string; rootId: string; nodeType: IUIPanel.NodeType; elementType: string; role: "element" | "background" | "font"; } ) => { if (!target) return; target.metadata = target.metadata || {}; target.metadata.__uiPanelNodeId = data.nodeId; target.metadata.__uiPanelRootId = data.rootId; target.metadata.__uiPanelNodeType = data.nodeType; target.metadata.__uiPanelElementType = data.elementType; target.metadata.__uiPanelRole = data.role; }; const removeChildAt = (parent: THREE.Object3D, child: THREE.Object3D) => { const index = parent.children.indexOf(child); if (index >= 0) { parent.children.splice(index, 1); child.parent = null; return index; } return -1; }; const insertChildAt = (parent: THREE.Object3D, child: THREE.Object3D, index: number) => { if (child.parent === parent) return; if (index < 0 || index >= parent.children.length) { parent.add(child); return; } parent.children.splice(index, 0, child); child.parent = parent; }; export class UIPanelElementController { private static controllerMap = new Map(); private static signalBound = false; private static historySignalBound = false; private static handleObjectChanged = (object: any) => { const controller = UIPanelElementController.controllerMap.get(object as UIPanelElementHost); controller?.syncExternalProps(); }; private static handleHistoryChanged = (cmd: any) => { const target = cmd?.object as UIPanelElementHost | undefined; if (!target) return; const controller = UIPanelElementController.controllerMap.get(target); controller?.syncHistoryCommand(cmd); }; private static registerController(host: UIPanelElementHost, controller: UIPanelElementController) { UIPanelElementController.controllerMap.set(host, controller); if (!UIPanelElementController.signalBound) { UIPanelElementController.signalBound = true; useAddSignal("objectChanged", UIPanelElementController.handleObjectChanged); } if (!UIPanelElementController.historySignalBound) { UIPanelElementController.historySignalBound = true; useAddSignal("historyChanged", UIPanelElementController.handleHistoryChanged); } } private static unregisterController(host: UIPanelElementHost) { UIPanelElementController.controllerMap.delete(host); } private host: UIPanelElementHost; private options: UIPanelNode; private elementType: string; private rootId: string; private resolvedProps: Record = {}; private backgroundTexture: THREE.Texture | null = null; private backgroundTextureUrl: string | null = null; private currentState: string | null = null; private lastVisible = true; constructor(host: UIPanelElementHost, options: UIPanelNode, elementType: string, init: UIPanelElementInit = {}) { this.host = host; this.options = options; this.elementType = elementType; this.rootId = init.rootId || ""; this.bindMeshHooks(); this.refreshMetadata(); this.applyProps(this.options.props || {}); this.lastVisible = Boolean(this.host.visible); UIPanelElementController.registerController(this.host, this); } updateRootId(rootId: string) { if (!rootId || this.rootId === rootId) return; this.rootId = rootId; this.refreshMetadata(); } updateOptions(options: UIPanelNode) { this.options = options; this.refreshMetadata(); } detachInternalMeshes() { const background = this.host._backgroundMesh || null; const font = this.host._fontMesh || null; const backgroundIndex = background ? removeChildAt(this.host, background) : -1; const fontIndex = font ? removeChildAt(this.host, font) : -1; return { background, font, backgroundIndex, fontIndex }; } restoreInternalMeshes(snapshot: { background: THREE.Object3D | null; font: THREE.Object3D | null; backgroundIndex: number; fontIndex: number }) { if (snapshot.background) { insertChildAt(this.host, snapshot.background, snapshot.backgroundIndex); } if (snapshot.font) { insertChildAt(this.host, snapshot.font, snapshot.fontIndex); } } setProps(patch: Record) { this.options.props = { ...(this.options.props || {}), ...patch }; if (Object.prototype.hasOwnProperty.call(patch, "visible")) { const nextVisible = Boolean(patch.visible); this.host.visible = nextVisible; this.lastVisible = nextVisible; this.markParentChildrenDirty(); } if ( Object.prototype.hasOwnProperty.call(patch, "backgroundColor") || Object.prototype.hasOwnProperty.call(patch, "backgroundOpacity") || Object.prototype.hasOwnProperty.call(patch, "backgroundImage") ) { this.ensureBackgroundMesh(); } this.applyProps(this.options.props || {}); } setStates(states?: UIPanelStateMap) { if (states && Object.keys(states).length > 0) { this.options.states = JSON.parse(JSON.stringify(states)); } else { delete this.options.states; } this.applyState(this.currentState); } hasInteractiveState() { const states = this.options.states; if (!states) return false; return Boolean(states.hover || states.active); } applyState(state: string | null) { this.currentState = state; const baseProps = this.resolvedProps || {}; const states = this.options.states || {}; const stateProps = state && states[state] ? { ...states[state] } : null; if (stateProps && typeof stateProps.backgroundImage === "string") { delete stateProps.backgroundImage; } if (this.host.set) { this.host.set(stateProps ? { ...baseProps, ...stateProps } : { ...baseProps }); } requestUIPanelRender(); } dispose() { this.releaseBackgroundTexture(); UIPanelElementController.unregisterController(this.host); } private refreshMetadata() { this.applyMeshMetadata(this.host, "element"); if (this.host._backgroundMesh) this.applyMeshMetadata(this.host._backgroundMesh, "background"); if (this.host._fontMesh) this.applyMeshMetadata(this.host._fontMesh, "font"); } private applyMeshMetadata(target: any, role: "element" | "background" | "font") { if (role === "background" || role === "font") { target.ignore = true; if (!target.proxy) { target.proxy = this.host; } this.enableUIPanelRaycast(target); } applyUIPanelMetadata(target, { nodeId: this.options.id, rootId: this.rootId, nodeType: this.options.type, elementType: this.elementType, role, }); } private enableUIPanelRaycast(target: any) { if (!target?.isMesh) return; if (target.raycast === THREE.Mesh.prototype.raycast) return; target.raycast = THREE.Mesh.prototype.raycast; } private bindMeshHooks() { const host = this.host as any; const originalSetBackgroundMesh = host.setBackgroundMesh?.bind(host); if (originalSetBackgroundMesh) { host.setBackgroundMesh = (mesh: any) => { this.applyMeshMetadata(mesh, "background"); return originalSetBackgroundMesh(mesh); }; } const originalSetFontMesh = host.setFontMesh?.bind(host); if (originalSetFontMesh) { host.setFontMesh = (mesh: any) => { this.applyMeshMetadata(mesh, "font"); return originalSetFontMesh(mesh); }; } } private ensureBackgroundMesh() { if (!this.host.setBackgroundMesh) return; const existing = this.host._backgroundMesh; if (existing) { const isAttached = existing.parent === this.host && this.host.children.includes(existing); if (!isAttached) { existing.parent?.remove(existing); this.host.setBackgroundMesh(existing as any); } return existing; } const backgroundMesh = new Frame(this.host as any); this.host.setBackgroundMesh(backgroundMesh); return backgroundMesh; } private releaseBackgroundTexture() { const texture = this.backgroundTexture; if (texture) { texture.dispose(); } this.backgroundTexture = null; this.backgroundTextureUrl = null; if (this.host.set) { this.host.set({ backgroundImage: null }); } } private setBackgroundTexture(texture: THREE.Texture) { this.releaseBackgroundTexture(); texture.colorSpace = THREE.SRGBColorSpace; this.backgroundTexture = texture; this.backgroundTextureUrl = "__texture__"; if (this.host.set) { this.host.set({ backgroundImage: texture }); } } private applyBackgroundImage(url: string) { const trimmed = typeof url === "string" ? url.trim() : ""; if (!trimmed) { this.releaseBackgroundTexture(); return; } if (this.backgroundTextureUrl === trimmed && this.backgroundTexture) { if (this.host.set) { this.host.set({ backgroundImage: this.backgroundTexture }); } return; } this.releaseBackgroundTexture(); this.backgroundTextureUrl = trimmed; App.resource.loadURLTexture(trimmed, (texture: THREE.Texture) => { if (this.backgroundTextureUrl !== trimmed) { texture.dispose?.(); return; } texture.colorSpace = THREE.SRGBColorSpace; this.backgroundTexture = texture; if (this.host.set) { this.host.set({ backgroundImage: texture }); } requestUIPanelRender(); }); } private buildResolvedProps(props: Record) { const resolved: Record = { ...props }; if (Object.prototype.hasOwnProperty.call(resolved, UIPANEL_LOCAL_POSITION_KEY)) { delete resolved[UIPANEL_LOCAL_POSITION_KEY]; } if (Object.prototype.hasOwnProperty.call(resolved, "backgroundImage")) { const value = resolved.backgroundImage; if (typeof value === "string") { delete resolved.backgroundImage; this.applyBackgroundImage(value); } else if (value instanceof THREE.Texture) { this.setBackgroundTexture(value); resolved.backgroundImage = value; } else if (!value) { delete resolved.backgroundImage; this.releaseBackgroundTexture(); } } else if (this.backgroundTexture) { resolved.backgroundImage = this.backgroundTexture; } return resolved; } private applyProps(props: Record) { this.resolvedProps = this.buildResolvedProps(props); this.applyLineBreak(props); this.applyState(this.currentState); } private applyLineBreak(props: Record) { const value = props.lineBreak ?? props.breakOn; if (value === undefined) return; const lineBreak = (this.host as any)?._lineBreak; if (!lineBreak || typeof lineBreak.value === "undefined") return; lineBreak.value = value; } private syncExternalProps() { this.syncExternalVisible(); } private syncExternalVisible() { const actual = Boolean(this.host.visible); if (actual === this.lastVisible) return; this.setProps({ visible: actual }); } private syncHistoryCommand(cmd: any) { if (!cmd || cmd.type !== "SetPositionCommand") return; this.syncLocalPositionFromHost(); } private syncLocalPositionFromHost() { const host = this.host as any; if (!host?.isUIPanelElement || !this.options) return; const props = this.options.props || (this.options.props = {}); const stored = props[UIPANEL_LOCAL_POSITION_KEY]; const position = this.host.position; const needsUpdate = !Array.isArray(stored) || stored.length < 3 || Math.abs(stored[0] - position.x) > UIPANEL_POSITION_EPSILON || Math.abs(stored[1] - position.y) > UIPANEL_POSITION_EPSILON || Math.abs(stored[2] - position.z) > UIPANEL_POSITION_EPSILON; if (needsUpdate) { props[UIPANEL_LOCAL_POSITION_KEY] = position.toArray(); } } private markParentChildrenDirty() { const parent = this.host.parent as any; if (!parent?.isUI || !parent._children) return; parent._children._needsUpdate = true; } }