feat(all): 迁移扩展相关功能
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user