diff --git a/src/tools/documents/blend-scene.lib.test.ts b/src/tools/documents/blend-scene.lib.test.ts index 503a425..25869e8 100644 --- a/src/tools/documents/blend-scene.lib.test.ts +++ b/src/tools/documents/blend-scene.lib.test.ts @@ -7,6 +7,7 @@ const jb = vi.hoisted(() => ({ extractMaterials: vi.fn(), extractLights: vi.fn(), extractCameras: vi.fn(), + extractScenes: vi.fn(), })); vi.mock('jsblender', () => jb); @@ -81,8 +82,9 @@ describe('blendToSceneData', () => { { name: 'Lamp', type: 'spot', color: [1, 1, 1], energy: 4 * Math.PI * 10, spotSize: 1, spotBlend: 0.2 }, ]); jb.extractCameras.mockReturnValue([ - { name: 'Cam', type: 'perspective', lens: 50, sensorWidth: 36, clipStart: 0.1, clipEnd: 100 }, + { name: 'Cam', type: 'perspective', lens: 50, sensorWidth: 36, sensorHeight: 24, sensorFit: 'auto', clipStart: 0.1, clipEnd: 100 }, ]); + jb.extractScenes.mockReturnValue([{ name: 'Scene', cameraObject: 'Cam', resolutionX: 1920, resolutionY: 1080 }]); }); it('maps jsblender output to renderer-free scene data', async () => { @@ -113,7 +115,9 @@ describe('blendToSceneData', () => { const cam = data.cameras.get('Cam')!; expect(cam.fov).toBeCloseTo((2 * Math.atan(36 / 100) * 180) / Math.PI); - expect(cam).toMatchObject({ near: 0.1, far: 100, ortho: false }); + expect(cam).toMatchObject({ near: 0.1, far: 100, ortho: false, lens: 50, sensorWidth: 36, sensorHeight: 24, sensorFit: 'auto' }); + expect(data.activeCamera).toBe('Cam'); + expect(data.renderAspect).toBeCloseTo(1920 / 1080); expect(data.skipped).toEqual([]); }); @@ -124,6 +128,14 @@ describe('blendToSceneData', () => { expect(data.meshes.size).toBe(1); }); + it('has no active camera when scenes cannot be read', async () => { + jb.extractScenes.mockImplementation(() => { throw new Error('bad scene'); }); + const data = await blendToSceneData(BLEND5); + expect(data.activeCamera).toBeUndefined(); + expect(data.renderAspect).toBeCloseTo(16 / 9); + expect(data.skipped).toEqual(['scenes']); + }); + it('decompresses zstd before reading the header', async () => { zstd.decompress.mockReturnValue(BLEND5); await blendToSceneData(new Uint8Array([0x28, 0xb5, 0x2f, 0xfd, 1, 2, 3])); diff --git a/src/tools/documents/blend-scene.lib.ts b/src/tools/documents/blend-scene.lib.ts index 0637cf5..8d58c63 100644 --- a/src/tools/documents/blend-scene.lib.ts +++ b/src/tools/documents/blend-scene.lib.ts @@ -48,6 +48,11 @@ export interface BlendCameraData { near: number; far: number; ortho: boolean; + /** Focal length + sensor (mm), for re-deriving the FOV at any aspect. */ + lens: number; + sensorWidth: number; + sensorHeight: number; + sensorFit: 'auto' | 'horizontal' | 'vertical' | 'unknown'; } export interface BlendSceneData { @@ -57,6 +62,10 @@ export interface BlendSceneData { materials: Map; lights: Map; cameras: Map; + /** Object name of the scene's active camera (the view the author framed). */ + activeCamera?: string; + /** Render width / height of the scene, for fitting the saved camera's frame. */ + renderAspect: number; /** Extractors that threw and were skipped (e.g. 'lights'). */ skipped: string[]; } @@ -224,8 +233,18 @@ export async function blendToSceneData(bytes: Uint8Array): Promise 0 ? c.clipStart : 0.1, far: c.clipEnd > 0 ? c.clipEnd : 1000, ortho: c.type === 'orthographic', + lens, + sensorWidth: sensor, + sensorHeight: c.sensorHeight > 0 ? c.sensorHeight : 24, + sensorFit: c.sensorFit ?? 'auto', }); } - return { version: header.version, meshes, objects, materials, lights, cameras, skipped }; + const scene = safe('scenes', () => jb.extractScenes(blend))[0]; + const renderAspect = scene && scene.resolutionX > 0 && scene.resolutionY > 0 ? scene.resolutionX / scene.resolutionY : 16 / 9; + + return { + version: header.version, meshes, objects, materials, lights, cameras, + activeCamera: scene?.cameraObject, renderAspect, skipped, + }; } diff --git a/src/tools/documents/model3d-load.lib.ts b/src/tools/documents/model3d-load.lib.ts index f3c601a..6ea3c6e 100644 --- a/src/tools/documents/model3d-load.lib.ts +++ b/src/tools/documents/model3d-load.lib.ts @@ -7,6 +7,7 @@ */ import * as THREE from 'three'; import { basename, type ModelFormat } from './model3d-format.lib'; +import type { SavedView } from './model3d-scene.lib'; export interface ModelSource { name: string; @@ -24,6 +25,8 @@ export interface LoadedModel { format: ModelFormat; /** Source-app version when known (e.g. "Blender 5.01"). */ version?: string; + /** The file's own camera (e.g. a .blend's active camera) for a viewport aspect, if it has one. */ + savedView?: (aspect: number) => SavedView | null; } /** Give up waiting for sidecar textures after this long (the model still shows). */ @@ -164,12 +167,18 @@ export async function loadModel(src: ModelSource): Promise { case 'blend': { await settle(); - const [{ blendToSceneData }, { buildBlendObject }] = await Promise.all([ + const [{ blendToSceneData }, { buildBlendObject, savedCameraView }] = await Promise.all([ import('./blend-scene.lib'), import('./model3d-scene.lib'), ]); const data = await blendToSceneData(new Uint8Array(src.buffer)); - return { root: buildBlendObject(data), animations: [], format: 'blend', version: `Blender ${data.version.toFixed(2)}` }; + return { + root: buildBlendObject(data), + animations: [], + format: 'blend', + version: `Blender ${data.version.toFixed(2)}`, + savedView: aspect => savedCameraView(data, aspect), + }; } } } catch (e) { diff --git a/src/tools/documents/model3d-scene.lib.test.ts b/src/tools/documents/model3d-scene.lib.test.ts index 6b9ef1b..0beaebc 100644 --- a/src/tools/documents/model3d-scene.lib.test.ts +++ b/src/tools/documents/model3d-scene.lib.test.ts @@ -5,7 +5,9 @@ import { computeSceneStats, sceneTree, fitCameraParams, + savedCameraView, disposeObject, + DEFAULT_VIEW_DIR, } from './model3d-scene.lib'; import type { BlendSceneData } from './blend-scene.lib'; @@ -50,13 +52,53 @@ describe('sceneTree', () => { }); describe('fitCameraParams', () => { - it('places the camera outside a unit box with sane clip planes', () => { - const box = new THREE.Box3(new THREE.Vector3(-0.5, -0.5, -0.5), new THREE.Vector3(0.5, 0.5, 0.5)); - const p = fitCameraParams(box, 45, 16 / 9); + const unitBox = () => new THREE.Box3(new THREE.Vector3(-0.5, -0.5, -0.5), new THREE.Vector3(0.5, 0.5, 0.5)); + + /** Largest |NDC| coordinate of the box corners seen from the fitted camera. */ + function maxNdc(box: THREE.Box3, fov: number, aspect: number) { + const p = fitCameraParams(box, fov, aspect); + const cam = new THREE.PerspectiveCamera(fov, aspect, p.near, p.far); + cam.position.copy(p.center).addScaledVector(p.direction, p.distance); + cam.lookAt(p.center); + cam.updateMatrixWorld(); + let max = 0; + for (const x of [box.min.x, box.max.x]) for (const y of [box.min.y, box.max.y]) for (const z of [box.min.z, box.max.z]) { + const v = new THREE.Vector3(x, y, z).project(cam); + max = Math.max(max, Math.abs(v.x), Math.abs(v.y)); + } + return max; + } + + it.each([ + ['landscape', 16 / 9], + ['square', 1], + ['phone portrait', 0.46], + ])('frames a box tightly (%s)', (_label, aspect) => { + const m = maxNdc(unitBox(), 45, aspect); + expect(m).toBeLessThanOrEqual(1); + expect(m).toBeGreaterThan(0.85); + }); + + it('frames flat, wide scenes tightly too', () => { + const slab = new THREE.Box3(new THREE.Vector3(-30, 0, -30), new THREE.Vector3(30, 2, 30)); + const m = maxNdc(slab, 45, 4 / 3); + expect(m).toBeLessThanOrEqual(1); + expect(m).toBeGreaterThan(0.85); + }); + + it('keeps sane clip planes and the default view direction', () => { + const p = fitCameraParams(unitBox(), 45, 16 / 9); expect(p.center.toArray()).toEqual([0, 0, 0]); - expect(p.distance).toBeGreaterThan(Math.sqrt(3) / 2); + expect(p.direction.toArray()).toEqual(DEFAULT_VIEW_DIR.toArray()); expect(p.near).toBeGreaterThan(0); - expect(p.far).toBeGreaterThan(p.distance); + expect(p.far).toBeGreaterThan(p.distance + Math.sqrt(3)); + }); + + it('honours a custom view direction', () => { + const p = fitCameraParams(unitBox(), 45, 1, new THREE.Vector3(0, 0, 5)); + expect(p.direction.toArray()).toEqual([0, 0, 1]); + // Straight on, the front face sits 0.5 in front of the centre. + expect(p.distance).toBeCloseTo((0.5 / Math.tan((22.5 * Math.PI) / 180) + 0.5) * 1.05, 5); }); it('handles an empty box', () => { @@ -66,6 +108,52 @@ describe('fitCameraParams', () => { }); }); +describe('savedCameraView', () => { + // Blender camera at (0, -10, 0) rotated +90° about X, so it looks down +Y (Z-up). + const lookAlongY = [1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, -10, 0, 1]; + const base = (overrides: Partial = {}): BlendSceneData => ({ + version: 5.01, + meshes: new Map(), + objects: [{ name: 'CamObj', kind: 'camera', dataName: 'Cam', matrix: lookAlongY }], + materials: new Map(), + lights: new Map(), + cameras: new Map([['Cam', { + fov: 39.6, near: 0.1, far: 100, ortho: false, lens: 50, sensorWidth: 36, sensorHeight: 24, sensorFit: 'auto', + }]]), + activeCamera: 'CamObj', + renderAspect: 1920 / 1080, + skipped: [], + ...overrides, + }); + + it('converts the active camera to three.js (Y-up) space', () => { + const v = savedCameraView(base(), 1920 / 1080)!; + expect(v.position.x).toBeCloseTo(0); + expect(v.position.y).toBeCloseTo(0); + expect(v.position.z).toBeCloseTo(10); + const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(v.quaternion); + expect(forward.z).toBeCloseTo(-1); + expect(v).toMatchObject({ near: 0.1, far: 100 }); + }); + + it('keeps the render frame visible on a portrait screen', () => { + const wide = savedCameraView(base(), 1920 / 1080)!; + const phone = savedCameraView(base(), 0.5)!; + const height = 36 / (1920 / 1080); + expect(wide.fov).toBeCloseTo((2 * Math.atan(height / 100) * 180) / Math.PI); + expect(phone.fov).toBeCloseTo((2 * Math.atan((height * (1920 / 1080) / 0.5) / 100) * 180) / Math.PI); + expect(phone.fov).toBeGreaterThan(wide.fov); + }); + + it('returns null without a usable active camera', () => { + expect(savedCameraView(base({ activeCamera: undefined }), 1)).toBeNull(); + expect(savedCameraView(base({ activeCamera: 'Missing' }), 1)).toBeNull(); + const ortho = base(); + ortho.cameras.get('Cam')!.ortho = true; + expect(savedCameraView(ortho, 1)).toBeNull(); + }); +}); + describe('disposeObject', () => { it('disposes geometries, materials (arrays too) and textures', () => { const map = new THREE.Texture(); @@ -103,7 +191,8 @@ describe('buildBlendObject', () => { ], materials: new Map([['Red', { color: [1, 0, 0], opacity: 1, metalness: 0, roughness: 0.5, emissive: [0, 0, 0] }]]), lights: new Map([['Lamp', { type: 'point', color: [1, 1, 1], intensity: 10 }]]), - cameras: new Map([['Cam', { fov: 40, near: 0.1, far: 100, ortho: false }]]), + cameras: new Map([['Cam', { fov: 40, near: 0.1, far: 100, ortho: false, lens: 50, sensorWidth: 36, sensorHeight: 24, sensorFit: 'auto' }]]), + renderAspect: 16 / 9, skipped: [], }; diff --git a/src/tools/documents/model3d-scene.lib.ts b/src/tools/documents/model3d-scene.lib.ts index 60bf9ba..2554a77 100644 --- a/src/tools/documents/model3d-scene.lib.ts +++ b/src/tools/documents/model3d-scene.lib.ts @@ -178,14 +178,75 @@ function isListed(o: THREE.Object3D): boolean { return !(parent && parent.target === o); } -/** Camera distance + clip planes that frame a bounding box. */ -export function fitCameraParams(box: THREE.Box3, fovDeg: number, aspect: number) { - const center = box.isEmpty() ? new THREE.Vector3() : box.getCenter(new THREE.Vector3()); - const radius = box.isEmpty() ? 1 : Math.max(box.getBoundingSphere(new THREE.Sphere()).radius, 1e-3); - const vFov = (fovDeg * Math.PI) / 180; - const hFov = 2 * Math.atan(Math.tan(vFov / 2) * Math.max(aspect, 1e-3)); - const distance = (radius / Math.sin(Math.min(vFov, hFov) / 2)) * 1.1; - return { center, distance, near: Math.max(distance / 1000, 1e-4), far: distance + radius * 100 }; +/** Initial viewing direction (from the target toward the camera): a ¾ view from above. */ +export const DEFAULT_VIEW_DIR = new THREE.Vector3(1, 0.7, 1).normalize(); + +/** Empty margin left around a fitted model (5%). */ +const FIT_MARGIN = 1.05; + +/** + * Camera distance + clip planes that frame a bounding box from `viewDir` + * (pointing from the target toward the camera). Each box corner is checked + * against the frustum, so the model fills the view on any aspect ratio — + * tighter than fitting its bounding sphere, which leaves wide margins. + */ +export function fitCameraParams(box: THREE.Box3, fovDeg: number, aspect: number, viewDir: THREE.Vector3 = DEFAULT_VIEW_DIR) { + const direction = viewDir.lengthSq() > 0 ? viewDir.clone().normalize() : DEFAULT_VIEW_DIR.clone(); + if (box.isEmpty()) return { center: new THREE.Vector3(), direction, distance: 5, near: 0.005, far: 500 }; + + const center = box.getCenter(new THREE.Vector3()); + const radius = Math.max(box.getBoundingSphere(new THREE.Sphere()).radius, 1e-3); + const worldUp = Math.abs(direction.y) > 0.999 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0); + const right = new THREE.Vector3().crossVectors(worldUp, direction).normalize(); + const up = new THREE.Vector3().crossVectors(direction, right); + const tanV = Math.tan(((fovDeg * Math.PI) / 180) / 2); + const tanH = tanV * Math.max(aspect, 1e-3); + + let distance = 0; + const v = new THREE.Vector3(); + for (const x of [box.min.x, box.max.x]) for (const y of [box.min.y, box.max.y]) for (const z of [box.min.z, box.max.z]) { + v.set(x, y, z).sub(center); + // A corner at depth `toward` (toward the camera) needs this much distance to stay in frame. + const toward = v.dot(direction); + distance = Math.max(distance, toward + Math.abs(v.dot(right)) / tanH, toward + Math.abs(v.dot(up)) / tanV); + } + distance = Math.max(distance * FIT_MARGIN, radius * 1e-3); + return { center, direction, distance, near: Math.max(distance / 1000, 1e-4), far: distance + radius * 100 }; +} + +export interface SavedView { + position: THREE.Vector3; + quaternion: THREE.Quaternion; + fov: number; + near: number; + far: number; +} + +/** + * The .blend scene's active camera as a three.js view (Y-up, matching + * buildBlendObject's root rotation). The FOV widens on narrow screens so the + * author's whole render frame stays visible. Null for no / orthographic camera. + */ +export function savedCameraView(data: BlendSceneData, aspect: number): SavedView | null { + const obj = data.objects.find(o => o.kind === 'camera' && o.name === data.activeCamera); + const cam = obj?.dataName ? data.cameras.get(obj.dataName) : undefined; + if (!obj || !cam || cam.ortho) return null; + + // Blender's sensor fit: 'auto' applies the sensor to the larger render dimension. + const renderAspect = data.renderAspect > 0 ? data.renderAspect : 16 / 9; + const vertical = cam.sensorFit === 'vertical' || (cam.sensorFit === 'auto' && renderAspect < 1); + const sensor = cam.sensorFit === 'vertical' ? cam.sensorHeight : cam.sensorWidth; + const frameHeight = vertical ? sensor : sensor / renderAspect; + const needed = Math.max(frameHeight, (frameHeight * renderAspect) / Math.max(aspect, 1e-3)); + const fov = (2 * Math.atan(needed / (2 * cam.lens)) * 180) / Math.PI; + + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + new THREE.Matrix4().fromArray(obj.matrix).decompose(position, quaternion, new THREE.Vector3()); + const zUpToYUp = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Z_UP_TO_Y_UP); + position.applyQuaternion(zUpToYUp); + quaternion.premultiply(zUpToYUp); + return { position, quaternion, fov: Math.min(Math.max(fov, 1), 150), near: cam.near, far: cam.far }; } /** Free every geometry, material and texture under `root`. */ diff --git a/src/tools/documents/model3d-stage.ts b/src/tools/documents/model3d-stage.ts index 310cfd3..c49084f 100644 --- a/src/tools/documents/model3d-stage.ts +++ b/src/tools/documents/model3d-stage.ts @@ -8,7 +8,7 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; -import { disposeObject, fitCameraParams } from './model3d-scene.lib'; +import { DEFAULT_VIEW_DIR, disposeObject, fitCameraParams, type SavedView } from './model3d-scene.lib'; import type { LoadedModel } from './model3d-load.lib'; export class NoWebGLError extends Error { @@ -20,10 +20,12 @@ export class NoWebGLError extends Error { export type LightMode = 'studio' | 'file'; +const DEFAULT_FOV = 45; + export class ModelStage { private readonly renderer: THREE.WebGLRenderer; private readonly scene = new THREE.Scene(); - private readonly camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000); + private readonly camera = new THREE.PerspectiveCamera(DEFAULT_FOV, 1, 0.01, 1000); private readonly controls: OrbitControls; private readonly envTexture: THREE.Texture; private readonly fillLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.4); @@ -121,7 +123,26 @@ export class ModelStage { model.root.updateMatrixWorld(true); if (model.animations.length) this.mixer = new THREE.AnimationMixer(model.root); this.rebuildGrid(); - this.fit(); + this.camera.fov = DEFAULT_FOV; + const saved = model.savedView?.(this.camera.aspect); + if (saved) this.applyView(saved); + else this.fit(false); + } + + /** Start from the file's own camera; orbit around the model point it looks at. */ + private applyView(view: SavedView) { + this.camera.fov = view.fov; + this.camera.near = view.near; + this.camera.far = view.far; + this.camera.position.copy(view.position); + this.camera.quaternion.copy(view.quaternion); + this.camera.updateProjectionMatrix(); + const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(view.quaternion); + const center = new THREE.Box3().setFromObject(this.model!.root).getCenter(new THREE.Vector3()); + const depth = Math.max(center.sub(view.position).dot(forward), view.near * 10, 0.1); + this.controls.target.copy(view.position).addScaledVector(forward, depth); + this.controls.update(); + this.dirty = true; } private clearModel() { @@ -134,13 +155,15 @@ export class ModelStage { } } - fit() { + /** Frame the whole model — from the current viewing angle, or the default ¾ view. */ + fit(keepAngle = true) { if (!this.model) return; const box = new THREE.Box3().setFromObject(this.model.root); - const p = fitCameraParams(box, this.camera.fov, this.camera.aspect); + const direction = keepAngle ? this.camera.position.clone().sub(this.controls.target) : DEFAULT_VIEW_DIR; + const p = fitCameraParams(box, this.camera.fov, this.camera.aspect, direction); this.camera.near = p.near; this.camera.far = p.far; - this.camera.position.copy(p.center).add(new THREE.Vector3(1, 0.7, 1).normalize().multiplyScalar(p.distance)); + this.camera.position.copy(p.center).addScaledVector(p.direction, p.distance); this.camera.updateProjectionMatrix(); this.controls.target.copy(p.center); this.controls.update();