Features: - Vue 3 frontend with Three.js/Online3DViewer - Node.js API with PostgreSQL and Redis - Python worker for model conversion - Docker Compose for deployment - ViewCube navigation with drag rotation and 90° snap - Cross-section, exploded view, and render settings - Parts tree with visibility controls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
659 lines
20 KiB
TypeScript
659 lines
20 KiB
TypeScript
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<string, PartData> = 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<string, THREE.Vector3>()
|
|
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<string, THREE.Vector3>): 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<string, THREE.Vector3>,
|
|
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
|
|
}
|