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
203 changes: 203 additions & 0 deletions src/studio/components/viewer/controllers/CameraHandler.fit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
// @vitest-environment happy-dom
//
// Regression cover for the two ways the viewer used to hand a visitor an
// unusable first frame:
//
// 1. The initial framing was a ~600ms tween that ANY pointer-down on the
// canvas cancelled, and `lastFitBounds` had already been stamped so no
// later run re-issued it. The camera stayed wherever the tween had got to
// — for a model straddling the default (40,40,40) camera, that is inside
// the part, i.e. a blank embed until the viewer finds the home button.
// 2. `distance = radius * 2.8` framed the bounding sphere with a
// tangent-derived constant and no aspect term, so the model overflowed the
// frame — mildly at 16:9, badly on anything portrait.
//
// Both are asserted on the camera the controller actually produces: does the
// scene's bounding sphere sit inside the resulting frustum?

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, render } from '@testing-library/react';
import * as THREE from 'three';
import { OrbitControls } from 'three-stdlib';
import type { GeometryResult } from '../../../../shared/worker/geometryEngine';

interface FiberState { camera: THREE.PerspectiveCamera; controls: OrbitControls | null }

let fiberState: FiberState;
let frameCallback: ((state: unknown, delta: number) => void) | null = null;
let sketchMode: { active: boolean; plane?: unknown } = { active: false };

vi.mock('@react-three/fiber', () => ({
useThree: () => fiberState,
// R3F keeps the LATEST callback in a mutable ref; mirror that.
useFrame: (cb: (state: unknown, delta: number) => void) => { frameCallback = cb; },
}));

vi.mock('../../../context/WorkbenchContext', () => ({
useWorkbench: () => ({ selectedFace: null, sketchMode }),
}));

const { CameraHandler } = await import('./CameraHandler');

/** One geometry whose vertex cloud spans `min`..`max`. */
function boxGeometry(min: [number, number, number], max: [number, number, number]): GeometryResult {
const v = [...min, ...max];
return {
faces: [{
vertices: new Float32Array(v),
indices: new Uint32Array(),
normals: new Float32Array(v.length),
faceId: 0,
}],
} as GeometryResult;
}

function boundingSphere(min: [number, number, number], max: [number, number, number]) {
const lo = new THREE.Vector3(...min);
const hi = new THREE.Vector3(...max);
return {
center: lo.clone().add(hi).multiplyScalar(0.5),
radius: Math.max(lo.distanceTo(hi) / 2, 1),
};
}

/** Signed slack, in world units, of the tightest frustum plane. Negative = clipped. */
function frustumSlack(camera: THREE.PerspectiveCamera, center: THREE.Vector3, radius: number): number {
camera.updateMatrixWorld(true);
camera.updateProjectionMatrix();
const frustum = new THREE.Frustum().setFromProjectionMatrix(
new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse),
);
return Math.min(...frustum.planes.map((p) => p.distanceToPoint(center) - radius));
}

function mountViewer(width: number, height: number) {
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 1000);
camera.position.set(40, 40, 40);
camera.lookAt(0, 0, 0);
const canvas = document.createElement('canvas');
const controls = new OrbitControls(camera, canvas as unknown as HTMLElement);
fiberState = { camera, controls };
return { camera, controls };
}

function runFrames(count: number) {
act(() => {
for (let i = 0; i < count; i += 1) frameCallback?.({}, 1 / 60);
});
}

beforeEach(() => {
frameCallback = null;
sketchMode = { active: false };
});

afterEach(() => {
cleanup();
fiberState?.controls?.dispose();
});

describe('CameraHandler initial framing', () => {
// A model that straddles the default camera: the corner (40,30,20) sits
// right beside the default eye at (40,40,40), so a framing that never
// lands leaves the viewer staring at (or inside) the part.
const MIN: [number, number, number] = [0, 0, 0];
const MAX: [number, number, number] = [40, 30, 20];

it('frames the model even when the viewer touches the canvas as it loads', () => {
const { camera, controls } = mountViewer(1280, 720);
const { rerender } = render(<CameraHandler geometries={[]} />);

rerender(<CameraHandler geometries={[boxGeometry(MIN, MAX)]} />);
// The visitor's pointer lands on the canvas the instant geometry shows
// up. OrbitControls fires 'start'; the old code dropped the framing.
act(() => { controls.dispatchEvent({ type: 'start' }); });
runFrames(240);

const sphere = boundingSphere(MIN, MAX);
expect(frustumSlack(camera, sphere.center, sphere.radius)).toBeGreaterThan(0);
});

it('frames the model inside the frustum on a 16:9 viewport', () => {
const { camera } = mountViewer(1280, 720);
const { rerender } = render(<CameraHandler geometries={[]} />);
rerender(<CameraHandler geometries={[boxGeometry(MIN, MAX)]} />);
runFrames(240);

const sphere = boundingSphere(MIN, MAX);
expect(frustumSlack(camera, sphere.center, sphere.radius)).toBeGreaterThan(0);
});

it('frames the model inside the frustum on a portrait viewport', () => {
// camera.fov is the VERTICAL angle, so a tall viewport has a much
// narrower horizontal one — the axis that actually crops.
const { camera } = mountViewer(420, 780);
const { rerender } = render(<CameraHandler geometries={[]} />);
rerender(<CameraHandler geometries={[boxGeometry(MIN, MAX)]} />);
runFrames(240);

const sphere = boundingSphere(MIN, MAX);
expect(frustumSlack(camera, sphere.center, sphere.radius)).toBeGreaterThan(0);
});

it('re-frames on the home/fit request', () => {
const { camera } = mountViewer(1280, 720);
const geometries = [boxGeometry(MIN, MAX)];
const { rerender } = render(<CameraHandler geometries={[]} />);
rerender(<CameraHandler geometries={geometries} />);
runFrames(240);

camera.position.set(400, 400, 400);
rerender(
<CameraHandler geometries={geometries} navigationRequest={{ target: 'fit', id: 1 }} />,
);
runFrames(240);

const sphere = boundingSphere(MIN, MAX);
expect(frustumSlack(camera, sphere.center, sphere.radius)).toBeGreaterThan(0);
});
});

describe('CameraHandler anti-twitch behaviour', () => {
const MIN: [number, number, number] = [0, 0, 0];
const MAX: [number, number, number] = [40, 30, 20];

it('leaves the orbit alone when a param nudge wobbles the bounds', () => {
const { camera } = mountViewer(1280, 720);
const { rerender } = render(<CameraHandler geometries={[]} />);
rerender(<CameraHandler geometries={[boxGeometry(MIN, MAX)]} />);
runFrames(240);

// The user orbits away from the fitted pose.
const orbited = new THREE.Vector3(-90, 20, 130);
camera.position.copy(orbited);

// A slider moves a joint: the tessellated AABB shifts a hair.
rerender(<CameraHandler geometries={[boxGeometry([0, 0, 0], [40.2, 30.1, 20.05])]} />);
runFrames(240);

expect(camera.position.distanceTo(orbited)).toBeLessThan(0.001);
});

it('still yields to the user when a real shape change re-fits', () => {
const { camera, controls } = mountViewer(1280, 720);
const { rerender } = render(<CameraHandler geometries={[]} />);
rerender(<CameraHandler geometries={[boxGeometry(MIN, MAX)]} />);
runFrames(240);

const orbited = new THREE.Vector3(-90, 20, 130);
camera.position.copy(orbited);

// A genuinely different model — this one is allowed to re-fit, but the
// re-fit is a tween the user can grab out of at any time.
rerender(<CameraHandler geometries={[boxGeometry([0, 0, 0], [400, 300, 200])]} />);
runFrames(2);
act(() => { controls.dispatchEvent({ type: 'start' }); });
const grabbed = camera.position.clone();
runFrames(240);

expect(camera.position.distanceTo(grabbed)).toBeLessThan(0.001);
});
});
62 changes: 45 additions & 17 deletions src/studio/components/viewer/controllers/CameraHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useWorkbench } from "../../../context/WorkbenchContext";
import type { GeometryResult } from "../../../../shared/worker/geometryEngine";
import type { ViewportFocusTarget } from "../../../store/shellStore";
import { computeGeometryBounds } from "./cameraBounds";
import { buildCameraPose, buildFitCameraPose, type ViewTarget } from "./cameraPose";
import { buildCameraPose, buildFitCameraPose, fitDistanceForCamera, type ViewTarget } from "./cameraPose";
import { filterGeometriesForFocusTarget } from "./focusTarget";

// Using the exported constants if needed, but they are defined in Viewer.tsx conventionally.
Expand All @@ -26,7 +26,14 @@ export function CameraHandler({
const { selectedFace, sketchMode } = useWorkbench();
const { camera, controls } = useThree();
const cameraRef = useRef(camera);
const targetState = useRef<{ position: THREE.Vector3; lookAt: THREE.Vector3; } | null>(null);
// `immediate` marks the FIRST framing of a scene: the camera has never
// pointed at this model, so there is nothing to animate from and nothing
// for a tween to fight. It is applied in one frame and cannot be cancelled.
const targetState = useRef<{
position: THREE.Vector3;
lookAt: THREE.Vector3;
immediate?: boolean;
} | null>(null);
const prevSketchActive = useRef(false);
const savedCameraState = useRef<{ position: THREE.Vector3; target: THREE.Vector3; } | null>(null);
const lastFitBounds = useRef<{ center: THREE.Vector3; radius: number } | null>(null);
Expand Down Expand Up @@ -55,12 +62,20 @@ export function CameraHandler({
&& Math.abs(bounds.radius - last.radius) <= tolerance,
);
if (boundsStable) return;
// The first framing of a scene has to LAND. `lastFitBounds` is stamped
// when the fit is requested, not when the camera arrives, so a fit that
// is abandoned mid-flight is never retried — `boundsStable` suppresses
// every later run. That is how a published embed ended up showing a
// blank viewport until the viewer found the home button: one pointer-
// down on the canvas during the ~600ms tween cancelled the only fit
// this scene would ever get. So deliver it in a single frame instead.
const isFirstFit = lastFitBounds.current === null;
lastFitBounds.current = { center: bounds.center.clone(), radius: bounds.radius };

const distance = Math.max(bounds.radius * 2.8, SKETCH_DISTANCE);
const distance = Math.max(fitDistanceForCamera(bounds.radius, cameraRef.current), SKETCH_DISTANCE);
const pose = buildFitCameraPose(bounds.center, distance);
cameraRef.current.up.copy(pose.up);
targetState.current = { position: pose.position, lookAt: pose.lookAt };
targetState.current = { position: pose.position, lookAt: pose.lookAt, immediate: isFirstFit };
cameraRef.current.near = Math.max(distance / 500, 0.01);
cameraRef.current.far = Math.max(distance * 20, 1000);
cameraRef.current.updateProjectionMatrix();
Expand All @@ -72,7 +87,7 @@ export function CameraHandler({
const bounds = computeGeometryBounds(geometries);
if (!bounds) return;

const distance = Math.max(bounds.radius * 2.8, SKETCH_DISTANCE);
const distance = Math.max(fitDistanceForCamera(bounds.radius, cameraRef.current), SKETCH_DISTANCE);
const pose = navigationRequest.target === 'fit'
? buildFitCameraPose(bounds.center, distance)
: buildCameraPose(navigationRequest.target, bounds.center, distance);
Expand All @@ -93,7 +108,7 @@ export function CameraHandler({
const bounds = computeGeometryBounds(focusedGeometries);
if (!bounds) return;

const distance = Math.max(bounds.radius * 2.8, SKETCH_DISTANCE);
const distance = Math.max(fitDistanceForCamera(bounds.radius, cameraRef.current), SKETCH_DISTANCE);
const pose = buildFitCameraPose(bounds.center, distance);
cameraRef.current.up.copy(pose.up);
cameraRef.current.near = Math.max(distance / 500, 0.01);
Expand Down Expand Up @@ -158,24 +173,37 @@ export function CameraHandler({
removeEventListener?: (type: string, fn: () => void) => void;
} | null;
if (!ctrl?.addEventListener) return;
const onUserInteractStart = () => { targetState.current = null; };
const onUserInteractStart = () => {
// ...but an in-flight FIRST framing is not the user's orbit being
// fought — it is the only thing that has ever pointed the camera at
// the model, and it is gone for good if dropped (the fit is already
// recorded in `lastFitBounds`, so no later run will re-issue it).
if (targetState.current?.immediate) return;
targetState.current = null;
};
ctrl.addEventListener('start', onUserInteractStart);
return () => ctrl.removeEventListener?.('start', onUserInteractStart);
}, [controls]);

useFrame((_state, delta) => {
if (!targetState.current) return;
const dampFactor = 5.0 * delta;
camera.position.lerp(targetState.current.position, dampFactor);
const target = targetState.current;
if (!target) return;
// A first framing is not animated: lerping at 1 sets the pose exactly.
const dampFactor = target.immediate ? 1 : 5.0 * delta;
camera.position.lerp(target.position, dampFactor);
const ctrl = controls as unknown as { target: THREE.Vector3, update: () => void };
if (ctrl && ctrl.target) {
ctrl.target.lerp(targetState.current.lookAt, dampFactor);
ctrl.update();
} else {
camera.lookAt(targetState.current.lookAt);
if (!ctrl?.target) {
// OrbitControls has not published itself to the store yet. Point the
// camera, but KEEP the pose pending: controls initialise their orbit
// target to the origin, so a framing retired before they exist gets
// silently re-aimed at (0,0,0) on their first update.
camera.lookAt(target.lookAt);
return;
}
if (camera.position.distanceTo(targetState.current.position) < 0.1 &&
(ctrl?.target?.distanceTo(targetState.current.lookAt) || 0) < 0.1) {
ctrl.target.lerp(target.lookAt, dampFactor);
ctrl.update();
if (camera.position.distanceTo(target.position) < 0.1 &&
ctrl.target.distanceTo(target.lookAt) < 0.1) {
targetState.current = null;
}
});
Expand Down
41 changes: 41 additions & 0 deletions src/studio/components/viewer/controllers/cameraPose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,47 @@ export function buildCameraPose(
};
}

/**
* Distance at which a bounding sphere of `radius` sits fully inside a
* perspective frustum.
*
* The constant this replaces (`radius * 2.8`) was wrong on two counts.
*
* 1. It under-shoots even the vertical limit. Framing a *sphere* needs
* `radius / sin(fov/2)`; only a flat disc facing the camera needs
* `radius / tan(fov/2)`. At the viewer's 40° fov that is 2.924 vs 2.747 —
* 2.8 sits between them, so the model always overflowed slightly.
* 2. It ignores the aspect ratio. `camera.fov` is the VERTICAL angle, so the
* horizontal one is narrower on any viewport taller than it is wide, and
* the narrow axis is the one that crops. A 420x780 embed needs 5.2 * radius;
* 2.8 put the camera less than half far enough away.
*
* `margin` leaves a little air around the model instead of framing it
* edge-to-edge.
*/
export function fitDistance(
radius: number,
fovDegrees: number,
aspect: number,
margin = 1.08,
): number {
const safeFov = Number.isFinite(fovDegrees) && fovDegrees > 0 && fovDegrees < 180
? fovDegrees
: 40;
// Aspect is 0/NaN until the canvas has been measured once; a square
// viewport is the neutral assumption (it never under-shoots a wide one).
const safeAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
const halfVertical = (safeFov * Math.PI) / 360;
const halfHorizontal = Math.atan(Math.tan(halfVertical) * safeAspect);
return (radius * margin) / Math.sin(Math.min(halfVertical, halfHorizontal));
}

/** Same, reading the fov/aspect off a camera that may not be perspective. */
export function fitDistanceForCamera(radius: number, camera: THREE.Camera): number {
const perspective = camera as THREE.PerspectiveCamera;
return fitDistance(radius, perspective.fov, perspective.aspect);
}

export function buildFitCameraPose(center: THREE.Vector3, distance: number): CameraPose {
const direction = new THREE.Vector3(1, 1, 0.75).normalize();
return {
Expand Down
Loading