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>
This commit is contained in:
658
frontend/src/services/explodeService.ts
Normal file
658
frontend/src/services/explodeService.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user