mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
Refactor mapping modes and improve UV mapping logic; update viewer axes and camera settings
This commit is contained in:
+2
-2
@@ -80,8 +80,8 @@
|
|||||||
<option value="2">Planar YZ</option>
|
<option value="2">Planar YZ</option>
|
||||||
<option value="3">Cylindrical</option>
|
<option value="3">Cylindrical</option>
|
||||||
<option value="4">Spherical</option>
|
<option value="4">Spherical</option>
|
||||||
<option value="5" selected>Triplanar</option>
|
<option value="5">Triplanar</option>
|
||||||
<option value="6">Cubic (Box)</option>
|
<option value="6" selected>Cubic (Box)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+81
-28
@@ -28,7 +28,6 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
|
|
||||||
const tmpPos = new THREE.Vector3();
|
const tmpPos = new THREE.Vector3();
|
||||||
const tmpNrm = new THREE.Vector3();
|
const tmpNrm = new THREE.Vector3();
|
||||||
// Reusable vectors for per-face normal computation
|
|
||||||
const vA = new THREE.Vector3();
|
const vA = new THREE.Vector3();
|
||||||
const vB = new THREE.Vector3();
|
const vB = new THREE.Vector3();
|
||||||
const vC = new THREE.Vector3();
|
const vC = new THREE.Vector3();
|
||||||
@@ -36,34 +35,72 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
const edge2 = new THREE.Vector3();
|
const edge2 = new THREE.Vector3();
|
||||||
const faceNrm = new THREE.Vector3();
|
const faceNrm = new THREE.Vector3();
|
||||||
|
|
||||||
const REPORT_EVERY = 5000;
|
const QUANT = 1e4;
|
||||||
|
const posKey = (x, y, z) =>
|
||||||
|
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
|
||||||
|
|
||||||
|
// ── WHY GAPS HAPPEN ───────────────────────────────────────────────────────
|
||||||
|
// The mesh is non-indexed (unrolled): every triangle has its own copy of
|
||||||
|
// each vertex. At a shared edge two triangles have the same position but
|
||||||
|
// different face normals. Displacing each copy along its own face normal
|
||||||
|
// moves them to DIFFERENT final positions → crack / gap.
|
||||||
|
//
|
||||||
|
// THE FIX: every copy of the same position must arrive at the exact same
|
||||||
|
// displaced point. We achieve this by computing a single *smooth* (area-
|
||||||
|
// weighted average) normal per unique position and using that both for the
|
||||||
|
// texture UV lookup and for the displacement direction. All copies of the
|
||||||
|
// same position then move by the same vector → watertight result.
|
||||||
|
//
|
||||||
|
// The tradeoff is that displaced normals are smooth at hard edges, but the
|
||||||
|
// underlying geometry is still faceted (the subdivision didn't change it),
|
||||||
|
// so printed edges remain sharp.
|
||||||
|
|
||||||
|
// ── Pass 1: accumulate area-weighted face normals per unique position ─────
|
||||||
|
// Map: posKey → { nx, ny, nz } (unnormalised sum)
|
||||||
|
const smoothNrmMap = new Map();
|
||||||
|
|
||||||
|
for (let t = 0; t < count; t += 3) {
|
||||||
|
vA.fromBufferAttribute(posAttr, t);
|
||||||
|
vB.fromBufferAttribute(posAttr, t + 1);
|
||||||
|
vC.fromBufferAttribute(posAttr, t + 2);
|
||||||
|
edge1.subVectors(vB, vA);
|
||||||
|
edge2.subVectors(vC, vA);
|
||||||
|
faceNrm.crossVectors(edge1, edge2); // length = 2× triangle area → natural area weighting
|
||||||
|
|
||||||
|
for (let v = 0; v < 3; v++) {
|
||||||
|
tmpPos.fromBufferAttribute(posAttr, t + v);
|
||||||
|
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
|
||||||
|
const existing = smoothNrmMap.get(k);
|
||||||
|
if (existing) {
|
||||||
|
existing[0] += faceNrm.x;
|
||||||
|
existing[1] += faceNrm.y;
|
||||||
|
existing[2] += faceNrm.z;
|
||||||
|
} else {
|
||||||
|
smoothNrmMap.set(k, [faceNrm.x, faceNrm.y, faceNrm.z]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise each accumulated normal
|
||||||
|
smoothNrmMap.forEach((n) => {
|
||||||
|
const len = Math.sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]) || 1;
|
||||||
|
n[0] /= len; n[1] /= len; n[2] /= len;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Pass 2: sample displacement texture once per unique position ──────────
|
||||||
|
const dispCache = new Map(); // posKey → grey [0, 1]
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
tmpPos.fromBufferAttribute(posAttr, i);
|
tmpPos.fromBufferAttribute(posAttr, i);
|
||||||
tmpNrm.fromBufferAttribute(nrmAttr, i);
|
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
|
||||||
|
if (dispCache.has(k)) continue;
|
||||||
|
|
||||||
// Compute a stable face normal from the triangle's own vertex positions.
|
const sn = smoothNrmMap.get(k);
|
||||||
// The subdivider deduplicates vertices by position only, so shared corner
|
tmpNrm.set(sn[0], sn[1], sn[2]);
|
||||||
// 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);
|
|
||||||
|
|
||||||
|
const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settings, bounds);
|
||||||
let grey;
|
let grey;
|
||||||
if (uvResult.triplanar) {
|
if (uvResult.triplanar) {
|
||||||
// Weighted blend of three samples
|
|
||||||
grey = 0;
|
grey = 0;
|
||||||
for (const s of uvResult.samples) {
|
for (const s of uvResult.samples) {
|
||||||
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, s.u, s.v) * s.w;
|
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, s.u, s.v) * s.w;
|
||||||
@@ -71,16 +108,32 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
|
|||||||
} else {
|
} else {
|
||||||
grey = sampleBilinear(imageData.data, imgWidth, imgHeight, uvResult.u, uvResult.v);
|
grey = sampleBilinear(imageData.data, imgWidth, imgHeight, uvResult.u, uvResult.v);
|
||||||
}
|
}
|
||||||
|
dispCache.set(k, grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pass 3: displace every vertex copy by the same vector ─────────────────
|
||||||
|
// Using the smooth normal for the displacement direction ensures all copies
|
||||||
|
// of the same position land at exactly the same 3-D point.
|
||||||
|
|
||||||
|
const REPORT_EVERY = 5000;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
tmpPos.fromBufferAttribute(posAttr, i);
|
||||||
|
tmpNrm.fromBufferAttribute(nrmAttr, i);
|
||||||
|
|
||||||
|
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
|
||||||
|
const sn = smoothNrmMap.get(k);
|
||||||
|
const grey = dispCache.get(k);
|
||||||
const disp = grey * settings.amplitude;
|
const disp = grey * settings.amplitude;
|
||||||
|
|
||||||
newPos[i*3] = tmpPos.x + useNrm.x * disp;
|
newPos[i*3] = tmpPos.x + sn[0] * disp;
|
||||||
newPos[i*3+1] = tmpPos.y + useNrm.y * disp;
|
newPos[i*3+1] = tmpPos.y + sn[1] * disp;
|
||||||
newPos[i*3+2] = tmpPos.z + useNrm.z * disp;
|
newPos[i*3+2] = tmpPos.z + sn[2] * disp;
|
||||||
|
|
||||||
newNrm[i*3] = useNrm.x;
|
// Keep per-face normal for shading (recomputed below anyway)
|
||||||
newNrm[i*3+1] = useNrm.y;
|
newNrm[i*3] = tmpNrm.x;
|
||||||
newNrm[i*3+2] = useNrm.z;
|
newNrm[i*3+1] = tmpNrm.y;
|
||||||
|
newNrm[i*3+2] = tmpNrm.z;
|
||||||
|
|
||||||
if (onProgress && i % REPORT_EVERY === 0) onProgress(i / count);
|
if (onProgress && i % REPORT_EVERY === 0) onProgress(i / count);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -15,7 +15,7 @@ let previewMaterial = null;
|
|||||||
let isExporting = false;
|
let isExporting = false;
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
mappingMode: 5, // Triplanar default — covers all faces of any shape
|
mappingMode: 6, // Cubic default
|
||||||
scaleU: 1.0,
|
scaleU: 1.0,
|
||||||
scaleV: 1.0,
|
scaleV: 1.0,
|
||||||
amplitude: 0.5,
|
amplitude: 0.5,
|
||||||
@@ -228,6 +228,16 @@ async function handleSTL(file) {
|
|||||||
previewMaterial = null;
|
previewMaterial = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-select Brick preset (index 5) on first load
|
||||||
|
const brickIdx = PRESETS.findIndex(p => p.name === 'Brick');
|
||||||
|
if (brickIdx >= 0 && !activeMapEntry) {
|
||||||
|
activeMapEntry = PRESETS[brickIdx];
|
||||||
|
activeMapName.textContent = PRESETS[brickIdx].name;
|
||||||
|
const swatches = document.querySelectorAll('.preset-swatch');
|
||||||
|
swatches.forEach((s, i) => s.classList.toggle('active', i === brickIdx));
|
||||||
|
}
|
||||||
|
mappingSelect.value = String(settings.mappingMode);
|
||||||
|
|
||||||
// Show mesh with a default material until a map is selected
|
// Show mesh with a default material until a map is selected
|
||||||
loadGeometry(geometry);
|
loadGeometry(geometry);
|
||||||
dropHint.classList.add('hidden');
|
dropHint.classList.add('hidden');
|
||||||
|
|||||||
+6
-6
@@ -51,12 +51,12 @@ export function computeUV(pos, normal, mode, settings, bounds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case MODE_CYLINDRICAL: {
|
case MODE_CYLINDRICAL: {
|
||||||
// Wrap around Y axis (vertical axis after Z-up → Y-up rotation)
|
// Z is up: wrap around Z axis, height along Z
|
||||||
const rx = pos.x - center.x;
|
const rx = pos.x - center.x;
|
||||||
const rz = pos.z - center.z;
|
const ry = pos.y - center.y;
|
||||||
const theta = Math.atan2(rz, rx); // [-PI, PI]
|
const theta = Math.atan2(ry, rx); // [-PI, PI]
|
||||||
u = (theta / TWO_PI) + 0.5; // [0, 1]
|
u = (theta / TWO_PI) + 0.5; // [0, 1]
|
||||||
v = (pos.y - min.y) / Math.max(size.y, 1e-6);
|
v = (pos.z - min.z) / Math.max(size.z, 1e-6);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ export function computeUV(pos, normal, mode, settings, bounds) {
|
|||||||
const ry = pos.y - center.y;
|
const ry = pos.y - center.y;
|
||||||
const rz = pos.z - center.z;
|
const rz = pos.z - center.z;
|
||||||
const r = Math.sqrt(rx*rx + ry*ry + rz*rz);
|
const r = Math.sqrt(rx*rx + ry*ry + rz*rz);
|
||||||
const phi = Math.acos(Math.max(-1, Math.min(1, ry / Math.max(r, 1e-6)))); // [0, PI], Y is up
|
const phi = Math.acos(Math.max(-1, Math.min(1, rz / Math.max(r, 1e-6)))); // [0, PI], Z is up
|
||||||
const theta = Math.atan2(rz, rx); // [-PI, PI]
|
const theta = Math.atan2(ry, rx); // [-PI, PI]
|
||||||
u = (theta / TWO_PI) + 0.5;
|
u = (theta / TWO_PI) + 0.5;
|
||||||
v = phi / Math.PI;
|
v = phi / Math.PI;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -77,16 +77,16 @@ const fragmentShader = /* glsl */`
|
|||||||
return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4)));
|
return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4)));
|
||||||
|
|
||||||
} else if (mappingMode == 3) {
|
} else if (mappingMode == 3) {
|
||||||
// Cylindrical around Y (vertical axis after Z-up → Y-up rotation)
|
// Cylindrical around Z axis (Z is up)
|
||||||
float u = atan(rel.z, rel.x) / TWO_PI + 0.5;
|
float u = atan(rel.y, rel.x) / TWO_PI + 0.5;
|
||||||
float v = (pos.y - boundsMin.y) / max(boundsSize.y, 1e-4);
|
float v = (pos.z - boundsMin.z) / max(boundsSize.z, 1e-4);
|
||||||
return sampleMap(vec2(u, v));
|
return sampleMap(vec2(u, v));
|
||||||
|
|
||||||
} else if (mappingMode == 4) {
|
} else if (mappingMode == 4) {
|
||||||
// Spherical
|
// Spherical — Z is up
|
||||||
float r = length(rel);
|
float r = length(rel);
|
||||||
float phi = acos(clamp(rel.y / max(r, 1e-4), -1.0, 1.0));
|
float phi = acos(clamp(rel.z / max(r, 1e-4), -1.0, 1.0));
|
||||||
float theta = atan(rel.z, rel.x);
|
float theta = atan(rel.y, rel.x);
|
||||||
return sampleMap(vec2(theta / TWO_PI + 0.5, phi / PI));
|
return sampleMap(vec2(theta / TWO_PI + 0.5, phi / PI));
|
||||||
|
|
||||||
} else if (mappingMode == 5) {
|
} else if (mappingMode == 5) {
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ function setupGeometry(geometry) {
|
|||||||
const centre = new THREE.Vector3();
|
const centre = new THREE.Vector3();
|
||||||
box.getCenter(centre);
|
box.getCenter(centre);
|
||||||
geometry.translate(-centre.x, -centre.y, -centre.z);
|
geometry.translate(-centre.x, -centre.y, -centre.z);
|
||||||
// Convert Z-up (3D-print convention) to Y-up (Three.js convention)
|
|
||||||
geometry.rotateX(-Math.PI / 2);
|
|
||||||
geometry.computeBoundingBox();
|
geometry.computeBoundingBox();
|
||||||
if (!geometry.attributes.normal) geometry.computeVertexNormals();
|
if (!geometry.attributes.normal) geometry.computeVertexNormals();
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-71
@@ -3,56 +3,57 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|||||||
|
|
||||||
let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid;
|
let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid;
|
||||||
let currentMesh = null;
|
let currentMesh = null;
|
||||||
let gizmoScene, gizmoCamera;
|
let axesGroup = null;
|
||||||
|
|
||||||
const GIZMO_PX = 90; // gizmo viewport size in CSS pixels
|
// Build a labelled coordinate axes indicator scaled to `size`.
|
||||||
const GIZMO_MARGIN = 14;
|
// X = red, Y = green, Z = blue (up).
|
||||||
|
function buildAxesIndicator(size) {
|
||||||
function buildGizmo() {
|
const group = new THREE.Group();
|
||||||
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) => {
|
const addAxis = (dir, hex, label) => {
|
||||||
// Shaft line
|
const r = size;
|
||||||
const shaft = new THREE.BufferGeometry().setFromPoints([
|
// Shaft
|
||||||
new THREE.Vector3(0, 0, 0),
|
const pts = [new THREE.Vector3(0, 0, 0), dir.clone().multiplyScalar(r * 0.78)];
|
||||||
dir.clone().multiplyScalar(0.78),
|
const line = new THREE.Line(
|
||||||
]);
|
new THREE.BufferGeometry().setFromPoints(pts),
|
||||||
gizmoScene.add(new THREE.Line(
|
new THREE.LineBasicMaterial({ color: hex, depthTest: false, transparent: true, opacity: 0.9 }),
|
||||||
shaft,
|
);
|
||||||
new THREE.LineBasicMaterial({ color: hex, depthTest: false }),
|
line.renderOrder = 999;
|
||||||
));
|
group.add(line);
|
||||||
|
|
||||||
// Arrow-head cone
|
// Cone arrowhead
|
||||||
const cone = new THREE.Mesh(
|
const cone = new THREE.Mesh(
|
||||||
new THREE.ConeGeometry(0.10, 0.24, 8),
|
new THREE.ConeGeometry(r * 0.07, r * 0.22, 8),
|
||||||
new THREE.MeshBasicMaterial({ color: hex, depthTest: false }),
|
new THREE.MeshBasicMaterial({ color: hex, depthTest: false }),
|
||||||
);
|
);
|
||||||
cone.position.copy(dir.clone().multiplyScalar(0.92));
|
cone.renderOrder = 999;
|
||||||
|
cone.position.copy(dir.clone().multiplyScalar(r * 0.89));
|
||||||
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
|
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
|
||||||
gizmoScene.add(cone);
|
group.add(cone);
|
||||||
|
|
||||||
// Text label sprite
|
// Text sprite label
|
||||||
const c = document.createElement('canvas');
|
const c = document.createElement('canvas');
|
||||||
c.width = c.height = 64;
|
c.width = c.height = 64;
|
||||||
const ctx = c.getContext('2d');
|
const ctx = c.getContext('2d');
|
||||||
ctx.fillStyle = `#${hex.toString(16).padStart(6, '0')}`;
|
ctx.fillStyle = `#${hex.toString(16).padStart(6, '0')}`;
|
||||||
ctx.font = 'bold 46px Arial';
|
ctx.font = 'bold 48px Arial';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(label, 32, 32);
|
ctx.fillText(label, 32, 32);
|
||||||
const sprite = new THREE.Sprite(
|
const sprite = new THREE.Sprite(
|
||||||
new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), depthTest: false }),
|
new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), depthTest: false }),
|
||||||
);
|
);
|
||||||
sprite.position.copy(dir.clone().multiplyScalar(1.26));
|
sprite.renderOrder = 999;
|
||||||
sprite.scale.set(0.42, 0.42, 1);
|
sprite.position.copy(dir.clone().multiplyScalar(r * 1.18));
|
||||||
gizmoScene.add(sprite);
|
sprite.scale.set(r * 0.32, r * 0.32, 1);
|
||||||
|
group.add(sprite);
|
||||||
};
|
};
|
||||||
|
|
||||||
addAxis(new THREE.Vector3(1, 0, 0), 0xff4040, 'X');
|
addAxis(new THREE.Vector3(1, 0, 0), 0xff3333, 'X');
|
||||||
addAxis(new THREE.Vector3(0, 1, 0), 0x44dd44, 'Y');
|
addAxis(new THREE.Vector3(0, 1, 0), 0x33dd55, 'Y');
|
||||||
addAxis(new THREE.Vector3(0, 0, 1), 0x5599ff, 'Z');
|
addAxis(new THREE.Vector3(0, 0, 1), 0x4488ff, 'Z');
|
||||||
|
|
||||||
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initViewer(canvas) {
|
export function initViewer(canvas) {
|
||||||
@@ -69,14 +70,16 @@ export function initViewer(canvas) {
|
|||||||
scene = new THREE.Scene();
|
scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color(0x111114);
|
scene.background = new THREE.Color(0x111114);
|
||||||
|
|
||||||
// Grid helper (subtle)
|
// Grid helper — in XY plane (Z-up)
|
||||||
grid = new THREE.GridHelper(200, 40, 0x222228, 0x1e1e24);
|
grid = new THREE.GridHelper(200, 40, 0x222228, 0x1e1e24);
|
||||||
grid.position.y = 0;
|
grid.rotation.x = Math.PI / 2; // rotate to XY plane for Z-up
|
||||||
|
grid.position.z = 0;
|
||||||
scene.add(grid);
|
scene.add(grid);
|
||||||
|
|
||||||
// Camera
|
// Camera — orthographic (parallel projection), Z-up
|
||||||
camera = new THREE.PerspectiveCamera(45, 1, 0.01, 5000);
|
camera = new THREE.OrthographicCamera(-150, 150, 150, -150, -10000, 10000);
|
||||||
camera.position.set(0, 80, 120);
|
camera.up.set(0, 0, 1);
|
||||||
|
camera.position.set(120, -200, 100);
|
||||||
camera.lookAt(0, 0, 0);
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
// Lights
|
// Lights
|
||||||
@@ -101,12 +104,8 @@ export function initViewer(canvas) {
|
|||||||
controls = new OrbitControls(camera, renderer.domElement);
|
controls = new OrbitControls(camera, renderer.domElement);
|
||||||
controls.enableDamping = true;
|
controls.enableDamping = true;
|
||||||
controls.dampingFactor = 0.08;
|
controls.dampingFactor = 0.08;
|
||||||
controls.minDistance = 1;
|
|
||||||
controls.maxDistance = 3000;
|
|
||||||
controls.screenSpacePanning = true;
|
controls.screenSpacePanning = true;
|
||||||
|
|
||||||
buildGizmo();
|
|
||||||
|
|
||||||
// Resize observer
|
// Resize observer
|
||||||
const resizeObserver = new ResizeObserver(() => onResize());
|
const resizeObserver = new ResizeObserver(() => onResize());
|
||||||
resizeObserver.observe(canvas.parentElement);
|
resizeObserver.observe(canvas.parentElement);
|
||||||
@@ -117,28 +116,7 @@ export function initViewer(canvas) {
|
|||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
controls.update();
|
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);
|
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);
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +125,11 @@ function onResize() {
|
|||||||
const w = el.clientWidth;
|
const w = el.clientWidth;
|
||||||
const h = el.clientHeight;
|
const h = el.clientHeight;
|
||||||
renderer.setSize(w, h, false);
|
renderer.setSize(w, h, false);
|
||||||
camera.aspect = w / h;
|
// 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();
|
camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,17 +161,26 @@ export function loadGeometry(geometry, material) {
|
|||||||
currentMesh.receiveShadow = true;
|
currentMesh.receiveShadow = true;
|
||||||
meshGroup.add(currentMesh);
|
meshGroup.add(currentMesh);
|
||||||
|
|
||||||
// Position grid at mesh bottom
|
// Position grid at mesh bottom (Z-up: move grid along Z)
|
||||||
geometry.computeBoundingBox();
|
geometry.computeBoundingBox();
|
||||||
const box = geometry.boundingBox;
|
const box = geometry.boundingBox;
|
||||||
const centerY = (box.min.y + box.max.y) / 2;
|
const groundZ = box.min.z - 0.01;
|
||||||
grid.position.y = box.min.y - 0.01;
|
grid.position.z = groundZ;
|
||||||
|
|
||||||
// Fit camera
|
// Fit camera
|
||||||
const sphere = new THREE.Sphere();
|
const sphere = new THREE.Sphere();
|
||||||
geometry.computeBoundingSphere();
|
geometry.computeBoundingSphere();
|
||||||
sphere.copy(geometry.boundingSphere);
|
sphere.copy(geometry.boundingSphere);
|
||||||
fitCamera(sphere);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,15 +206,26 @@ export function setMeshMaterial(material) {
|
|||||||
export function getGrid() { return grid; }
|
export function getGrid() { return grid; }
|
||||||
|
|
||||||
function fitCamera(sphere) {
|
function fitCamera(sphere) {
|
||||||
const fov = THREE.MathUtils.degToRad(camera.fov);
|
const sz = renderer.getSize(new THREE.Vector2());
|
||||||
const dist = (sphere.radius * 2.2) / Math.tan(fov / 2);
|
const aspect = sz.x / sz.y;
|
||||||
const dir = camera.position.clone().sub(controls.target).normalize();
|
const halfH = sphere.radius * 1.4;
|
||||||
controls.target.copy(sphere.center);
|
|
||||||
camera.position.copy(sphere.center).addScaledVector(dir, dist);
|
camera.left = -halfH * aspect;
|
||||||
controls.update();
|
camera.right = halfH * aspect;
|
||||||
camera.near = dist * 0.001;
|
camera.top = halfH;
|
||||||
camera.far = dist * 10;
|
camera.bottom = -halfH;
|
||||||
|
camera.near = -sphere.radius * 200;
|
||||||
|
camera.far = sphere.radius * 200;
|
||||||
|
camera.zoom = 1;
|
||||||
camera.updateProjectionMatrix();
|
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 getRenderer() { return renderer; }
|
||||||
|
|||||||
Reference in New Issue
Block a user