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

487 lines
17 KiB
TypeScript

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<string, Record<string, any>>;
export type UIPanelElementHost = THREE.Object3D & {
set?: (options: Record<string, any>) => 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<IUIPanel.NodeType, string> = {
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<UIPanelNode>,
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<UIPanelElementHost, UIPanelElementController>();
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<string, any> = {};
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<string, any>) {
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<string, any>) {
const resolved: Record<string, any> = { ...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<string, any>) {
this.resolvedProps = this.buildResolvedProps(props);
this.applyLineBreak(props);
this.applyState(this.currentState);
}
private applyLineBreak(props: Record<string, any>) {
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;
}
}