TkAstral3D/packages/sdk/lib/core/objects/UIPanel.ts
2026-04-08 15:34:43 +08:00

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);
}
}