feat(camera): perceptually uniform focus moves - #531
Open
rulkens wants to merge 24 commits into
Open
Conversation
Focus moves ease target and distance as independent channels; the eye sees their composition, so neither channel's curve controls what it perceives. Replaces both with one geodesic through van Wijk & Nuij's (u, w) view space and derives duration from path length. The prior attempt (5da4fea, reverted in e7acdc5) flipped only the distance channel to log space. That is the correct solution to the paper's DEGENERATE case — pure zoom, where w(s) = w0·exp(kρs) — applied to the general one, which is why it moved nothing on a star approach and introduced a direction reversal zooming out. Ground preparation found the joint half-built: PathTrack is already a composite writer superseding the base layer for all four channels, and buildPathTrack's ds = sqrt(angular² + dLog²) IS the van Wijk metric at ρ = 1. The only missing joint is that supersession is all-four-or-nothing, so a writer owning target+distance while yaw/pitch stay independent is inexpressible. Per-move duration needs no prep — CameraTweenDescriptor.durationMs is already per-descriptor and the wake gate reads presence, not elapsed. Two numerical landmines are recorded because both fail silently and both bite at skymap's scale range: the paper's r_i formula returns -Infinity for three of four real moves (use asinh), and its u(s) subtracts two ~1e11 terms, returning exactly 0 for a six-decade zoom with a small pan. Also records that planets never reach this path at all — watchFocusTweenSaga bails for any row liveBodyPosition resolves, so planet approach is followBody's lerp. Famous stars do fall through to the tween. That third path gets a backlog item rather than a fix here; its target moves every frame, so sharing the fixed-endpoint evaluator may not be right. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
skymap | f0148ac | Commit Preview URL Branch Preview URL |
Jul 31 2026, 09:07 PM |
Swept rho in [0.8, 3.0] against real skymap constants. Settled on rho=1.42, V=6, duration clamped to [0.4s, 4s]. The sweep changed the framing: rho is near-irrelevant for the move that motivated this work. Star -> Milky Way peaks at exactly 1.00x its endpoint distance at every rho, because the destination view is already ~21x wider than the journey. The wobble is fixed by the coupling, not the pull-back. Also settles the ease default to 'linear' -- constant s velocity is the model's entire claim, and an ease-out is what produces the 'grows in the last two frames' symptom in the first place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-measured star -> Milky Way in full 3-D, sweeping the angle between the view direction and the direction of travel. e7acdc5 was right and the prototype was wrong: today's path has 0 reversals at every angle and path/net displacement of exactly 1.0000. That is forced, not lucky. With yaw/pitch unchanged the view direction F is fixed, so cam(t) = cam(0) + e(t)·[delta·uhat - (d1-d0)·F] -- a fixed vector scaled by a monotone scalar. No ease and no view angle bends it. The geodesic scores 0 too, so the metric cannot distinguish the models and must not become the pinning test. Replaced it with perceived-velocity uniformity, which is what the model actually claims: CV 0.000 for the glide against 15.30 / 3.64 / 2.49 / 0.89 for four sampled moves today. Second result: for galaxy -> galaxy the current channel model is already perfectly uniform once the ease is linear. easeOutCubic alone inflates it to 4.80e7. For the most common focus click the ease is the whole defect and the geodesic changes nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 TDD tasks. Prep P1 (composite track declares its channels) and P2 (fovYRad through the compile path) sequenced first as their own commits, per spec section 6. Task 1 is the ease change, sequenced ahead of the geodesic because the measurement showed it is independently valuable: for galaxy -> galaxy the current channel model is already perfectly uniform once the ease is linear. It also exposes a real bug -- CameraTweenDescriptor.easing is never read; tweenToClip hardcodes easeOutCubic on all four channels. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o linear CameraTweenDescriptor.easing was never read -- tweenToClip hardcoded easeOutCubic on all four channels, so the field was decorative and #530's widening of it to the full Ease union had no effect on this path. Both focus-tween producers now ask for 'linear'. Measured under the van Wijk & Nuij metric, a galaxy -> galaxy move (the commonest focus click) is already perfectly uniform under the current two-channel model once the ease is linear -- coefficient of variation 0.000 against 0.89, max/min 1.00 against 4.80e7. An ease-out spends its last decade of scale in its last few frames, which is the 'arrives too fast' symptom itself. Plan task 1 of 10: docs/superpowers/plans/2026-07-31-perceptually-uniform-focus-moves.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…siteTracks Mechanical rename only, no behaviour change. A flyPath is one kind of composite writer; a glide will be another, owning a subset of channels. Docblocks now describe the general shape rather than 'a flyPath'. Symbol rename via npm run refactor; the object field and the module-private validator renamed by hand (the CLI does exported symbols). Plan task 2 of 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spec section 6, prep P1. CompositeTrack gains channels: readonly Channel[] and sample returns Partial<CameraPose>; the evaluator resolves each channel independently, falling back to the base layer for channels no active track declares. validateCompositeExclusivity checks the declared set instead of looping ALL_CHANNELS. Behaviour-neutral: flyPath declares all four channels, so the suite passing unchanged is the deliverable. Verified by mutation -- declaring only 'distance' makes 3 flyPath tests fail on yaw/pitch/target, which is what proves the evaluator reads the declared channel set rather than trusting whatever keys sample() returns. That distinction is load-bearing: the declared set is what validateCompositeExclusivity checked against, so a track returning an extra key would otherwise silently override a base writer the validator had already allowed. ALL_CHANNELS moves to channelSpace so buildPathTrack can import it without a cycle back through compileClip. Plan task 3 of 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rulkens
force-pushed
the
worktree-focus-move-interpolation
branch
from
July 31, 2026 10:55
901c88d to
67fe659
Compare
Spec section 6, prep P2. compileClip(data, frameBasis?, fovYRad?) and evaluateClip(data, elapsedSec, frameBasis?, fovYRad?); the compile cache keys on it too. Both cameraDrivers rows pass the live state.cameraRuntime.projection.fovYRad, matching what followBody already did. Behaviour-neutral: nothing reads fovYRad yet. It is a pure passthrough with no default applied here, mirroring frameBasis, whose "absent => identity" is likewise resolved by the consumer rather than by compileClip. The one test stub that needed extending is cameraDrivers.test.ts's FAKE_ENGINE_STATE; the other three buildCameraDrivers callers already carry projection.fovYRad or never reach a pose() that reads it. No optional chaining was added to paper over a missing field -- a missing projection in production should be a hard TypeError. Plan task 4 of 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pure model: eq. 9 parametrised by arc length in the perceptual metric, so constant ds/dt is constant perceived velocity. Unit-agnostic, no knowledge of CameraPose, Mpc or FOV. Two of the paper's printed formulas are replaced because they do not survive skymap's scale range, and both replacements are pinned by tests that were mutation-verified against the literal forms: r_i = asinh(-b_i), not ln(-b + sqrt(b^2+1)). Over a 1000-combination sweep the literal returns -Infinity in 379 cases and a wrong-but-finite value in 137 more -- 52% broken. b reaches 1e37 here because our moves are far more zoom-dominated than the 2-D map browsing the paper targets. u(s) = u0 + (w0/rho^2)*sinh(rho*s)/cosh(rho*s+r0), not the literal cosh(r0)*tanh(...) - sinh(r0), which subtracts two ~1e18 quantities whose difference is the answer and returns exactly u0 for small du. The test file transcribes eq. 9 verbatim as an independent oracle, in the ease.test.ts REF pattern -- but only exercises it at the paper's own scale, where the literal forms are still sound. At skymap's scale the verbatim transcription IS the bug, which is what the property tests pin. Plan task 5 of 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One home for the unit contract: w = 2*distance*tan(fovY/2) (fovY, not fovX, no aspect factor), du = |target1 - target0| with the scalar u lifted back onto the straight segment, and durationSec = clamp(length / V, MIN, MAX). It exists because both startCameraTween producers need a duration and cannot reach buildGlideTrack, so without it the conversion would be re-derived in three places -- which is exactly how the 2*tan factor goes missing in one of them. The target path is a straight line; the geodesic bows in w, never in world space. Pinned by a test asserting the cross product with the segment direction stays ~0 and the projection onto it is monotone. Plan task 6 of 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A glide is a composite writer over target + distance only, so yaw and pitch stay independent scalar tweens beside it (spec section 5.2). It goes on Effect, not CameraAction: every CameraAction arm carries a single ch: Channel and is a per-channel writer, which is why flyPath lives on Effect too. The compileClip arm returns track.endSec - atSec, NOT effect.over: with `over` omitted the duration only exists once the geodesic has been measured, and the timeline cursor has to move by the derived value or every following effect lands at the wrong time. This is the first writer to own a SUBSET of channels, so it is where P1's per-channel merge is finally proven end-to-end. The evaluateClip test pins the coupling rather than smoke-testing: at half time the move has 98% of its pan done while the zoom still has an order of magnitude to run, which no independently-eased pair of channels can produce. Nothing constructs a glide yet -- the focus path is still four independent channel tweens. Task 8 does the cutover. Known gap, recorded in the code: compositePoseAt's "copy only declared channels" guard cannot fail today. Both builders return exactly what they declare, pinned in their own tests, so the guard is a backstop for a third builder drifting. Verified by mutation -- replacing it with a blind Object.assign keeps all 1417 engine tests green. Plan task 7 of 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cutover. tweenToClip now emits one glide over target + distance plus scalar yaw/pitch tweens beside it, and both startCameraTween producers derive durationMs from glidePath(...).durationSec instead of the flat 600 ms FOCUS_TWEEN_MS. A hop across a galaxy and a descent to a planet surface stop taking the same time. Settles spec section 5.2: yaw/pitch stay independent scalar tweens. Van Wijk & Nuij does not model orientation, angles are scale-free so they never had the 1/d problem, and focus moves carry yaw/pitch through unchanged anyway. Verified by mutation -- reverting tweenToClip to the four independent channel tweens fails two tests, each on a signature value of the uncoupled model: 876.25 (lerp(10,1000,easeOutCubic(0.5))) and 60 (the arithmetic midpoint of 10..110). The cameraDrivers unit-slip oracle is rewritten, not deleted. Its descriptor is a pure zoom, so it now lands on the degenerate branch and the expectation becomes exponential: distance(f) = d0*(d1/d0)^f, the paper's own closed form re-derived in the test with rho cancelling. The >10 / <1000 bounds that actually catch a forgotten /1000 are unchanged. FOCUS_TWEEN_MS survives untouched as followBody's constant; only its docblock changed, because it claimed to be shared by every focus tween and that would have invited someone to wire it back. Plan task 8 of 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The paper's rho = 1.42 made a galaxy click rise to 8.79x its endpoint distance, which reads as an unwanted zoom-out. Low rho makes zooming expensive in the metric and panning cheap, so the path stops climbing; at 0.15 it never rises past its destination. V and the clamp come down together because the moves were also too long. Also measured and rejected: a strictly monotone path (straight in u, log w) that can never rise. Its arc length is 3.7e9 for star -> Milky Way against the geodesic's 19.8, and 1.4e17 for Earth -> universe -- panning at low altitude across a huge separation costs enormous perceived distance, which is exactly what the bow-out exists to avoid. The bow-out is load-bearing at large scale ratios; rho is the right lever. Known consequence, recorded in glideCalibration's docblock: below about rho = 0.3 the arc length goes bimodal, because the 1/rho^2 weight on the zoom term swamps the pan term. Same-scale moves collapse to ~1 unit and scale-changing ones explode past 180, so most moves now land on a clamp bound rather than a derived duration. Two tests had gone blind to that and are repaired rather than relaxed: glidePath's FOV test compared two durations that both pinned to the same clamp bound, so it could no longer detect a dropped 2*tan(fovY/2) factor. It now probes at(0.5).distance, which cannot be clamped. focusTweenDescriptor's duration test varied SEPARATION, which the arc length is barely sensitive to at this rho. It now varies the destination framing scale, which is what actually moves the length. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The visual pass overturned the deferral. With galaxy moves smooth, the planet target snap reads as a defect rather than a known gap. Recorded where the snap actually is, because it is not where it appears: followBody declares pivotsOnFocusedBody, so applyFocusedBodyPivot absolutely SETS target = bodyPosition + panOffset after the driver runs. The driver's own `target: livePos` is already dead code, and any interpolation inside the driver would be discarded. Two shapes were considered. Making the pin interpolate preserves the body-owns-the-pivot un-braid but leaves distance independently eased -- it would relocate this feature's defect to planets rather than remove it. So followBody instead opts out of the pin and authors its full pose, exactly as clip and tween already do and for the same stated reason. Backlog item removed in the same change, per the hygiene convention. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four sliders under settings.debug.glide, seeded from DEFAULT_GLIDE_TUNING so the numbers stay spelled once in glideCalibration. Picking rho by rebuilding was the bottleneck; it is a feel question and needs a live control. rho has to reach BOTH consumers or the slider lies: the duration derivation in the producers, and the compiled path shape in buildGlideTrack. It is carried on CameraTweenDescriptor next to easing, so the descriptor states the exact rho its durationMs was measured under and the two cannot disagree. glidePath's 4th param became Partial<GlideTuning>, read field-by-field with ?? rather than a spread: buildGlideTrack relays an absent knob as an explicit `undefined`, and a spread would overwrite the default with it -- silently sending rho: undefined into the geodesic for every authored clip glide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e pin The last path with the old feel. followBody drops pivotsOnFocusedBody and authors its full pose, exactly as clip and tween do and for the same stated reason -- they keyframe a path including the target. The snap was never in the driver. followBody's `target: livePos` was already dead code: applyFocusedBodyPivot absolutely SETS target = body + panOffset after the driver returns, so interpolating inside the driver would have changed nothing. Opting out is what moves the target. Making the pin interpolate instead was considered and rejected in the spec: it preserves the body-owns-the-pivot un-braid but leaves distance independently eased, relocating this feature's own defect to planets. The geodesic is re-measured every approach frame from the fixed capture to the CURRENT pivot -- the body moves under the approach, and van Wijk & Nuij's stateless recomputation is what absorbs a moving endpoint. Only the DURATION is frozen at activation, because shouldKeepTicking gates the render loop on that same field: a duration re-derived per frame would drift out from under the wake window and freeze the approach part-way, resuming only on the next input event. Mutation-verified -- restoring a flat 600 ms window fails 3 tests, in both the freeze and the waste direction. FOCUS_TWEEN_MS had no readers left; focusTweenDuration.ts is deleted. Two behaviour notes. Grabbing a drag mid-approach snaps the pivot -- the cost the spec accepted when followBody left the pin. Releasing one is strictly better than before: the re-capture edge now also ends the approach, where the old code lerped from a stale capture at an unsaturated t and yanked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ngularity rho = 0 was asked for. It is a SINGULARITY, not a small value: the metric's zoom term is 1/rho^2, so b divides by zero, asinh gives -Infinity, and length evaluates (Inf - Inf)/0 -> NaN. Nothing downstream rejects a NaN pose; it reaches the camera as a dead frame. The measurement gives the same intent safely: the path CONVERGES below ~0.05 -- numerically identical at 0.05, 0.02, 0.01 and 0.001, with a galaxy click sitting at exactly its endpoint distance mid-move and a pure zoom passing through the geometric mean. That is the rho->0 limit, so 0.05 is the default and glidePath floors rho at GLIDE_RHO_MIN. The guard has its own test, mutation-verified. Two tests had gone blind to the shorter clamp and are repaired, not relaxed. buildGlideTrack's "a longer path takes longer" compared against a destination that now saturates GLIDE_MAX_SEC, so it would have passed vacuously the moment any other move saturated; it now varies destination SCALE (what arc length is sensitive to at this rho) between two endpoints asserted to lie strictly inside the clamp. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The arrival was the ask: with `linear`, a deep descent spends only 8.8% of its wall clock (0.13s of 1.5s) with the target between 0.6x and 1x of its final apparent size. Measured on the same move: linear 8.8% 0.13s easeOutSine 26.9% 0.40s easeOutCubic 44.5% 0.67s easeOutQuart 54.5% 0.82s easeOutExpo 65.0% 0.97s This does not contradict the earlier argument for `linear`. The old easeOutCubic was harmful because it was applied to two INDEPENDENTLY eased channels in linear distance space, back-loading distance while the target ran on its own schedule. Applied to arc length along the geodesic it is a pure time-warp on a coupled path -- spec section 2.4: easing reparametrises the path without deforming it. So the ease is now a free aesthetic choice where before it was load-bearing and wrong. Every *Back and *Elastic curve is excluded from the list, with the reason recorded: on a geodesic an overshoot walks the arc PAST its endpoint, so the camera flies through the target and back, and nothing throws. Threaded to followBody as well as the two tween producers -- it sampled the glide with raw linear progress, so the selector would have visibly worked for galaxies and silently done nothing for planets, the same shape of bug rho had. Mutation-verified: dropping the ease there fails the named test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported as wild frame-to-frame target jumps on the last leg of an Andromeda -> Earth move, worst under easeOut curves and unaffected by rho. It was neither the path nor the ease: it was float64. glidePath computed the target as lerp(from, to, u/du). Near arrival that is the difference of two ~0.78 Mpc coordinates, so the result quantises to one ULP of the SEPARATION: 1.732e-16 Mpc, or 0.84 EARTH RADII. Measured with easeOutQuart, the final frames sit 8.9e-16 Mpc from Earth while the target jumps 2.9e-15 -- 3.3x the camera-to-target distance, so Earth swings across the whole field of view between frames. rho cannot help because it is not the path; easeOut is worse only because it lingers where the quantisation dominates. Fixed by measuring the offset from whichever endpoint is nearer, using the SAME geodesic run in reverse for the far half, so the small quantity is computed directly instead of as (large - large). Verified the reverse traces the identical curve: lengths agree exactly and u_fwd + u_rev = du to 1e-15. Tail jitter drops from 1.5e-2 of the camera distance to 2.8e-13. Same class of fix as zoomPanGeodesic's landmine 2, and the third time this shape of bug has appeared in this feature -- a small answer obtained by cancelling two large ones is the recurring hazard of a 19-decade scale range. Also sets the calibration defaults: rho 0.18, min 0.6s, max 2.2s, ease easeOutQuint. Three tests needed repair, each a real weakness the new defaults exposed: a hardcoded 'linear' expectation that would have passed even if the producer ignored the tuning; two clamp-saturated endpoints; and a toBeCloseTo(..., 6) comparing Earth-framing distances, where an absolute 5e-7 tolerance calls every pair of poses equal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 5 was still asserting rho 1.42 / V 6 / clamp [0.4s, 4s] with a duration table derived from them, and still framing 'linear' as the default with an eased arrival as heresy. Shipped is rho 0.18, V 20, clamp [0.6s, 2.2s], easeOutQuint -- the opposite on both counts. Anyone reading it next month would be wrong about every number that matters. Rewritten as what shipped, then WHY the two earlier calibrations lost. Both were internally consistent and both were rejected on sight, which is the actual finding: this was a feel question that measurement narrows but does not settle. That is also why the sliders exist. Two things promoted out of scattered comments into stated principles: 2.3b -- three separate bugs in this feature were one bug: a small quantity obtained by subtracting two large ones over a 19-decade range. asinh, the restructured u(s), and now the two-ended target offset. None of them fail loudly; all three return plausible wrong values. 8.1 -- four assertions went blind under calibration changes in a single session, none of them failing. Clamp-saturated comparisons and absolute tolerances on values spanning decades. The general check: would this still fail if someone re-tuned the calibration? Plan checkboxes ticked and a completion note added recording the four things the plan did not anticipate, all of them visual-pass driven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deferred from PR #531 -- each is a real seam with a named silent failure mode, none of them blocking the feature. The root one is GlideTuning: a record created whole, then shredded into per-knob params at four boundaries, so rho has nine homes. Its cost is already half-realised -- the same shape of bug has been fixed twice during this PR (rho on the follow driver, then ease), and the third instance is pre-built: clip-authored glides never see the store's V, min or max. CompositeTrack.channels is the one with the nastiest failure mode. It restates sample's key set, and the two directions of disagreement are not symmetric: over-returning is guarded (by a guard that provably cannot fire), while over-DECLARING is unguarded and silently freezes the channel at the clip's opening value -- with the writer that would have driven it rejected at compile time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two were live defects, two were hot-path/hygiene. ONE HOME FOR THE ARRIVAL CURVE. glide() defaulted ease to 'linear' while GLIDE_EASE_DEFAULT was 'easeOutQuint', so a focus tween and a clip-authored glide of the same motion arrived on different curves, and four docblocks asserted the wrong one. ease is now optional on the glide Effect arm and falls through to DEFAULT_GLIDE_TUNING. Mutation-verified: restoring the ?? 'linear' fails the new end-to-end test with 14.14 (the linear midpoint) against 2.26 (easeOutQuint's). AN ASSERTION THAT PASSED FOR ZERO. cameraDrivers asserted a ~9.4e-16 Mpc framing distance with toBeCloseTo(..., 12) -- an absolute tolerance 529x the value, so it passed for 0 and for a 100x error. The mechanical cause was relErr being defined three times and unreachable from that directory, so the file reached for toBeCloseTo; extracted to tests/support/relErr.ts and all four sites import it. Mutation-verified: returning 0 at saturation now fails, and passed under the old assertion. A PER-FRAME SORT IN THE HOT PATH. evaluateClip copied and sorted compositeTracks every frame, in a file whose contract advertises precomputed work. Sorted once in compileClip, with the ordering now documented on CompiledClip and a test that a fork-delayed track really does emit out of order. ONE HOME FOR THE FOCUSED-BODY PIVOT. body position + panOffset was spelled in both applyFocusedBodyPivot and followBody's pose once followBody left the pin. Extracted to focusedBodyPivot; the pin is a writer over it, the driver a reader. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 2.4 still said "Default to 'linear'" while section 5.3 said easeOutQuint shipped. Rewrote 2.4 to record what the original argument got right and what it got wrong -- an ease on INDEPENDENT channels is destructive, an ease on a coupled geodesic is a free time-warp -- and pointed the value at its one home. Also softened section 4's "independent confirmation of the 'linear' default", which confirmed something narrower than it claimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DoD audit READY: 6077 tests / 913 files pass, both typecheck configs clean, eslint clean, 66/66 checkboxes, no unowned TODOs, visual pass confirmed twice (the second found the Earth-scale arrival stutter, now fixed). Ten planned tasks plus four unplanned, all four surfaced by looking at the screen rather than by planning: followBody pulled into scope, a DebugPanel tuning section, easeOutQuint over 'linear', and the arrival stutter. Deferred with detail files: four entanglement-radar findings, the leaf-star sprite floor, and target vel/osc broadcasting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rulkens
marked this pull request as ready for review
July 31, 2026 21:06
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.
Replaces the two independently-eased
targetanddistancechannels in a focus move with one geodesic through van Wijk & Nuij's (u, w) view space, and derives the move's duration from the geodesic's length instead of pinning every move at 600 ms.Spec:
docs/superpowers/specs/2026-07-31-perceptually-uniform-focus-moves.mdPlan:
docs/superpowers/plans/2026-07-31-perceptually-uniform-focus-moves.md(10 tasks)What the measurements changed
The reversal metric everyone reached for first is a dead end: today's path scores 0 reversals at every view angle with path/net displacement of exactly 1.0000 (it is a straight line by construction), and the geodesic scores 0 too. The metric distinguishes nothing, so no test is built on it.
The metric that discriminates is perceived velocity under the paper's own metric — coefficient of variation across the move:
easeOutCubic)The middle column is why the ease change (task 1) lands first and alone: for galaxy → galaxy, the most common focus click, the current channel model is already uniform once the ease is linear. There,
easeOutCubicis the whole defect.Calibration (settled)
ρ = 1.42, V = 6, duration = clamp(S / V, 0.4 s, 4.0 s). ρ turned out to be a far weaker knob than the paper's framing suggests — star → Milky Way peaks at exactly 1.00× its endpoint distance at every ρ in [0.8, 3.0], because the destination view already frames the whole journey.
Progress
'linear'PathTrack→CompositeTrack(mechanical rename)fovYRadinto the compile pathzoomPanGeodesic+ calibration constantsglidePath(units in, duration out)glideeffect arm +buildGlideTracktweenToClipOut of scope, named
followBodyis a third interpolation path outsideevaluateClipwith the same root cause — every planet focus goes through it, so planet approaches keep the old feel. Filed atdocs/backlog/2026-07-31-followbody-third-interpolation-path.md.🤖 Generated with Claude Code