import * as THREE from 'three' import { getRenderService } from './renderService' import { getClippingService } from './clippingService' import { getPartsTreeService } from './partsTreeService' interface PartData { mesh: THREE.Object3D uuid: string originalPosition: THREE.Vector3 direction: THREE.Vector3 // Direction relative to parent (or model center for roots) distance: number // Distance to parent center (or model center for roots) isExploded: boolean // For per-part explosion tracking parentUuid: string | null // Parent part UUID (null for root parts) childrenUuids: string[] // Children part UUIDs depth: number // Hierarchy depth (0 for roots) } export class ExplodeService { private parts: PartData[] = [] private partsMap: Map = new Map() private modelCenter: THREE.Vector3 = new THREE.Vector3() private maxExplosionDistance: number = 1 private initialized: boolean = false private currentFactor: number = 0 private animationId: number | null = null private onRenderCallback: (() => void) | null = null /** * Initialize explosion data from the Three.js scene * Builds hierarchical parent-child relationships for recursive explosion */ initializeFromScene(scene: THREE.Scene, onRender?: () => void): void { this.parts = [] this.partsMap.clear() this.initialized = false this.currentFactor = 0 this.onRenderCallback = onRender || null // Calculate overall bounding box for model center const modelBox = new THREE.Box3().setFromObject(scene) if (modelBox.isEmpty()) { console.warn('Scene bounding box is empty') return } modelBox.getCenter(this.modelCenter) // Calculate max dimension for scaling explosion distance const modelSize = new THREE.Vector3() modelBox.getSize(modelSize) this.maxExplosionDistance = Math.max(modelSize.x, modelSize.y, modelSize.z) * 0.5 // Pass 1: Collect all mesh parts scene.traverse((object) => { const isMesh = object.type === 'Mesh' || (object as unknown as { isMesh?: boolean }).isMesh === true if (isMesh) { const mesh = object as THREE.Mesh if (!mesh.geometry) return const partData: PartData = { mesh: object, uuid: object.uuid, originalPosition: object.position.clone(), direction: new THREE.Vector3(), distance: 0, isExploded: false, parentUuid: null, childrenUuids: [], depth: 0, } this.parts.push(partData) this.partsMap.set(object.uuid, partData) } }) // Pass 2: Build parent-child relationships (only mesh parents) for (const part of this.parts) { let parent = part.mesh.parent while (parent && parent.type !== 'Scene') { if (this.partsMap.has(parent.uuid)) { part.parentUuid = parent.uuid this.partsMap.get(parent.uuid)!.childrenUuids.push(part.uuid) break } parent = parent.parent } } // Pass 3: Calculate depth and explosion directions relative to parent for (const part of this.parts) { // Calculate depth part.depth = this.calculateDepth(part.uuid) // Get parent center for direction calculation let parentCenter: THREE.Vector3 if (part.parentUuid) { // Has mesh parent - use parent's center parentCenter = this.getPartCenter(part.parentUuid) } else { // Root part - try to find a Group ancestor for better explosion direction const groupCenter = this.findGroupParentCenter(part.mesh) parentCenter = groupCenter || this.modelCenter } // Get this part's center const partCenter = this.getPartCenter(part.uuid) // Calculate direction from parent center to part center const direction = new THREE.Vector3().subVectors(partCenter, parentCenter) const distance = direction.length() if (distance > 0.001) { direction.normalize() } else { // For parts at same center as parent, use a pseudo-random direction direction.set( Math.sin(part.mesh.id * 0.1), Math.cos(part.mesh.id * 0.2), Math.sin(part.mesh.id * 0.3) ).normalize() } part.direction = direction part.distance = distance } this.initialized = this.parts.length > 0 const rootCount = this.parts.filter(p => !p.parentUuid).length const maxDepth = Math.max(...this.parts.map(p => p.depth), 0) console.log(`ExplodeService: Initialized with ${this.parts.length} parts (${rootCount} roots, max depth: ${maxDepth})`) } /** * Calculate hierarchy depth for a part */ private calculateDepth(uuid: string): number { const part = this.partsMap.get(uuid) if (!part || !part.parentUuid) return 0 return 1 + this.calculateDepth(part.parentUuid) } /** * Get the world-space center of a part */ private getPartCenter(uuid: string): THREE.Vector3 { const part = this.partsMap.get(uuid) if (!part) return new THREE.Vector3() const box = new THREE.Box3().setFromObject(part.mesh) return box.getCenter(new THREE.Vector3()) } /** * Get the world-space center of any Object3D */ private getObjectCenter(object: THREE.Object3D): THREE.Vector3 { const box = new THREE.Box3().setFromObject(object) return box.getCenter(new THREE.Vector3()) } /** * Find the center of the nearest Group ancestor with multiple mesh descendants * Used for calculating explosion direction for root parts */ private findGroupParentCenter(mesh: THREE.Object3D): THREE.Vector3 | null { let parent = mesh.parent while (parent && parent.type !== 'Scene') { const meshCount = this.countMeshDescendants(parent) if (meshCount > 1) { return this.getObjectCenter(parent) } parent = parent.parent } return null } /** * Check if an object is a mesh */ private isMesh(object: THREE.Object3D): boolean { return object.type === 'Mesh' || (object as unknown as { isMesh?: boolean }).isMesh === true } /** * Count mesh descendants of an object (excluding the object itself) */ private countMeshDescendants(object: THREE.Object3D): number { let count = 0 object.traverse((child) => { if (child !== object && this.isMesh(child)) { count++ } }) return count } /** * Convert a world-space direction vector to local-space for a given parent object * This is needed because mesh.position is in local coordinates, but our explosion * directions are calculated in world coordinates */ private worldToLocalDirection(parent: THREE.Object3D, worldDir: THREE.Vector3): THREE.Vector3 { // Get the inverse of the parent's world matrix const parentWorldMatrixInverse = new THREE.Matrix4() parentWorldMatrixInverse.copy(parent.matrixWorld).invert() // Transform direction only (rotation/scale, not translation) // Note: transformDirection normalizes the result, so we need to restore length const length = worldDir.length() const localDir = worldDir.clone() localDir.transformDirection(parentWorldMatrixInverse) localDir.setLength(length) return localDir } /** * Apply explosion with given factor (0 = collapsed, 100 = fully exploded) * Uses hierarchical recursive explosion based on assembly tree structure * @param factor - Explosion factor (0-100) * @param isDragging - If true, skip expensive sync operations for better performance during slider drag */ applyExplosion(factor: number, isDragging: boolean = false): void { if (!this.initialized) return this.currentFactor = factor if (factor === 0) { // Reset all parts to original position this.parts.forEach((part) => { if (part.mesh && part.mesh.parent) { part.mesh.position.copy(part.originalPosition) } }) } else { // Normalize factor from 0-100 to 0-1 const normalizedFactor = Math.max(0, Math.min(1, factor / 100)) // Scale factor for visible effect const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5 // Find root parts (no parent) and recursively apply explosion const rootParts = this.parts.filter(p => !p.parentUuid) for (const root of rootParts) { this.applyExplosionRecursive(root, explosionScale, new THREE.Vector3()) } } // Sync edge lines with new positions (now O(n) after optimization, fast enough for dragging) getRenderService().syncEdgeTransforms() // Skip expensive operations during dragging for smooth slider interaction if (!isDragging) { // Update clipping bounds when explosion changes getClippingService().updateBounds() // Sync selection overlay positions getPartsTreeService().syncSelectionOverlays() } } /** * Recursively apply explosion offset, accumulating parent offsets * parentOffset is in world coordinates and gets converted to local coordinates for each part */ private applyExplosionRecursive( part: PartData, explosionScale: number, parentWorldOffset: THREE.Vector3 // World-space accumulated offset ): void { // Check if mesh is still valid if (!part.mesh || !part.mesh.parent) return // Calculate this part's explosion offset in world coordinates // (direction is already in world coordinates from initialization) const partWorldOffset = part.direction.clone().multiplyScalar( part.distance > 0.001 ? explosionScale : explosionScale * 0.5 ) // Total world offset = parent's accumulated world offset + this part's world offset const totalWorldOffset = parentWorldOffset.clone().add(partWorldOffset) // Convert world-space offset to local-space for this mesh's parent const localOffset = this.worldToLocalDirection(part.mesh.parent, totalWorldOffset) // Apply local offset to local position part.mesh.position.copy(part.originalPosition).add(localOffset) // Recursively apply to children with accumulated world offset for (const childUuid of part.childrenUuids) { const childPart = this.partsMap.get(childUuid) if (childPart) { this.applyExplosionRecursive(childPart, explosionScale, totalWorldOffset) } } } /** * Animate explosion to target factor */ animateExplosion( targetFactor: number, duration: number = 500, onComplete?: () => void ): void { if (!this.initialized) return // Cancel any existing animation this.cancelAnimation() const startFactor = this.currentFactor const startTime = performance.now() const animate = (currentTime: number) => { const elapsed = currentTime - startTime const progress = Math.min(elapsed / duration, 1) // Ease out cubic const eased = 1 - Math.pow(1 - progress, 3) const factor = startFactor + (targetFactor - startFactor) * eased this.applyExplosionDirect(factor) if (this.onRenderCallback) { this.onRenderCallback() } if (progress < 1) { this.animationId = requestAnimationFrame(animate) } else { this.animationId = null this.currentFactor = targetFactor // Update clipping bounds when animation completes getClippingService().updateBounds() if (onComplete) onComplete() } } this.animationId = requestAnimationFrame(animate) } /** * Direct apply explosion (used by animation, bypasses per-part logic) * Uses hierarchical recursive explosion */ private applyExplosionDirect(factor: number): void { this.currentFactor = factor if (factor === 0) { this.parts.forEach((part) => { part.mesh.position.copy(part.originalPosition) }) } else { const normalizedFactor = Math.max(0, Math.min(1, factor / 100)) const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5 // Find root parts and recursively apply explosion const rootParts = this.parts.filter(p => !p.parentUuid) for (const root of rootParts) { this.applyExplosionRecursive(root, explosionScale, new THREE.Vector3()) } } // Sync edge lines with new positions getRenderService().syncEdgeTransforms() // Sync selection overlay positions getPartsTreeService().syncSelectionOverlays() } /** * Cancel any running animation */ cancelAnimation(): void { if (this.animationId !== null) { cancelAnimationFrame(this.animationId) this.animationId = null } } /** * Check if animation is running */ isAnimating(): boolean { return this.animationId !== null } /** * Explode a single part by UUID (also moves children) */ explodePart(uuid: string, factor: number = 100): void { const part = this.partsMap.get(uuid) if (!part || !part.mesh.parent) return part.isExploded = true const normalizedFactor = Math.max(0, Math.min(1, factor / 100)) const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5 // Calculate offset in world coordinates const worldOffset = part.direction.clone().multiplyScalar( part.distance > 0.001 ? explosionScale : explosionScale * 0.5 ) // Convert to local coordinates for this part const localOffset = this.worldToLocalDirection(part.mesh.parent, worldOffset) // Move this part part.mesh.position.copy(part.originalPosition).add(localOffset) // Move all descendants by the same world offset (each converted to their local space) this.moveDescendants(part, worldOffset) // Sync edge lines with new position getRenderService().syncEdgeTransforms() // Sync selection overlay positions getPartsTreeService().syncSelectionOverlays() if (this.onRenderCallback) { this.onRenderCallback() } } /** * Recursively move all descendants by a world-space offset */ private moveDescendants(part: PartData, worldOffset: THREE.Vector3): void { for (const childUuid of part.childrenUuids) { const child = this.partsMap.get(childUuid) if (child && child.mesh.parent) { // Convert world offset to child's local coordinate system const localOffset = this.worldToLocalDirection(child.mesh.parent, worldOffset) child.mesh.position.copy(child.originalPosition).add(localOffset) this.moveDescendants(child, worldOffset) } } } /** * Animate explode a single part by UUID (also moves children) */ animateExplodePart( uuid: string, targetFactor: number = 100, duration: number = 300 ): void { const part = this.partsMap.get(uuid) if (!part || !part.mesh.parent) return // Collect start positions for part and all descendants const startPositions = new Map() startPositions.set(uuid, part.mesh.position.clone()) this.collectDescendantPositions(part, startPositions) const normalizedFactor = Math.max(0, Math.min(1, targetFactor / 100)) const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5 // Calculate world offset const worldOffset = part.direction.clone().multiplyScalar( part.distance > 0.001 ? explosionScale : explosionScale * 0.5 ) // Convert to local offset for target position const localOffset = this.worldToLocalDirection(part.mesh.parent, worldOffset) const targetPosition = part.originalPosition.clone().add(localOffset) const startTime = performance.now() const animate = (currentTime: number) => { const elapsed = currentTime - startTime const progress = Math.min(elapsed / duration, 1) const eased = 1 - Math.pow(1 - progress, 3) // Move main part const startPos = startPositions.get(uuid)! part.mesh.position.lerpVectors(startPos, targetPosition, eased) // Move all descendants by interpolated world offset (converted to local for each) const currentWorldOffset = worldOffset.clone().multiplyScalar(eased) this.animateDescendants(part, startPositions, currentWorldOffset) // Sync edge lines with new position getRenderService().syncEdgeTransforms() // Sync selection overlay positions getPartsTreeService().syncSelectionOverlays() if (this.onRenderCallback) { this.onRenderCallback() } if (progress < 1) { requestAnimationFrame(animate) } else { part.isExploded = targetFactor > 0 } } requestAnimationFrame(animate) } /** * Collect start positions for all descendants */ private collectDescendantPositions(part: PartData, positions: Map): void { for (const childUuid of part.childrenUuids) { const child = this.partsMap.get(childUuid) if (child) { positions.set(childUuid, child.mesh.position.clone()) this.collectDescendantPositions(child, positions) } } } /** * Animate descendants during single-part animation * worldOffset is in world coordinates */ private animateDescendants( part: PartData, startPositions: Map, worldOffset: THREE.Vector3 ): void { for (const childUuid of part.childrenUuids) { const child = this.partsMap.get(childUuid) if (child && child.mesh.parent) { const startPos = startPositions.get(childUuid) if (startPos) { // Convert world offset to child's local coordinate system const localOffset = this.worldToLocalDirection(child.mesh.parent, worldOffset) child.mesh.position.copy(child.originalPosition).add(localOffset) } this.animateDescendants(child, startPositions, worldOffset) } } } /** * Reset a single part to original position (also resets children) */ resetPart(uuid: string): void { const part = this.partsMap.get(uuid) if (!part) return part.isExploded = false part.mesh.position.copy(part.originalPosition) // Reset all descendants too this.resetDescendants(part) // Sync edge lines with new position getRenderService().syncEdgeTransforms() // Sync selection overlay positions getPartsTreeService().syncSelectionOverlays() if (this.onRenderCallback) { this.onRenderCallback() } } /** * Recursively reset descendants to original positions */ private resetDescendants(part: PartData): void { for (const childUuid of part.childrenUuids) { const child = this.partsMap.get(childUuid) if (child) { child.isExploded = false child.mesh.position.copy(child.originalPosition) this.resetDescendants(child) } } } /** * Animate reset a single part */ animateResetPart(uuid: string, duration: number = 300): void { this.animateExplodePart(uuid, 0, duration) } /** * Get current explosion factor */ getCurrentFactor(): number { return this.currentFactor } /** * Reset to original positions */ reset(): void { this.cancelAnimation() this.parts.forEach((part) => { part.mesh.position.copy(part.originalPosition) part.isExploded = false }) this.currentFactor = 0 // Sync edge lines with reset positions getRenderService().syncEdgeTransforms() // Update clipping bounds when reset getClippingService().updateBounds() } /** * Check if service is initialized */ isInitialized(): boolean { return this.initialized } /** * Get number of parts */ getPartsCount(): number { return this.parts.length } /** * Check if a part is exploded */ isPartExploded(uuid: string): boolean { const part = this.partsMap.get(uuid) return part?.isExploded ?? false } /** * Get exploded parts UUIDs */ getExplodedParts(): string[] { return this.parts.filter(p => p.isExploded).map(p => p.uuid) } } // Singleton instance let explodeService: ExplodeService | null = null export function getExplodeService(): ExplodeService { if (!explodeService) { explodeService = new ExplodeService() } return explodeService } export function resetExplodeService(): void { if (explodeService) { explodeService.reset() } explodeService = null }