Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-map3d/.nornir/camera-feel-and-ground.md

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:

  1. smooth the near-ground wheel zoom (no snap),
  2. distance-to-surface + ground collision (camera never flies through the ground),
  3. 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 of partstep_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)

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:

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():

  1. lifts the target to surface + SURFACE_EPS if a pan/dolly dragged it below ground;
  2. if the eye would still be below surface + EPS, shrinks distance (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):

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) returns max(terrain, building_top_under(x,z)), and the SAME clamp_above_ground binary 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 from extrude.rs) and clamp distance at the first hit minus a skin. The binary-search clamp here already works for an arbitrary non-flat surface_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


Laws honoured