mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
516 lines
17 KiB
JavaScript
516 lines
17 KiB
JavaScript
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 dimensionGroup = 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, transparent: true, opacity: 0.9 }),
|
||
);
|
||
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 }),
|
||
);
|
||
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) }),
|
||
);
|
||
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;
|
||
}
|
||
|
||
// Create a canvas-texture sprite label for a dimension annotation.
|
||
// Flat ground-plane label — no billboard, no background, lies directly on the bed.
|
||
function buildDimensionLabel(text, hex, worldW, worldH) {
|
||
const c = document.createElement('canvas');
|
||
c.width = 256;
|
||
c.height = 64;
|
||
const ctx = c.getContext('2d');
|
||
ctx.clearRect(0, 0, 256, 64);
|
||
ctx.fillStyle = `#${hex.toString(16).padStart(6, '0')}`;
|
||
ctx.font = 'bold 36px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(text, 128, 32);
|
||
const mesh = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(worldW, worldH),
|
||
new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(c), transparent: true, side: THREE.DoubleSide }),
|
||
);
|
||
return mesh;
|
||
}
|
||
|
||
// Build X/Y dimension-line annotations lying flat on the ground plane.
|
||
function buildDimensions(box, groundZ, scale) {
|
||
const group = new THREE.Group();
|
||
const fmt = v => v.toFixed(2);
|
||
const pad = scale * 0.18;
|
||
const tick = scale * 0.08;
|
||
const lblW = scale * 0.50;
|
||
const lblH = scale * 0.12;
|
||
const zOff = 0.02; // tiny lift to avoid z-fighting with the grid
|
||
|
||
const addLine = (pts, hex) => {
|
||
const line = new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints(pts),
|
||
new THREE.LineBasicMaterial({ color: hex, transparent: true, opacity: 0.75 }),
|
||
);
|
||
group.add(line);
|
||
};
|
||
|
||
const addTick = (centre, dir, hex) => {
|
||
addLine([
|
||
centre.clone().addScaledVector(dir, -tick * 0.5),
|
||
centre.clone().addScaledVector(dir, tick * 0.5),
|
||
], hex);
|
||
};
|
||
|
||
// X dimension — line along the front edge of the model
|
||
{
|
||
const hex = 0xff3333;
|
||
const y = box.min.y - pad;
|
||
addLine([new THREE.Vector3(box.min.x, y, groundZ), new THREE.Vector3(box.max.x, y, groundZ)], hex);
|
||
addTick(new THREE.Vector3(box.min.x, y, groundZ), new THREE.Vector3(0, 1, 0), hex);
|
||
addTick(new THREE.Vector3(box.max.x, y, groundZ), new THREE.Vector3(0, 1, 0), hex);
|
||
const lbl = buildDimensionLabel(`X: ${fmt(box.max.x - box.min.x)}`, hex, lblW, lblH);
|
||
lbl.position.set((box.min.x + box.max.x) / 2, y - lblH * 0.7, groundZ + zOff);
|
||
group.add(lbl);
|
||
}
|
||
|
||
// Y dimension — line along the right edge of the model
|
||
{
|
||
const hex = 0x33dd55;
|
||
const x = box.max.x + pad;
|
||
addLine([new THREE.Vector3(x, box.min.y, groundZ), new THREE.Vector3(x, box.max.y, groundZ)], hex);
|
||
addTick(new THREE.Vector3(x, box.min.y, groundZ), new THREE.Vector3(1, 0, 0), hex);
|
||
addTick(new THREE.Vector3(x, box.max.y, groundZ), new THREE.Vector3(1, 0, 0), hex);
|
||
const lbl = buildDimensionLabel(`Y: ${fmt(box.max.y - box.min.y)}`, hex, lblW, lblH);
|
||
lbl.position.set(x + lblH * 0.7, (box.min.y + box.max.y) / 2, groundZ + zOff);
|
||
lbl.rotation.z = Math.PI / 2;
|
||
group.add(lbl);
|
||
}
|
||
|
||
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;
|
||
controls.enableZoom = false; // we handle zoom ourselves for cursor-centric behaviour
|
||
|
||
// Cursor-centric zoom: zoom toward the mouse pointer instead of screen centre
|
||
renderer.domElement.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||
const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||
|
||
// World position under cursor before zoom
|
||
const before = new THREE.Vector3(ndcX, ndcY, 0).unproject(camera);
|
||
|
||
// Apply zoom
|
||
const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1;
|
||
camera.zoom = Math.max(0.05, Math.min(200, camera.zoom * factor));
|
||
camera.updateProjectionMatrix();
|
||
|
||
// World position under cursor after zoom
|
||
const after = new THREE.Vector3(ndcX, ndcY, 0).unproject(camera);
|
||
|
||
// Shift camera + target so the world point stays under the cursor
|
||
const delta = before.clone().sub(after);
|
||
camera.position.add(delta);
|
||
controls.target.add(delta);
|
||
controls.update();
|
||
}, { passive: false });
|
||
|
||
// 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);
|
||
|
||
// Bounding-box dimension annotations on the ground plane
|
||
if (dimensionGroup) scene.remove(dimensionGroup);
|
||
dimensionGroup = buildDimensions(box, groundZ, sphere.radius);
|
||
scene.add(dimensionGroup);
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Swap only the geometry on the current mesh, keeping material and camera.
|
||
* Rebuilds wireframe if visible. Does NOT reset camera or grid.
|
||
* The caller is responsible for disposing old geometry if needed.
|
||
* @param {THREE.BufferGeometry} geometry
|
||
*/
|
||
export function setMeshGeometry(geometry) {
|
||
if (!currentMesh) return;
|
||
if (!geometry.attributes.normal) geometry.computeVertexNormals();
|
||
currentMesh.geometry = geometry;
|
||
// Rebuild wireframe overlay to match the new geometry
|
||
if (wireframeLines) {
|
||
meshGroup.remove(wireframeLines);
|
||
wireframeLines.geometry.dispose();
|
||
wireframeLines.material.dispose();
|
||
wireframeLines = null;
|
||
}
|
||
if (wireframeVisible) _buildWireframe(geometry);
|
||
}
|
||
|
||
/**
|
||
* 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; }
|
||
|
||
export function setSceneBackground(hexColor) {
|
||
if (scene) scene.background = new THREE.Color(hexColor);
|
||
}
|
||
|
||
export function setViewerTheme(isLight) {
|
||
if (!scene) return;
|
||
scene.background = new THREE.Color(isLight ? 0xf0f0f5 : 0x111114);
|
||
const savedZ = grid ? grid.position.z : 0;
|
||
if (grid) {
|
||
scene.remove(grid);
|
||
grid.geometry.dispose();
|
||
grid.material.dispose();
|
||
}
|
||
grid = new THREE.GridHelper(
|
||
200, 40,
|
||
isLight ? 0xb0b0c8 : 0x222228,
|
||
isLight ? 0xd0d0e0 : 0x1e1e24
|
||
);
|
||
grid.rotation.x = Math.PI / 2;
|
||
grid.position.z = savedZ;
|
||
scene.add(grid);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|