Skip to content

feat(editor): push-past zoom, framing, projection toggle, cardinal views (#211) - #260

Merged
JArmandoAnaya merged 1 commit into
mainfrom
p1-s2-framing-projection
Jul 15, 2026
Merged

feat(editor): push-past zoom, framing, projection toggle, cardinal views (#211)#260
JArmandoAnaya merged 1 commit into
mainfrom
p1-s2-framing-projection

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Second sprint of Pillar P1 — Interaction & Navigation (epic #250), on top of the merged p1-s1. Completes the GW-1 camera model.

Closes #211

Push-past-pivot zoom

Zooming in used to dead-stop when distance hit its 2 m minimum. Now the remainder of the dolly moves the pivot along the view direction instead, so the eye keeps travelling at exactly the rate it was — you can fly through a scene on the wheel alone. Zooming out never relocates the pivot.

The invariant worth reviewing is continuity: at the boundary, the eye's step must equal what an unclamped dolly would have taken. EyeTravelIsContinuousAcrossThePushPastBoundary pins that; PushPastMovesTheTargetAlongTheViewAxisOnly pins that zooming in can't slew the view off its subject.

Orthographic

ortho_half_height = distance · tan(kFovY/2) — what the perspective frustum spans at the pivot depth. Three things fall out of that one choice:

  • the O/P toggle cannot jump (both modes span the same world height where you're looking);
  • zoom() needs no mode branch — distance is the ortho scale;
  • ortho's near plane sits behind the eye, so geometry between eye and pivot isn't clipped when the pivot is inside the scene.

Ortho's wheel zooms toward the cursor (zoom_about). Perspective already does that via the eye dolly, so there it's a plain zoom — asserted, not assumed.

Framing was buggy, not just coarse

Discovery found frame_selection copied whole roads/props into a temp NetworkMesh and framed that. Consequences, all real:

  • a selected signal framed its entire road;
  • a selected lane framed its whole road;
  • junction selections were ignored outright.

New viewport/framing.cpp computes per-kind bounds — road extents, a lane's own patch vertices (it indexes into the road's shared vertex array), an instance grown by its model's radius/height, a junction's floor. test_framing.cpp has a named regression test for each of the three bugs. No core/ changes: there's no Aabb there, and framing is a viewport concern (approved decision).

Bindings

Select VQ (approved), freeing V for frame-on-cursor — which moves the pivot and preserves the zoom distance, so it re-aims rather than dollying. Cardinals snap yaw only; top-down is near-vertical (π/2 − 0.01) because exactly π/2 makes the look-at basis degenerate. Numpad digits primary, top-row digits alternate, and everything is in the View menu — the keys are accelerators, not the only route.

Ortho didn't need code — it needed proof

Discovery said picking and gizmo screen math were already projection-agnostic. This PR confirms rather than changes them: ortho rays are parallel instead of fanning from an eye, the same pixel hits the same lane in either projection, and ortho doesn't foreshorten (with the perspective case asserted alongside, so the test can't pass vacuously).

Visual evidence

Added a --camera ortho screenshot preset so the projection is verifiable at all — including in CI's editor visual artifacts job. Rendering gs1_urban_intersection.xodr from directly overhead in each mode:

  • --camera ortho — road widths stay constant edge to edge; the four arms are exactly parallel-projected.
  • --camera top (perspective) — the same arms visibly taper toward the frame edges and the lane arrows shrink with distance.

That difference is the feature working. GitHub attachments aren't reachable from the CLI; reproduce with python scripts/editor_screenshot.py assets/samples/gs1_urban_intersection.xodr out.png --camera ortho.

Tests

  • test_camera.cpp (+13): push-past (no stall, never below min, view-axis only, continuity, zoom-out doesn't relocate), ortho (toggle preserves pivot-plane scale, parallel matrix, zoom_about pins the anchor, perspective unaffected), cardinals (yaw-only snap, eye-side checks rather than yaw literals — a sign flip couldn't hide, near-vertical top-down keeps an orthonormal basis).
  • NEW test_framing.cpp (9): per-kind bounds, the three bug regressions, mixed union, unknown model still frames something, stale ids contribute nothing.
  • test_projection.cpp (+3), test_picking.cpp (+3) for ortho.

A test caught me twice. PushPastMovesTheTargetAlongTheViewAxisOnly failed at first: zoom(20) only takes 80 m → 9.7 m, never reaching the 2 m minimum, so nothing pushed past — the test setup was wrong (needs ~40 steps). And the -Werror build rejected a doublefloat narrowing plus a wrong field name (PickHit::point is position) in the new picking test.

Verification

  • 824/824 green on ci-macos (-Werror); whole-tree clang-format --dry-run --Werror clean.
  • ASan + UBSan on the camera/framing/picking/nav suites: clean (camera math changed → sanitizer run per the contributing rules).
  • Docs: GW-1 amended per the approved list (bindings preamble with the Q rebind + numpad-less alternates, drag-up-zooms-in, V preserves distance, yaw-only cardinals, near-vertical top-down); camera-navigation.md grows framing/projection/cardinals sections.

Acceptance

GW-1 steps 4 and 7–14. With p1-s1 that's all of GW-1 bar a hand-run on a real mouse.

…ews (#211)

Completes the GW-1 camera model on top of the p1-s1 pivot and chords.

Zooming into the pivot used to dead-stop at the minimum distance. It now
pushes past: the remainder of the dolly moves the pivot along the view
direction, so the eye keeps travelling at exactly the rate it was and you
can fly through a scene on the wheel alone. Zooming out never relocates
the pivot.

Orthographic derives its half-height from the orbit distance, matching
what the perspective frustum spans at the pivot depth. That makes the O/P
toggle jump-free for free and leaves zoom() with no mode-specific branch
— distance IS the ortho scale. Its near plane sits behind the eye so
geometry between eye and pivot is not clipped. Ortho's wheel zooms toward
the cursor (zoom_about); perspective already does that via the eye dolly,
so it is a plain zoom there.

Framing was buggy, not just coarse: it copied whole roads into a temp
NetworkMesh, so a selected signal framed its entire road, a lane framed
its whole road, and a junction framed nothing at all. The new
viewport/framing.cpp computes per-kind bounds — road extents, a lane's
own patch vertices, an instance grown by its model's dimensions, a
junction's floor — and test_framing.cpp pins each of those three bugs.
No core/ changes: there is no Aabb there and framing is a viewport
concern.

Select moves V -> Q so V can be frame-on-cursor, which moves the pivot
while preserving the zoom. Cardinals snap yaw only (pitch, distance and
pivot survive), and top-down is near-vertical: exactly pi/2 makes the
look-at basis degenerate. Numpad digits with the top-row digits as
alternates, all of it also in the View menu.

Picking and the gizmo screen math were already projection-agnostic, as
discovery found — so ortho gets regression tests rather than changes:
parallel rays, the same pixel hitting the same lane, and no
foreshortening.

Adds a --camera ortho screenshot preset so the projection is visually
verifiable, including in CI's artifact job.

Refs #211
@JArmandoAnaya
JArmandoAnaya merged commit 755b221 into main Jul 15, 2026
14 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the p1-s2-framing-projection branch July 15, 2026 21:15
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.

p1-s2: push-past-pivot zoom, F/V framing, ortho/persp, cardinal views

1 participant