Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/tools/documents/blend-scene.lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const jb = vi.hoisted(() => ({
extractMaterials: vi.fn(),
extractLights: vi.fn(),
extractCameras: vi.fn(),
extractScenes: vi.fn(),
}));
vi.mock('jsblender', () => jb);

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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([]);
});

Expand All @@ -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]));
Expand Down
21 changes: 20 additions & 1 deletion src/tools/documents/blend-scene.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -57,6 +62,10 @@ export interface BlendSceneData {
materials: Map<string, BlendMaterialData>;
lights: Map<string, BlendLightData>;
cameras: Map<string, BlendCameraData>;
/** 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[];
}
Expand Down Expand Up @@ -224,8 +233,18 @@ export async function blendToSceneData(bytes: Uint8Array): Promise<BlendSceneDat
near: c.clipStart > 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,
};
}
13 changes: 11 additions & 2 deletions src/tools/documents/model3d-load.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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). */
Expand Down Expand Up @@ -164,12 +167,18 @@ export async function loadModel(src: ModelSource): Promise<LoadedModel> {

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) {
Expand Down
101 changes: 95 additions & 6 deletions src/tools/documents/model3d-scene.lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
computeSceneStats,
sceneTree,
fitCameraParams,
savedCameraView,
disposeObject,
DEFAULT_VIEW_DIR,
} from './model3d-scene.lib';
import type { BlendSceneData } from './blend-scene.lib';

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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> = {}): 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();
Expand Down Expand Up @@ -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: [],
};

Expand Down
77 changes: 69 additions & 8 deletions src/tools/documents/model3d-scene.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down
Loading
Loading