diff --git a/src/studio/components/viewer/controllers/CameraHandler.fit.test.tsx b/src/studio/components/viewer/controllers/CameraHandler.fit.test.tsx new file mode 100644 index 000000000..34dccf6ed --- /dev/null +++ b/src/studio/components/viewer/controllers/CameraHandler.fit.test.tsx @@ -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(); + + rerender(); + // 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(); + rerender(); + 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(); + rerender(); + 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(); + rerender(); + runFrames(240); + + camera.position.set(400, 400, 400); + rerender( + , + ); + 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(); + rerender(); + 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(); + 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(); + rerender(); + 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(); + runFrames(2); + act(() => { controls.dispatchEvent({ type: 'start' }); }); + const grabbed = camera.position.clone(); + runFrames(240); + + expect(camera.position.distanceTo(grabbed)).toBeLessThan(0.001); + }); +}); diff --git a/src/studio/components/viewer/controllers/CameraHandler.tsx b/src/studio/components/viewer/controllers/CameraHandler.tsx index f7ff18e57..516e7bcd4 100644 --- a/src/studio/components/viewer/controllers/CameraHandler.tsx +++ b/src/studio/components/viewer/controllers/CameraHandler.tsx @@ -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. @@ -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); @@ -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(); @@ -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); @@ -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); @@ -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; } }); diff --git a/src/studio/components/viewer/controllers/cameraPose.ts b/src/studio/components/viewer/controllers/cameraPose.ts index 0b9efd410..f93917a43 100644 --- a/src/studio/components/viewer/controllers/cameraPose.ts +++ b/src/studio/components/viewer/controllers/cameraPose.ts @@ -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 {