Files
Web_FreeCAD_Bitbybit/src/facade/threeViewport.ts

628 lines
32 KiB
TypeScript

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<Array<[number, number, number]>> = []
private readonly objectMeshes = new Map<string, { asset: MeshAsset; mesh: THREE.Mesh }>()
private interactionHandlers: ViewportInteractionHandlers = {}
private hoveredSubshapeId = ''
private selectedObjectId = ''
private selectedObjectIds = new Set<string>()
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<Array<[number, number, number]>>) {
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<typeof orientation, THREE.Vector3> = {
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 }
}