CAMERA-FEEL + GROUND-COLLISION + BOX-FACE — design + guide + for-idiots
Branch
agent/map3d-feel-fixes(2026-06-22). Three fixes to the OSM 3D viewer (facett-map3d) from Rickard driving the live demo:
- smooth the near-ground wheel zoom (no snap),
- distance-to-surface + ground collision (camera never flies through the ground),
- extruded box faces show a visible diagonal seam (one quad shading as 2 triangles).
Tests:
src/camera.rs(unit),src/extrude.rs(unit),tests/box_face_proof.rs(PNG). Peers ofpartstep_sweep.rs/clip_pipeline.rs(the scissor oracle — NOT regressed by these changes).
FIX 1 — smooth the near-ground zoom
Design
The dolly already multiplies the goal distance by factor = exp(-scroll·k) — a distance-proportional step (the same wheel tick moves you metres at altitude, centimetres near the ground), so the approach is inherently smooth. The snap came from the hard clamp(min_distance, max_distance) at the end: above the floor the step was full-size, at the floor it was zero — the last ~200 m collapsed onto min_distance = 0.4 in a single tick.
The fix replaces the hard min clamp with a soft, asymptotic ease-out toward a much smaller floor MIN_DISTANCE = 0.06:
out = MIN + SOFT · softplus((raw − MIN)/SOFT), SOFT = 0.015
softplus(t) = ln(1 + e^t) (numerically stable: ≈ t for large t)
- Far from the floor (
raw ≫ MIN) this is≈ raw— the far-out speed is bit-for-bit unchanged (SOFT is tiny, so the easing only bites in the last ~cm). - As
raw → MINthe output decelerates into the floor instead of slamming onto it. The iterationg ← soft_min_distance(g·factor)converges to a fixed point a hair aboveMIN(house level is reachable; the camera just eases in). - The far
max_distanceis still a plain.min(max)(no soft ceiling needed — zooming OUT to the horizon has no "snap" complaint).
Why SOFT is tiny
A large SOFT (e.g. 0.10) pushes the fixed point of the dolly iteration well above the floor — the zoom stalls short of the ground. SOFT = 0.015 keeps the stall within ~0.02 du of MIN while still removing the discontinuity. The trade is explicit in the soft_min_distance doc-comment.
Test (dolly_to_ground_is_smooth_no_snap, multi-value)
Sweep start distances {18,12,8,4} × tick sizes {20,40,80}; dolly IN tick-by-tick to convergence and assert:
- (a) distance strictly monotone decreasing, reaches
≈ MIN_DISTANCE, never below it; - (b) far-out speed preserved: while
d ≫ floor, each tick's factor equals the requestedexp(-tick·k)within 1e-3; - (c) no snap: the absolute per-tick shrink tapers — no single step exceeds 1.5× the previous step's shrink (a hard clamp would show one giant final step then zero).
Plus soft_min_distance_is_smooth_and_asymptotic: ≈identity far out, strictly above MIN in the realistic range, monotone in its input.
FIX 2 — distance-to-surface + ground collision
Design
The eye is derived from the orbit: eye = target + distance · dir(az, el). To keep the camera above the terrain surface, OrbitCamera::clamp_above_ground():
- lifts the target to
surface + SURFACE_EPSif a pan/dolly dragged it below ground; - if the eye would still be below
surface + EPS, shrinksdistance(binary search for the largest distance whose eye clears the surface) — we keep the orbit angles + target and only pull the eye in along the view ray.
clamp_above_ground() is called from update() (every damped frame) and settle() (deterministic poses), so damping toward an underground goal can't re-sink the eye.
New accessors (surfaced in state_json and camera.pose_json):
surface_height_at(world_x, world_z) -> f32— the ground height under a point. Today it returns the flat terrain plane height (0.0) for any(x,z).distance_to_surface() -> f32—eye.y − surface_height_at(eye.xz), clamped≥ 0. The "air under the camera" readout.
The house-collision SEAM (future-proof hook)
surface_height_at is the single place building collision plugs in. The plan (NOT implemented now — ground only, per the work order):
When building AABBs/heights are available,
surface_height_at(x,z)returnsmax(terrain, building_top_under(x,z)), and the SAMEclamp_above_groundbinary search keeps the eye above rooftops. For a true "don't clip through the house you plotted" you raycast the camera ray (eye→target) against the building prisms (the extruded box AABBs fromextrude.rs) and clampdistanceat the first hit minus a skin. The binary-search clamp here already works for an arbitrary non-flatsurface_height_at, so only the height lookup + a ray/prism intersector need wiring — no change to the clamp itself.
Test (camera_never_sinks_through_the_ground, pose sweep)
8 azimuths × {MIN_ELEV, 0.1, 0.3, 0.6, ELEV_LIMIT} elevations × target-Y dragged to {−2, −0.5, 0, 0.05} × distance {0.05, 0.5, 3, 12}. After settle() AND after 200 damping ticks toward the underground goal: assert eye.y ≥ surface + EPS and distance_to_surface() ≥ 0. Plus surface_height_and_distance_to_surface_are_exposed pins the flat-terrain seam contract.
FIX 3 — box faces show a visible diagonal seam
Root cause (confirmed by PNG)
The square wall face = 2 triangles. The OLD extrude_prism side walls shared rim vertices between adjacent walls and gave each rim vertex a per-vertex radial normal (x−cx, 0, z−cz) (pointing from the footprint centroid). The render path (CPU painter + the wgpu lane) shades each triangle by the average of its 3 vertex normals. A wall quad's two triangles average DIFFERENT mixes of the two corners' radial normals → two different face normals → two different shades → a lighting seam exactly along the split diagonal. (Both render lanes use cull_mode: None and abs(n·light), so it was never a winding/cull issue — purely the normals.)
The reproduction PNG (/tmp during calibration) shows the front wall split corner- to-corner: lower-left triangle dark, upper-right bright. That is Rickard's "square = 2 triangles, one side flipped/open".
Fix
Each wall quad now emits its OWN four corners carrying ONE flat outward face-normal — the edge's ground-plane perpendicular (dir × up, flipped to point away from the centroid). Both triangles of the quad share that single normal, so the quad shades as one uniform lit surface. The two triangles also wind consistently (outward CCW) so neither half back-faces. All 6 faces remain present (roof +y cap, floor −y cap, 4 walls); caps are unchanged (they already carried flat ±y normals).
Vertex budget per wall rose from 2n (shared) to 4n (per-quad); triangle count is unchanged (2n).
Tests
box_faces_are_uniform_quads_no_diagonal_seam(unit,extrude.rs): extrude one known box; every wall quad's two triangles share one geometric normal (dot > 0.999) AND one stored normal (what the renderer averages), point outward (n · (face−centre) > 0), and are horizontal. Asserts roof + floor caps exist (no missing/open face).box_front_face_is_a_uniform_lit_quad_no_diagonal(PNG,tests/box_face_proof.rs): render a single tall box FACE-ON through egui_kittest wgpu, savetests/snapshots/box_face.png, isolate the dominant wall face, split its bbox along the diagonal, and assert the two triangular halves' mean luminance differ by < 0.04. Calibrated against the bug: the OLD radial normals give diff ≈ 0.07 (FAILS), the fix gives ≈ 0.018 (PASSES). The seam is sharply visible in the bug-version render and gone in the golden.
Laws honoured
- Inject-assert, multi-value sweeps, every change a real
#[test]in the matrix. functional_status/testmatrix emit on the PNG visual (not return-value-observable).- PNG blob read by the author (the bug diagonal confirmed, then confirmed gone).
- Did NOT regress
partstep_sweep/clip_pipeline(the scissor oracle) — the wall rewrite keeps the same triangle set; the camera clamp only pulls the eye in, never pushes geometry outside the rect.