import { THREE, OrbitControls, mergeGeometries, Brush, Evaluator, SUBTRACTION, INTERSECTION, } from './vendor/engine.js'; const Z = new THREE.Vector3(0, 0, 1); function disposeObject(object) { object.traverse((child) => { if (child.geometry) child.geometry.dispose(); if (child.material) { const materials = Array.isArray(child.material) ? child.material : [child.material]; for (const material of materials) material.dispose(); } }); } function cleanPoints(raw) { const points = []; for (const p of raw || []) { const v = new THREE.Vector2(Number(p[0]) || 0, Number(p[1]) || 0); if (points.length === 0 || points[points.length - 1].distanceToSquared(v) > 1e-12) { points.push(v); } } if (points.length > 1 && points[0].distanceToSquared(points[points.length - 1]) < 1e-12) { points.pop(); } let area = 0; for (let i = 0; i < points.length; i += 1) { const a = points[i]; const b = points[(i + 1) % points.length]; area += a.x * b.y - b.x * a.y; } if (area < 0) points.reverse(); return points; } function cleanGeometry(geometry) { let g = geometry.index ? geometry.toNonIndexed() : geometry; for (const name of Object.keys(g.attributes)) { if (name !== 'position' && name !== 'normal') g.deleteAttribute(name); } if (!g.attributes.normal) g.computeVertexNormals(); return g; } function orientAlong(geometry, dirArray, pointArray) { const dir = new THREE.Vector3().fromArray(dirArray || [0, 0, 1]); if (dir.lengthSq() < 1e-12) dir.set(0, 0, 1); dir.normalize(); if (Math.abs(dir.z - 1) > 1e-8) { const q = new THREE.Quaternion().setFromUnitVectors(Z, dir); geometry.applyQuaternion(q); } const p = pointArray || [0, 0, 0]; geometry.translate(p[0] || 0, p[1] || 0, p[2] || 0); } function primitive(node) { let geometry; const segments = 48; switch (node.op) { case 'box': { const l = Math.max(1e-4, Number(node.l) || 0); const w = Math.max(1e-4, Number(node.w) || 0); const h = Math.max(1e-4, Number(node.h) || 0); geometry = new THREE.BoxGeometry(l, w, h, 1, 1, 1); geometry.translate(l / 2, w / 2, h / 2); orientAlong(geometry, node.dir, node.pnt); return geometry; } case 'cylinder': { const r = Math.max(1e-4, Number(node.r) || 0); const h = Math.max(1e-4, Number(node.h) || 0); const angle = Math.min(360, Math.max(1, Number(node.angle) || 360)); geometry = new THREE.CylinderGeometry( r, r, h, segments, 1, false, 0, (angle / 360) * Math.PI * 2, ); geometry.rotateX(Math.PI / 2); geometry.translate(0, 0, h / 2); orientAlong(geometry, node.dir, node.pnt); return geometry; } case 'cone': { const r1 = Math.max(0, Number(node.r1) || 0); const r2 = Math.max(0, Number(node.r2) || 0); const h = Math.max(1e-4, Number(node.h) || 0); const angle = Math.min(360, Math.max(1, Number(node.angle) || 360)); geometry = new THREE.CylinderGeometry( r2, r1, h, segments, 1, false, 0, (angle / 360) * Math.PI * 2, ); geometry.rotateX(Math.PI / 2); geometry.translate(0, 0, h / 2); orientAlong(geometry, node.dir, node.pnt); return geometry; } case 'sphere': { const r = Math.max(1e-4, Number(node.r) || 0); geometry = new THREE.SphereGeometry(r, segments, 32); const p = node.pnt || [0, 0, 0]; geometry.translate(p[0] || 0, p[1] || 0, p[2] || 0); return geometry; } case 'torus': { const r1 = Math.max(1e-4, Number(node.r1) || 0); const r2 = Math.max(1e-4, Number(node.r2) || 0); geometry = new THREE.TorusGeometry(r1, Math.min(r2, r1 * 0.95), 20, 56); orientAlong(geometry, node.dir, node.pnt); return geometry; } case 'extrude': { const points = cleanPoints(node.profile); if (points.length < 3) return null; const vec = new THREE.Vector3().fromArray(node.vector || [0, 0, 1]); const depth = Math.max(1e-4, vec.length()); const shape = new THREE.Shape(points); geometry = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false, steps: 1, curveSegments: 24, }); if (depth > 1e-9) { const q = new THREE.Quaternion().setFromUnitVectors(Z, vec.clone().normalize()); geometry.applyQuaternion(q); } return geometry; } case 'face': { const points = cleanPoints(node.profile); if (points.length < 3) return null; geometry = new THREE.ShapeGeometry(new THREE.Shape(points), 24); return geometry; } case 'wire': { const points = cleanPoints(node.profile); if (points.length < 2) return null; const curve = new THREE.CatmullRomCurve3( points.map((p) => new THREE.Vector3(p.x, p.y, 0)), Boolean(node.closed), 'catmullrom', 0.08, ); geometry = new THREE.TubeGeometry( curve, Math.max(12, points.length * 3), 0.16, 8, Boolean(node.closed), ); return geometry; } default: return null; } } function makeBytes(geometry) { return cleanGeometry(geometry); } function unionGeometries(a, b) { const merged = mergeGeometries([a, b], false); return merged || a; } function csgGeometry(a, b, operation) { try { const evaluator = new Evaluator(); evaluator.attributes = ['position', 'normal']; evaluator.useGroups = false; const brushA = new Brush(a); const brushB = new Brush(b); brushA.updateMatrixWorld(); brushB.updateMatrixWorld(); const result = evaluator.evaluate(brushA, brushB, operation); const out = result.geometry; if (!out || !out.attributes.position || out.attributes.position.count === 0) return a; return cleanGeometry(out.clone()); } catch (error) { console.warn('CSG failed', error); return a; } } function nodeHasBoolean(node) { if (!node) return false; if (node.op === 'boolean') return true; if (node.op === 'transform') return nodeHasBoolean(node.child); if (node.op === 'group') return (node.children || []).some(nodeHasBoolean); return false; } function buildNode(node) { if (!node) return null; switch (node.op) { case 'boolean': { const a = buildNode(node.a); const b = buildNode(node.b); if (!a) return b; if (!b) return a; if (node.kind === 'cut') return csgGeometry(a, b, SUBTRACTION); if (node.kind === 'common') return csgGeometry(a, b, INTERSECTION); return unionGeometries(a, b); } case 'group': { const parts = (node.children || []).map(buildNode).filter(Boolean); if (parts.length === 0) return null; if (parts.length === 1) return parts[0]; return mergeGeometries(parts, false) || parts[0]; } case 'transform': { const child = buildNode(node.child); if (!child) return null; const matrix = new THREE.Matrix4().fromArray(node.m || []); child.applyMatrix4(matrix); return child; } default: return primitive(node); } } function makeMaterial(item) { const [r, g, b] = item.color || [0.8, 0.8, 0.8]; const color = new THREE.Color().setRGB(r, g, b, THREE.SRGBColorSpace); const luminance = r * 0.2126 + g * 0.7152 + b * 0.0722; const dark = luminance < 0.2; const opacity = typeof item.opacity === 'number' ? item.opacity : 1; return new THREE.MeshStandardMaterial({ color, roughness: dark ? 0.36 : 0.82, metalness: dark ? 0.12 : 0.02, transparent: opacity < 0.999, opacity, depthWrite: opacity > 0.999, side: THREE.FrontSide, }); } export function createViewer(canvas, options = {}) { const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance', }); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; renderer.setClearColor(0x000000, 0); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 10000); camera.up.set(0, 0, 1); let viewDirection = new THREE.Vector3().fromArray(options.direction || [0.86, -1.35, 0.98]).normalize(); const showEdges = options.edges !== false; const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; controls.dampingFactor = 0.075; controls.enablePan = false; controls.rotateSpeed = 0.6; controls.zoomSpeed = 0.8; controls.autoRotate = options.autoRotate !== false; controls.autoRotateSpeed = 0.75; scene.add(new THREE.HemisphereLight(0xfff8ec, 0xb2a690, 1.35)); const key = new THREE.DirectionalLight(0xfff2df, 2.7); key.castShadow = true; key.shadow.mapSize.set(2048, 2048); key.shadow.bias = -0.0004; key.shadow.radius = 3; scene.add(key); scene.add(key.target); const fill = new THREE.DirectionalLight(0xdfe9ff, 0.85); fill.position.set(-30, 45, 30); scene.add(fill); const rim = new THREE.DirectionalLight(0xffffff, 0.7); rim.position.set(20, 50, -35); scene.add(rim); const root = new THREE.Group(); scene.add(root); const floor = new THREE.Mesh( new THREE.PlaneGeometry(400, 400), new THREE.ShadowMaterial({ color: 0x2a241c, opacity: 0.16 }), ); floor.rotation.x = Math.PI / 2; floor.receiveShadow = true; floor.renderOrder = -2; scene.add(floor); let grid = null; let size = 10; let center = new THREE.Vector3(); let radius = 5; let fitted = false; let autoRotateEnabled = options.autoRotate !== false; let resumeAt = 0; function resize() { const parent = canvas.parentElement; if (!parent) return; const width = parent.clientWidth; const height = parent.clientHeight; if (width < 2 || height < 2) return; renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); if (fitted) fit(false); } function fit(resetDirection) { if (!fitted) return; const aspect = camera.aspect || 1; const dir = viewDirection.clone(); if (!resetDirection) { const current = camera.position.clone().sub(controls.target).normalize(); if (current.lengthSq() > 0.5) dir.copy(current); } const forward = dir.clone().negate(); let up = camera.up.clone(); let right = new THREE.Vector3().crossVectors(forward, up); if (right.lengthSq() < 1e-8) { up.set(0, 1, 0); right = new THREE.Vector3().crossVectors(forward, up); } right.normalize(); const camUp = new THREE.Vector3().crossVectors(right, forward).normalize(); const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)); const tanH = tanV * aspect; const max = new THREE.Vector3(); const box = new THREE.Box3().setFromObject(root); if (box.isEmpty()) { box.setFromCenterAndSize(center, new THREE.Vector3(radius * 2, radius * 2, radius * 2)); } box.getSize(max); const half = max.multiplyScalar(0.5); const corners = []; for (const x of [-1, 1]) { for (const y of [-1, 1]) { for (const z of [-1, 1]) { corners.push( new THREE.Vector3(x * half.x, y * half.y, z * half.z), ); } } } let distance = 0; for (const corner of corners) { const x = corner.dot(right); const y = corner.dot(camUp); const z = corner.dot(forward); distance = Math.max(distance, Math.abs(x) / tanH - z, Math.abs(y) / tanV - z); } distance = Math.max(distance * 1.08, radius * 0.5); camera.position.copy(center).addScaledVector(dir, distance); camera.near = Math.max(0.01, distance - radius * 4); camera.far = distance + radius * 12; camera.updateProjectionMatrix(); controls.target.copy(center); controls.minDistance = radius * 0.25; controls.maxDistance = radius * 8; controls.update(); key.position.copy(center).add(new THREE.Vector3(0.7, -0.9, 1.5).normalize().multiplyScalar(radius * 3.6)); key.target.position.copy(center); key.target.updateMatrixWorld(); const extent = radius * 1.55; const shadow = key.shadow.camera; shadow.left = -extent; shadow.right = extent; shadow.top = extent; shadow.bottom = -extent; shadow.near = 0.1; shadow.far = radius * 12; shadow.updateProjectionMatrix(); } function setScene(sceneData, direction) { if (direction && direction.length === 3) { viewDirection = new THREE.Vector3().fromArray(direction).normalize(); } for (const child of [...root.children]) { root.remove(child); disposeObject(child); } if (grid) { scene.remove(grid); grid.geometry.dispose(); grid.material.dispose(); grid = null; } const objects = (sceneData && sceneData.objects) || []; let built = 0; let skipped = 0; for (const item of objects) { const geometry = buildNode(item.node); if (!geometry) { skipped += 1; continue; } const clean = makeBytes(geometry); const material = makeMaterial(item); const mesh = new THREE.Mesh(clean, material); mesh.castShadow = true; mesh.receiveShadow = true; mesh.name = item.name || 'Part'; if (showEdges && !nodeHasBoolean(item.node) && clean.attributes.position && clean.attributes.position.count < 180000) { const edges = new THREE.EdgesGeometry(clean, 48); const line = new THREE.LineSegments( edges, new THREE.LineBasicMaterial({ color: 0x2f2a22, transparent: true, opacity: 0.1, depthWrite: false, }), ); line.renderOrder = 3; mesh.add(line); } root.add(mesh); built += 1; } const box = new THREE.Box3().setFromObject(root); if (box.isEmpty()) { size = 10; center.set(0, 0, 0); radius = 5; fitted = false; floor.visible = false; controls.target.set(0, 0, 0); camera.position.set(18, -28, 22); camera.near = 0.1; camera.far = 1000; camera.updateProjectionMatrix(); controls.update(); return { objects: 0, skipped, visible: [] }; } box.getCenter(center); const dims = box.getSize(new THREE.Vector3()); radius = Math.max(1, dims.length() * 0.5); size = Math.max(20, Math.max(dims.x, dims.y, dims.z) * 3.2); fitted = true; floor.visible = true; floor.position.set(center.x, center.y, box.min.z - 0.005); floor.updateMatrixWorld(); grid = new THREE.GridHelper(size, 18, 0xb9ae9b, 0xd0c7b7); grid.rotation.x = Math.PI / 2; grid.position.set(center.x, center.y, box.min.z + 0.003); grid.material.transparent = true; grid.material.opacity = 0.24; grid.material.depthWrite = false; scene.add(grid); fit(true); return { objects: built, skipped, visible: root.children.map((o) => o.name), bounds: { min: box.min.toArray(), max: box.max.toArray() }, }; } function resetView() { if (!fitted) return; const box = new THREE.Box3().setFromObject(root); if (box.isEmpty()) return; box.getCenter(center); fit(true); resumeAt = performance.now() + 3500; } function setAutoRotate(enabled) { autoRotateEnabled = Boolean(enabled); controls.autoRotate = autoRotateEnabled; resumeAt = performance.now() + 3500; } function onPointerDown() { controls.autoRotate = false; resumeAt = performance.now() + 4500; } function onPointerUp() { if (autoRotateEnabled) { resumeAt = performance.now() + 900; } } canvas.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointerup', onPointerUp); const observer = new ResizeObserver(resize); if (canvas.parentElement) observer.observe(canvas.parentElement); resize(); renderer.setAnimationLoop(() => { if (autoRotateEnabled && resumeAt && performance.now() > resumeAt) { controls.autoRotate = true; resumeAt = 0; } controls.update(); renderer.render(scene, camera); }); return { setScene, resetView, setAutoRotate, resize, isAutoRotate: () => autoRotateEnabled, dispose() { renderer.setAnimationLoop(null); observer.disconnect(); window.removeEventListener('pointerup', onPointerUp); for (const child of [...root.children]) { root.remove(child); disposeObject(child); } controls.dispose(); renderer.dispose(); }, }; }