628 lines
32 KiB
TypeScript
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 }
|
|
}
|