fix(viewer): make the first camera fit uncancellable and frame to the real frustum - #691
Merged
Conversation
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
enabled auto-merge
August 25, 2026 23:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.currentis stamped when the fit is requested (line 58), but the fit is delivered over a ~600 ms lerp inuseFrame— and that lerp is cancellable:onUserInteractStartnullstargetStateon OrbitControls'startevent. Once cancelled, every later run of the effect hitsif (boundsStable) returnunder 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 abox(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:
(55.60, 52.72, 43.06)(56.32, 53.31, 43.21)— never moves again(45.50, 44.49, 41.08)(46.77, 45.52, 41.33)— never moves againOn 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:
targetStategainsimmediate, set whenlastFitBounds.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 whilecontrolsis still null — retiring one before OrbitControls publishes itself lets its origin-initialisedtargetre-aim the camera at(0,0,0).2. The fit distance is wrong — no interaction needed
distance = radius * 2.8is wrong twice: framing a sphere needsradius / sin(fov/2)(2.924 at fov 40), not the tangent (2.747); andcamera.fovis 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)usingsinof 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, wrappingrenderer.render(scene, camera)to log the camera every frame. The page reportedv0.15.0 (115a262)— exactlydevelopHEAD.That trace disproved two plausible theories: there is no demand frameloop (
frameloop/invalidate()appear nowhere insrc/), and nothing else writes the Studio camera (CameraHandler.tsxis 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.tsx→FunnelViewer→ the sameViewer→ the sameCameraHandler.Verification
tsc --noEmitclean; eslint clean on all 3 changed files.npx vitest run src/studio→ 100 files, 660 tests, all passing.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.Assertions are on the camera pose the controller actually produces, driven through the real
three-stdlibOrbitControls — not on pixels.Not verifiable here
No local browser check:
npx vitewon't start on this machine —Cannot find module '../lightningcss.darwin-arm64.node', the same broken native binary behind the knownviteReviewLiveEndpointfailure. It blocksnpm run devandvite buildgenerally, not just this change. The live-renderer probing above was done against deployed production instead.