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

nordisk/facett

.nornir/model3d-clip-design.md

The 3D model panes: clip, depth and winding — design

The chosen design for how facett-graph3d::RabbitLogo3D and facett-model3d::Model3D turn a mesh into pixels. Alternatives considered and rejected live in model3d-clip-reasoning.md; this file names only what ships.

f0 — the whole

Both 3D model panes draw a small mesh with no GPU render pass, so they light up identically native, headless and on wasm. The pipeline is four stages, and the last two live once, in facett_core::render::cpu::faces, and are consumed by both panes (L5):

mesh ──▶ f1 PROJECT ──▶ f2 CULL ──▶ f3 FIT+CLIP ──▶ f4 RASTER ──▶ egui texture
        (pane-local)   (pane-local)  (facett-core)   (facett-core)
stage who what
f1 project RabbitLogo3D::project_tris / Model3D::project_tris yaw→tilt rotate, orthographic screen map, Lambert shade
f2 cull same screen-space winding test; drops back faces
f3 fit + clip faces::fit_scale, faces::tessellate_faces pose-invariant fit; Sutherland–Hodgman scissor to the viewport; degenerate rejection
f4 raster faces::raster_zbuffer, faces::paint_tris_zbuffered per-pixel z-buffer into an RGBA buffer, blitted as a one-frame texture

The observable of the whole is faces::FaceStats — visible_triangles, ink_outside_rect, lit_fraction, depth_monotone, max_extent_px, raw_overshoot_px. It deliberately uses the same field names as facett-map3d::RenderStats so the two render lanes speak one vocabulary.

f1 — projection

Orthographic, y-up view space, +z toward the viewer. ShadedTri carries the three screen points, the per-vertex depth vz, and the shaded colour. Per-vertex depth is the load-bearing part: a per-face mean depth is what a painter's-algorithm sort uses, and a mean cannot order two surfaces that interpenetrate.

f2 — culling, and the winding contract it depends on

A back-face cull tests the triangle's index order. The lighting reads its authored vertex normal. Those two are independent statements about which way a surface faces, and a mesh in which they disagree renders inside out: the cull throws away exactly the faces that should be drawn.

Two invariants make that impossible to reintroduce:

The oracle for both is facett_core::render::cpu::inside_out_triangles, which must be 0.

f3 — fit and clip

Fit is fit_scale(viewport, mesh.bounding_radius(), 0.94). A bounding sphere is rotation-invariant, so a scale derived from it keeps the model inside the viewport at every yaw and tilt — a property of the geometry rather than of the particular meshes that happened to ship. The viewport is widget_rect ∩ ui.clip_rect(): what is actually on screen, so a pane shorter than the viewer's MIN_H floor shrinks the model instead of amputating it.

Clip is the shared cpu::scissor::clip_poly_to_rect applied per face, plus rejection of any face whose |2·area| is under MIN_FACE_AREA2_PX. Geometry is confined before it becomes vertices, not cropped afterwards.

f4 — the raster

raster_zbuffer interpolates vz linearly (the projection is orthographic, so screen z is already linear — no 1/z needed) and keeps the nearer sample per pixel. Output is a straight-RGBA buffer exactly the size of the viewport in physical pixels, uploaded as a one-frame texture and blitted. Two consequences fall out for free:

Each pane passes its own texture key ("facett-model3d:{title}"), so two model viewers on one screen — the demo's SplitPane hosts exactly that — do not collide.

Testing

facett-model3d/tests/clip_pose_sweep.rs and facett-graph3d/tests/logo_clip_pose_sweep.rs each drive a 360° yaw × zoom × tilt grid (72 yaws × 5 tilts × 4 viewports = 1440 poses) through clip_stats, asserting the FaceStats oracles per pose and naming the worst pose on failure. pose_grid and sweep live in facett-core so the grid is written once.

Every oracle has a sensitivity arm that proves it can go red:

oracle sensitivity arm measured
ink_outside_rect == 0 tessellate the same poses the old way leaks at 1332/1440 poses, worst overshoot 118510656 px
footprint coverage single-sided cull on the open Avocado measured negative — coverage stays 1.0, so the two-sided path was not shipped
inside_out_triangles == 0 reverse the whole mesh flags all 1040 / 1288 triangles
pose-invariant fit a tiny pane vs a large one max_extent_px scales with the viewport

The middle row is deliberate: an open shell is textbook grounds for two-sided drawing, but on these meshes it was measured to change nothing (the shell's inner surface is itself front-facing from the open side), so the code was removed rather than shipped on a plausible rationale. back_face_culling_an_open_shell_is_not_what_broke_it pins that finding and will go red if a future mesh makes it false.