feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces

ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.

Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.

Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-17 20:02:56 +03:00
commit ae5231427b
5 changed files with 329 additions and 51 deletions

View file

@ -36,6 +36,36 @@ pub struct MeshModel {
///
/// `lods` holds up to one MeshModel per LOD level (high → low). Empty
/// slots fall back to the nearest available LOD at render time.
/// A curved face's generator, kept so a view-dependent silhouette (DISPSILH)
/// can be computed per frame — the silhouette is where the surface turns away
/// from the eye, which no baked edge can capture. World-space, post body
/// transform; the base point carries a double-single low half so it stays
/// precise at UTM scale like the mesh verts.
#[derive(Clone, Copy, Debug)]
pub struct CurvedGen {
/// Base-circle centre (world), high half of the double-single.
pub base: [f32; 3],
/// Low residual paired with `base`.
pub base_low: [f32; 3],
/// Unit axis direction (world).
pub axis: [f32; 3],
/// Radial frame: `u` is the θ=0 direction, `v = axis × u`.
pub u_dir: [f32; 3],
pub v_dir: [f32; 3],
/// Radius at the base (`h = 0`).
pub radius: f32,
/// `tan(half-angle)`: radius at height `h` is `radius + h * tan_a`
/// (0 for a cylinder, non-zero for a cone).
pub tan_a: f32,
/// Height span along the axis the face covers.
pub h_min: f32,
pub h_max: f32,
/// Angular span the face covers; `full` = a closed revolution.
pub theta_min: f32,
pub theta_span: f32,
pub full: bool,
}
#[derive(Clone, Debug)]
pub struct MeshLodSet {
pub lods: Vec<MeshModel>,
@ -46,6 +76,9 @@ pub struct MeshLodSet {
pub edge_verts: Vec<[f32; 3]>,
/// Low residual paired with `edge_verts`.
pub edge_verts_low: Vec<[f32; 3]>,
/// Curved-face generators for per-frame silhouette (DISPSILH). Empty for a
/// solid with no curved faces, or when silhouettes aren't wanted.
pub curved_gens: Vec<CurvedGen>,
/// World XY AABB `[min_x, min_y, max_x, max_y]` of the mesh — used
/// by the per-frame LOD selector to compute the projected pixel
/// diagonal.
@ -87,6 +120,7 @@ impl MeshLodSet {
lods,
edge_verts: Vec::new(),
edge_verts_low: Vec::new(),
curved_gens: Vec::new(),
world_aabb,
z_aabb,
}