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(["play", "pause", "stop"]); const activeTweens = new WeakMap>>>(); const pendingUpdates = new WeakMap(); function isPlainObject(value: unknown): value is Record { 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) { 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, 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 }; } }