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

425 lines
14 KiB
TypeScript

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);
}
}