Files
archived-stlTexturizer/js/viewer.js
T
CNCKitchen 1d3e756245 feat: add surface exclusions panel and functionality
- Introduced a new section in the UI for surface exclusions, allowing users to exclude triangles from displacement using brush and bucket fill tools.
- Implemented brush type switching (single and radius) and radius control for the brush tool.
- Added functionality for bucket fill with a threshold angle to control the fill area.
- Integrated exclusion weights into the displacement algorithm to ensure excluded faces are handled correctly during subdivision.
- Created adjacency and centroid calculations for triangles to support the bucket fill operation.
- Developed overlay geometries for visual feedback on excluded faces and hover previews.
- Enhanced the CSS for the new exclusion tools and their layout in the UI.
2026-03-17 14:35:45 +01:00

364 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
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.MeshBasicMaterial({
color: 0xff6600,
side: THREE.DoubleSide,
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;
}
// EdgesGeometry gives one segment per unique triangle edge
const edgesGeo = new THREE.EdgesGeometry(geometry, 1);
// Convert to LineSegmentsGeometry (required by LineMaterial / LineSegments2)
const lsGeo = new LineSegmentsGeometry().fromEdgesGeometry(edgesGeo);
edgesGeo.dispose();
const lsMat = new LineMaterial({
color: 0xffffff,
opacity: 0.75,
transparent: true,
linewidth: 1.5, // pixels — works on all desktop GPUs
depthTest: true,
resolution: new THREE.Vector2(
renderer.domElement.width * renderer.getPixelRatio(),
renderer.domElement.height * renderer.getPixelRatio(),
),
});
wireframeLines = new LineSegments2(lsGeo, lsMat);
wireframeLines.renderOrder = 1;
// Add to meshGroup so it's automatically removed when a new model is loaded
meshGroup.add(wireframeLines);
}