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(); 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); // 立即请求一帧渲染,防止场景静止时 sceneRendered 永远不触发导致流动停止 (App.viewer as any)?.pluginRequestRender?.(true); } 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 = {}, 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) { 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); } }