import * as THREE from 'three' import type { BitBybitViewportAdapter, MeshAsset, SubshapeRef, ViewportInteractionHandlers, ViewportMeshAsset } from './types' type ScreenRect = { left: number; top: number; right: number; bottom: number } export const resolveScreenBoxSelection = (candidates: Array<{ objectId: string; bounds: ScreenRect }>, selection: ScreenRect, mode: 'window' | 'crossing'): string[] => candidates.filter(({ bounds }) => mode === 'window' ? bounds.left >= selection.left && bounds.right <= selection.right && bounds.top >= selection.top && bounds.bottom <= selection.bottom : bounds.right >= selection.left && bounds.left <= selection.right && bounds.bottom >= selection.top && bounds.top <= selection.bottom).map(({ objectId }) => objectId) export const resolveMeshSubshape = (mesh: MeshAsset, triangleIndex: number): SubshapeRef | null => { if (!Number.isSafeInteger(triangleIndex) || triangleIndex < 0) return null const range = mesh.subshapeRanges?.find((entry) => triangleIndex >= entry.startTriangle && triangleIndex < entry.startTriangle + entry.triangleCount) return range ? { ...range.ref, candidates: range.ref.candidates ? [...range.ref.candidates] : undefined } : null } /** Internal renderer boundary. React receives only the adapter contract. */ export class ThreeViewportAdapter implements BitBybitViewportAdapter { private host: HTMLElement | null = null private renderer: THREE.WebGLRenderer | null = null private scene: THREE.Scene | null = null private camera: THREE.PerspectiveCamera | null = null private subshapeHighlight: THREE.Mesh | THREE.Line | THREE.Points | null = null private toolpath: THREE.Group | null = null private toolpathSource: Array> = [] private readonly objectMeshes = new Map() private interactionHandlers: ViewportInteractionHandlers = {} private hoveredSubshapeId = '' private selectedObjectId = '' private selectedObjectIds = new Set() private selectionFlashObjectId = '' private selectionFlashUntil = 0 private boxDrag: { startX: number; startY: number; pointerId: number; additive: boolean } | null = null private selectionBox: HTMLDivElement | null = null private suppressNextClick = false private readonly raycaster = new THREE.Raycaster() private readonly pointer = new THREE.Vector2() private viewTarget = new THREE.Vector3(0, 0.8, 0) private drag: { x: number; y: number; mode: 'pan' | 'orbit'; pointerId: number } | null = null private frame = 0 private readonly handleWheel = (event: WheelEvent) => { event.preventDefault() this.zoomBy(Math.exp(Math.sign(event.deltaY) * 0.12)) } private readonly handlePointerDown = (event: PointerEvent) => { const additive = event.shiftKey || event.ctrlKey || event.metaKey if (event.button === 0 && (additive || !this.pickHit(event.clientX, event.clientY))) { event.preventDefault() this.boxDrag = { startX: event.clientX, startY: event.clientY, pointerId: event.pointerId, additive } this.showSelectionBox(event.clientX, event.clientY) this.renderer?.domElement.setPointerCapture(event.pointerId) return } if (event.button !== 1 && !(this.drag && (event.button === 0 || event.button === 2))) return event.preventDefault() const mode = this.drag && (event.button === 0 || event.button === 2) ? 'orbit' : 'pan' this.drag = { x: event.clientX, y: event.clientY, mode, pointerId: event.pointerId } this.renderer?.domElement.setPointerCapture(event.pointerId) } private readonly handlePointerMove = (event: PointerEvent) => { if (this.boxDrag) { this.showSelectionBox(event.clientX, event.clientY) return } if (!this.drag) { if (event.buttons === 0) this.updatePreselection(event) return } if (!this.camera || (event.buttons & 4) === 0) return const deltaX = event.clientX - this.drag.x const deltaY = event.clientY - this.drag.y this.drag.x = event.clientX this.drag.y = event.clientY this.drag.mode = (event.buttons & 3) !== 0 ? 'orbit' : 'pan' if (this.drag.mode === 'orbit') { const offset = this.camera.position.clone().sub(this.viewTarget) const spherical = new THREE.Spherical().setFromVector3(offset) spherical.theta -= deltaX * 0.006 spherical.phi = THREE.MathUtils.clamp(spherical.phi - deltaY * 0.006, 0.04, Math.PI - 0.04) this.camera.position.copy(this.viewTarget).add(new THREE.Vector3().setFromSpherical(spherical)) this.camera.up.set(0, 1, 0) } else { const distance = this.camera.position.distanceTo(this.viewTarget) const scale = distance * 0.0014 const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0) const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 1) const translation = right.multiplyScalar(-deltaX * scale).add(up.multiplyScalar(deltaY * scale)) this.camera.position.add(translation) this.viewTarget.add(translation) } this.camera.lookAt(this.viewTarget) } private readonly handlePointerUp = (event: PointerEvent) => { if (this.boxDrag) { const box = this.boxDrag const width = Math.abs(event.clientX - box.startX) const height = Math.abs(event.clientY - box.startY) if (this.renderer?.domElement.hasPointerCapture(box.pointerId)) this.renderer.domElement.releasePointerCapture(box.pointerId) this.boxDrag = null this.clearSelectionBox() if (width >= 4 && height >= 4) { this.suppressNextClick = true const mode = event.clientX >= box.startX ? 'window' : 'crossing' const objectIds = this.pickObjectsInBox(box.startX, box.startY, event.clientX, event.clientY, mode) if (this.renderer) { this.renderer.domElement.dataset.boxSelectionMode = mode this.renderer.domElement.dataset.boxSelectionObjectIds = objectIds.join(',') } this.interactionHandlers.onBoxSelect?.({ objectIds, additive: box.additive, mode }) } return } if (!this.drag) return if ((event.buttons & 4) !== 0) { this.drag.mode = 'pan' this.drag.x = event.clientX this.drag.y = event.clientY return } if (this.renderer?.domElement.hasPointerCapture(this.drag.pointerId)) this.renderer.domElement.releasePointerCapture(this.drag.pointerId) this.drag = null } private readonly handleContextMenu = (event: MouseEvent) => { if (this.drag) event.preventDefault() } private readonly handleClick = (event: MouseEvent) => { if (this.suppressNextClick) { this.suppressNextClick = false event.preventDefault() event.stopPropagation() return } const hit = this.pickHit(event.clientX, event.clientY) const additive = event.shiftKey || event.ctrlKey || event.metaKey if (hit && additive) { event.stopPropagation() this.interactionHandlers.onObjectClick?.(hit.objectId, { additive: true }) return } const picked = hit ? this.pickSubshape(event.clientX, event.clientY, hit) : null if (picked) { event.stopPropagation() this.interactionHandlers.onSubshapeClick?.({ kind: picked.ref.kind, persistentId: picked.ref.persistentId, objectId: picked.objectId }) } else if (hit) { event.stopPropagation() this.interactionHandlers.onObjectClick?.(hit.objectId, { additive: false }) } } private readonly handlePointerLeave = () => this.updateHoveredSubshape(null) private showSelectionBox(clientX: number, clientY: number) { if (!this.boxDrag) return if (!this.selectionBox) { this.selectionBox = document.createElement('div') Object.assign(this.selectionBox.style, { position: 'fixed', pointerEvents: 'none', zIndex: '1000' }) document.body.appendChild(this.selectionBox) } const windowMode = clientX >= this.boxDrag.startX this.selectionBox.style.border = `1px ${windowMode ? 'solid #74a7ff' : 'dashed #6ee7d8'}` this.selectionBox.style.background = windowMode ? 'rgba(116, 167, 255, 0.12)' : 'rgba(110, 231, 216, 0.12)' const left = Math.min(this.boxDrag.startX, clientX) const top = Math.min(this.boxDrag.startY, clientY) this.selectionBox.style.left = `${left}px` this.selectionBox.style.top = `${top}px` this.selectionBox.style.width = `${Math.abs(clientX - this.boxDrag.startX)}px` this.selectionBox.style.height = `${Math.abs(clientY - this.boxDrag.startY)}px` } private clearSelectionBox() { this.selectionBox?.remove() this.selectionBox = null } private pickObjectsInBox(startX: number, startY: number, endX: number, endY: number, mode: 'window' | 'crossing') { if (!this.renderer || !this.camera) return [] const viewport = this.renderer.domElement.getBoundingClientRect() const selection = { left: Math.min(startX, endX), top: Math.min(startY, endY), right: Math.max(startX, endX), bottom: Math.max(startY, endY) } this.camera.updateMatrixWorld() const candidates = [...this.objectMeshes.entries()].flatMap(([objectId, entry]) => { entry.mesh.updateMatrixWorld() if (!entry.mesh.geometry.boundingBox) entry.mesh.geometry.computeBoundingBox() const bounds = entry.mesh.geometry.boundingBox if (!bounds || bounds.isEmpty()) return [] const points = [ [bounds.min.x, bounds.min.y, bounds.min.z], [bounds.min.x, bounds.min.y, bounds.max.z], [bounds.min.x, bounds.max.y, bounds.min.z], [bounds.min.x, bounds.max.y, bounds.max.z], [bounds.max.x, bounds.min.y, bounds.min.z], [bounds.max.x, bounds.min.y, bounds.max.z], [bounds.max.x, bounds.max.y, bounds.min.z], [bounds.max.x, bounds.max.y, bounds.max.z], ].map(([x, y, z]) => new THREE.Vector3(x, y, z).applyMatrix4(entry.mesh.matrixWorld).project(this.camera as THREE.Camera)) if (!points.some((point) => Number.isFinite(point.x) && Number.isFinite(point.y) && point.z >= -1 && point.z <= 1)) return [] const xs = points.map((point) => viewport.left + (point.x + 1) * 0.5 * viewport.width) const ys = points.map((point) => viewport.top + (1 - point.y) * 0.5 * viewport.height) return [{ objectId, bounds: { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) } }] }) return resolveScreenBoxSelection(candidates, selection, mode) } private pickHit(clientX: number, clientY: number) { if (!this.renderer || !this.camera || this.objectMeshes.size === 0) return null const bounds = this.renderer.domElement.getBoundingClientRect() if (bounds.width <= 0 || bounds.height <= 0) return null this.pointer.set((clientX - bounds.left) / bounds.width * 2 - 1, -((clientY - bounds.top) / bounds.height) * 2 + 1) this.raycaster.setFromCamera(this.pointer, this.camera) const hit = this.raycaster.intersectObjects([...this.objectMeshes.values()].map((entry) => entry.mesh), false)[0] const faceIndex = hit?.faceIndex if (!hit || !(hit.object instanceof THREE.Mesh)) return null const entry = [...this.objectMeshes.entries()].find(([, candidate]) => candidate.mesh === hit.object) if (!entry) return null const projectedHit = hit.point.clone().project(this.camera) return { bounds, hitDepth: projectedHit.z, faceIndex: typeof faceIndex === 'number' && Number.isSafeInteger(faceIndex) ? faceIndex : -1, objectId: entry[0], asset: entry[1].asset, mesh: entry[1].mesh } } private projectPoint(point: [number, number, number], bounds: DOMRect, mesh: THREE.Mesh) { if (!this.camera) return null const projected = new THREE.Vector3(...point).applyMatrix4(mesh.matrixWorld).project(this.camera) if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || !Number.isFinite(projected.z)) return null return { x: bounds.left + (projected.x + 1) * 0.5 * bounds.width, y: bounds.top + (1 - projected.y) * 0.5 * bounds.height, depth: projected.z, } } private pickSubshape(clientX: number, clientY: number, hit = this.pickHit(clientX, clientY)): { ref: SubshapeRef; objectId: string; asset: MeshAsset; mesh: THREE.Mesh } | null { if (!hit) return null const pointer = { x: clientX, y: clientY } const distanceToSegment = (point: { x: number; y: number }, start: { x: number; y: number }, end: { x: number; y: number }) => { const dx = end.x - start.x const dy = end.y - start.y const lengthSquared = dx * dx + dy * dy if (lengthSquared <= 1e-9) return Math.hypot(point.x - start.x, point.y - start.y) const factor = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared)) return Math.hypot(point.x - (start.x + factor * dx), point.y - (start.y + factor * dy)) } const vertices = (hit.asset.subshapeVertices ?? []).flatMap((candidate) => { const projected = this.projectPoint(candidate.position, hit.bounds, hit.mesh) return projected && projected.depth <= hit.hitDepth + 0.08 ? [{ ref: candidate.ref, distance: Math.hypot(pointer.x - projected.x, pointer.y - projected.y) }] : [] }).sort((left, right) => left.distance - right.distance) if (vertices[0] && vertices[0].distance <= 10) return { ref: vertices[0].ref, objectId: hit.objectId, asset: hit.asset, mesh: hit.mesh } const edges = (hit.asset.subshapeEdges ?? []).flatMap((candidate) => { const start = this.projectPoint(candidate.start, hit.bounds, hit.mesh) const end = this.projectPoint(candidate.end, hit.bounds, hit.mesh) if (!start || !end || Math.min(start.depth, end.depth) > hit.hitDepth + 0.08) return [] return [{ ref: candidate.ref, distance: distanceToSegment(pointer, start, end) }] }).sort((left, right) => left.distance - right.distance) if (edges[0] && edges[0].distance <= 8) return { ref: edges[0].ref, objectId: hit.objectId, asset: hit.asset, mesh: hit.mesh } const ref = resolveMeshSubshape(hit.asset, hit.faceIndex) return ref ? { ref, objectId: hit.objectId, asset: hit.asset, mesh: hit.mesh } : null } private updatePreselection(event: PointerEvent) { const picked = this.pickSubshape(event.clientX, event.clientY) this.updateHoveredSubshape(picked) } private updateHoveredSubshape(picked: { ref: SubshapeRef; objectId: string; asset: MeshAsset; mesh: THREE.Mesh } | null) { const nextId = picked ? `${picked.objectId}:${picked.ref.kind}:${picked.ref.persistentId}` : '' if (nextId === this.hoveredSubshapeId) return this.hoveredSubshapeId = nextId this.renderSubshapeHighlight(picked) this.interactionHandlers.onSubshapeHover?.(picked ? { kind: picked.ref.kind, persistentId: picked.ref.persistentId, objectId: picked.objectId } : null) } private clearSubshapeHighlight() { if (!this.subshapeHighlight) return this.subshapeHighlight.geometry.dispose() if (this.subshapeHighlight.material instanceof THREE.Material) this.subshapeHighlight.material.dispose() this.scene?.remove(this.subshapeHighlight) this.subshapeHighlight = null } private renderSubshapeHighlight(picked: { ref: SubshapeRef; objectId: string; asset: MeshAsset; mesh: THREE.Mesh } | null) { this.clearSubshapeHighlight() if (!picked || !this.scene) return const { ref, asset, mesh } = picked if (ref.kind === 'edge') { const edges = asset.subshapeEdges?.filter((entry) => entry.ref.persistentId === ref.persistentId) ?? [] if (edges.length === 0) return const geometry = new THREE.BufferGeometry().setFromPoints(edges.flatMap((edge) => [new THREE.Vector3(...edge.start), new THREE.Vector3(...edge.end)])) const material = new THREE.LineBasicMaterial({ color: 0xffd05a, transparent: true, opacity: 0.95, depthTest: false }) this.subshapeHighlight = new THREE.LineSegments(geometry, material) } else if (ref.kind === 'vertex') { const vertex = asset.subshapeVertices?.find((entry) => entry.ref.persistentId === ref.persistentId) if (!vertex) return const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(...vertex.position)]) const material = new THREE.PointsMaterial({ color: 0xffd05a, size: 12, sizeAttenuation: false, depthTest: false }) this.subshapeHighlight = new THREE.Points(geometry, material) } else { const range = asset.subshapeRanges?.find((entry) => entry.ref.kind === ref.kind && entry.ref.persistentId === ref.persistentId) if (!range) return const source = mesh.geometry const sourceIndex = source.getIndex() if (!sourceIndex) return const start = range.startTriangle * 3 const count = range.triangleCount * 3 const indices = Array.from({ length: count }, (_, offset) => sourceIndex.getX(start + offset)) const geometry = new THREE.BufferGeometry() const positions = source.getAttribute('position') const normals = source.getAttribute('normal') geometry.setAttribute('position', positions.clone()) if (normals) geometry.setAttribute('normal', normals.clone()) geometry.setIndex(indices) const material = new THREE.MeshBasicMaterial({ color: 0xffd05a, transparent: true, opacity: 0.48, depthTest: false, side: THREE.DoubleSide }) this.subshapeHighlight = new THREE.Mesh(geometry, material) } this.subshapeHighlight.position.copy(mesh.position) this.subshapeHighlight.rotation.copy(mesh.rotation) this.subshapeHighlight.scale.copy(mesh.scale) this.subshapeHighlight.renderOrder = 20 this.scene.add(this.subshapeHighlight) } mount(host: HTMLElement) { this.host = host this.scene = new THREE.Scene() this.scene.background = new THREE.Color(0x0e1417) this.camera = new THREE.PerspectiveCamera(38, 1, 0.1, 1000) this.camera.position.set(3.8, 3.1, 5.2) this.camera.lookAt(this.viewTarget) this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' }) this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) this.renderer.setSize(host.clientWidth || 1, host.clientHeight || 1, false) this.renderer.domElement.dataset.geometrySource = 'three-fallback' this.renderer.domElement.style.touchAction = 'none' this.renderer.domElement.addEventListener('wheel', this.handleWheel, { passive: false }) this.renderer.domElement.addEventListener('pointerdown', this.handlePointerDown) this.renderer.domElement.addEventListener('pointermove', this.handlePointerMove) this.renderer.domElement.addEventListener('pointerup', this.handlePointerUp) this.renderer.domElement.addEventListener('pointercancel', this.handlePointerUp) this.renderer.domElement.addEventListener('pointerleave', this.handlePointerLeave) this.renderer.domElement.addEventListener('click', this.handleClick) this.renderer.domElement.addEventListener('contextmenu', this.handleContextMenu) host.appendChild(this.renderer.domElement) const grid = new THREE.GridHelper(8, 16, 0x31555a, 0x1e3439) grid.rotation.x = 0 this.scene.add(grid) this.scene.add(new THREE.HemisphereLight(0xb8eeee, 0x142125, 2.1)) const key = new THREE.DirectionalLight(0xffffff, 2.4) key.position.set(4, 6, 4) this.scene.add(key) const render = () => { if (!this.renderer || !this.scene || !this.camera) return this.updateSelectionFlash() this.renderer.render(this.scene, this.camera) this.frame = requestAnimationFrame(render) } this.resize(host.clientWidth || 1, host.clientHeight || 1) render() } setMesh(mesh: MeshAsset | null) { this.setMeshes(mesh ? [{ objectId: this.selectedObjectId || mesh.shapeId, mesh }] : []) } private clearObjectMeshes() { for (const { mesh } of this.objectMeshes.values()) { mesh.geometry.dispose() if (mesh.material instanceof THREE.Material) mesh.material.dispose() this.scene?.remove(mesh) } this.objectMeshes.clear() } setMeshes(entries: ViewportMeshAsset[]) { if (!this.scene) return this.clearSubshapeHighlight() this.updateHoveredSubshape(null) this.clearObjectMeshes() const uniqueEntries = [...new Map(entries.filter((entry) => entry.objectId).map((entry) => [entry.objectId, entry])).values()] const verticalOffset = uniqueEntries.length > 0 ? -Math.min(...uniqueEntries.map((entry) => entry.mesh.bounds.min[1])) : 0 for (const { objectId, mesh: asset } of uniqueEntries) { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.BufferAttribute(asset.positions, 3)) geometry.setAttribute('normal', new THREE.BufferAttribute(asset.normals, 3)) geometry.setIndex(new THREE.BufferAttribute(asset.indices, 1)) if (!asset.normals.some((component) => component !== 0)) geometry.computeVertexNormals() geometry.computeBoundingBox() geometry.computeBoundingSphere() const material = new THREE.MeshStandardMaterial({ color: 0x579a9c, roughness: 0.62, metalness: 0.1 }) const objectMesh = new THREE.Mesh(geometry, material) objectMesh.name = objectId objectMesh.position.set(0, verticalOffset, 0) this.scene.add(objectMesh) this.objectMeshes.set(objectId, { asset, mesh: objectMesh }) } this.applySelectionMaterials() this.renderToolpath() if (uniqueEntries.length > 0) this.fitAll() if (uniqueEntries.length === 0) { if (this.renderer) { this.renderer.domElement.dataset.geometrySource = 'none' delete this.renderer.domElement.dataset.triangleCount this.renderer.domElement.dataset.objectCount = '0' this.renderer.domElement.dataset.topologySource = 'none' this.renderer.domElement.dataset.faceTopologySource = 'none' this.renderer.domElement.dataset.edgeTopologySource = 'none' this.renderer.domElement.dataset.vertexTopologySource = 'none' this.renderer.domElement.dataset.subshapeEdgeCount = '0' this.renderer.domElement.dataset.subshapeVertexCount = '0' } return } if (this.renderer) { this.renderer.domElement.dataset.geometrySource = 'bitbybit-occt' this.renderer.domElement.dataset.triangleCount = String(uniqueEntries.reduce((sum, entry) => sum + entry.mesh.indices.length / 3, 0)) this.renderer.domElement.dataset.objectCount = String(uniqueEntries.length) const edgeRefs = new Set(uniqueEntries.flatMap((entry) => (entry.mesh.subshapeEdges ?? []).map((edge) => edge.ref.persistentId))) const vertexRefs = new Set(uniqueEntries.flatMap((entry) => (entry.mesh.subshapeVertices ?? []).map((vertex) => vertex.ref.persistentId))) const analyticFaces = uniqueEntries.every((entry) => (entry.mesh.subshapeRanges ?? []).every((range) => range.ref.signature?.includes('surface='))) const analyticEdges = uniqueEntries.every((entry) => (entry.mesh.subshapeEdges ?? []).every((edge) => edge.ref.signature?.includes('curve='))) const analyticVertices = uniqueEntries.every((entry) => (entry.mesh.subshapeVertices ?? []).every((vertex) => vertex.ref.signature?.startsWith('vertex|degree='))) this.renderer.domElement.dataset.topologySource = analyticFaces && analyticEdges && analyticVertices ? 'occt-analytic' : 'mesh-fallback' this.renderer.domElement.dataset.faceTopologySource = analyticFaces ? 'occt-analytic' : 'mesh-fallback' this.renderer.domElement.dataset.edgeTopologySource = analyticEdges ? 'occt-analytic' : 'mesh-fallback' this.renderer.domElement.dataset.vertexTopologySource = analyticVertices ? 'occt-analytic' : 'mesh-fallback' this.renderer.domElement.dataset.subshapeEdgeCount = String(edgeRefs.size) this.renderer.domElement.dataset.subshapeVertexCount = String(vertexRefs.size) } } private clearToolpath() { if (!this.toolpath) return for (const child of this.toolpath.children) { if (child instanceof THREE.Line) { child.geometry.dispose() if (child.material instanceof THREE.Material) child.material.dispose() } } this.toolpath.clear() } private renderToolpath() { if (!this.scene) return if (!this.toolpath) { this.toolpath = new THREE.Group() this.toolpath.name = 'CAM Toolpath' this.scene.add(this.toolpath) } this.clearToolpath() const points = this.toolpathSource.flat() const targetMesh = this.objectMeshes.get(this.selectedObjectId)?.mesh ?? this.objectMeshes.values().next().value?.mesh if (points.length === 0 || !targetMesh) { if (this.renderer) { this.renderer.domElement.dataset.toolpathPoints = '0' this.renderer.domElement.dataset.toolpathOperations = '0' } return } const sourceMin = [Math.min(...points.map((entry) => entry[0])), Math.min(...points.map((entry) => entry[1])), Math.min(...points.map((entry) => entry[2]))] const sourceMax = [Math.max(...points.map((entry) => entry[0])), Math.max(...points.map((entry) => entry[1])), Math.max(...points.map((entry) => entry[2]))] const target = new THREE.Box3().setFromObject(targetMesh) const targetSize = target.getSize(new THREE.Vector3()) const map = (value: number, axis: number, min: number, size: number) => { const span = sourceMax[axis] - sourceMin[axis] return span <= 1e-9 ? min + size * 0.5 : min + (value - sourceMin[axis]) / span * size } const colors = [0xffd05a, 0x6ee7d8, 0xf79ac0, 0xa7d47b] this.toolpathSource.forEach((path, index) => { if (path.length < 2) return const positions = path.map(([x, y, z]) => new THREE.Vector3(map(x, 0, target.min.x, targetSize.x), map(z, 2, target.min.y, targetSize.y) + 0.015, map(y, 1, target.min.z, targetSize.z))) const geometry = new THREE.BufferGeometry().setFromPoints(positions) const material = new THREE.LineBasicMaterial({ color: colors[index % colors.length], depthTest: false, transparent: true, opacity: 0.96 }) const line = new THREE.Line(geometry, material) line.renderOrder = 10 this.toolpath?.add(line) }) if (this.renderer) { this.renderer.domElement.dataset.toolpathPoints = String(points.length) this.renderer.domElement.dataset.toolpathOperations = String(this.toolpathSource.length) } } setToolpath(paths: Array>) { this.toolpathSource = paths.map((path) => path.map((entry) => [...entry] as [number, number, number])) this.renderToolpath() } setInteractionHandlers(handlers: ViewportInteractionHandlers) { this.interactionHandlers = { ...handlers } } resize(width: number, height: number, devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2)) { if (!this.renderer || !this.camera) return this.camera.aspect = Math.max(width, 1) / Math.max(height, 1) this.camera.updateProjectionMatrix() this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) this.renderer.setSize(Math.max(width, 1), Math.max(height, 1), false) } setSelection(objectId: string) { this.setSelectedObjects(objectId ? [objectId] : []) } setSelectedObjects(objectIds: string[]) { const uniqueIds = [...new Set(objectIds)] this.selectedObjectId = uniqueIds[0] ?? '' this.selectedObjectIds = new Set(uniqueIds) this.applySelectionMaterials() } private applySelectionMaterials() { for (const [objectId, entry] of this.objectMeshes) { if (!(entry.mesh.material instanceof THREE.MeshStandardMaterial)) continue const selected = this.selectedObjectIds.has(objectId) entry.mesh.material.color.set(selected ? 0x5ed6d6 : 0x579a9c) entry.mesh.material.emissive.set(selected ? 0x123c3c : 0x000000) entry.mesh.material.emissiveIntensity = selected ? 1 : 0 } } flashSelection() { if (!this.objectMeshes.has(this.selectedObjectId)) return this.selectionFlashObjectId = this.selectedObjectId this.selectionFlashUntil = performance.now() + 1400 } private updateSelectionFlash() { if (this.selectionFlashUntil <= 0) return const selection = this.objectMeshes.get(this.selectionFlashObjectId)?.mesh if (!selection || !(selection.material instanceof THREE.MeshStandardMaterial)) { this.selectionFlashUntil = 0 this.selectionFlashObjectId = '' return } const material = selection.material const remaining = this.selectionFlashUntil - performance.now() if (remaining <= 0) { this.selectionFlashUntil = 0 this.selectionFlashObjectId = '' this.applySelectionMaterials() return } const phase = (1400 - remaining) / 1400 * Math.PI * 6 material.emissive.set(0xffb347) material.emissiveIntensity = 0.35 + (0.65 * (0.5 + 0.5 * Math.sin(phase))) } setView(orientation: 'axonometric' | 'front' | 'rear' | 'left' | 'right' | 'top' | 'bottom') { if (!this.camera) return const directions: Record = { axonometric: new THREE.Vector3(1, 1, 1), front: new THREE.Vector3(0, 0, 1), rear: new THREE.Vector3(0, 0, -1), left: new THREE.Vector3(-1, 0, 0), right: new THREE.Vector3(1, 0, 0), top: new THREE.Vector3(0, 1, 0), bottom: new THREE.Vector3(0, -1, 0), } const distance = Math.max(this.camera.position.distanceTo(this.viewTarget), 0.1) const direction = directions[orientation].normalize() this.camera.up.set(0, orientation === 'top' || orientation === 'bottom' ? 0 : 1, orientation === 'top' ? -1 : orientation === 'bottom' ? 1 : 0) this.camera.position.copy(this.viewTarget).addScaledVector(direction, distance) this.camera.lookAt(this.viewTarget) this.camera.updateProjectionMatrix() } fitAll() { if (!this.camera || this.objectMeshes.size === 0) return const bounds = new THREE.Box3() for (const { mesh } of this.objectMeshes.values()) bounds.expandByObject(mesh) if (bounds.isEmpty()) return const center = bounds.getCenter(new THREE.Vector3()) const size = bounds.getSize(new THREE.Vector3()) const radius = Math.max(size.x, size.y, size.z, 0.1) * 0.5 const distance = radius / Math.tan(THREE.MathUtils.degToRad(this.camera.fov * 0.5)) * 1.35 const direction = this.camera.position.clone().sub(this.viewTarget).normalize() if (direction.lengthSq() === 0) direction.set(1, 1, 1).normalize() this.viewTarget.copy(center) this.camera.position.copy(center).addScaledVector(direction, distance) this.camera.near = Math.max(distance / 1000, 0.01) this.camera.far = Math.max(distance * 100, 100) this.camera.lookAt(center) this.camera.updateProjectionMatrix() } zoomBy(factor: number) { if (!this.camera || !Number.isFinite(factor) || factor <= 0) return const offset = this.camera.position.clone().sub(this.viewTarget) const distance = THREE.MathUtils.clamp(offset.length() * factor, 0.1, 500) this.camera.position.copy(this.viewTarget).addScaledVector(offset.normalize(), distance) this.camera.lookAt(this.viewTarget) this.camera.updateProjectionMatrix() } dispose() { cancelAnimationFrame(this.frame) this.renderer?.domElement.removeEventListener('wheel', this.handleWheel) this.renderer?.domElement.removeEventListener('pointerdown', this.handlePointerDown) this.renderer?.domElement.removeEventListener('pointermove', this.handlePointerMove) this.renderer?.domElement.removeEventListener('pointerup', this.handlePointerUp) this.renderer?.domElement.removeEventListener('pointercancel', this.handlePointerUp) this.renderer?.domElement.removeEventListener('pointerleave', this.handlePointerLeave) this.renderer?.domElement.removeEventListener('click', this.handleClick) this.renderer?.domElement.removeEventListener('contextmenu', this.handleContextMenu) this.clearSubshapeHighlight() this.clearSelectionBox() this.clearObjectMeshes() this.clearToolpath() if (this.toolpath && this.scene) this.scene.remove(this.toolpath) this.renderer?.dispose() this.renderer?.domElement.remove() this.host = null this.renderer = null this.scene = null this.camera = null this.toolpath = null this.toolpathSource = [] this.interactionHandlers = {} this.hoveredSubshapeId = '' this.selectedObjectId = '' this.selectedObjectIds.clear() this.selectionFlashObjectId = '' this.selectionFlashUntil = 0 this.viewTarget.set(0, 0.8, 0) this.drag = null this.boxDrag = null this.suppressNextClick = false } getBackend() { return 'webgl2' as const } }