646 lines
18 KiB
TypeScript
646 lines
18 KiB
TypeScript
import * as THREE from "three";
|
|
import { Easing, Tween } from "three/examples/jsm/libs/tween.module.js";
|
|
import App from "@/core/app/App";
|
|
|
|
export interface AstralMaterialFormat {
|
|
color?: string;
|
|
opacity?: number;
|
|
transparent?: boolean;
|
|
metalness?: number;
|
|
roughness?: number;
|
|
emissive?: string;
|
|
emissiveIntensity?: number;
|
|
}
|
|
|
|
export type AstralAnimationBehavior = "play" | "pause" | "stop";
|
|
|
|
export interface AstralAnimationCommand {
|
|
name: string;
|
|
behavior: AstralAnimationBehavior;
|
|
duration?: number;
|
|
}
|
|
|
|
export interface AstralDataFormat {
|
|
position?: [number, number, number];
|
|
rotation?: [number, number, number];
|
|
scale?: [number, number, number];
|
|
material?: AstralMaterialFormat;
|
|
visible?: boolean;
|
|
animations?: AstralAnimationCommand[];
|
|
}
|
|
|
|
export interface DataBindingResult {
|
|
changed: boolean;
|
|
errors: string[];
|
|
}
|
|
|
|
export interface DataBindingOptions {
|
|
transition?: number;
|
|
onUpdate?: (object: THREE.Object3D) => void;
|
|
}
|
|
|
|
export interface DataComponentConfig {
|
|
dataSetId?: unknown;
|
|
filterEnabled?: boolean;
|
|
filterBody?: string;
|
|
autoRefreshEnabled?: boolean;
|
|
autoRefreshInterval?: number;
|
|
applyToModel?: boolean;
|
|
transition?: number;
|
|
}
|
|
|
|
export interface DataComponentEntry {
|
|
config?: DataComponentConfig;
|
|
data?: unknown;
|
|
}
|
|
|
|
export interface SetConfigOptions {
|
|
index?: number;
|
|
}
|
|
|
|
export interface SetDataOptions {
|
|
index?: number;
|
|
applyToModel?: boolean;
|
|
transition?: number;
|
|
onUpdate?: (object: THREE.Object3D) => void;
|
|
}
|
|
|
|
type Vector3Tuple = [number, number, number];
|
|
|
|
const animationBehaviors = new Set<AstralAnimationBehavior>(["play", "pause", "stop"]);
|
|
const activeTweens = new WeakMap<THREE.Object3D, Map<string, Set<Tween<any>>>>();
|
|
const pendingUpdates = new WeakMap<THREE.Object3D, boolean>();
|
|
function isPlainObject(value: unknown): value is Record<string, any> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function parseVector3(value: unknown, field: string, errors: string[]): Vector3Tuple | null {
|
|
if (!Array.isArray(value) || value.length !== 3) {
|
|
errors.push(`${field} must be an array of 3 numbers`);
|
|
return null;
|
|
}
|
|
const parsed = value.map(item => Number(item)) as number[];
|
|
if (parsed.some(item => !Number.isFinite(item))) {
|
|
errors.push(`${field} must contain finite numbers`);
|
|
return null;
|
|
}
|
|
return [parsed[0], parsed[1], parsed[2]];
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
if (!Number.isFinite(value)) return null;
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function normalizeTransition(value: number | undefined) {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric) || numeric <= 0) return 0;
|
|
return numeric;
|
|
}
|
|
|
|
function normalizeIndex(value: number | undefined) {
|
|
const index = Number(value);
|
|
if (!Number.isFinite(index) || index < 0) return 0;
|
|
return Math.floor(index);
|
|
}
|
|
|
|
function ensureDataComponentEntry(object: THREE.Object3D, index: number): DataComponentEntry {
|
|
const list = Array.isArray((object as any).dataComponent) ? ((object as any).dataComponent as DataComponentEntry[]) : [];
|
|
if (!Array.isArray((object as any).dataComponent)) {
|
|
(object as any).dataComponent = list;
|
|
}
|
|
let entry = list[index];
|
|
if (!entry || typeof entry !== "object") {
|
|
entry = { config: {}, data: undefined };
|
|
list[index] = entry;
|
|
} else if (!entry.config || typeof entry.config !== "object") {
|
|
entry.config = {};
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function scheduleObjectUpdate(object: THREE.Object3D, onUpdate?: (object: THREE.Object3D) => void) {
|
|
if (!onUpdate) return;
|
|
if (pendingUpdates.get(object)) return;
|
|
pendingUpdates.set(object, true);
|
|
if (typeof requestAnimationFrame === "function") {
|
|
requestAnimationFrame(() => {
|
|
pendingUpdates.delete(object);
|
|
onUpdate(object);
|
|
});
|
|
} else {
|
|
pendingUpdates.delete(object);
|
|
onUpdate(object);
|
|
}
|
|
}
|
|
|
|
function getTweenBucket(object: THREE.Object3D, key: string) {
|
|
let map = activeTweens.get(object);
|
|
if (!map) {
|
|
map = new Map();
|
|
activeTweens.set(object, map);
|
|
}
|
|
let bucket = map.get(key);
|
|
if (!bucket) {
|
|
bucket = new Set();
|
|
map.set(key, bucket);
|
|
}
|
|
return bucket;
|
|
}
|
|
|
|
function trackTween(object: THREE.Object3D, key: string, tween: Tween<any>) {
|
|
const bucket = getTweenBucket(object, key);
|
|
bucket.add(tween);
|
|
const cleanup = () => {
|
|
const map = activeTweens.get(object);
|
|
const currentBucket = map?.get(key);
|
|
if (!currentBucket) return;
|
|
currentBucket.delete(tween);
|
|
if (currentBucket.size === 0) {
|
|
map?.delete(key);
|
|
}
|
|
if (map && map.size === 0) {
|
|
activeTweens.delete(object);
|
|
}
|
|
};
|
|
tween.onStop(cleanup);
|
|
tween.onComplete(cleanup);
|
|
}
|
|
|
|
function stopTweensForKey(object: THREE.Object3D, key: string) {
|
|
const map = activeTweens.get(object);
|
|
const bucket = map?.get(key);
|
|
if (!bucket) return;
|
|
bucket.forEach(tween => tween.stop());
|
|
map?.delete(key);
|
|
if (map && map.size === 0) {
|
|
activeTweens.delete(object);
|
|
}
|
|
}
|
|
|
|
function tweenVector3(
|
|
object: THREE.Object3D,
|
|
key: string,
|
|
target: THREE.Vector3,
|
|
value: Vector3Tuple,
|
|
duration: number,
|
|
onUpdate?: (object: THREE.Object3D) => void
|
|
) {
|
|
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
|
|
return false;
|
|
}
|
|
const state = { x: target.x, y: target.y, z: target.z };
|
|
const tween = new Tween(state)
|
|
.to({ x: value[0], y: value[1], z: value[2] }, duration)
|
|
.easing(Easing.Linear.None)
|
|
.onUpdate(() => {
|
|
target.set(state.x, state.y, state.z);
|
|
scheduleObjectUpdate(object, onUpdate);
|
|
});
|
|
trackTween(object, key, tween);
|
|
tween.start();
|
|
return true;
|
|
}
|
|
|
|
function tweenEuler(
|
|
object: THREE.Object3D,
|
|
key: string,
|
|
target: THREE.Euler,
|
|
value: Vector3Tuple,
|
|
duration: number,
|
|
onUpdate?: (object: THREE.Object3D) => void
|
|
) {
|
|
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
|
|
return false;
|
|
}
|
|
const state = { x: target.x, y: target.y, z: target.z };
|
|
const tween = new Tween(state)
|
|
.to({ x: value[0], y: value[1], z: value[2] }, duration)
|
|
.easing(Easing.Linear.None)
|
|
.onUpdate(() => {
|
|
target.set(state.x, state.y, state.z);
|
|
scheduleObjectUpdate(object, onUpdate);
|
|
});
|
|
trackTween(object, key, tween);
|
|
tween.start();
|
|
return true;
|
|
}
|
|
|
|
function tweenNumber(
|
|
object: THREE.Object3D,
|
|
key: string,
|
|
target: Record<string, any>,
|
|
prop: string,
|
|
value: number,
|
|
duration: number,
|
|
onUpdate?: (object: THREE.Object3D) => void
|
|
) {
|
|
if (Number(target[prop]) === value) {
|
|
return false;
|
|
}
|
|
const state = { value: Number(target[prop]) };
|
|
const tween = new Tween(state)
|
|
.to({ value }, duration)
|
|
.easing(Easing.Linear.None)
|
|
.onUpdate(() => {
|
|
target[prop] = state.value;
|
|
scheduleObjectUpdate(object, onUpdate);
|
|
});
|
|
trackTween(object, key, tween);
|
|
tween.start();
|
|
return true;
|
|
}
|
|
|
|
function tweenColor(
|
|
object: THREE.Object3D,
|
|
key: string,
|
|
target: THREE.Color,
|
|
value: THREE.Color,
|
|
duration: number,
|
|
onUpdate?: (object: THREE.Object3D) => void
|
|
) {
|
|
if (target.equals(value)) {
|
|
return false;
|
|
}
|
|
const state = { r: target.r, g: target.g, b: target.b };
|
|
const tween = new Tween(state)
|
|
.to({ r: value.r, g: value.g, b: value.b }, duration)
|
|
.easing(Easing.Linear.None)
|
|
.onUpdate(() => {
|
|
target.setRGB(state.r, state.g, state.b);
|
|
scheduleObjectUpdate(object, onUpdate);
|
|
});
|
|
trackTween(object, key, tween);
|
|
tween.start();
|
|
return true;
|
|
}
|
|
|
|
function updateVector3(target: THREE.Vector3, value: Vector3Tuple) {
|
|
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
|
|
return false;
|
|
}
|
|
target.set(value[0], value[1], value[2]);
|
|
return true;
|
|
}
|
|
|
|
function updateEuler(target: THREE.Euler, value: Vector3Tuple) {
|
|
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
|
|
return false;
|
|
}
|
|
target.set(value[0], value[1], value[2]);
|
|
return true;
|
|
}
|
|
|
|
function updateMaterial(
|
|
object: THREE.Object3D,
|
|
material: THREE.Material,
|
|
data: AstralMaterialFormat,
|
|
errors: string[],
|
|
transition: number,
|
|
onUpdate?: (object: THREE.Object3D) => void
|
|
) {
|
|
let changed = false;
|
|
const duration = normalizeTransition(transition);
|
|
const mat = material as any;
|
|
const materialKey = material.uuid || "material";
|
|
|
|
if (data.color !== undefined && mat.color && typeof mat.color.set === "function") {
|
|
let nextColor: THREE.Color | null = null;
|
|
try {
|
|
nextColor = new THREE.Color();
|
|
nextColor.set(data.color);
|
|
} catch (error) {
|
|
errors.push(`material.color invalid: ${String((error as Error).message || error)}`);
|
|
}
|
|
if (nextColor) {
|
|
const key = `material:${materialKey}:color`;
|
|
stopTweensForKey(object, key);
|
|
if (duration > 0) {
|
|
changed = tweenColor(object, key, mat.color, nextColor, duration, onUpdate) || changed;
|
|
} else if (typeof mat.color.equals !== "function" || !mat.color.equals(nextColor)) {
|
|
mat.color.copy(nextColor);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.emissive !== undefined && mat.emissive && typeof mat.emissive.set === "function") {
|
|
let nextEmissive: THREE.Color | null = null;
|
|
try {
|
|
nextEmissive = new THREE.Color();
|
|
nextEmissive.set(data.emissive);
|
|
} catch (error) {
|
|
errors.push(`material.emissive invalid: ${String((error as Error).message || error)}`);
|
|
}
|
|
if (nextEmissive) {
|
|
const key = `material:${materialKey}:emissive`;
|
|
stopTweensForKey(object, key);
|
|
if (duration > 0) {
|
|
changed = tweenColor(object, key, mat.emissive, nextEmissive, duration, onUpdate) || changed;
|
|
} else if (typeof mat.emissive.equals !== "function" || !mat.emissive.equals(nextEmissive)) {
|
|
mat.emissive.copy(nextEmissive);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.opacity !== undefined && "opacity" in mat) {
|
|
const next = clamp(Number(data.opacity), 0, 1);
|
|
if (next === null) {
|
|
errors.push("material.opacity must be a number between 0 and 1");
|
|
} else {
|
|
const key = `material:${materialKey}:opacity`;
|
|
stopTweensForKey(object, key);
|
|
if (duration > 0) {
|
|
changed = tweenNumber(object, key, mat, "opacity", next, duration, onUpdate) || changed;
|
|
} else if (mat.opacity !== next) {
|
|
mat.opacity = next;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.transparent !== undefined && "transparent" in mat) {
|
|
const next = Boolean(data.transparent);
|
|
const key = `material:${materialKey}:transparent`;
|
|
stopTweensForKey(object, key);
|
|
if (mat.transparent !== next) {
|
|
mat.transparent = next;
|
|
changed = true;
|
|
scheduleObjectUpdate(object, onUpdate);
|
|
}
|
|
}
|
|
|
|
if (data.metalness !== undefined && "metalness" in mat) {
|
|
const next = clamp(Number(data.metalness), 0, 1);
|
|
if (next === null) {
|
|
errors.push("material.metalness must be a number between 0 and 1");
|
|
} else {
|
|
const key = `material:${materialKey}:metalness`;
|
|
stopTweensForKey(object, key);
|
|
if (duration > 0) {
|
|
changed = tweenNumber(object, key, mat, "metalness", next, duration, onUpdate) || changed;
|
|
} else if (mat.metalness !== next) {
|
|
mat.metalness = next;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.roughness !== undefined && "roughness" in mat) {
|
|
const next = clamp(Number(data.roughness), 0, 1);
|
|
if (next === null) {
|
|
errors.push("material.roughness must be a number between 0 and 1");
|
|
} else {
|
|
const key = `material:${materialKey}:roughness`;
|
|
stopTweensForKey(object, key);
|
|
if (duration > 0) {
|
|
changed = tweenNumber(object, key, mat, "roughness", next, duration, onUpdate) || changed;
|
|
} else if (mat.roughness !== next) {
|
|
mat.roughness = next;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.emissiveIntensity !== undefined && "emissiveIntensity" in mat) {
|
|
const next = clamp(Number(data.emissiveIntensity), 0, 1);
|
|
if (next === null) {
|
|
errors.push("material.emissiveIntensity must be a number between 0 and 1");
|
|
} else {
|
|
const key = `material:${materialKey}:emissiveIntensity`;
|
|
stopTweensForKey(object, key);
|
|
if (duration > 0) {
|
|
changed = tweenNumber(object, key, mat, "emissiveIntensity", next, duration, onUpdate) || changed;
|
|
} else if (mat.emissiveIntensity !== next) {
|
|
mat.emissiveIntensity = next;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (changed && "needsUpdate" in mat) {
|
|
mat.needsUpdate = true;
|
|
}
|
|
|
|
if (duration === 0 && changed) {
|
|
scheduleObjectUpdate(object, onUpdate);
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
function getAnimationActionByName(object: THREE.Object3D, name: string) {
|
|
const animations = (object as any)?.animations;
|
|
if (!Array.isArray(animations)) return null;
|
|
|
|
for (const animation of animations) {
|
|
if (!animation || typeof (animation as any).getClip !== "function") continue;
|
|
|
|
const clip = (animation as THREE.AnimationAction).getClip?.();
|
|
if (!clip || clip.name !== name) continue;
|
|
|
|
if (typeof (animation as any).play === "function") {
|
|
return animation as THREE.AnimationAction;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function applyAnimations(object: THREE.Object3D, commands: AstralAnimationCommand[], errors: string[]) {
|
|
let changed = false;
|
|
|
|
commands.forEach((command, index) => {
|
|
if (!isPlainObject(command)) {
|
|
errors.push(`animations[${index}] must be an object`);
|
|
return;
|
|
}
|
|
|
|
const name = typeof command.name === "string" ? command.name.trim() : "";
|
|
if (!name) {
|
|
errors.push(`animations[${index}].name is required`);
|
|
return;
|
|
}
|
|
|
|
const behaviorValue = typeof command.behavior === "string" ? command.behavior.trim().toLowerCase() : "";
|
|
if (!animationBehaviors.has(behaviorValue as AstralAnimationBehavior)) {
|
|
errors.push(`animations[${index}].behavior must be play | pause | stop`);
|
|
return;
|
|
}
|
|
|
|
const action = getAnimationActionByName(object, name);
|
|
if (!action) return;
|
|
|
|
if (command.duration !== undefined) {
|
|
const duration = Number(command.duration);
|
|
if (!Number.isFinite(duration) || duration <= 0) {
|
|
errors.push(`animations[${index}].duration must be a positive number`);
|
|
return;
|
|
}
|
|
if (typeof action.setDuration === "function") {
|
|
action.setDuration(duration);
|
|
}
|
|
}
|
|
|
|
switch (behaviorValue) {
|
|
case "play":
|
|
action.play();
|
|
action.paused = false;
|
|
break;
|
|
case "pause":
|
|
action.paused = true;
|
|
break;
|
|
case "stop":
|
|
action.stop();
|
|
action.paused = false;
|
|
break;
|
|
}
|
|
|
|
changed = true;
|
|
});
|
|
|
|
return changed;
|
|
}
|
|
|
|
export class DataBindingManager {
|
|
static setConfig(object: THREE.Object3D, config: DataComponentConfig, options: SetConfigOptions = {}) {
|
|
const index = normalizeIndex(options.index);
|
|
const entry = ensureDataComponentEntry(object, index);
|
|
entry.config = config && typeof config === "object" ? { ...config } : {};
|
|
return entry;
|
|
}
|
|
|
|
static setData(object: THREE.Object3D, data: unknown, options: SetDataOptions = {}): DataBindingResult {
|
|
const index = normalizeIndex(options.index);
|
|
const entry = ensureDataComponentEntry(object, index);
|
|
entry.data = data;
|
|
|
|
const config = entry.config || {};
|
|
if (App.viewer) {
|
|
App.viewer.dispatchEvent({
|
|
type: "bindDataChange",
|
|
object,
|
|
data,
|
|
config,
|
|
index,
|
|
});
|
|
}
|
|
|
|
const shouldApply = options.applyToModel ?? Boolean(config.applyToModel);
|
|
if (!shouldApply) {
|
|
return { changed: false, errors: [] };
|
|
}
|
|
|
|
const transition =
|
|
options.transition !== undefined ? options.transition : Number.isFinite(Number(config.transition)) ? Number(config.transition) : undefined;
|
|
|
|
return DataBindingManager.applyDataToObject(object, data, {
|
|
transition,
|
|
onUpdate: options.onUpdate,
|
|
});
|
|
}
|
|
|
|
static applyDataToObject(object: THREE.Object3D, data: unknown, options: DataBindingOptions = {}): DataBindingResult {
|
|
const errors: string[] = [];
|
|
let changed = false;
|
|
|
|
if (!isPlainObject(data)) {
|
|
return { changed: false, errors: ["data must be an object"] };
|
|
}
|
|
|
|
const payload = data as AstralDataFormat;
|
|
const transition = normalizeTransition(options.transition);
|
|
const onUpdate = options.onUpdate;
|
|
|
|
if (payload.position !== undefined) {
|
|
const next = parseVector3(payload.position, "position", errors);
|
|
if (next) {
|
|
const key = "position";
|
|
stopTweensForKey(object, key);
|
|
if (transition > 0) {
|
|
changed = tweenVector3(object, key, object.position, next, transition, onUpdate) || changed;
|
|
} else {
|
|
changed = updateVector3(object.position, next) || changed;
|
|
if (changed) {
|
|
scheduleObjectUpdate(object, onUpdate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (payload.rotation !== undefined) {
|
|
const next = parseVector3(payload.rotation, "rotation", errors);
|
|
if (next) {
|
|
const key = "rotation";
|
|
stopTweensForKey(object, key);
|
|
if (transition > 0) {
|
|
changed = tweenEuler(object, key, object.rotation, next, transition, onUpdate) || changed;
|
|
} else {
|
|
changed = updateEuler(object.rotation, next) || changed;
|
|
if (changed) {
|
|
scheduleObjectUpdate(object, onUpdate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (payload.scale !== undefined) {
|
|
const next = parseVector3(payload.scale, "scale", errors);
|
|
if (next) {
|
|
const key = "scale";
|
|
stopTweensForKey(object, key);
|
|
if (transition > 0) {
|
|
changed = tweenVector3(object, key, object.scale, next, transition, onUpdate) || changed;
|
|
} else {
|
|
changed = updateVector3(object.scale, next) || changed;
|
|
if (changed) {
|
|
scheduleObjectUpdate(object, onUpdate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (payload.visible !== undefined) {
|
|
const next = Boolean(payload.visible);
|
|
const key = "visible";
|
|
stopTweensForKey(object, key);
|
|
if (object.visible !== next) {
|
|
object.visible = next;
|
|
changed = true;
|
|
scheduleObjectUpdate(object, onUpdate);
|
|
}
|
|
}
|
|
|
|
if (payload.material && isPlainObject(payload.material)) {
|
|
const targetMaterial = (object as any).material;
|
|
if (Array.isArray(targetMaterial)) {
|
|
targetMaterial.forEach((mat: THREE.Material) => {
|
|
changed =
|
|
updateMaterial(object, mat, payload.material as AstralMaterialFormat, errors, transition, onUpdate) || changed;
|
|
});
|
|
} else if (targetMaterial) {
|
|
changed =
|
|
updateMaterial(
|
|
object,
|
|
targetMaterial as THREE.Material,
|
|
payload.material as AstralMaterialFormat,
|
|
errors,
|
|
transition,
|
|
onUpdate
|
|
) || changed;
|
|
}
|
|
}
|
|
|
|
if (payload.animations !== undefined) {
|
|
if (!Array.isArray(payload.animations)) {
|
|
errors.push("animations must be an array");
|
|
} else {
|
|
changed = applyAnimations(object, payload.animations, errors) || changed;
|
|
}
|
|
}
|
|
|
|
return { changed, errors };
|
|
}
|
|
}
|