mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: add perspective view toggle and update camera handling
This commit is contained in:
@@ -5,9 +5,11 @@ export default {
|
||||
"theme.toggleAriaLabel": "Hell/Dunkel-Modus wechseln",
|
||||
"dropHint.text": "<strong>.stl</strong>-, <strong>.obj</strong>- oder <strong>.3mf</strong>-Datei hier ablegen<br/>oder <label for=\"stl-file-input\" class=\"link-label\">zum Durchsuchen klicken</label>",
|
||||
"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",
|
||||
|
||||
@@ -5,9 +5,11 @@ export default {
|
||||
"theme.toggleAriaLabel": "Toggle light/dark mode",
|
||||
"dropHint.text": "Drop an <strong>.stl</strong>, <strong>.obj</strong> or <strong>.3mf</strong> file here<br/>or <label for=\"stl-file-input\" class=\"link-label\">click to browse</label>",
|
||||
"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",
|
||||
|
||||
@@ -5,9 +5,11 @@ export default {
|
||||
"theme.toggleAriaLabel": "Alternar modo claro/oscuro",
|
||||
"dropHint.text": "Arrastra aquí un archivo <strong>.stl</strong>, <strong>.obj</strong> o <strong>.3mf</strong><br/>o <label for=\"stl-file-input\" class=\"link-label\">haz clic para explorar</label>",
|
||||
"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",
|
||||
|
||||
@@ -5,9 +5,11 @@ export default {
|
||||
"theme.toggleAriaLabel": "Basculer mode clair/sombre",
|
||||
"dropHint.text": "Déposez un fichier <strong>.stl</strong>, <strong>.obj</strong> ou <strong>.3mf</strong> ici<br/>ou <label for=\"stl-file-input\" class=\"link-label\">cliquez pour parcourir</label>",
|
||||
"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",
|
||||
|
||||
@@ -5,9 +5,11 @@ export default {
|
||||
"theme.toggleAriaLabel": "Attiva/disattiva modalità chiara/scura",
|
||||
"dropHint.text": "Trascina qui un file <strong>.stl</strong>, <strong>.obj</strong> o <strong>.3mf</strong><br/>o <label for=\"stl-file-input\" class=\"link-label\">clicca per sfogliare</label>",
|
||||
"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",
|
||||
|
||||
@@ -5,9 +5,11 @@ export default {
|
||||
"theme.toggleAriaLabel": "ライト/ダークモードを切り替え",
|
||||
"dropHint.text": "<strong>.stl</strong>、<strong>.obj</strong>、<strong>.3mf</strong> ファイルをここにドロップ<br/>または <label for=\"stl-file-input\" class=\"link-label\">クリックして参照</label>",
|
||||
"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": "マップが選択されていません",
|
||||
|
||||
@@ -5,9 +5,11 @@ export default {
|
||||
"theme.toggleAriaLabel": "Alternar modo claro/escuro",
|
||||
"dropHint.text": "Arraste um arquivo <strong>.stl</strong>, <strong>.obj</strong> ou <strong>.3mf</strong> aqui<br/>ou <label for=\"stl-file-input\" class=\"link-label\">clique para procurar</label>",
|
||||
"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",
|
||||
|
||||
+5
-1
@@ -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'));
|
||||
|
||||
+173
-59
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user