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