Enhance displacement calculation and UV mapping; add gizmo visualization in viewer

This commit is contained in:
CNCKitchen
2026-03-16 21:09:12 +01:00
parent 92e7f487ce
commit 59b689c9ef
5 changed files with 114 additions and 15 deletions
+33 -9
View File
@@ -26,8 +26,15 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const newPos = new Float32Array(count * 3);
const newNrm = new Float32Array(count * 3);
const tmpPos = new THREE.Vector3();
const tmpNrm = new THREE.Vector3();
const tmpPos = new THREE.Vector3();
const tmpNrm = new THREE.Vector3();
// Reusable vectors for per-face normal computation
const vA = new THREE.Vector3();
const vB = new THREE.Vector3();
const vC = new THREE.Vector3();
const edge1 = new THREE.Vector3();
const edge2 = new THREE.Vector3();
const faceNrm = new THREE.Vector3();
const REPORT_EVERY = 5000;
@@ -35,7 +42,24 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
tmpPos.fromBufferAttribute(posAttr, i);
tmpNrm.fromBufferAttribute(nrmAttr, i);
const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settings, bounds);
// Compute a stable face normal from the triangle's own vertex positions.
// The subdivider deduplicates vertices by position only, so shared corner
// vertices pick up whichever face's normal happened to be stored first.
// For hard-edged meshes (e.g. a cube) this corrupts the stored normals at
// edges/corners. Recomputing from the triangle geometry is always correct
// for the flat-shaded STL source data and gives the right normal for both
// displacement direction and UV projection.
const base = Math.floor(i / 3) * 3;
vA.fromBufferAttribute(posAttr, base);
vB.fromBufferAttribute(posAttr, base + 1);
vC.fromBufferAttribute(posAttr, base + 2);
edge1.subVectors(vB, vA);
edge2.subVectors(vC, vA);
faceNrm.crossVectors(edge1, edge2);
// Fall back to the stored vertex normal for degenerate triangles
const useNrm = faceNrm.lengthSq() > 1e-10 ? faceNrm.normalize() : tmpNrm;
const uvResult = computeUV(tmpPos, useNrm, settings.mappingMode, settings, bounds);
let grey;
if (uvResult.triplanar) {
@@ -50,13 +74,13 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const disp = grey * settings.amplitude;
newPos[i*3] = tmpPos.x + tmpNrm.x * disp;
newPos[i*3+1] = tmpPos.y + tmpNrm.y * disp;
newPos[i*3+2] = tmpPos.z + tmpNrm.z * disp;
newPos[i*3] = tmpPos.x + useNrm.x * disp;
newPos[i*3+1] = tmpPos.y + useNrm.y * disp;
newPos[i*3+2] = tmpPos.z + useNrm.z * disp;
newNrm[i*3] = tmpNrm.x;
newNrm[i*3+1] = tmpNrm.y;
newNrm[i*3+2] = tmpNrm.z;
newNrm[i*3] = useNrm.x;
newNrm[i*3+1] = useNrm.y;
newNrm[i*3+2] = useNrm.z;
if (onProgress && i % REPORT_EVERY === 0) onProgress(i / count);
}
+1 -1
View File
@@ -245,7 +245,7 @@ async function handleSTL(file) {
// Default edge length = 1/100 of the largest bounding box dimension
const maxDim = Math.max(bounds.size.x, bounds.size.y, bounds.size.z);
const defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 100).toFixed(2)));
const defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 200).toFixed(2)));
settings.refineLength = defaultEdge;
refineLenSlider.value = defaultEdge;
refineLenVal.textContent = `${defaultEdge.toFixed(2)} mm`;
+3 -3
View File
@@ -78,9 +78,9 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const az = Math.abs(normal.z);
let uRaw, vRaw;
if (ax >= ay && ax >= az) {
// ±X dominant → project onto YZ
uRaw = (pos.y - min.y) / Math.max(size.y, 1e-6);
vRaw = (pos.z - min.z) / Math.max(size.z, 1e-6);
// ±X dominant → project onto ZY (U=Z, V=Y keeps texture upright on side faces)
uRaw = (pos.z - min.z) / Math.max(size.z, 1e-6);
vRaw = (pos.y - min.y) / Math.max(size.y, 1e-6);
} else if (ay >= ax && ay >= az) {
// ±Y dominant → project onto XZ
uRaw = (pos.x - min.x) / Math.max(size.x, 1e-6);
+2 -2
View File
@@ -106,8 +106,8 @@ const fragmentShader = /* glsl */`
// Picks the single planar projection whose axis is most aligned with the face normal.
vec3 absN = abs(MN);
if (absN.x >= absN.y && absN.x >= absN.z) {
// ±X dominant → project onto YZ plane
return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4)));
// ±X dominant → project onto ZY plane (U=Z, V=Y keeps texture upright on side faces)
return sampleMap((pos.zy - boundsMin.zy) / max(boundsSize.zy, vec2(1e-4)));
} else if (absN.y >= absN.x && absN.y >= absN.z) {
// ±Y dominant → project onto XZ plane
return sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4)));
+75
View File
@@ -3,6 +3,57 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid;
let currentMesh = null;
let gizmoScene, gizmoCamera;
const GIZMO_PX = 90; // gizmo viewport size in CSS pixels
const GIZMO_MARGIN = 14;
function buildGizmo() {
gizmoScene = new THREE.Scene();
gizmoCamera = new THREE.OrthographicCamera(-1.6, 1.6, 1.6, -1.6, 0.1, 10);
gizmoCamera.position.set(0, 0, 3);
const addAxis = (dir, hex, label) => {
// Shaft line
const shaft = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
dir.clone().multiplyScalar(0.78),
]);
gizmoScene.add(new THREE.Line(
shaft,
new THREE.LineBasicMaterial({ color: hex, depthTest: false }),
));
// Arrow-head cone
const cone = new THREE.Mesh(
new THREE.ConeGeometry(0.10, 0.24, 8),
new THREE.MeshBasicMaterial({ color: hex, depthTest: false }),
);
cone.position.copy(dir.clone().multiplyScalar(0.92));
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
gizmoScene.add(cone);
// Text label sprite
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 46px 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.position.copy(dir.clone().multiplyScalar(1.26));
sprite.scale.set(0.42, 0.42, 1);
gizmoScene.add(sprite);
};
addAxis(new THREE.Vector3(1, 0, 0), 0xff4040, 'X');
addAxis(new THREE.Vector3(0, 1, 0), 0x44dd44, 'Y');
addAxis(new THREE.Vector3(0, 0, 1), 0x5599ff, 'Z');
}
export function initViewer(canvas) {
// Renderer
@@ -54,6 +105,8 @@ export function initViewer(canvas) {
controls.maxDistance = 3000;
controls.screenSpacePanning = true;
buildGizmo();
// Resize observer
const resizeObserver = new ResizeObserver(() => onResize());
resizeObserver.observe(canvas.parentElement);
@@ -63,7 +116,29 @@ export function initViewer(canvas) {
(function animate() {
requestAnimationFrame(animate);
controls.update();
const cw = renderer.domElement.clientWidth;
const ch = renderer.domElement.clientHeight;
// 1. Main scene — full viewport
renderer.setScissorTest(false);
renderer.setViewport(0, 0, cw, ch);
renderer.render(scene, camera);
// 2. Gizmo overlay — upper-right corner
// WebGL y=0 is at bottom, so upper-right means large y.
const gx = cw - GIZMO_PX - GIZMO_MARGIN;
const gy = ch - GIZMO_PX - GIZMO_MARGIN;
gizmoCamera.quaternion.copy(camera.quaternion);
renderer.setScissorTest(true);
renderer.setScissor(gx, gy, GIZMO_PX, GIZMO_PX);
renderer.setViewport(gx, gy, GIZMO_PX, GIZMO_PX);
renderer.autoClear = false;
renderer.clearDepth();
renderer.render(gizmoScene, gizmoCamera);
renderer.autoClear = true;
renderer.setScissorTest(false);
renderer.setViewport(0, 0, cw, ch);
})();
}