Skip to main content

facett_core/
rabbit.rs

1//! **The znippy rabbit** — facett's synthetic/test mascot, as reusable
2//! *parametric* geometry. znippy ships no logo asset, so the rabbit is generated
3//! here: a friendly sitting-rabbit silhouette (rounded body + head + two upright
4//! ears + an eye dot), built from deterministic arcs in a normalised `[-1, 1]²`
5//! design box (x right, y **up**). No RNG, no per-frame randomness — two calls
6//! produce byte-identical geometry, so the wgpu/CPU snapshots stay stable.
7//!
8//! Two consumers share this one source of truth:
9//!
10//! - **2D** — [`rabbit_outline`] returns the silhouette as closed polygon loops.
11//!   facett-map draws them as filled silhouette + crisp outline (the synthetic
12//!   map fallback), and any 2D painter can fill/stroke the loops directly.
13//! - **3D** — [`rabbit_mesh`] *extrudes* the silhouette into a solid logo
14//!   (front + back faces + side walls, with per-vertex normals), which
15//!   facett-graph3d's wgpu engine lights and slowly spins.
16//!
17//! Both are pure functions of a [`Rabbit`] parameter set, so the look is tunable
18//! without touching either renderer.
19
20use std::f64::consts::TAU;
21
22/// Parametric knobs for the rabbit silhouette. All in the normalised `[-1, 1]`
23/// design box (y **up**). [`Rabbit::default`] is the tuned mascot; tests and
24/// renderers should use it so the geometry is the same everywhere.
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub struct Rabbit {
27    /// Body ellipse centre + radii (the sitting haunch).
28    pub body_cy: f64,
29    pub body_rx: f64,
30    pub body_ry: f64,
31    /// Head circle centre + radius (sits above/forward of the body).
32    pub head_cx: f64,
33    pub head_cy: f64,
34    pub head_r: f64,
35    /// Ear geometry: half-width at the base, length, lean (x-offset of the tip),
36    /// and the gap between the two ear centres at the head.
37    pub ear_w: f64,
38    pub ear_len: f64,
39    pub ear_lean: f64,
40    pub ear_gap: f64,
41    /// Eye dot centre (offset from the head centre) + radius.
42    pub eye_dx: f64,
43    pub eye_dy: f64,
44    pub eye_r: f64,
45    /// How many segments to sample each rounded part with (even → symmetric).
46    pub segments: usize,
47}
48
49impl Default for Rabbit {
50    fn default() -> Self {
51        // Tuned so the parts overlap into ONE friendly sitting-rabbit silhouette
52        // (head fused to the body, two upright ears leaning slightly out), not a
53        // pile of disconnected blobs.
54        Self {
55            body_cy: -0.40,
56            body_rx: 0.44,
57            body_ry: 0.50,
58            head_cx: 0.05,
59            head_cy: 0.22,
60            head_r: 0.32,
61            ear_w: 0.105,
62            ear_len: 0.56,
63            ear_lean: 0.17,
64            ear_gap: 0.15,
65            eye_dx: 0.12,
66            eye_dy: 0.05,
67            eye_r: 0.05,
68            segments: 64,
69        }
70    }
71}
72
73/// One closed loop of `(x, y)` vertices in the `[-1, 1]` design box (y up). The
74/// first vertex is **not** repeated at the end; close it yourself if you stroke
75/// it as a ring. Loops are returned outer-first.
76pub type Loop = Vec<(f64, f64)>;
77
78impl Rabbit {
79    /// Sample an axis-aligned ellipse as a closed loop (CCW), `n` segments.
80    fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64, n: usize) -> Loop {
81        (0..n)
82            .map(|i| {
83                let t = i as f64 / n as f64 * TAU;
84                (cx + rx * t.cos(), cy + ry * t.sin())
85            })
86            .collect()
87    }
88
89    /// One ear: a tall rounded "petal" rising from `base_x` at the top of the
90    /// head, leaning out by `lean`. Built as a closed **CCW** loop (y up): up the
91    /// *right* side to the tip, then down the *left* side back to the base.
92    ///
93    /// **The winding is load-bearing, not cosmetic.** It used to walk up the left
94    /// side and down the right, which traces the petal **clockwise** — breaking the
95    /// "each loop is a CCW ring" contract this type documents. [`rabbit_mesh`] takes
96    /// that contract at its word when it extrudes: it emits the front cap `centre →
97    /// a → b` (CCW seen from `+z`) and the side-wall quads outward. Fed a CW loop it
98    /// produced an **inside-out ear** — front cap wound backwards (so a back-face
99    /// cull threw it away), back cap facing the camera, side walls facing inward. In
100    /// the running demo both of the rabbit's ears rendered as hollow open troughs:
101    /// you looked straight through where the front of the ear should be and saw its
102    /// inner back surface. 528 of the mesh's 1040 triangles — both whole ears — had
103    /// a geometric face normal that contradicted their own authored vertex normal.
104    /// See `rabbit_mesh_is_consistently_wound_outward` for the fence.
105    fn ear(&self, base_x: f64, lean: f64, n: usize) -> Loop {
106        let base_y = self.head_cy + self.head_r * 0.55; // tucked into the head
107        let tip_x = base_x + lean;
108        let tip_y = base_y + self.ear_len;
109        let w = self.ear_w;
110        let half = (n / 2).max(2);
111        let mut pts = Vec::with_capacity(half * 2 + 2);
112        // Outer (right) side: base → tip (a gentle taper + slight outward bow).
113        for i in 0..=half {
114            let s = i as f64 / half as f64; // 0..1 up the ear
115            let cx = base_x + (tip_x - base_x) * s;
116            let cy = base_y + (tip_y - base_y) * s;
117            let hw = w * (1.0 - 0.45 * s); // narrows toward the tip
118            let bow = (s * std::f64::consts::PI).sin() * 0.04 * lean.signum();
119            pts.push((cx + hw + bow, cy));
120        }
121        // Inner (left) side: tip → base (mirror of the outward walk → closed petal,
122        // traced CCW overall).
123        for i in (0..=half).rev() {
124            let s = i as f64 / half as f64;
125            let cx = base_x + (tip_x - base_x) * s;
126            let cy = base_y + (tip_y - base_y) * s;
127            let hw = w * (1.0 - 0.45 * s);
128            let bow = (s * std::f64::consts::PI).sin() * 0.04 * lean.signum();
129            pts.push((cx - hw + bow, cy));
130        }
131        pts
132    }
133
134    /// Twice the signed area of a loop (shoelace). `> 0` = **CCW** in the y-up
135    /// design box — the winding every loop this type returns must have, and the one
136    /// [`rabbit_mesh`] extrudes against.
137    #[must_use]
138    pub fn signed_area2(lp: &[(f64, f64)]) -> f64 {
139        let n = lp.len();
140        let mut a = 0.0;
141        for k in 0..n {
142            let (x0, y0) = lp[k];
143            let (x1, y1) = lp[(k + 1) % n];
144            a += x0 * y1 - x1 * y0;
145        }
146        a
147    }
148
149    /// The rabbit as closed polygon loops, **outer silhouette first**, then the
150    /// two ears, then the eye dot. Each loop is a CCW ring in the `[-1, 1]` box
151    /// (y up). The 2D renderer fills the body+head as the silhouette, fills the
152    /// ears on top, and punches the eye as a small dark dot.
153    pub fn outline(&self) -> Vec<Loop> {
154        let n = self.segments.max(8);
155        let mut loops = Vec::new();
156        // Silhouette body (big haunch) — the main mass.
157        loops.push(Self::ellipse(0.0, self.body_cy, self.body_rx, self.body_ry, n));
158        // Head — overlaps the top of the body so they read as one shape.
159        loops.push(Self::ellipse(self.head_cx, self.head_cy, self.head_r, self.head_r, n));
160        // Two ears.
161        let lx = self.head_cx - self.ear_gap;
162        let rx = self.head_cx + self.ear_gap;
163        loops.push(self.ear(lx, -self.ear_lean, n));
164        loops.push(self.ear(rx, self.ear_lean, n));
165        // Eye dot (small).
166        loops.push(self.eye());
167        loops
168    }
169
170    /// The **fillable body silhouette**: just the body + head + ears (no eye),
171    /// the loops a 2D renderer fills as the solid mascot. Returned outer→inner so
172    /// the renderer can paint them in order (body, head, ears) with one colour.
173    pub fn silhouette(&self) -> Vec<Loop> {
174        let mut all = self.outline();
175        all.pop(); // drop the eye — it's a feature, not part of the fill
176        all
177    }
178
179    /// The eye dot loop alone (the dark feature drawn on top of the fill).
180    pub fn eye(&self) -> Loop {
181        Self::ellipse(
182            self.head_cx + self.eye_dx,
183            self.head_cy + self.eye_dy,
184            self.eye_r,
185            self.eye_r,
186            (self.segments / 2).max(8),
187        )
188    }
189}
190
191/// Convenience: the default mascot's outline loops. See [`Rabbit::outline`].
192pub fn rabbit_outline() -> Vec<Loop> {
193    Rabbit::default().outline()
194}
195
196// ───────────────────────── 3D extruded mesh ──────────────────────────────────
197
198/// A triangle-soup mesh of the extruded rabbit logo: interleaved positions +
199/// normals, indexed. Coordinates are in the `[-1, 1]` design box (y up) with the
200/// extrusion along **z** (`±depth/2`). Pure data — the renderer (wgpu or the CPU
201/// fallback) projects + lights it.
202#[derive(Clone, Debug, Default)]
203pub struct RabbitMesh {
204    /// `(x, y, z)` per vertex.
205    pub positions: Vec<[f32; 3]>,
206    /// Unit normal per vertex (parallel to `positions`).
207    pub normals: Vec<[f32; 3]>,
208    /// Triangle indices (3 per face) into `positions`.
209    pub indices: Vec<u32>,
210}
211
212impl RabbitMesh {
213    pub fn vertex_count(&self) -> usize {
214        self.positions.len()
215    }
216    pub fn triangle_count(&self) -> usize {
217        self.indices.len() / 3
218    }
219
220    /// The **bounding-sphere radius** about the design-box origin — the
221    /// pose-invariant half-extent a viewer fits its pane to
222    /// ([`crate::render::cpu::fit_scale`]). Rotation-invariant, so a scale derived
223    /// from it can never let the logo leave the widget rect at any yaw/tilt.
224    pub fn bounding_radius(&self) -> f32 {
225        crate::render::cpu::bounding_radius(self.positions.iter())
226    }
227}
228
229/// Extrude the rabbit silhouette into a solid 3D logo: a **front** face at
230/// `z = +depth/2`, a **back** face at `z = -depth/2`, and the **side walls**
231/// joining their rims. Each silhouette loop becomes its own extruded shell
232/// (body, head, two ears) — they read as one fused logo when lit. `depth` is the
233/// total thickness in design units (~0.3 looks like a chunky logo).
234///
235/// Front/back faces are triangle-fanned from the loop centroid (the loops are
236/// convex-ish ellipses/petals, so a fan is watertight enough for a lit logo) and
237/// get axial normals (`+z` / `-z`); the side walls get outward normals derived
238/// from the rim edge, so the logo catches the light around its edge.
239pub fn rabbit_mesh(depth: f32) -> RabbitMesh {
240    let r = Rabbit::default();
241    let mut mesh = RabbitMesh::default();
242    let hz = depth * 0.5;
243
244    for mut loop_pts in r.silhouette() {
245        let n = loop_pts.len();
246        if n < 3 {
247            continue;
248        }
249        // **Winding guard.** Everything below emits the front cap CCW-from-`+z` and
250        // the side walls outward *on the assumption that the loop is CCW*. A CW loop
251        // silently extrudes an inside-out shell (front cap culled, walls facing in) —
252        // that was the hollow-eared rabbit. `Rabbit::ear` is fixed at the source; this
253        // makes the extruder robust so no future loop can reintroduce it.
254        if Rabbit::signed_area2(&loop_pts) < 0.0 {
255            loop_pts.reverse();
256        }
257        let loop_pts = loop_pts;
258        // Centroid for the fan + side-wall outward direction.
259        let (mut cx, mut cy) = (0.0f64, 0.0f64);
260        for &(x, y) in &loop_pts {
261            cx += x;
262            cy += y;
263        }
264        cx /= n as f64;
265        cy /= n as f64;
266
267        // ── front face (z = +hz), normal +z ──
268        let front_centre = mesh.positions.len() as u32;
269        mesh.positions.push([cx as f32, cy as f32, hz]);
270        mesh.normals.push([0.0, 0.0, 1.0]);
271        let front_rim0 = mesh.positions.len() as u32;
272        for &(x, y) in &loop_pts {
273            mesh.positions.push([x as f32, y as f32, hz]);
274            mesh.normals.push([0.0, 0.0, 1.0]);
275        }
276        for i in 0..n as u32 {
277            let a = front_rim0 + i;
278            let b = front_rim0 + (i + 1) % n as u32;
279            // CCW when viewed from +z (front).
280            mesh.indices.extend_from_slice(&[front_centre, a, b]);
281        }
282
283        // ── back face (z = -hz), normal -z ──
284        let back_centre = mesh.positions.len() as u32;
285        mesh.positions.push([cx as f32, cy as f32, -hz]);
286        mesh.normals.push([0.0, 0.0, -1.0]);
287        let back_rim0 = mesh.positions.len() as u32;
288        for &(x, y) in &loop_pts {
289            mesh.positions.push([x as f32, y as f32, -hz]);
290            mesh.normals.push([0.0, 0.0, -1.0]);
291        }
292        for i in 0..n as u32 {
293            let a = back_rim0 + i;
294            let b = back_rim0 + (i + 1) % n as u32;
295            // reverse winding so the back face points -z
296            mesh.indices.extend_from_slice(&[back_centre, b, a]);
297        }
298
299        // ── side walls: quad per rim edge between front & back rims ──
300        let wall0 = mesh.positions.len() as u32;
301        for &(x, y) in &loop_pts {
302            // Outward normal in the xy-plane (from centroid toward the rim).
303            let (mut nx, mut ny) = ((x - cx) as f32, (y - cy) as f32);
304            let len = (nx * nx + ny * ny).sqrt().max(1e-6);
305            nx /= len;
306            ny /= len;
307            // front vertex then back vertex of this rim point.
308            mesh.positions.push([x as f32, y as f32, hz]);
309            mesh.normals.push([nx, ny, 0.0]);
310            mesh.positions.push([x as f32, y as f32, -hz]);
311            mesh.normals.push([nx, ny, 0.0]);
312        }
313        for i in 0..n as u32 {
314            let i0f = wall0 + i * 2;
315            let i0b = wall0 + i * 2 + 1;
316            let j = (i + 1) % n as u32;
317            let i1f = wall0 + j * 2;
318            let i1b = wall0 + j * 2 + 1;
319            // two triangles forming the wall quad (outward-facing)
320            mesh.indices.extend_from_slice(&[i0f, i0b, i1f]);
321            mesh.indices.extend_from_slice(&[i1f, i0b, i1b]);
322        }
323    }
324    mesh
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    /// **THE HOLLOW-EARS FENCE.** Every loop this type returns must be **CCW** —
332    /// that is the contract [`rabbit_mesh`] extrudes against, and it is load-bearing:
333    /// the two ear loops used to come out **clockwise**, so their front caps were
334    /// wound backwards, a back-face cull threw them away, and both ears rendered in
335    /// the live demo as hollow open troughs you could look straight into.
336    ///
337    /// RED-when-broken: flip a loop and the winding oracle says so.
338    #[test]
339    fn every_silhouette_loop_is_wound_ccw() {
340        let r = Rabbit::default();
341        let loops = r.silhouette();
342        assert_eq!(loops.len(), 4, "body + head + 2 ears");
343        for (i, lp) in loops.iter().enumerate() {
344            let a = Rabbit::signed_area2(lp);
345            assert!(a > 0.0, "loop {i} must be CCW (signed area2 = {a:.5}) — a CW loop extrudes inside out");
346        }
347        // Sensitivity: the oracle really does distinguish the two windings.
348        let mut cw = loops[2].clone();
349        cw.reverse();
350        assert!(Rabbit::signed_area2(&cw) < 0.0, "a reversed loop reads as CW");
351    }
352
353    /// **THE INSIDE-OUT FENCE.** After extrusion, *no* triangle's geometric winding
354    /// may contradict its own authored vertex normal. This read **528 of 1040** (both
355    /// whole ears) before `Rabbit::ear`'s winding was fixed.
356    ///
357    /// RED-when-broken: hand-extrude a deliberately reversed loop and the count is
358    /// non-zero, so the assertion below is not one nothing can trip.
359    #[test]
360    fn rabbit_mesh_is_consistently_wound_outward() {
361        let m = rabbit_mesh(0.34);
362        let bad = crate::render::cpu::inside_out_triangles(&m.positions, &m.normals, &m.indices);
363        assert_eq!(bad, 0, "{bad} of {} triangles are wound inside out", m.triangle_count());
364        assert!(m.triangle_count() > 500, "and it really is a substantial mesh");
365
366        // Sensitivity: reverse the winding of every triangle → every one is inside out.
367        let mut flipped = m.clone();
368        for t in flipped.indices.chunks_exact_mut(3) {
369            t.swap(1, 2);
370        }
371        let bad_flipped =
372            crate::render::cpu::inside_out_triangles(&flipped.positions, &flipped.normals, &flipped.indices);
373        assert_eq!(
374            bad_flipped,
375            m.triangle_count(),
376            "the oracle flags every triangle when the whole mesh is reversed"
377        );
378    }
379
380    /// The extruder is **robust to a clockwise loop**, not merely fixed at the source:
381    /// feeding it a CW silhouette must still produce an outward-wound shell.
382    #[test]
383    fn the_extruder_normalises_a_clockwise_loop() {
384        // `rabbit_mesh` reverses any CW loop before extruding, so the mesh built from
385        // the default (all-CCW) rabbit is byte-identical whichever way the loops came.
386        let m = rabbit_mesh(0.34);
387        assert_eq!(
388            crate::render::cpu::inside_out_triangles(&m.positions, &m.normals, &m.indices),
389            0,
390            "the winding guard keeps every shell outward-facing"
391        );
392        // And the guard's predicate is the one the fence uses.
393        let mut cw: Loop = Rabbit::default().silhouette()[2].clone();
394        cw.reverse();
395        assert!(Rabbit::signed_area2(&cw) < 0.0, "the guard's input predicate detects CW");
396    }
397
398    /// Inject-assert: the default rabbit produces the expected loop structure —
399    /// body, head, two ears, eye = 5 loops; the silhouette drops the eye → 4.
400    #[test]
401    fn outline_has_body_head_two_ears_and_an_eye() {
402        let loops = rabbit_outline();
403        assert_eq!(loops.len(), 5, "body + head + 2 ears + eye");
404        assert_eq!(Rabbit::default().silhouette().len(), 4, "silhouette drops the eye");
405        // Every loop is a non-trivial closed ring.
406        for (i, l) in loops.iter().enumerate() {
407            assert!(l.len() >= 8, "loop {i} has enough vertices: {}", l.len());
408        }
409    }
410
411    /// Determinism (FC-7): two builds are byte-identical (no RNG / per-frame state).
412    #[test]
413    fn geometry_is_deterministic() {
414        assert_eq!(rabbit_outline(), rabbit_outline());
415        let a = rabbit_mesh(0.3);
416        let b = rabbit_mesh(0.3);
417        assert_eq!(a.positions, b.positions);
418        assert_eq!(a.indices, b.indices);
419    }
420
421    /// All geometry sits inside the normalised `[-1, 1]` design box, and the ears
422    /// rise ABOVE the head (the recognizable mascot, not a blob).
423    #[test]
424    fn geometry_fits_design_box_and_ears_stand_up() {
425        let r = Rabbit::default();
426        let mut max_y = f64::MIN;
427        for l in r.outline() {
428            for (x, y) in l {
429                assert!((-1.0..=1.0).contains(&x), "x in box: {x}");
430                assert!((-1.0..=1.0).contains(&y), "y in box: {y}");
431                max_y = max_y.max(y);
432            }
433        }
434        // The ear tips are the highest points and clearly above the head crown.
435        let head_top = r.head_cy + r.head_r;
436        assert!(max_y > head_top + 0.3, "ears stand well above the head: {max_y} vs {head_top}");
437    }
438
439    /// The extruded mesh is a solid: front + back + side-wall vertices, indexed
440    /// triangles, normals parallel to positions, and it has real depth in z.
441    #[test]
442    fn mesh_extrudes_with_depth_normals_and_triangles() {
443        let depth = 0.3f32;
444        let m = rabbit_mesh(depth);
445        assert!(m.vertex_count() > 100, "a real mesh, got {}", m.vertex_count());
446        assert_eq!(m.normals.len(), m.positions.len(), "one normal per vertex");
447        assert!(m.triangle_count() > 50, "front+back+walls tessellate to many tris");
448        assert_eq!(m.indices.len() % 3, 0, "indices are whole triangles");
449        // Every index is in range.
450        let vc = m.vertex_count() as u32;
451        assert!(m.indices.iter().all(|&i| i < vc), "indices in range");
452        // Real depth: z spans -depth/2 .. +depth/2.
453        let (mut zmin, mut zmax) = (f32::MAX, f32::MIN);
454        for p in &m.positions {
455            zmin = zmin.min(p[2]);
456            zmax = zmax.max(p[2]);
457        }
458        assert!((zmax - depth * 0.5).abs() < 1e-5 && (zmin + depth * 0.5).abs() < 1e-5, "z spans the full depth");
459        // Normals are unit-length.
460        for nml in &m.normals {
461            let l = (nml[0] * nml[0] + nml[1] * nml[1] + nml[2] * nml[2]).sqrt();
462            assert!((l - 1.0).abs() < 1e-4, "unit normal, got {l}");
463        }
464    }
465}