From 0998e9ee0024b6acf33c35bf37dda72fe0394771 Mon Sep 17 00:00:00 2001 From: CNCKitchen Date: Tue, 7 Apr 2026 12:00:05 +0200 Subject: [PATCH] feat: add perspective view toggle and update camera handling --- index.html | 7 +- js/i18n/de.js | 2 + js/i18n/en.js | 2 + js/i18n/es.js | 2 + js/i18n/fr.js | 2 + js/i18n/it.js | 2 + js/i18n/ja.js | 2 + js/i18n/pt.js | 2 + js/main.js | 6 +- js/viewer.js | 232 +++++++++++++++++++++++++++++++++++++------------- 10 files changed, 198 insertions(+), 61 deletions(-) diff --git a/index.html b/index.html index 9455356..8b2b4fc 100644 --- a/index.html +++ b/index.html @@ -31,7 +31,7 @@
@@ -76,6 +76,10 @@ Wireframe +
Left drag: orbit  ·  Right drag: pan  ·  Scroll: zoom
@@ -96,6 +100,7 @@ Place on Face
+

All processing runs locally in your browser — no data is uploaded.

diff --git a/js/i18n/de.js b/js/i18n/de.js index bfe1a94..8827528 100644 --- a/js/i18n/de.js +++ b/js/i18n/de.js @@ -5,9 +5,11 @@ export default { "theme.toggleAriaLabel": "Hell/Dunkel-Modus wechseln", "dropHint.text": ".stl-, .obj- oder .3mf-Datei hier ablegen
oder ", "ui.wireframe": "Drahtgitter", + "ui.perspective": "Perspektivansicht", "ui.controlsHint": "Linke Maustaste: Drehen  ·  Rechte Maustaste: Verschieben  ·  Mausrad: Zoomen", "ui.meshInfo": "{n} Dreiecke · {mb} MB · {sx} × {sy} × {sz} mm", "ui.loadStl": "Modell laden…", + "ui.localProcessingNote": "Alle Berechnungen laufen lokal in Ihrem Browser — keine Daten werden hochgeladen.", "sections.displacementMap": "Textur", "ui.uploadCustomMap": "Eigene Textur hochladen", "ui.noMapSelected": "Keine Textur ausgewählt", diff --git a/js/i18n/en.js b/js/i18n/en.js index 601d9b1..d5b714b 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -5,9 +5,11 @@ export default { "theme.toggleAriaLabel": "Toggle light/dark mode", "dropHint.text": "Drop an .stl, .obj or .3mf file here
or ", "ui.wireframe": "Wireframe", + "ui.perspective": "Perspective View", "ui.controlsHint": "Left drag: orbit  ·  Right drag: pan  ·  Scroll: zoom", "ui.meshInfo": "{n} triangles · {mb} MB · {sx} × {sy} × {sz} mm", "ui.loadStl": "Load Model…", + "ui.localProcessingNote": "All processing runs locally in your browser — no data is uploaded.", "sections.displacementMap": "Displacement Map", "ui.uploadCustomMap": "Upload custom map", "ui.noMapSelected": "No map selected", diff --git a/js/i18n/es.js b/js/i18n/es.js index 3a89a39..5424d26 100644 --- a/js/i18n/es.js +++ b/js/i18n/es.js @@ -5,9 +5,11 @@ export default { "theme.toggleAriaLabel": "Alternar modo claro/oscuro", "dropHint.text": "Arrastra aquí un archivo .stl, .obj o .3mf
o ", "ui.wireframe": "Malla de alambre", + "ui.perspective": "Vista en perspectiva", "ui.controlsHint": "Arrastrar izq.: orbitar  ·  Arrastrar der.: desplazar  ·  Rueda: zoom", "ui.meshInfo": "{n} triángulos · {mb} MB · {sx} × {sy} × {sz} mm", "ui.loadStl": "Cargar modelo…", + "ui.localProcessingNote": "Todo el procesamiento se realiza localmente en tu navegador — no se suben datos.", "sections.displacementMap": "Mapa de desplazamiento", "ui.uploadCustomMap": "Subir mapa personalizado", "ui.noMapSelected": "Ningún mapa seleccionado", diff --git a/js/i18n/fr.js b/js/i18n/fr.js index 004d3e5..1fd072e 100644 --- a/js/i18n/fr.js +++ b/js/i18n/fr.js @@ -5,9 +5,11 @@ export default { "theme.toggleAriaLabel": "Basculer mode clair/sombre", "dropHint.text": "Déposez un fichier .stl, .obj ou .3mf ici
ou ", "ui.wireframe": "Fil de fer", + "ui.perspective": "Vue en perspective", "ui.controlsHint": "Bouton gauche : orbiter  ·  Bouton droit : panoramique  ·  Molette : zoom", "ui.meshInfo": "{n} triangles · {mb} Mo · {sx} × {sy} × {sz} mm", "ui.loadStl": "Charger un modèle…", + "ui.localProcessingNote": "Tout le traitement s'effectue localement dans votre navigateur — aucune donnée n'est envoyée.", "sections.displacementMap": "Carte de déplacement", "ui.uploadCustomMap": "Charger une carte personnalisée", "ui.noMapSelected": "Aucune carte sélectionnée", diff --git a/js/i18n/it.js b/js/i18n/it.js index f8bed2f..a873c4f 100644 --- a/js/i18n/it.js +++ b/js/i18n/it.js @@ -5,9 +5,11 @@ export default { "theme.toggleAriaLabel": "Attiva/disattiva modalità chiara/scura", "dropHint.text": "Trascina qui un file .stl, .obj o .3mf
o ", "ui.wireframe": "Wireframe", + "ui.perspective": "Vista prospettica", "ui.controlsHint": "Trascina a sx: orbita  ·  Trascina a dx: sposta  ·  Scorri: zoom", "ui.meshInfo": "{n} triangoli · {mb} MB · {sx} × {sy} × {sz} mm", "ui.loadStl": "Carica Modello…", + "ui.localProcessingNote": "Tutta l'elaborazione avviene localmente nel browser — nessun dato viene caricato.", "sections.displacementMap": "Mappa di Deformazione", "ui.uploadCustomMap": "Carica mappa personalizzata", "ui.noMapSelected": "Nessuna mappa selezionata", diff --git a/js/i18n/ja.js b/js/i18n/ja.js index 41e9405..97b5f02 100644 --- a/js/i18n/ja.js +++ b/js/i18n/ja.js @@ -5,9 +5,11 @@ export default { "theme.toggleAriaLabel": "ライト/ダークモードを切り替え", "dropHint.text": ".stl.obj.3mf ファイルをここにドロップ
または ", "ui.wireframe": "ワイヤーフレーム", + "ui.perspective": "透視投影ビュー", "ui.controlsHint": "左ドラッグ: 回転  ·  右ドラッグ: パン  ·  スクロール: ズーム", "ui.meshInfo": "{n} 三角形 · {mb} MB · {sx} × {sy} × {sz} mm", "ui.loadStl": "モデルを読み込む…", + "ui.localProcessingNote": "すべての処理はブラウザ内でローカルに実行されます — データはアップロードされません。", "sections.displacementMap": "ディスプレイスメントマップ", "ui.uploadCustomMap": "カスタムマップをアップロード", "ui.noMapSelected": "マップが選択されていません", diff --git a/js/i18n/pt.js b/js/i18n/pt.js index 03424c7..134b0ff 100644 --- a/js/i18n/pt.js +++ b/js/i18n/pt.js @@ -5,9 +5,11 @@ export default { "theme.toggleAriaLabel": "Alternar modo claro/escuro", "dropHint.text": "Arraste um arquivo .stl, .obj ou .3mf aqui
ou ", "ui.wireframe": "Wireframe", + "ui.perspective": "Vista em perspectiva", "ui.controlsHint": "Arrastar esq.: orbitar  ·  Arrastar dir.: deslocar  ·  Roda: zoom", "ui.meshInfo": "{n} triângulos · {mb} MB · {sx} × {sy} × {sz} mm", "ui.loadStl": "Carregar modelo…", + "ui.localProcessingNote": "Todo o processamento é feito localmente no seu navegador — nenhum dado é enviado.", "sections.displacementMap": "Mapa de deslocamento", "ui.uploadCustomMap": "Enviar mapa personalizado", "ui.noMapSelected": "Nenhum mapa selecionado", diff --git a/js/main.js b/js/main.js index a00d01c..d13fdbf 100644 --- a/js/main.js +++ b/js/main.js @@ -2,7 +2,7 @@ import * as THREE from 'three'; import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe, getControls, getCamera, getCurrentMesh, setExclusionOverlay, setHoverPreview, setViewerTheme, - requestRender } from './viewer.js'; + setProjection, requestRender } from './viewer.js'; import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js'; import { loadPresets, loadCustomTexture } from './presetTextures.js'; import { createPreviewMaterial, updateMaterial } from './previewMaterial.js'; @@ -178,6 +178,7 @@ const exportProgPct = document.getElementById('export-progress-pct'); const exportProgLbl = document.getElementById('export-progress-label'); const triLimitWarning = document.getElementById('tri-limit-warning'); const wireframeToggle = document.getElementById('wireframe-toggle'); +const projectionToggle = document.getElementById('projection-toggle'); const placeOnFaceBtn = document.getElementById('place-on-face-btn'); const mappingSelect = document.getElementById('mapping-mode'); @@ -580,6 +581,9 @@ function wireEvents() { // ── Wireframe ── wireframeToggle.addEventListener('change', () => setWireframe(wireframeToggle.checked)); + // ── Projection toggle ── + projectionToggle.addEventListener('change', () => setProjection(projectionToggle.checked)); + // ── Exclusion tool wiring ───────────────────────────────────────────────── exclBrushBtn.addEventListener('click', () => setExclusionTool('brush')); diff --git a/js/viewer.js b/js/viewer.js index 6a6fc34..282d379 100644 --- a/js/viewer.js +++ b/js/viewer.js @@ -12,7 +12,8 @@ const _tmpV2 = new THREE.Vector3(); const _tmpV3 = new THREE.Vector3(); const _tmpV4 = new THREE.Vector3(); -let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid; +let renderer, orthoCamera, perspCamera, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid; +let _isPerspective = false; let currentMesh = null; let axesGroup = null; let dimensionGroup = null; @@ -164,11 +165,19 @@ export function initViewer(canvas) { 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); + // Camera — orthographic (parallel projection), Z-up (default) + orthoCamera = new THREE.OrthographicCamera(-150, 150, 150, -150, -10000, 10000); + orthoCamera.up.set(0, 0, 1); + orthoCamera.position.set(120, -200, 100); + orthoCamera.lookAt(0, 0, 0); + + // Camera — perspective, Z-up + perspCamera = new THREE.PerspectiveCamera(50, 1, 0.1, 20000); + perspCamera.up.set(0, 0, 1); + perspCamera.position.copy(orthoCamera.position); + perspCamera.lookAt(0, 0, 0); + + camera = orthoCamera; // Lights ambientLight = new THREE.AmbientLight(0xffffff, 0.4); @@ -235,7 +244,9 @@ export function initViewer(canvas) { // Show marker, sized as ~1.5 % of the visible frustum height _pivotMarker.position.copy(_customPivot); - const markerScale = (camera.top / camera.zoom) * 0.015; + const markerScale = _isPerspective + ? _customPivot.distanceTo(camera.position) * Math.tan(THREE.MathUtils.degToRad(perspCamera.fov / 2)) * 0.015 + : (orthoCamera.top / orthoCamera.zoom) * 0.015; _pivotMarker.scale.setScalar(markerScale); _pivotMarker.visible = true; _needsRender = true; @@ -249,12 +260,16 @@ export function initViewer(canvas) { if (dx === 0 && dy === 0) return; const rotSpeed = 0.005; - // Horizontal: rotate around world Z (up) - _tmpQ1.setFromAxisAngle(_tmpV1.set(0, 0, 1), -dx * rotSpeed); - // Vertical: rotate around camera's local X (right vector) - _tmpV2.setFromMatrixColumn(camera.matrixWorld, 0).normalize(); - _tmpQ2.setFromAxisAngle(_tmpV2, -dy * rotSpeed); - _tmpQ1.premultiply(_tmpQ2); // _tmpQ1 = qV * qH (total rotation) + + // Build a pure quaternion rotation: horizontal around world Z, + // vertical around camera's right axis. No polar clamping — the + // camera can orbit freely over the poles. + camera.updateMatrixWorld(); + _tmpV2.setFromMatrixColumn(camera.matrixWorld, 0).normalize(); // camera right + + _tmpQ1.setFromAxisAngle(_tmpV1.set(0, 0, 1), -dx * rotSpeed); // yaw + _tmpQ2.setFromAxisAngle(_tmpV2, -dy * rotSpeed); // pitch + _tmpQ1.premultiply(_tmpQ2); // Rotate camera position around the pivot _tmpV3.copy(camera.position).sub(_customPivot); @@ -266,7 +281,9 @@ export function initViewer(canvas) { _tmpV4.applyQuaternion(_tmpQ1); controls.target.copy(_customPivot).add(_tmpV4); - camera.lookAt(controls.target); + // Rotate camera orientation directly — avoids lookAt pole singularity + camera.quaternion.premultiply(_tmpQ1); + camera.updateMatrixWorld(); _needsRender = true; }); @@ -275,6 +292,9 @@ export function initViewer(canvas) { _customPivot = null; _lastPointer = null; controls.enableRotate = true; + // Re-sync up vector for OrbitControls + camera.up.set(0, 0, 1); + camera.lookAt(controls.target); _pivotMarker.visible = false; _needsRender = true; } @@ -311,22 +331,48 @@ export function initViewer(canvas) { const curNdcX = ((midX - rect.left) / rect.width) * 2 - 1; const curNdcY = -((midY - rect.top) / rect.height) * 2 + 1; - _tmpV1.set(prevNdcX, prevNdcY, 0).unproject(camera); - _tmpV2.set(curNdcX, curNdcY, 0).unproject(camera); - _tmpV1.sub(_tmpV2); // panDelta - camera.position.add(_tmpV1); - controls.target.add(_tmpV1); + if (_isPerspective) { + // Pan on the plane through controls.target perpendicular to the view direction + const camDir = _tmpV1.copy(controls.target).sub(camera.position).normalize(); + const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(camDir, controls.target); + const ray1 = new THREE.Ray(); + const ray2 = new THREE.Ray(); + _tmpV2.set(prevNdcX, prevNdcY, 0.5).unproject(camera); + ray1.set(camera.position, _tmpV2.sub(camera.position).normalize()); + _tmpV3.set(curNdcX, curNdcY, 0.5).unproject(camera); + ray2.set(camera.position, _tmpV3.sub(camera.position).normalize()); + const p1 = new THREE.Vector3(), p2 = new THREE.Vector3(); + if (ray1.intersectPlane(plane, p1) && ray2.intersectPlane(plane, p2)) { + _tmpV4.subVectors(p1, p2); + camera.position.add(_tmpV4); + controls.target.add(_tmpV4); + } + } else { + _tmpV1.set(prevNdcX, prevNdcY, 0).unproject(camera); + _tmpV2.set(curNdcX, curNdcY, 0).unproject(camera); + _tmpV1.sub(_tmpV2); // panDelta + camera.position.add(_tmpV1); + controls.target.add(_tmpV1); + } // ── Zoom: zoom toward the current midpoint ──────────────────────── const factor = newDist / _pinchDist; - _tmpV3.set(curNdcX, curNdcY, 0).unproject(camera); - camera.zoom = Math.max(0.05, Math.min(200, camera.zoom * factor)); - camera.updateProjectionMatrix(); - _tmpV4.set(curNdcX, curNdcY, 0).unproject(camera); - - _tmpV3.sub(_tmpV4); // zoomDelta - camera.position.add(_tmpV3); - controls.target.add(_tmpV3); + if (_isPerspective) { + _tmpV3.set(curNdcX, curNdcY, 0.5).unproject(camera); + _tmpV3.sub(camera.position).normalize(); + const dist = camera.position.distanceTo(controls.target); + const dolly = dist * (1 - 1 / factor); + camera.position.addScaledVector(_tmpV3, dolly); + controls.target.addScaledVector(_tmpV3, dolly); + } else { + _tmpV3.set(curNdcX, curNdcY, 0).unproject(camera); + camera.zoom = Math.max(0.05, Math.min(200, camera.zoom * factor)); + camera.updateProjectionMatrix(); + _tmpV4.set(curNdcX, curNdcY, 0).unproject(camera); + _tmpV3.sub(_tmpV4); // zoomDelta + camera.position.add(_tmpV3); + controls.target.add(_tmpV3); + } _pinchDist = newDist; _pinchMid = { x: midX, y: midY }; @@ -349,22 +395,28 @@ export function initViewer(canvas) { 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 - _tmpV1.set(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 - _tmpV2.set(ndcX, ndcY, 0).unproject(camera); - - // Shift camera + target so the world point stays under the cursor - _tmpV1.sub(_tmpV2); // delta = before - after - camera.position.add(_tmpV1); - controls.target.add(_tmpV1); - controls.update(); + if (_isPerspective) { + // Perspective: dolly camera toward/away from point under cursor + const factor = e.deltaY > 0 ? 0.9 : 1.1; + _tmpV1.set(ndcX, ndcY, 0.5).unproject(camera); + _tmpV1.sub(camera.position).normalize(); + const dist = camera.position.distanceTo(controls.target); + const dolly = dist * (1 - 1 / factor); + camera.position.addScaledVector(_tmpV1, dolly); + controls.target.addScaledVector(_tmpV1, dolly); + controls.update(); + } else { + // Orthographic: cursor-centric zoom via frustum zoom + _tmpV1.set(ndcX, ndcY, 0).unproject(camera); + const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1; + camera.zoom = Math.max(0.05, Math.min(200, camera.zoom * factor)); + camera.updateProjectionMatrix(); + _tmpV2.set(ndcX, ndcY, 0).unproject(camera); + _tmpV1.sub(_tmpV2); + camera.position.add(_tmpV1); + controls.target.add(_tmpV1); + controls.update(); + } }, { passive: false }); // Resize observer @@ -391,12 +443,14 @@ function onResize() { 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(); + // Update both cameras so switching stays seamless + const halfH = orthoCamera.top; + orthoCamera.left = -halfH * aspect; + orthoCamera.right = halfH * aspect; + orthoCamera.updateProjectionMatrix(); + perspCamera.aspect = aspect; + perspCamera.updateProjectionMatrix(); // LineMaterial needs the actual pixel resolution to compute linewidth correctly if (wireframeLines) { wireframeLines.material.resolution.set( @@ -531,21 +585,38 @@ function fitCamera(sphere) { 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(); + // Orthographic frustum + orthoCamera.left = -halfH * aspect; + orthoCamera.right = halfH * aspect; + orthoCamera.top = halfH; + orthoCamera.bottom = -halfH; + orthoCamera.near = -sphere.radius * 200; + orthoCamera.far = sphere.radius * 200; + orthoCamera.zoom = 1; + orthoCamera.updateProjectionMatrix(); + + // Perspective frustum + perspCamera.aspect = aspect; + perspCamera.near = sphere.radius * 0.01; + perspCamera.far = sphere.radius * 400; + perspCamera.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); + + // Ortho: position doesn't affect rendered size, just direction + orthoCamera.position.copy(sphere.center).addScaledVector(dir, halfH * 4); + orthoCamera.up.set(0, 0, 1); + orthoCamera.lookAt(sphere.center); + + // Perspective: place far enough so the sphere fills the view + const fovRad = THREE.MathUtils.degToRad(perspCamera.fov / 2); + const perspDist = halfH / Math.tan(fovRad); + perspCamera.position.copy(sphere.center).addScaledVector(dir, perspDist); + perspCamera.up.set(0, 0, 1); + perspCamera.lookAt(sphere.center); + controls.update(); } @@ -557,6 +628,49 @@ export function getScene() { return scene; } export function getControls() { return controls; } export function getCurrentMesh() { return currentMesh; } +/** + * Switch between orthographic and perspective projection. + * Syncs position, target and up so the view doesn't jump. + * @param {boolean} perspective – true for perspective, false for orthographic + */ +export function setProjection(perspective) { + if (perspective === _isPerspective) return; + _isPerspective = perspective; + const oldCam = camera; + const newCam = perspective ? perspCamera : orthoCamera; + + // Copy spatial state so the view doesn't jump + newCam.position.copy(oldCam.position); + newCam.up.copy(oldCam.up); + newCam.quaternion.copy(oldCam.quaternion); + + if (perspective) { + // Estimate a reasonable distance if ortho camera was at an arbitrary depth + // Use the ortho frustum half-height divided by tan(fov/2) as reference dist + const halfH = orthoCamera.top / orthoCamera.zoom; + const fovRad = THREE.MathUtils.degToRad(perspCamera.fov / 2); + const dist = halfH / Math.tan(fovRad); + const dir = new THREE.Vector3().subVectors(oldCam.position, controls.target).normalize(); + newCam.position.copy(controls.target).addScaledVector(dir, dist); + } + + camera = newCam; + controls.object = camera; + const sz = renderer.getSize(new THREE.Vector2()); + const aspect = sz.x / sz.y; + if (perspective) { + perspCamera.aspect = aspect; + } else { + const halfH = orthoCamera.top; + orthoCamera.left = -halfH * aspect; + orthoCamera.right = halfH * aspect; + orthoCamera.zoom = 1; + } + camera.updateProjectionMatrix(); + controls.update(); + requestRender(); +} + export function setSceneBackground(hexColor) { if (scene) scene.background = new THREE.Color(hexColor); requestRender();