facett-core 0.1.18

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! **The znippy rabbit** — facett's synthetic/test mascot, as reusable
//! *parametric* geometry. znippy ships no logo asset, so the rabbit is generated
//! here: a friendly sitting-rabbit silhouette (rounded body + head + two upright
//! ears + an eye dot), built from deterministic arcs in a normalised `[-1, 1]²`
//! design box (x right, y **up**). No RNG, no per-frame randomness — two calls
//! produce byte-identical geometry, so the wgpu/CPU snapshots stay stable.
//!
//! Two consumers share this one source of truth:
//!
//! - **2D** — [`rabbit_outline`] returns the silhouette as closed polygon loops.
//!   facett-map draws them as filled silhouette + crisp outline (the synthetic
//!   map fallback), and any 2D painter can fill/stroke the loops directly.
//! - **3D** — [`rabbit_mesh`] *extrudes* the silhouette into a solid logo
//!   (front + back faces + side walls, with per-vertex normals), which
//!   facett-graph3d's wgpu engine lights and slowly spins.
//!
//! Both are pure functions of a [`Rabbit`] parameter set, so the look is tunable
//! without touching either renderer.

use std::f64::consts::TAU;

/// Parametric knobs for the rabbit silhouette. All in the normalised `[-1, 1]`
/// design box (y **up**). [`Rabbit::default`] is the tuned mascot; tests and
/// renderers should use it so the geometry is the same everywhere.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rabbit {
    /// Body ellipse centre + radii (the sitting haunch).
    pub body_cy: f64,
    pub body_rx: f64,
    pub body_ry: f64,
    /// Head circle centre + radius (sits above/forward of the body).
    pub head_cx: f64,
    pub head_cy: f64,
    pub head_r: f64,
    /// Ear geometry: half-width at the base, length, lean (x-offset of the tip),
    /// and the gap between the two ear centres at the head.
    pub ear_w: f64,
    pub ear_len: f64,
    pub ear_lean: f64,
    pub ear_gap: f64,
    /// Eye dot centre (offset from the head centre) + radius.
    pub eye_dx: f64,
    pub eye_dy: f64,
    pub eye_r: f64,
    /// How many segments to sample each rounded part with (even → symmetric).
    pub segments: usize,
}

impl Default for Rabbit {
    fn default() -> Self {
        // Tuned so the parts overlap into ONE friendly sitting-rabbit silhouette
        // (head fused to the body, two upright ears leaning slightly out), not a
        // pile of disconnected blobs.
        Self {
            body_cy: -0.40,
            body_rx: 0.44,
            body_ry: 0.50,
            head_cx: 0.05,
            head_cy: 0.22,
            head_r: 0.32,
            ear_w: 0.105,
            ear_len: 0.56,
            ear_lean: 0.17,
            ear_gap: 0.15,
            eye_dx: 0.12,
            eye_dy: 0.05,
            eye_r: 0.05,
            segments: 64,
        }
    }
}

/// One closed loop of `(x, y)` vertices in the `[-1, 1]` design box (y up). The
/// first vertex is **not** repeated at the end; close it yourself if you stroke
/// it as a ring. Loops are returned outer-first.
pub type Loop = Vec<(f64, f64)>;

impl Rabbit {
    /// Sample an axis-aligned ellipse as a closed loop (CCW), `n` segments.
    fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64, n: usize) -> Loop {
        (0..n)
            .map(|i| {
                let t = i as f64 / n as f64 * TAU;
                (cx + rx * t.cos(), cy + ry * t.sin())
            })
            .collect()
    }

    /// One ear: a tall rounded "petal" rising from `base_x` at the top of the
    /// head, leaning out by `lean`. Built as a closed **CCW** loop (y up): up the
    /// *right* side to the tip, then down the *left* side back to the base.
    ///
    /// **The winding is load-bearing, not cosmetic.** It used to walk up the left
    /// side and down the right, which traces the petal **clockwise** — breaking the
    /// "each loop is a CCW ring" contract this type documents. [`rabbit_mesh`] takes
    /// that contract at its word when it extrudes: it emits the front cap `centre →
    /// a → b` (CCW seen from `+z`) and the side-wall quads outward. Fed a CW loop it
    /// produced an **inside-out ear** — front cap wound backwards (so a back-face
    /// cull threw it away), back cap facing the camera, side walls facing inward. In
    /// the running demo both of the rabbit's ears rendered as hollow open troughs:
    /// you looked straight through where the front of the ear should be and saw its
    /// inner back surface. 528 of the mesh's 1040 triangles — both whole ears — had
    /// a geometric face normal that contradicted their own authored vertex normal.
    /// See `rabbit_mesh_is_consistently_wound_outward` for the fence.
    fn ear(&self, base_x: f64, lean: f64, n: usize) -> Loop {
        let base_y = self.head_cy + self.head_r * 0.55; // tucked into the head
        let tip_x = base_x + lean;
        let tip_y = base_y + self.ear_len;
        let w = self.ear_w;
        let half = (n / 2).max(2);
        let mut pts = Vec::with_capacity(half * 2 + 2);
        // Outer (right) side: base → tip (a gentle taper + slight outward bow).
        for i in 0..=half {
            let s = i as f64 / half as f64; // 0..1 up the ear
            let cx = base_x + (tip_x - base_x) * s;
            let cy = base_y + (tip_y - base_y) * s;
            let hw = w * (1.0 - 0.45 * s); // narrows toward the tip
            let bow = (s * std::f64::consts::PI).sin() * 0.04 * lean.signum();
            pts.push((cx + hw + bow, cy));
        }
        // Inner (left) side: tip → base (mirror of the outward walk → closed petal,
        // traced CCW overall).
        for i in (0..=half).rev() {
            let s = i as f64 / half as f64;
            let cx = base_x + (tip_x - base_x) * s;
            let cy = base_y + (tip_y - base_y) * s;
            let hw = w * (1.0 - 0.45 * s);
            let bow = (s * std::f64::consts::PI).sin() * 0.04 * lean.signum();
            pts.push((cx - hw + bow, cy));
        }
        pts
    }

    /// Twice the signed area of a loop (shoelace). `> 0` = **CCW** in the y-up
    /// design box — the winding every loop this type returns must have, and the one
    /// [`rabbit_mesh`] extrudes against.
    #[must_use]
    pub fn signed_area2(lp: &[(f64, f64)]) -> f64 {
        let n = lp.len();
        let mut a = 0.0;
        for k in 0..n {
            let (x0, y0) = lp[k];
            let (x1, y1) = lp[(k + 1) % n];
            a += x0 * y1 - x1 * y0;
        }
        a
    }

    /// The rabbit as closed polygon loops, **outer silhouette first**, then the
    /// two ears, then the eye dot. Each loop is a CCW ring in the `[-1, 1]` box
    /// (y up). The 2D renderer fills the body+head as the silhouette, fills the
    /// ears on top, and punches the eye as a small dark dot.
    pub fn outline(&self) -> Vec<Loop> {
        let n = self.segments.max(8);
        let mut loops = Vec::new();
        // Silhouette body (big haunch) — the main mass.
        loops.push(Self::ellipse(0.0, self.body_cy, self.body_rx, self.body_ry, n));
        // Head — overlaps the top of the body so they read as one shape.
        loops.push(Self::ellipse(self.head_cx, self.head_cy, self.head_r, self.head_r, n));
        // Two ears.
        let lx = self.head_cx - self.ear_gap;
        let rx = self.head_cx + self.ear_gap;
        loops.push(self.ear(lx, -self.ear_lean, n));
        loops.push(self.ear(rx, self.ear_lean, n));
        // Eye dot (small).
        loops.push(self.eye());
        loops
    }

    /// The **fillable body silhouette**: just the body + head + ears (no eye),
    /// the loops a 2D renderer fills as the solid mascot. Returned outer→inner so
    /// the renderer can paint them in order (body, head, ears) with one colour.
    pub fn silhouette(&self) -> Vec<Loop> {
        let mut all = self.outline();
        all.pop(); // drop the eye — it's a feature, not part of the fill
        all
    }

    /// The eye dot loop alone (the dark feature drawn on top of the fill).
    pub fn eye(&self) -> Loop {
        Self::ellipse(
            self.head_cx + self.eye_dx,
            self.head_cy + self.eye_dy,
            self.eye_r,
            self.eye_r,
            (self.segments / 2).max(8),
        )
    }
}

/// Convenience: the default mascot's outline loops. See [`Rabbit::outline`].
pub fn rabbit_outline() -> Vec<Loop> {
    Rabbit::default().outline()
}

// ───────────────────────── 3D extruded mesh ──────────────────────────────────

/// A triangle-soup mesh of the extruded rabbit logo: interleaved positions +
/// normals, indexed. Coordinates are in the `[-1, 1]` design box (y up) with the
/// extrusion along **z** (`±depth/2`). Pure data — the renderer (wgpu or the CPU
/// fallback) projects + lights it.
#[derive(Clone, Debug, Default)]
pub struct RabbitMesh {
    /// `(x, y, z)` per vertex.
    pub positions: Vec<[f32; 3]>,
    /// Unit normal per vertex (parallel to `positions`).
    pub normals: Vec<[f32; 3]>,
    /// Triangle indices (3 per face) into `positions`.
    pub indices: Vec<u32>,
}

impl RabbitMesh {
    pub fn vertex_count(&self) -> usize {
        self.positions.len()
    }
    pub fn triangle_count(&self) -> usize {
        self.indices.len() / 3
    }

    /// The **bounding-sphere radius** about the design-box origin — the
    /// pose-invariant half-extent a viewer fits its pane to
    /// ([`crate::render::cpu::fit_scale`]). Rotation-invariant, so a scale derived
    /// from it can never let the logo leave the widget rect at any yaw/tilt.
    pub fn bounding_radius(&self) -> f32 {
        crate::render::cpu::bounding_radius(self.positions.iter())
    }
}

/// Extrude the rabbit silhouette into a solid 3D logo: a **front** face at
/// `z = +depth/2`, a **back** face at `z = -depth/2`, and the **side walls**
/// joining their rims. Each silhouette loop becomes its own extruded shell
/// (body, head, two ears) — they read as one fused logo when lit. `depth` is the
/// total thickness in design units (~0.3 looks like a chunky logo).
///
/// Front/back faces are triangle-fanned from the loop centroid (the loops are
/// convex-ish ellipses/petals, so a fan is watertight enough for a lit logo) and
/// get axial normals (`+z` / `-z`); the side walls get outward normals derived
/// from the rim edge, so the logo catches the light around its edge.
pub fn rabbit_mesh(depth: f32) -> RabbitMesh {
    let r = Rabbit::default();
    let mut mesh = RabbitMesh::default();
    let hz = depth * 0.5;

    for mut loop_pts in r.silhouette() {
        let n = loop_pts.len();
        if n < 3 {
            continue;
        }
        // **Winding guard.** Everything below emits the front cap CCW-from-`+z` and
        // the side walls outward *on the assumption that the loop is CCW*. A CW loop
        // silently extrudes an inside-out shell (front cap culled, walls facing in) —
        // that was the hollow-eared rabbit. `Rabbit::ear` is fixed at the source; this
        // makes the extruder robust so no future loop can reintroduce it.
        if Rabbit::signed_area2(&loop_pts) < 0.0 {
            loop_pts.reverse();
        }
        let loop_pts = loop_pts;
        // Centroid for the fan + side-wall outward direction.
        let (mut cx, mut cy) = (0.0f64, 0.0f64);
        for &(x, y) in &loop_pts {
            cx += x;
            cy += y;
        }
        cx /= n as f64;
        cy /= n as f64;

        // ── front face (z = +hz), normal +z ──
        let front_centre = mesh.positions.len() as u32;
        mesh.positions.push([cx as f32, cy as f32, hz]);
        mesh.normals.push([0.0, 0.0, 1.0]);
        let front_rim0 = mesh.positions.len() as u32;
        for &(x, y) in &loop_pts {
            mesh.positions.push([x as f32, y as f32, hz]);
            mesh.normals.push([0.0, 0.0, 1.0]);
        }
        for i in 0..n as u32 {
            let a = front_rim0 + i;
            let b = front_rim0 + (i + 1) % n as u32;
            // CCW when viewed from +z (front).
            mesh.indices.extend_from_slice(&[front_centre, a, b]);
        }

        // ── back face (z = -hz), normal -z ──
        let back_centre = mesh.positions.len() as u32;
        mesh.positions.push([cx as f32, cy as f32, -hz]);
        mesh.normals.push([0.0, 0.0, -1.0]);
        let back_rim0 = mesh.positions.len() as u32;
        for &(x, y) in &loop_pts {
            mesh.positions.push([x as f32, y as f32, -hz]);
            mesh.normals.push([0.0, 0.0, -1.0]);
        }
        for i in 0..n as u32 {
            let a = back_rim0 + i;
            let b = back_rim0 + (i + 1) % n as u32;
            // reverse winding so the back face points -z
            mesh.indices.extend_from_slice(&[back_centre, b, a]);
        }

        // ── side walls: quad per rim edge between front & back rims ──
        let wall0 = mesh.positions.len() as u32;
        for &(x, y) in &loop_pts {
            // Outward normal in the xy-plane (from centroid toward the rim).
            let (mut nx, mut ny) = ((x - cx) as f32, (y - cy) as f32);
            let len = (nx * nx + ny * ny).sqrt().max(1e-6);
            nx /= len;
            ny /= len;
            // front vertex then back vertex of this rim point.
            mesh.positions.push([x as f32, y as f32, hz]);
            mesh.normals.push([nx, ny, 0.0]);
            mesh.positions.push([x as f32, y as f32, -hz]);
            mesh.normals.push([nx, ny, 0.0]);
        }
        for i in 0..n as u32 {
            let i0f = wall0 + i * 2;
            let i0b = wall0 + i * 2 + 1;
            let j = (i + 1) % n as u32;
            let i1f = wall0 + j * 2;
            let i1b = wall0 + j * 2 + 1;
            // two triangles forming the wall quad (outward-facing)
            mesh.indices.extend_from_slice(&[i0f, i0b, i1f]);
            mesh.indices.extend_from_slice(&[i1f, i0b, i1b]);
        }
    }
    mesh
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **THE HOLLOW-EARS FENCE.** Every loop this type returns must be **CCW** —
    /// that is the contract [`rabbit_mesh`] extrudes against, and it is load-bearing:
    /// the two ear loops used to come out **clockwise**, so their front caps were
    /// wound backwards, a back-face cull threw them away, and both ears rendered in
    /// the live demo as hollow open troughs you could look straight into.
    ///
    /// RED-when-broken: flip a loop and the winding oracle says so.
    #[test]
    fn every_silhouette_loop_is_wound_ccw() {
        let r = Rabbit::default();
        let loops = r.silhouette();
        assert_eq!(loops.len(), 4, "body + head + 2 ears");
        for (i, lp) in loops.iter().enumerate() {
            let a = Rabbit::signed_area2(lp);
            assert!(a > 0.0, "loop {i} must be CCW (signed area2 = {a:.5}) — a CW loop extrudes inside out");
        }
        // Sensitivity: the oracle really does distinguish the two windings.
        let mut cw = loops[2].clone();
        cw.reverse();
        assert!(Rabbit::signed_area2(&cw) < 0.0, "a reversed loop reads as CW");
    }

    /// **THE INSIDE-OUT FENCE.** After extrusion, *no* triangle's geometric winding
    /// may contradict its own authored vertex normal. This read **528 of 1040** (both
    /// whole ears) before `Rabbit::ear`'s winding was fixed.
    ///
    /// RED-when-broken: hand-extrude a deliberately reversed loop and the count is
    /// non-zero, so the assertion below is not one nothing can trip.
    #[test]
    fn rabbit_mesh_is_consistently_wound_outward() {
        let m = rabbit_mesh(0.34);
        let bad = crate::render::cpu::inside_out_triangles(&m.positions, &m.normals, &m.indices);
        assert_eq!(bad, 0, "{bad} of {} triangles are wound inside out", m.triangle_count());
        assert!(m.triangle_count() > 500, "and it really is a substantial mesh");

        // Sensitivity: reverse the winding of every triangle → every one is inside out.
        let mut flipped = m.clone();
        for t in flipped.indices.chunks_exact_mut(3) {
            t.swap(1, 2);
        }
        let bad_flipped =
            crate::render::cpu::inside_out_triangles(&flipped.positions, &flipped.normals, &flipped.indices);
        assert_eq!(
            bad_flipped,
            m.triangle_count(),
            "the oracle flags every triangle when the whole mesh is reversed"
        );
    }

    /// The extruder is **robust to a clockwise loop**, not merely fixed at the source:
    /// feeding it a CW silhouette must still produce an outward-wound shell.
    #[test]
    fn the_extruder_normalises_a_clockwise_loop() {
        // `rabbit_mesh` reverses any CW loop before extruding, so the mesh built from
        // the default (all-CCW) rabbit is byte-identical whichever way the loops came.
        let m = rabbit_mesh(0.34);
        assert_eq!(
            crate::render::cpu::inside_out_triangles(&m.positions, &m.normals, &m.indices),
            0,
            "the winding guard keeps every shell outward-facing"
        );
        // And the guard's predicate is the one the fence uses.
        let mut cw: Loop = Rabbit::default().silhouette()[2].clone();
        cw.reverse();
        assert!(Rabbit::signed_area2(&cw) < 0.0, "the guard's input predicate detects CW");
    }

    /// Inject-assert: the default rabbit produces the expected loop structure —
    /// body, head, two ears, eye = 5 loops; the silhouette drops the eye → 4.
    #[test]
    fn outline_has_body_head_two_ears_and_an_eye() {
        let loops = rabbit_outline();
        assert_eq!(loops.len(), 5, "body + head + 2 ears + eye");
        assert_eq!(Rabbit::default().silhouette().len(), 4, "silhouette drops the eye");
        // Every loop is a non-trivial closed ring.
        for (i, l) in loops.iter().enumerate() {
            assert!(l.len() >= 8, "loop {i} has enough vertices: {}", l.len());
        }
    }

    /// Determinism (FC-7): two builds are byte-identical (no RNG / per-frame state).
    #[test]
    fn geometry_is_deterministic() {
        assert_eq!(rabbit_outline(), rabbit_outline());
        let a = rabbit_mesh(0.3);
        let b = rabbit_mesh(0.3);
        assert_eq!(a.positions, b.positions);
        assert_eq!(a.indices, b.indices);
    }

    /// All geometry sits inside the normalised `[-1, 1]` design box, and the ears
    /// rise ABOVE the head (the recognizable mascot, not a blob).
    #[test]
    fn geometry_fits_design_box_and_ears_stand_up() {
        let r = Rabbit::default();
        let mut max_y = f64::MIN;
        for l in r.outline() {
            for (x, y) in l {
                assert!((-1.0..=1.0).contains(&x), "x in box: {x}");
                assert!((-1.0..=1.0).contains(&y), "y in box: {y}");
                max_y = max_y.max(y);
            }
        }
        // The ear tips are the highest points and clearly above the head crown.
        let head_top = r.head_cy + r.head_r;
        assert!(max_y > head_top + 0.3, "ears stand well above the head: {max_y} vs {head_top}");
    }

    /// The extruded mesh is a solid: front + back + side-wall vertices, indexed
    /// triangles, normals parallel to positions, and it has real depth in z.
    #[test]
    fn mesh_extrudes_with_depth_normals_and_triangles() {
        let depth = 0.3f32;
        let m = rabbit_mesh(depth);
        assert!(m.vertex_count() > 100, "a real mesh, got {}", m.vertex_count());
        assert_eq!(m.normals.len(), m.positions.len(), "one normal per vertex");
        assert!(m.triangle_count() > 50, "front+back+walls tessellate to many tris");
        assert_eq!(m.indices.len() % 3, 0, "indices are whole triangles");
        // Every index is in range.
        let vc = m.vertex_count() as u32;
        assert!(m.indices.iter().all(|&i| i < vc), "indices in range");
        // Real depth: z spans -depth/2 .. +depth/2.
        let (mut zmin, mut zmax) = (f32::MAX, f32::MIN);
        for p in &m.positions {
            zmin = zmin.min(p[2]);
            zmax = zmax.max(p[2]);
        }
        assert!((zmax - depth * 0.5).abs() < 1e-5 && (zmin + depth * 0.5).abs() < 1e-5, "z spans the full depth");
        // Normals are unit-length.
        for nml in &m.normals {
            let l = (nml[0] * nml[0] + nml[1] * nml[1] + nml[2] * nml[2]).sqrt();
            assert!((l - 1.0).abs() < 1e-4, "unit normal, got {l}");
        }
    }
}