Files
3Dviewer/frontend/src/services/explodeService.ts
likegears 7af9c323f6 Initial commit: 3D Viewer application
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>
2025-12-12 14:00:17 +08:00

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
}