feat(all): 迁移扩展相关功能

This commit is contained in:
plum
2026-04-08 15:34:43 +08:00
parent ad2dd979eb
commit d7c0dba569
278 changed files with 25207 additions and 62 deletions
+453
View File
@@ -0,0 +1,453 @@
import * as THREE from "three";
import h337 from "heatmap.js-fix";
import { deepAssign, deepEqual } from "@/utils";
type HeatmapRenderer = {
canvas: HTMLCanvasElement;
};
type HeatmapInstance = {
setData: (data: { max: number; min?: number; data: HeatmapCanvasPoint[] }) => void;
configure?: (config: Record<string, unknown>) => void;
_renderer?: HeatmapRenderer;
_store?: { _cfgRadius?: number };
};
type HeatmapCanvasPoint = {
x: number;
y: number;
value: number;
radius?: number;
};
export const getDefaultHeatmapOptions = (): IHeatmap.options => ({
name: "Heatmap",
position: [0, 0, 0],
mode: "flat",
size: {
width: 10,
height: 10,
},
resolution: {
width: 512,
height: 512,
},
material: {
transparent: true,
opacity: 1,
depthWrite: false,
depthTest: true,
side: "double",
},
height: {
scale: 2,
segments: {
width: 128,
height: 128,
},
},
heatmap: {
radius: 40,
blur: 0.85,
maxOpacity: 1,
minOpacity: 0,
gradient: {
"0.0": "#2c7bb6",
"0.5": "#ffffbf",
"1.0": "#d7191c",
},
},
data: {
max: 1,
min: 0,
points: [],
},
});
const DEFAULT_HEATMAP_RADIUS = getDefaultHeatmapOptions().heatmap.radius ?? 0;
export default class Heatmap extends THREE.Mesh {
type = "Heatmap";
isHeatmap = true;
options: IHeatmap.options = getDefaultHeatmapOptions();
heatmap: HeatmapInstance;
container: HTMLDivElement;
canvas: HTMLCanvasElement;
texture: THREE.CanvasTexture;
constructor(options: Partial<IHeatmap.options> = {}) {
super();
if (typeof document === "undefined") {
throw new Error("[Astral 3D]: Heatmap requires a DOM environment.");
}
deepAssign(this.options, options);
if (options.heatmap?.gradient) {
this.options.heatmap.gradient = { ...options.heatmap.gradient };
}
this.name = this.options.name;
this.container = this.createContainer();
this.heatmap = this.createHeatmap();
this.canvas = this.resolveCanvas();
this.texture = new THREE.CanvasTexture(this.canvas);
this.texture.colorSpace = THREE.SRGBColorSpace;
this.material = new THREE.MeshBasicMaterial({
map: this.texture,
transparent: this.options.material.transparent,
opacity: this.options.material.opacity,
depthWrite: this.options.material.depthWrite,
depthTest: this.options.material.depthTest,
side: this.resolveSide(this.options.material.side),
});
this.geometry = this.createGeometry();
const position = this.options.position;
this.position.set(position[0] || 0, position[1] || 0, position[2] || 0);
// Default to horizontal (XZ) plane with Y-up.
this.rotation.x = -Math.PI / 2;
if (this.options.data?.points?.length) {
this.setData(this.options.data);
} else {
this.heatmap.setData({
max: this.options.data.max || 1,
min: this.options.data.min || 0,
data: [],
});
}
}
private createContainer() {
const container = document.createElement("div");
const { width, height } = this.options.resolution;
container.style.position = "absolute";
container.style.left = "-10000px";
container.style.top = "-10000px";
container.style.width = `${width}px`;
container.style.height = `${height}px`;
container.style.visibility = "hidden";
container.style.pointerEvents = "none";
const host = document.body || document.documentElement;
host.appendChild(container);
return container;
}
private createHeatmap() {
const config = {
container: this.container,
radius: this.options.heatmap.radius,
blur: this.options.heatmap.blur,
maxOpacity: this.options.heatmap.maxOpacity,
minOpacity: this.options.heatmap.minOpacity,
gradient: this.options.heatmap.gradient,
};
return (h337 as { create: (cfg: Record<string, unknown>) => HeatmapInstance }).create(config);
}
private clearContainer() {
while (this.container.firstChild) {
this.container.removeChild(this.container.firstChild);
}
}
private refreshCanvas() {
const canvas = this.resolveCanvas();
if (canvas !== this.canvas) {
this.canvas = canvas;
this.texture.image = this.canvas;
}
this.texture.needsUpdate = true;
}
private rebuildHeatmap() {
this.clearContainer();
this.heatmap = this.createHeatmap();
this.refreshCanvas();
}
private syncHeatmapRuntimeConfig() {
const heatmapAny = this.heatmap as HeatmapInstance | undefined;
if (!heatmapAny) return;
if (heatmapAny._store && typeof this.options.heatmap.radius === "number") {
heatmapAny._store._cfgRadius = this.options.heatmap.radius;
}
const rendererAny = heatmapAny._renderer as { _templates?: Record<string, HTMLCanvasElement> } | undefined;
if (rendererAny && rendererAny._templates) {
rendererAny._templates = {};
}
}
private getSegments() {
if (this.options.mode !== "height") {
return { width: 1, height: 1 };
}
const segments = this.options.height?.segments || { width: 1, height: 1 };
return {
width: Math.max(1, Math.floor(segments.width)),
height: Math.max(1, Math.floor(segments.height)),
};
}
private createGeometry() {
const size = this.options.size;
const segments = this.getSegments();
return new THREE.PlaneGeometry(size.width, size.height, segments.width, segments.height);
}
private ensureGeometry() {
const size = this.options.size;
const segments = this.getSegments();
const geometry = this.geometry as THREE.PlaneGeometry | undefined;
const parameters = geometry?.parameters;
const needsRebuild =
!parameters ||
parameters.width !== size.width ||
parameters.height !== size.height ||
parameters.widthSegments !== segments.width ||
parameters.heightSegments !== segments.height;
if (!needsRebuild) return;
this.geometry.dispose();
this.geometry = new THREE.PlaneGeometry(size.width, size.height, segments.width, segments.height);
}
private resolveCanvas() {
const canvas = this.heatmap._renderer?.canvas || this.container.querySelector("canvas");
if (!canvas) {
const fallback = document.createElement("canvas");
this.container.appendChild(fallback);
return fallback;
}
canvas.width = this.options.resolution.width;
canvas.height = this.options.resolution.height;
return canvas;
}
private flattenGeometry() {
const geometry = this.geometry as THREE.BufferGeometry;
const position = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
if (!position) return;
for (let i = 0; i < position.count; i += 1) {
position.setZ(i, 0);
}
position.needsUpdate = true;
geometry.computeVertexNormals();
}
private updateRelief() {
this.ensureGeometry();
if (this.options.mode !== "height") {
this.flattenGeometry();
return;
}
const context = this.canvas.getContext("2d");
if (!context) return;
const canvasWidth = this.canvas.width;
const canvasHeight = this.canvas.height;
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight).data;
const geometry = this.geometry as THREE.BufferGeometry;
const position = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
const uv = geometry.getAttribute("uv") as THREE.BufferAttribute | undefined;
if (!position || !uv) return;
const heightScale = this.options.height?.scale ?? 1;
for (let i = 0; i < position.count; i += 1) {
const u = uv.getX(i);
const v = uv.getY(i);
const x = Math.round(this.clamp(u * (canvasWidth - 1), 0, canvasWidth - 1));
const y = Math.round(this.clamp((1 - v) * (canvasHeight - 1), 0, canvasHeight - 1));
const index = (y * canvasWidth + x) * 4;
const alpha = imageData[index + 3] / 255;
position.setZ(i, alpha * heightScale);
}
position.needsUpdate = true;
geometry.computeVertexNormals();
}
private resolveSide(side?: IHeatmap.Side) {
switch (side) {
case "front":
return THREE.FrontSide;
case "back":
return THREE.BackSide;
case "double":
default:
return THREE.DoubleSide;
}
}
private clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
private toCanvasPoint(point: IHeatmap.Point): HeatmapCanvasPoint {
const size = this.options.size;
const resolution = this.options.resolution;
const safeWidth = Math.max(size.width, 0.000001);
const safeHeight = Math.max(size.height, 0.000001);
const scaleX = resolution.width / safeWidth;
const scaleY = resolution.height / safeHeight;
// Map local plane coordinates (centered at origin) to heatmap pixels.
const x = (point.x + size.width / 2) * scaleX;
const y = (size.height / 2 - point.y) * scaleY;
const canvasPoint: HeatmapCanvasPoint = {
x: this.clamp(x, 0, resolution.width),
y: this.clamp(y, 0, resolution.height),
value: point.value,
};
if (typeof point.radius === "number") {
const radiusScale = Math.min(scaleX, scaleY);
const baseRadius = typeof this.options.heatmap.radius === "number" ? this.options.heatmap.radius : DEFAULT_HEATMAP_RADIUS;
const radiusFactor = DEFAULT_HEATMAP_RADIUS > 0 ? Math.max(0, baseRadius) / DEFAULT_HEATMAP_RADIUS : 1;
canvasPoint.radius = point.radius * radiusScale * radiusFactor;
}
return canvasPoint;
}
private buildHeatmapData(data: IHeatmap.Data) {
const points = data.points || [];
const values = points.map((point) => point.value).filter((value) => Number.isFinite(value));
const max = typeof data.max === "number" ? data.max : values.length ? Math.max(...values) : 1;
const min = typeof data.min === "number" ? data.min : values.length ? Math.min(...values) : 0;
return {
max,
min,
data: points.map((point) => this.toCanvasPoint(point)),
};
}
setData(data: IHeatmap.Data) {
this.options.data = {
max: data.max,
min: data.min,
points: Array.isArray(data.points) ? data.points.slice() : [],
};
const heatmapData = this.buildHeatmapData(this.options.data);
this.heatmap.setData(heatmapData);
this.texture.needsUpdate = true;
this.updateRelief();
}
addData(points: IHeatmap.Point | IHeatmap.Point[]) {
const list = Array.isArray(points) ? points : [points];
const next = this.options.data.points.concat(list);
this.setData({
...this.options.data,
points: next,
});
}
clear() {
this.setData({
max: 1,
min: 0,
points: [],
});
return this;
}
setSize(size: IHeatmap.Size) {
this.options.size = { width: size.width, height: size.height };
this.ensureGeometry();
this.setData(this.options.data);
}
setMode(mode: IHeatmap.Mode) {
if (this.options.mode === mode) return this;
this.options.mode = mode;
this.ensureGeometry();
this.updateRelief();
return this;
}
updateHeatmapConfig(config: Partial<IHeatmap.HeatmapConfig>) {
const previousGradient = this.options.heatmap.gradient;
const { gradient, ...rest } = config;
deepAssign(this.options.heatmap, rest);
if (gradient && typeof gradient === "object" && !Array.isArray(gradient)) {
this.options.heatmap.gradient = { ...gradient };
}
const gradientChanged = !deepEqual(previousGradient, this.options.heatmap.gradient);
this.syncHeatmapRuntimeConfig();
if (gradientChanged) {
this.rebuildHeatmap();
} else if (this.heatmap.configure) {
this.heatmap.configure({
radius: this.options.heatmap.radius,
blur: this.options.heatmap.blur,
maxOpacity: this.options.heatmap.maxOpacity,
minOpacity: this.options.heatmap.minOpacity,
gradient: this.options.heatmap.gradient,
});
this.refreshCanvas();
} else {
this.rebuildHeatmap();
}
this.setData(this.options.data);
}
toJSON(meta?: THREE.JSONMeta) {
const options = JSON.parse(JSON.stringify(this.options));
options.position = this.position.toArray();
// 同步材质属性
options.material = {
transparent: (this.material as THREE.Material).transparent,
opacity: (this.material as THREE.Material).opacity,
depthWrite: (this.material as THREE.Material).depthWrite,
depthTest: (this.material as THREE.Material).depthTest,
side: this.options.material.side,
};
const superJSON = super.toJSON(meta);
superJSON.object.type = this.type;
superJSON.object.options = options;
return superJSON;
}
static fromJSON(json: { options: IHeatmap.options }) {
return new Heatmap(json.options);
}
dispose() {
this.geometry.dispose();
this.texture.dispose();
(this.material as THREE.Material).dispose();
if (this.container.parentElement) {
this.container.parentElement.removeChild(this.container);
}
}
}
+424
View File
@@ -0,0 +1,424 @@
import * as THREE from "three";
import { PathGeometry, PathPointList, PathTubeGeometry } from "three.path";
import App from "@/core/app/App";
import { useAddSignal, useRemoveSignal } from "@/hooks";
import { deepAssign } from "@/utils";
export const getDefaultPathOptions = (): IPath.options => ({
name: "Path",
position: [0, 0, 0],
mode: "path",
points: [],
closed: false,
cornerRadius: 0.1,
cornerSplit: 10,
up: [0, 1, 0],
path: {
width: 0.4,
arrow: false,
progress: 1,
side: "both",
},
tube: {
radius: 0.1,
radialSegments: 8,
progress: 1,
startRad: 0,
},
flow: {
enabled: false,
speed: 0.2,
direction: [1, 0],
},
material: {
color: "#ffffff",
transparent: true,
opacity: 1,
depthWrite: false,
depthTest: true,
side: "double",
map: "",
repeat: [1, 1],
offset: [0, 0],
rotation: 0,
},
});
export default class Path extends THREE.Mesh {
type = "Path";
isPath = true;
options: IPath.options = getDefaultPathOptions();
private pathPointList = new PathPointList();
private texture?: THREE.Texture;
private static flowPaths = new Set<Path>();
private static flowSignalBound = false;
private static flowLastTime = 0;
private handleAdded = () => {
this.updateFlowRegistration();
};
private handleRemoved = () => {
this.updateFlowRegistration();
};
private static bindFlowSignal() {
if (Path.flowSignalBound) return;
Path.flowSignalBound = true;
useAddSignal("sceneRendered", Path.handleFlowTick);
}
private static unbindFlowSignal() {
if (!Path.flowSignalBound) return;
Path.flowSignalBound = false;
Path.flowLastTime = 0;
useRemoveSignal("sceneRendered", Path.handleFlowTick);
}
private static handleFlowTick() {
if (Path.flowPaths.size === 0) {
Path.flowLastTime = 0;
return;
}
const now = performance.now();
const hasPrevious = Path.flowLastTime > 0;
const delta = hasPrevious ? (now - Path.flowLastTime) / 1000 : 0;
Path.flowLastTime = now;
if (delta > 0) {
Path.flowPaths.forEach(path => path.updateFlow(delta));
}
(App.viewer as any)?.pluginRequestRender?.(true);
}
constructor(options: Partial<IPath.options> = {}, material?: THREE.Material) {
super();
deepAssign(this.options, options);
//if (options.path) this.options.path = { ...this.options.path, ...options.path };
//if (options.tube) this.options.tube = { ...this.options.tube, ...options.tube };
// if (options.flow) {
// this.options.flow = { ...this.options.flow, ...options.flow };
// if (Array.isArray(options.flow.direction)) {
// this.options.flow.direction = options.flow.direction.slice();
// }
// }
// if (options.material) this.options.material = { ...this.options.material, ...options.material };
// if (Array.isArray(options.points)) {
// this.options.points = options.points.map(point => ({ ...point }));
// }
// if (Array.isArray(options.position)) {
// this.options.position = options.position.slice();
// }
// if (Array.isArray(options.up)) {
// this.options.up = options.up.slice();
// }
this.name = this.options.name;
this.updatePathPointList();
this.geometry = this.createGeometry();
if (material) {
this.material = material;
if (options.material) {
this.applyMaterialOptions(this.material);
}
} else {
this.material = this.createMaterial();
}
this.position.fromArray(this.options.position);
this.addEventListener("added", this.handleAdded);
this.addEventListener("removed", this.handleRemoved);
this.updateFlowRegistration();
}
private resolveMaterialSide(side?: IPath.MaterialSide) {
switch (side) {
case "front":
return THREE.FrontSide;
case "back":
return THREE.BackSide;
case "double":
default:
return THREE.DoubleSide;
}
}
private resolveMaterialSideName(side?: THREE.Side): IPath.MaterialSide {
switch (side) {
case THREE.FrontSide:
return "front";
case THREE.BackSide:
return "back";
case THREE.DoubleSide:
default:
return "double";
}
}
private createMaterial() {
const materialOptions = this.options.material;
const color = materialOptions?.color ?? "#ffffff";
const material = new THREE.MeshBasicMaterial({
color: new THREE.Color(color as any),
transparent: materialOptions?.transparent ?? true,
opacity: materialOptions?.opacity ?? 1,
depthWrite: materialOptions?.depthWrite ?? false,
depthTest: materialOptions?.depthTest ?? true,
side: this.resolveMaterialSide(materialOptions?.side),
});
this.applyMaterialOptions(material);
return material;
}
private updatePathPointList() {
const points = this.options.points || [];
const vectors = points.map(point => new THREE.Vector3(point.x, point.y, point.z));
const up = Array.isArray(this.options.up) ? new THREE.Vector3(this.options.up[0], this.options.up[1], this.options.up[2]) : null;
this.pathPointList.set(
vectors,
this.options.cornerRadius ?? 0,
this.options.cornerSplit ?? 0,
up,
Boolean(this.options.closed)
);
}
private createGeometry(): PathGeometry | PathTubeGeometry {
const mode = this.options.mode;
if (mode === "tube") {
return new PathTubeGeometry({
pathPointList: this.pathPointList,
options: this.options.tube,
usage: THREE.DynamicDrawUsage,
});
}
return new PathGeometry({
pathPointList: this.pathPointList,
options: this.options.path,
usage: THREE.DynamicDrawUsage,
});
}
private updateGeometry() {
this.updatePathPointList();
this.rebuildGeometry();
this.geometry.computeBoundingBox();
this.geometry.computeBoundingSphere();
}
private rebuildGeometry() {
this.geometry.dispose();
this.geometry = this.createGeometry();
}
private loadTexture(url: string) {
const loader = new THREE.TextureLoader();
const texture = loader.load(url, () => {
(this.material as THREE.Material).needsUpdate = true;
});
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
const repeat = this.options.material?.repeat;
if (Array.isArray(repeat) && repeat.length >= 2) {
texture.repeat.set(Number(repeat[0]) || 1, Number(repeat[1]) || 1);
}
const offset = this.options.material?.offset;
if (Array.isArray(offset) && offset.length >= 2) {
texture.offset.set(Number(offset[0]) || 0, Number(offset[1]) || 0);
}
if (typeof this.options.material?.rotation === "number") {
texture.rotation = this.options.material.rotation;
}
return texture;
}
private updateFlowRegistration() {
if (this.options.flow?.enabled && this.parent) {
Path.flowPaths.add(this);
Path.bindFlowSignal();
} else {
Path.flowPaths.delete(this);
}
if (Path.flowPaths.size === 0) {
Path.unbindFlowSignal();
}
}
private updateFlow(delta: number) {
const flow = this.options.flow;
if (!flow?.enabled) return;
const meshMaterial = this.material as THREE.MeshBasicMaterial;
const map = meshMaterial?.map;
if (!map) return;
const speed = typeof flow.speed === "number" ? flow.speed : 0;
if (speed === 0) return;
const direction = Array.isArray(flow.direction) && flow.direction.length >= 2 ? flow.direction : [1, 0];
map.offset.x += direction[0] * speed * delta;
map.offset.y += direction[1] * speed * delta;
map.offset.x -= Math.floor(map.offset.x);
map.offset.y -= Math.floor(map.offset.y);
}
private applyMaterialOptions(material: THREE.Material) {
const materialOptions = this.options.material;
if (!materialOptions) return;
const meshMaterial = material as THREE.MeshBasicMaterial;
if (materialOptions.color !== undefined && meshMaterial.color) {
meshMaterial.color = new THREE.Color(materialOptions.color as any);
}
if (typeof materialOptions.opacity === "number") {
meshMaterial.opacity = materialOptions.opacity;
}
if (typeof materialOptions.transparent === "boolean") {
meshMaterial.transparent = materialOptions.transparent;
}
if (typeof materialOptions.depthWrite === "boolean") {
meshMaterial.depthWrite = materialOptions.depthWrite;
}
if (typeof materialOptions.depthTest === "boolean") {
meshMaterial.depthTest = materialOptions.depthTest;
}
if (materialOptions.side) {
meshMaterial.side = this.resolveMaterialSide(materialOptions.side);
}
if (typeof materialOptions.map === "string" && materialOptions.map.trim() !== "") {
if (this.texture) this.texture.dispose();
this.texture = this.loadTexture(materialOptions.map);
meshMaterial.map = this.texture;
meshMaterial.transparent = true;
}
meshMaterial.needsUpdate = true;
}
updateOptions(options: Partial<IPath.options>) {
let needsGeometryUpdate = false;
let flowChanged = false;
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.points)) {
this.options.points = options.points.map(point => ({ ...point }));
needsGeometryUpdate = true;
}
if (options.mode) {
this.options.mode = options.mode;
needsGeometryUpdate = true;
}
if (typeof options.closed === "boolean") {
this.options.closed = options.closed;
needsGeometryUpdate = true;
}
if (typeof options.cornerRadius === "number") {
this.options.cornerRadius = options.cornerRadius;
needsGeometryUpdate = true;
}
if (typeof options.cornerSplit === "number") {
this.options.cornerSplit = options.cornerSplit;
needsGeometryUpdate = true;
}
if (Array.isArray(options.up)) {
this.options.up = options.up.slice();
needsGeometryUpdate = true;
}
if (options.path) {
this.options.path = { ...this.options.path, ...options.path };
needsGeometryUpdate = true;
}
if (options.tube) {
this.options.tube = { ...this.options.tube, ...options.tube };
needsGeometryUpdate = true;
}
if (options.flow) {
this.options.flow = { ...this.options.flow, ...options.flow };
if (Array.isArray(options.flow.direction)) {
this.options.flow.direction = options.flow.direction.slice();
}
flowChanged = true;
}
if (options.material) this.options.material = { ...this.options.material, ...options.material };
if (needsGeometryUpdate) {
this.updateGeometry();
}
if (options.material) {
this.applyMaterialOptions(this.material as THREE.Material);
}
if (flowChanged) {
this.updateFlowRegistration();
}
}
setPoints(points: IPath.Point[]) {
this.options.points = points.map(point => ({ ...point }));
this.updateGeometry();
}
toJSON(meta?: THREE.JSONMeta) {
const options = JSON.parse(JSON.stringify(this.options));
options.position = this.position.toArray();
const material = this.material as THREE.Material & { color?: THREE.Color };
if (!options.material) options.material = {};
if (material.color) {
options.material.color = `#${material.color.getHexString()}`;
}
options.material.opacity = material.opacity;
options.material.transparent = material.transparent;
options.material.depthWrite = material.depthWrite;
options.material.depthTest = material.depthTest;
options.material.side = this.resolveMaterialSideName(material.side);
const superJSON = super.toJSON(meta);
superJSON.object.type = this.type;
superJSON.object.options = options;
return superJSON;
}
static fromJSON(json: { options: IPath.options; material?: THREE.Material }) {
return new Path(json.options, json.material);
}
dispose() {
this.removeEventListener("added", this.handleAdded);
this.removeEventListener("removed", this.handleRemoved);
this.geometry.dispose();
if (Array.isArray(this.material)) {
this.material.forEach(mat => mat.dispose());
} else {
(this.material as THREE.Material).dispose();
}
if (this.texture) {
this.texture.dispose();
this.texture = undefined;
}
Path.flowPaths.delete(this);
}
}
+548
View File
@@ -0,0 +1,548 @@
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);
}
}
@@ -0,0 +1,113 @@
import * as THREE from "three";
import { Block } from "@/core/libs/three-mesh-ui/src/three-mesh-ui.js";
import App from "@/core/app/App";
import { findUIPanelRoot } from "@/utils/scene/uipanel";
import {
UIPanelElementController,
UIPanelElementInit,
UIPanelNode,
extractUIPanelProps,
normalizeUIPanelElementOptions,
} from "./UIPanelElementBase";
const BlockBase = Block as unknown as new (options?: Record<string, any>) => THREE.Object3D;
export default class UIPanelBlock extends BlockBase {
type = "UIPanelBlock";
isUIPanelElement = true;
options: UIPanelNode;
declare set: (options: Record<string, any>) => void;
private controller: UIPanelElementController;
private handleAdded = () => {
const parent = this.parent as any;
if (!parent || !this.isValidParent(parent)) {
parent?.remove?.(this);
return;
}
const panel = findUIPanelRoot(this) as any;
if (!panel) return;
this.controller.updateRootId(panel.uuid);
panel.registerElement?.(this);
this.traverse(child => {
if (child === this) return;
const anyChild = child as any;
if (!anyChild.isUIPanelElement || !anyChild.options?.id) return;
panel.registerElement?.(anyChild);
});
panel.requestUpdate?.();
};
private handleRemoved = () => {
const rootId = (this as any).metadata?.__uiPanelRootId;
const panel = rootId ? App.getObjectByUuid(rootId) : null;
panel?.unregisterElement?.(this);
};
constructor(options: Partial<UIPanelNode> = {}, init: UIPanelElementInit = {}) {
const normalized = normalizeUIPanelElementOptions(options, "block", "Block");
const { props } = extractUIPanelProps(normalized);
super(props);
this.options = normalized;
this.name = normalized.name || "Block";
this.controller = new UIPanelElementController(this, this.options, this.type, init);
this.addEventListener("added", this.handleAdded);
this.addEventListener("removed", this.handleRemoved);
}
updateRootId(rootId: string) {
this.controller.updateRootId(rootId);
}
updateOptions(options: Partial<UIPanelNode>) {
if (options.name !== undefined) {
this.options.name = options.name;
this.name = options.name || this.name;
}
if (options.props) this.setProps(options.props);
if (options.states !== undefined) this.setStates(options.states);
}
setProps(patch: Record<string, any>) {
this.controller.setProps(patch);
}
setStates(states?: Record<string, Record<string, any>>) {
this.controller.setStates(states);
const rootId = (this as any).metadata?.__uiPanelRootId;
const panel = rootId ? App.getObjectByUuid(rootId) : null;
panel?.refreshInteraction?.();
}
applyState(state: string | null) {
this.controller.applyState(state);
}
hasInteractiveState() {
return this.controller.hasInteractiveState();
}
dispose() {
this.controller.dispose();
}
toJSON(meta?: THREE.JSONMeta) {
const snapshot = this.controller.detachInternalMeshes();
const data = super.toJSON(meta) as any;
this.controller.restoreInternalMeshes(snapshot);
data.object.type = this.type;
const options = JSON.parse(JSON.stringify(this.options)) as UIPanelNode;
options.name = this.name;
data.object.options = options;
return data;
}
static fromJSON(json: { options: UIPanelNode }, init: UIPanelElementInit = {}) {
return new UIPanelBlock(json.options, init);
}
private isValidParent(parent: any) {
return parent?.type === "UIPanel" || parent?.type === "UIPanelBlock";
}
}
@@ -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;
}
}
@@ -0,0 +1,107 @@
import * as THREE from "three";
import { Inline } from "@/core/libs/three-mesh-ui/src/three-mesh-ui.js";
import App from "@/core/app/App";
import { findUIPanelRoot } from "@/utils/scene/uipanel";
import {
UIPanelElementController,
UIPanelElementInit,
UIPanelNode,
extractUIPanelProps,
normalizeUIPanelElementOptions,
} from "./UIPanelElementBase";
const InlineBase = Inline as unknown as new (options?: Record<string, any>) => THREE.Object3D;
export default class UIPanelInline extends InlineBase {
type = "UIPanelInline";
isUIPanelElement = true;
options: UIPanelNode;
declare set: (options: Record<string, any>) => void;
private controller: UIPanelElementController;
private handleAdded = () => {
const parent = this.parent as any;
if (!parent || !this.isValidParent(parent)) {
parent?.remove?.(this);
return;
}
const panel = findUIPanelRoot(this) as any;
if (!panel) return;
this.controller.updateRootId(panel.uuid);
panel.registerElement?.(this);
panel.requestUpdate?.();
};
private handleRemoved = () => {
const rootId = (this as any).metadata?.__uiPanelRootId;
const panel = rootId ? App.getObjectByUuid(rootId) : null;
panel?.unregisterElement?.(this);
};
constructor(options: Partial<UIPanelNode> = {}, init: UIPanelElementInit = {}) {
const normalized = normalizeUIPanelElementOptions(options, "inline", "Inline");
const { props } = extractUIPanelProps(normalized);
super(props);
this.options = normalized;
this.name = normalized.name || "Inline";
this.controller = new UIPanelElementController(this, this.options, this.type, init);
this.addEventListener("added", this.handleAdded);
this.addEventListener("removed", this.handleRemoved);
}
updateRootId(rootId: string) {
this.controller.updateRootId(rootId);
}
updateOptions(options: Partial<UIPanelNode>) {
if (options.name !== undefined) {
this.options.name = options.name;
this.name = options.name || this.name;
}
if (options.props) this.setProps(options.props);
if (options.states !== undefined) this.setStates(options.states);
}
setProps(patch: Record<string, any>) {
this.controller.setProps(patch);
}
setStates(states?: Record<string, Record<string, any>>) {
this.controller.setStates(states);
const rootId = (this as any).metadata?.__uiPanelRootId;
const panel = rootId ? App.getObjectByUuid(rootId) : null;
panel?.refreshInteraction?.();
}
applyState(state: string | null) {
this.controller.applyState(state);
}
hasInteractiveState() {
return this.controller.hasInteractiveState();
}
dispose() {
this.controller.dispose();
}
toJSON(meta?: THREE.JSONMeta) {
const snapshot = this.controller.detachInternalMeshes();
const data = super.toJSON(meta) as any;
this.controller.restoreInternalMeshes(snapshot);
data.object.type = this.type;
const options = JSON.parse(JSON.stringify(this.options)) as UIPanelNode;
options.name = this.name;
data.object.options = options;
return data;
}
static fromJSON(json: { options: UIPanelNode }, init: UIPanelElementInit = {}) {
return new UIPanelInline(json.options, init);
}
private isValidParent(parent: any) {
return parent?.type === "UIPanel" || parent?.type === "UIPanelBlock";
}
}
@@ -0,0 +1,107 @@
import * as THREE from "three";
import { InlineBlock } from "@/core/libs/three-mesh-ui/src/three-mesh-ui.js";
import App from "@/core/app/App";
import { findUIPanelRoot } from "@/utils/scene/uipanel";
import {
UIPanelElementController,
UIPanelElementInit,
UIPanelNode,
extractUIPanelProps,
normalizeUIPanelElementOptions,
} from "./UIPanelElementBase";
const InlineBlockBase = InlineBlock as unknown as new (options?: Record<string, any>) => THREE.Object3D;
export default class UIPanelInlineBlock extends InlineBlockBase {
type = "UIPanelInlineBlock";
isUIPanelElement = true;
options: UIPanelNode;
declare set: (options: Record<string, any>) => void;
private controller: UIPanelElementController;
private handleAdded = () => {
const parent = this.parent as any;
if (!parent || !this.isValidParent(parent)) {
parent?.remove?.(this);
return;
}
const panel = findUIPanelRoot(this) as any;
if (!panel) return;
this.controller.updateRootId(panel.uuid);
panel.registerElement?.(this);
panel.requestUpdate?.();
};
private handleRemoved = () => {
const rootId = (this as any).metadata?.__uiPanelRootId;
const panel = rootId ? App.getObjectByUuid(rootId) : null;
panel?.unregisterElement?.(this);
};
constructor(options: Partial<UIPanelNode> = {}, init: UIPanelElementInit = {}) {
const normalized = normalizeUIPanelElementOptions(options, "inlineBlock", "InlineBlock");
const { props } = extractUIPanelProps(normalized);
super(props);
this.options = normalized;
this.name = normalized.name || "InlineBlock";
this.controller = new UIPanelElementController(this, this.options, this.type, init);
this.addEventListener("added", this.handleAdded);
this.addEventListener("removed", this.handleRemoved);
}
updateRootId(rootId: string) {
this.controller.updateRootId(rootId);
}
updateOptions(options: Partial<UIPanelNode>) {
if (options.name !== undefined) {
this.options.name = options.name;
this.name = options.name || this.name;
}
if (options.props) this.setProps(options.props);
if (options.states !== undefined) this.setStates(options.states);
}
setProps(patch: Record<string, any>) {
this.controller.setProps(patch);
}
setStates(states?: Record<string, Record<string, any>>) {
this.controller.setStates(states);
const rootId = (this as any).metadata?.__uiPanelRootId;
const panel = rootId ? App.getObjectByUuid(rootId) : null;
panel?.refreshInteraction?.();
}
applyState(state: string | null) {
this.controller.applyState(state);
}
hasInteractiveState() {
return this.controller.hasInteractiveState();
}
dispose() {
this.controller.dispose();
}
toJSON(meta?: THREE.JSONMeta) {
const snapshot = this.controller.detachInternalMeshes();
const data = super.toJSON(meta) as any;
this.controller.restoreInternalMeshes(snapshot);
data.object.type = this.type;
const options = JSON.parse(JSON.stringify(this.options)) as UIPanelNode;
options.name = this.name;
data.object.options = options;
return data;
}
static fromJSON(json: { options: UIPanelNode }, init: UIPanelElementInit = {}) {
return new UIPanelInlineBlock(json.options, init);
}
private isValidParent(parent: any) {
return parent?.type === "UIPanel" || parent?.type === "UIPanelBlock";
}
}
@@ -0,0 +1,199 @@
import * as THREE from "three";
import { Text } from "@/core/libs/three-mesh-ui/src/three-mesh-ui.js";
import App from "@/core/app/App";
import { findUIPanelRoot } from "@/utils/scene/uipanel";
import {
UIPanelElementController,
UIPanelElementInit,
UIPanelNode,
extractUIPanelProps,
normalizeUIPanelElementOptions,
} from "./UIPanelElementBase";
const TextBase = Text as unknown as new (options?: Record<string, any>) => THREE.Object3D;
export default class UIPanelText extends TextBase {
type = "UIPanelText";
isUIPanelElement = true;
options: UIPanelNode;
declare set: (options: Record<string, any>) => void;
declare addAfterUpdate: (fn: () => void) => void;
declare removeAfterUpdate: (fn: () => void) => void;
private controller: UIPanelElementController;
private inlineRebuildQueued = false;
private handleAdded = () => {
const parent = this.parent as any;
if (!parent || !this.isValidParent(parent)) {
parent?.remove?.(this);
return;
}
const panel = findUIPanelRoot(this) as any;
if (!panel) return;
this.controller.updateRootId(panel.uuid);
panel.registerElement?.(this);
panel.requestUpdate?.();
this.syncAnonymousInlineProxy();
};
private handleRemoved = () => {
const rootId = (this as any).metadata?.__uiPanelRootId;
const panel = rootId ? App.getObjectByUuid(rootId) : null;
panel?.unregisterElement?.(this);
};
private handleAfterUpdate = () => {
if (this.reconcileAnonymousInlineChildren()) {
return;
}
this.syncAnonymousInlineProxy();
};
constructor(options: Partial<UIPanelNode> = {}, init: UIPanelElementInit = {}) {
const normalized = normalizeUIPanelElementOptions(options, "text", "Text");
const { props } = extractUIPanelProps(normalized);
super(props);
this.options = normalized;
this.name = normalized.name || "Text";
this.controller = new UIPanelElementController(this, this.options, this.type, init);
this.addEventListener("added", this.handleAdded);
this.addEventListener("removed", this.handleRemoved);
this.addAfterUpdate?.(this.handleAfterUpdate);
}
updateRootId(rootId: string) {
this.controller.updateRootId(rootId);
}
updateOptions(options: Partial<UIPanelNode>) {
if (options.name !== undefined) {
this.options.name = options.name;
this.name = options.name || this.name;
}
if (options.props) this.setProps(options.props);
if (options.states !== undefined) this.setStates(options.states);
}
setProps(patch: Record<string, any>) {
this.controller.setProps(patch);
if (Object.prototype.hasOwnProperty.call(patch, "textContent")) {
this.syncAnonymousInlineProxy();
}
}
setStates(states?: Record<string, Record<string, any>>) {
this.controller.setStates(states);
const rootId = (this as any).metadata?.__uiPanelRootId;
const panel = rootId ? App.getObjectByUuid(rootId) : null;
panel?.refreshInteraction?.();
}
applyState(state: string | null) {
this.controller.applyState(state);
}
hasInteractiveState() {
return this.controller.hasInteractiveState();
}
dispose() {
this.controller.dispose();
this.removeAfterUpdate?.(this.handleAfterUpdate);
}
toJSON(meta?: THREE.JSONMeta) {
const snapshot = this.controller.detachInternalMeshes();
const inlineSnapshot = this.detachAnonymousInlineChildren();
const data = super.toJSON(meta) as any;
this.restoreAnonymousInlineChildren(inlineSnapshot);
this.controller.restoreInternalMeshes(snapshot);
data.object.type = this.type;
const options = JSON.parse(JSON.stringify(this.options)) as UIPanelNode;
options.name = this.name;
data.object.options = options;
return data;
}
static fromJSON(json: { options: UIPanelNode }, init: UIPanelElementInit = {}) {
return new UIPanelText(json.options, init);
}
private isValidParent(parent: any) {
return parent?.type === "UIPanel" || parent?.type === "UIPanelBlock";
}
private syncAnonymousInlineProxy() {
this.children.forEach(child => {
if (!this.isAnonymousInlineChild(child)) return;
this.enableAnonymousInlineRaycast(child as any);
child.traverse(node => {
const anyNode = node as any;
if (!anyNode.proxy) {
anyNode.proxy = this;
}
});
});
}
private reconcileAnonymousInlineChildren() {
const anonymousChildren = this.children.filter(child => this.isAnonymousInlineChild(child));
if (anonymousChildren.length === 0) return false;
const hasInline = anonymousChildren.some(child => (child as any).isInline);
if (anonymousChildren.length === 1 && hasInline) {
return false;
}
anonymousChildren.forEach(child => {
this.remove(child);
(child as any).clear?.();
});
const textProp = (this as any)._textContent;
if (textProp) {
textProp._needsUpdate = true;
}
this.queueInlineRebuild();
return true;
}
private queueInlineRebuild() {
if (this.inlineRebuildQueued) return;
this.inlineRebuildQueued = true;
queueMicrotask(() => {
this.inlineRebuildQueued = false;
const panel = findUIPanelRoot(this) as any;
panel?.requestUpdate?.();
});
}
private detachAnonymousInlineChildren() {
const snapshot: Array<{ child: THREE.Object3D; index: number }> = [];
for (let i = this.children.length - 1; i >= 0; i--) {
const child = this.children[i];
if (!this.isAnonymousInlineChild(child)) continue;
this.children.splice(i, 1);
child.parent = null;
snapshot.push({ child, index: i });
}
return snapshot;
}
private restoreAnonymousInlineChildren(snapshot: Array<{ child: THREE.Object3D; index: number }>) {
if (!snapshot.length) return;
snapshot.sort((a, b) => a.index - b.index).forEach(({ child, index }) => {
if (child.parent === this) return;
const targetIndex = Math.min(Math.max(index, 0), this.children.length);
this.children.splice(targetIndex, 0, child);
child.parent = this;
});
}
private isAnonymousInlineChild(child: THREE.Object3D) {
return (child as any).name === "anonymousInline";
}
private enableAnonymousInlineRaycast(child: any) {
const fontMesh = child?._fontMesh;
if (fontMesh?.isMesh) {
fontMesh.raycast = THREE.Mesh.prototype.raycast;
}
}
}
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1,4 +1,12 @@
export {default as Billboard, getDefaultBillboardOptions} from "./Billboard";
export {HtmlPanelConverter, HtmlPanel, HtmlSprite} from "./HtmlPanel";
export {default as Heatmap, getDefaultHeatmapOptions} from "./Heatmap";
export {default as UIPanel, getDefaultUIPanelOptions} from "./UIPanel";
export {default as UIPanelBlock} from "./UIPanelBlock";
export {default as UIPanelText} from "./UIPanelText";
export {default as UIPanelInline} from "./UIPanelInline";
export {default as UIPanelInlineBlock} from "./UIPanelInlineBlock";
export {default as Path, getDefaultPathOptions} from "./Path";
export {default as ParticleEmitter, getDefaultParticleConfig} from "./ParticleEmitter";
export {default as Tiles, getDefault3DTilesOptions} from "./Tile.ts";
export {default as Tiles, getDefault3DTilesOptions} from "./Tile.ts";
export {WaterPool} from "./WaterPool";