487 lines
17 KiB
TypeScript
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;
|
|
}
|
|
}
|