Skip to content

fix(viewer): make the first camera fit uncancellable and frame to the real frustum - #691

Merged
w1ne merged 1 commit into
developfrom
fix/viewer-camera-fit
Aug 25, 2026
Merged

fix(viewer): make the first camera fit uncancellable and frame to the real frustum#691
w1ne merged 1 commit into
developfrom
fix/viewer-camera-fit

Conversation

@w1ne

@w1ne w1ne commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Embedded models can display an empty viewport. Two independent defects in the same area, both fixed here.

1. A cancelled first fit is never re-issued

lastFitBounds.current is stamped when the fit is requested (line 58), but the fit is delivered over a ~600 ms lerp in useFrame — and that lerp is cancellable: onUserInteractStart nulls targetState on OrbitControls' start event. Once cancelled, every later run of the effect hits if (boundsStable) return under the 10 % tolerance, so the fit is never re-issued. The camera strands wherever the tween happened to be, permanently.

The default eye (40,40,40) sits right on the corner of a box(40,30,20) (which spans [0,40]×[0,30]×[0,20]), so an early abort leaves the camera at or inside the part — a blank frame.

Measured on the live embed, one click during the tween:

click camera at click camera at t=10 s
120 ms after mesh (55.60, 52.72, 43.06) (56.32, 53.31, 43.21) — never moves again
immediately (45.50, 44.49, 41.08) (46.77, 45.52, 41.33) — never moves again

On an embed this is very likely to fire: viewers scroll or tap the moment a page loads, and the damage is permanent rather than recoverable.

Fix: targetState gains immediate, set when lastFitBounds.current === null. A first framing lands in one frame and refuses cancellation; later re-fits keep the tween and keep yielding to the user. A pending pose is also held while controls is still null — retiring one before OrbitControls publishes itself lets its origin-initialised target re-aim the camera at (0,0,0).

2. The fit distance is wrong — no interaction needed

distance = radius * 2.8 is wrong twice: framing a sphere needs radius / sin(fov/2) (2.924 at fov 40), not the tangent (2.747); and camera.fov is the vertical angle, with no aspect term at all. So a portrait or narrow viewport overflows.

Live probe, untouched, 420×780: shipped distance 75.39, required 151.2 — the model overflowed all four edges. At 16:9 it needs 85.0 vs 75.4, which is the ~1° of clipping visible at the frame edges.

Fix: fitDistance(radius, fov, aspect, margin=1.08) using sin of the narrower of the two half-angles, used by all three fit paths (auto, home, focus).

How it was diagnosed

Not by reading. Playwright + real Chrome against production with window.__THREE_DEVTOOLS__ defined before load, wrapping renderer.render(scene, camera) to log the camera every frame. The page reported v0.15.0 (115a262) — exactly develop HEAD.

That trace disproved two plausible theories: there is no demand frameloop (frameloop/invalidate() appear nowhere in src/), and nothing else writes the Studio camera (CameraHandler.tsx is the only writer; demoPlayer/* is a separate viewer). With zero interaction the auto-fit does complete on its own by t≈3 s.

/p/<slug> shares the cause — embed.$slug.tsxFunnelViewer → the same Viewer → the same CameraHandler.

Verification

  • tsc --noEmit clean; eslint clean on all 3 changed files.
  • npx vitest run src/studio100 files, 660 tests, all passing.
  • Negative control (re-run independently): reverting both source files with the test kept fails 4 tests — expected -12.11 to be greater than 0, -1.18, -12.45, -1.11. The value is the signed slack of the tightest frustum plane in world units; negative means clipped. Restored → 6 passed.
  • The two anti-twitch tests pass in both states by design: a param nudge must not wipe the user's orbit, and a genuine shape change must still re-fit with a grabbable tween. They prove the documented behaviour survives.

Assertions are on the camera pose the controller actually produces, driven through the real three-stdlib OrbitControls — not on pixels.

Not verifiable here

No local browser check: npx vite won't start on this machine — Cannot find module '../lightningcss.darwin-arm64.node', the same broken native binary behind the known viteReviewLiveEndpoint failure. It blocks npm run dev and vite build generally, not just this change. The live-renderer probing above was done against deployed production instead.

An embed opened at app.kernelcad.com showed a blank viewport until the
viewer clicked the home control. The auto-fit was running and computing the
right pose — a Chrome trace of WebGLRenderer.render on the published embed
shows the camera reaching (67.03, 62.04, 45.31), exactly buildFitCameraPose
for that model — but it got there over a ~600ms lerp, and the lerp is
cancelled by OrbitControls' 'start' event so the user can grab the camera.

`lastFitBounds` is stamped when the fit is REQUESTED, not when the camera
arrives, so a cancelled first fit is never re-issued: `boundsStable`
suppresses every later run of the effect. One pointer-down on the canvas
while the model loads therefore strands the camera permanently. Measured on
the live embed: a click 120ms after the mesh appeared left the camera at
(56.32, 53.31, 43.21) and it never moved again.

A first framing has nothing to animate from, so deliver it in one frame and
make it uncancellable. Later re-fits keep the tween and keep yielding to the
user. Also hold a pending pose while `controls` is still null — retiring one
before OrbitControls exists lets its origin-initialised target re-aim the
camera at (0,0,0).

Separately, the framing distance was wrong: `radius * 2.8` frames a flat
disc (tan), not a sphere (sin, 2.924 at fov 40), and has no aspect term at
all, while camera.fov is the VERTICAL angle. A 420x780 embed needs
5.2 * radius; the model overflowed every edge. fitDistance() now derives the
distance from the camera's own fov and aspect, using the narrower axis.
@w1ne
w1ne enabled auto-merge August 25, 2026 23:15
@w1ne
w1ne merged commit 07cf029 into develop Aug 25, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant