425 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|