import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid; let currentMesh = null; let axesGroup = null; let wireframeLines = null; // LineSegments overlay, or null when hidden let wireframeVisible = false; let exclusionMesh = null; // flat orange overlay for user-excluded faces let hoverMesh = null; // semi-transparent yellow bucket-fill preview // Build a labelled coordinate axes indicator scaled to `size`. // X = red, Y = green, Z = blue (up). function buildAxesIndicator(size) { const group = new THREE.Group(); const addAxis = (dir, hex, label) => { const r = size; // Shaft const pts = [new THREE.Vector3(0, 0, 0), dir.clone().multiplyScalar(r * 0.78)]; const line = new THREE.Line( new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: hex, depthTest: false, transparent: true, opacity: 0.9 }), ); line.renderOrder = 999; group.add(line); // Cone arrowhead const cone = new THREE.Mesh( new THREE.ConeGeometry(r * 0.07, r * 0.22, 8), new THREE.MeshBasicMaterial({ color: hex, depthTest: false }), ); cone.renderOrder = 999; cone.position.copy(dir.clone().multiplyScalar(r * 0.89)); cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir); group.add(cone); // Text sprite label const c = document.createElement('canvas'); c.width = c.height = 64; const ctx = c.getContext('2d'); ctx.fillStyle = `#${hex.toString(16).padStart(6, '0')}`; ctx.font = 'bold 48px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, 32, 32); const sprite = new THREE.Sprite( new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), depthTest: false }), ); sprite.renderOrder = 999; sprite.position.copy(dir.clone().multiplyScalar(r * 1.18)); sprite.scale.set(r * 0.32, r * 0.32, 1); group.add(sprite); }; addAxis(new THREE.Vector3(1, 0, 0), 0xff3333, 'X'); addAxis(new THREE.Vector3(0, 1, 0), 0x33dd55, 'Y'); addAxis(new THREE.Vector3(0, 0, 1), 0x4488ff, 'Z'); return group; } export function initViewer(canvas) { // Renderer renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.1; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Scene scene = new THREE.Scene(); scene.background = new THREE.Color(0x111114); // Grid helper — in XY plane (Z-up) grid = new THREE.GridHelper(200, 40, 0x222228, 0x1e1e24); grid.rotation.x = Math.PI / 2; // rotate to XY plane for Z-up grid.position.z = 0; scene.add(grid); // Camera — orthographic (parallel projection), Z-up camera = new THREE.OrthographicCamera(-150, 150, 150, -150, -10000, 10000); camera.up.set(0, 0, 1); camera.position.set(120, -200, 100); camera.lookAt(0, 0, 0); // Lights ambientLight = new THREE.AmbientLight(0xffffff, 0.4); scene.add(ambientLight); dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2); dirLight1.position.set(80, 120, 60); dirLight1.castShadow = true; dirLight1.shadow.mapSize.set(1024, 1024); scene.add(dirLight1); dirLight2 = new THREE.DirectionalLight(0x8899ff, 0.4); dirLight2.position.set(-60, -20, -80); scene.add(dirLight2); // Group to hold the mesh meshGroup = new THREE.Group(); scene.add(meshGroup); // Controls controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.screenSpacePanning = true; // Resize observer const resizeObserver = new ResizeObserver(() => onResize()); resizeObserver.observe(canvas.parentElement); onResize(); // Render loop (function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); })(); } function onResize() { const el = renderer.domElement.parentElement; const w = el.clientWidth; const h = el.clientHeight; renderer.setSize(w, h, false); // Orthographic: keep the frustum half-height, update left/right for new aspect const aspect = w / h; const halfH = camera.top; camera.left = -halfH * aspect; camera.right = halfH * aspect; camera.updateProjectionMatrix(); // LineMaterial needs the actual pixel resolution to compute linewidth correctly if (wireframeLines) { wireframeLines.material.resolution.set( w * renderer.getPixelRatio(), h * renderer.getPixelRatio(), ); } } /** * Replace the mesh in the scene with new geometry. * @param {THREE.BufferGeometry} geometry * @param {THREE.Material} [material] – if omitted, a default material is used */ export function loadGeometry(geometry, material) { // Clear previous mesh while (meshGroup.children.length) { const old = meshGroup.children[0]; old.geometry.dispose(); if (old.material && old.material.dispose) old.material.dispose(); meshGroup.remove(old); } const mat = material || new THREE.MeshStandardMaterial({ color: 0xaaaacc, roughness: 0.6, metalness: 0.1, side: THREE.DoubleSide, }); if (!geometry.attributes.normal) geometry.computeVertexNormals(); currentMesh = new THREE.Mesh(geometry, mat); currentMesh.castShadow = true; currentMesh.receiveShadow = true; meshGroup.add(currentMesh); // Rebuild wireframe overlay to match the new geometry // (old overlay is already gone because meshGroup was cleared above) wireframeLines = null; if (wireframeVisible) _buildWireframe(geometry); // Position grid at mesh bottom (Z-up: move grid along Z) geometry.computeBoundingBox(); const box = geometry.boundingBox; const groundZ = box.min.z - 0.01; grid.position.z = groundZ; // Fit camera const sphere = new THREE.Sphere(); geometry.computeBoundingSphere(); sphere.copy(geometry.boundingSphere); fitCamera(sphere); // Place coordinate axes away from the part corner if (axesGroup) scene.remove(axesGroup); const axisSize = sphere.radius * 0.30; axesGroup = buildAxesIndicator(axisSize); // Offset from the bounding box corner by ~1 axis-length so it doesn't overlap the mesh const axisPad = axisSize * 1.8; axesGroup.position.set(box.min.x - axisPad, box.min.y - axisPad, groundZ); scene.add(axesGroup); } /** * Update only the material on the current mesh. * @param {THREE.Material} material */ export function setMeshMaterial(material) { if (!currentMesh) return; if (currentMesh.material && currentMesh.material.dispose) { currentMesh.material.dispose(); } currentMesh.material = material || new THREE.MeshStandardMaterial({ color: 0xaaaacc, roughness: 0.6, metalness: 0.1, side: THREE.DoubleSide, }); } /** * Get the grid object so callers can adjust position. */ export function getGrid() { return grid; } function fitCamera(sphere) { const sz = renderer.getSize(new THREE.Vector2()); const aspect = sz.x / sz.y; const halfH = sphere.radius * 1.4; camera.left = -halfH * aspect; camera.right = halfH * aspect; camera.top = halfH; camera.bottom = -halfH; camera.near = -sphere.radius * 200; camera.far = sphere.radius * 200; camera.zoom = 1; camera.updateProjectionMatrix(); // Isometric-ish view from front-right-above in Z-up space const dir = new THREE.Vector3(0.6, -1.2, 0.8).normalize(); controls.target.copy(sphere.center); camera.position.copy(sphere.center).addScaledVector(dir, halfH * 4); camera.up.set(0, 0, 1); camera.lookAt(sphere.center); controls.update(); } export function getRenderer() { return renderer; } export function getCamera() { return camera; } export function getScene() { return scene; } export function getControls() { return controls; } export function getCurrentMesh() { return currentMesh; } /** * Replace (or clear) the flat orange exclusion overlay mesh. * overlayGeo must be a non-indexed BufferGeometry with a 'position' attribute, * or null / an empty geometry to clear the overlay. * The mesh lives directly in the scene so loadGeometry() (which clears * meshGroup) never accidentally removes it. * * @param {THREE.BufferGeometry|null} overlayGeo */ export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0) { if (exclusionMesh) { scene.remove(exclusionMesh); exclusionMesh.geometry.dispose(); exclusionMesh.material.dispose(); exclusionMesh = null; } if (!overlayGeo || overlayGeo.attributes.position.count === 0) return; exclusionMesh = new THREE.Mesh( overlayGeo, new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide, transparent: opacity < 1.0, opacity, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1, }), ); exclusionMesh.renderOrder = 1; scene.add(exclusionMesh); } /** * Replace (or clear) the yellow hover-preview overlay shown before a bucket-fill * click is confirmed. Pass null or an empty geometry to clear it. * * @param {THREE.BufferGeometry|null} overlayGeo */ export function setHoverPreview(overlayGeo) { if (hoverMesh) { scene.remove(hoverMesh); hoverMesh.geometry.dispose(); hoverMesh.material.dispose(); hoverMesh = null; } if (!overlayGeo || overlayGeo.attributes.position.count === 0) return; hoverMesh = new THREE.Mesh( overlayGeo, new THREE.MeshBasicMaterial({ color: 0xffee00, side: THREE.DoubleSide, transparent: true, opacity: 0.45, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2, }), ); hoverMesh.renderOrder = 2; scene.add(hoverMesh); } /** * Show or hide the triangle-edge wireframe overlay. * @param {boolean} enabled */ export function setWireframe(enabled) { wireframeVisible = enabled; if (enabled) { if (!wireframeLines && currentMesh) _buildWireframe(currentMesh.geometry); if (wireframeLines) wireframeLines.visible = true; } else { if (wireframeLines) wireframeLines.visible = false; } } function _buildWireframe(geometry) { // Dispose any stale overlay if (wireframeLines) { if (wireframeLines.parent) wireframeLines.parent.remove(wireframeLines); wireframeLines.geometry.dispose(); wireframeLines.material.dispose(); wireframeLines = null; } // WireframeGeometry gives every triangle edge; EdgesGeometry skips edges // between near-coplanar faces so large flat STL regions lose their grid lines. const wireGeo = new THREE.WireframeGeometry(geometry); const lsGeo = new LineSegmentsGeometry(); lsGeo.setPositions(wireGeo.attributes.position.array); wireGeo.dispose(); const lsMat = new LineMaterial({ color: 0xffffff, opacity: 0.65, transparent: true, linewidth: 1.2, depthTest: true, // Pull lines slightly in front so they beat the base mesh AND the // exclusion overlay (polygonOffsetFactor -1,-1) in the depth test. polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2, resolution: new THREE.Vector2( renderer.domElement.width * renderer.getPixelRatio(), renderer.domElement.height * renderer.getPixelRatio(), ), }); wireframeLines = new LineSegments2(lsGeo, lsMat); wireframeLines.renderOrder = 3; // draw after base mesh (0), overlays (1-2) // Add to meshGroup so it's automatically removed when a new model is loaded meshGroup.add(wireframeLines); }