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:
facett_core::rabbit— every loopRabbit::silhouette()returns is CCW (Rabbit::signed_area2 > 0), andrabbit_meshadditionally normalises any CW loop before extruding. Fenced byevery_silhouette_loop_is_wound_ccwandrabbit_mesh_is_consistently_wound_outward.facett_model3d::Mesh3d::repair_winding— runs at the end ofload_glband flips any triangle whose geometric normal contradicts its own averaged vertex normal. A no-op on a sound mesh, and a no-op by construction where normals were derived from the winding in the first place.
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:
ink_outside_rect == 0structurally — every write is bounds-clamped to the buffer, so no ink can exist outside the rect at all.- no tessellator —
epaint's filled-path feathering uses a miter join (Path::add_line_loop,normal / length_sq) that explodes on a near-degenerate face. Not entering that path is what removes the spokes.
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.