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 loop: up the inner side,
91 /// down the outer side, tapering toward a rounded tip.
92 fn ear(&self, base_x: f64, lean: f64, n: usize) -> Loop {
93 let base_y = self.head_cy + self.head_r * 0.55; // tucked into the head
94 let tip_x = base_x + lean;
95 let tip_y = base_y + self.ear_len;
96 let w = self.ear_w;
97 let half = (n / 2).max(2);
98 let mut pts = Vec::with_capacity(half * 2 + 2);
99 // Inner side: base → tip (a gentle taper + slight outward bow).
100 for i in 0..=half {
101 let s = i as f64 / half as f64; // 0..1 up the ear
102 let cx = base_x + (tip_x - base_x) * s;
103 let cy = base_y + (tip_y - base_y) * s;
104 let hw = w * (1.0 - 0.45 * s); // narrows toward the tip
105 let bow = (s * std::f64::consts::PI).sin() * 0.04 * lean.signum();
106 pts.push((cx - hw + bow, cy));
107 }
108 // Outer side: tip → base (mirror of the inner walk → closed petal).
109 for i in (0..=half).rev() {
110 let s = i as f64 / half as f64;
111 let cx = base_x + (tip_x - base_x) * s;
112 let cy = base_y + (tip_y - base_y) * s;
113 let hw = w * (1.0 - 0.45 * s);
114 let bow = (s * std::f64::consts::PI).sin() * 0.04 * lean.signum();
115 pts.push((cx + hw + bow, cy));
116 }
117 pts
118 }
119
120 /// The rabbit as closed polygon loops, **outer silhouette first**, then the
121 /// two ears, then the eye dot. Each loop is a CCW ring in the `[-1, 1]` box
122 /// (y up). The 2D renderer fills the body+head as the silhouette, fills the
123 /// ears on top, and punches the eye as a small dark dot.
124 pub fn outline(&self) -> Vec<Loop> {
125 let n = self.segments.max(8);
126 let mut loops = Vec::new();
127 // Silhouette body (big haunch) — the main mass.
128 loops.push(Self::ellipse(0.0, self.body_cy, self.body_rx, self.body_ry, n));
129 // Head — overlaps the top of the body so they read as one shape.
130 loops.push(Self::ellipse(self.head_cx, self.head_cy, self.head_r, self.head_r, n));
131 // Two ears.
132 let lx = self.head_cx - self.ear_gap;
133 let rx = self.head_cx + self.ear_gap;
134 loops.push(self.ear(lx, -self.ear_lean, n));
135 loops.push(self.ear(rx, self.ear_lean, n));
136 // Eye dot (small).
137 loops.push(self.eye());
138 loops
139 }
140
141 /// The **fillable body silhouette**: just the body + head + ears (no eye),
142 /// the loops a 2D renderer fills as the solid mascot. Returned outer→inner so
143 /// the renderer can paint them in order (body, head, ears) with one colour.
144 pub fn silhouette(&self) -> Vec<Loop> {
145 let mut all = self.outline();
146 all.pop(); // drop the eye — it's a feature, not part of the fill
147 all
148 }
149
150 /// The eye dot loop alone (the dark feature drawn on top of the fill).
151 pub fn eye(&self) -> Loop {
152 Self::ellipse(
153 self.head_cx + self.eye_dx,
154 self.head_cy + self.eye_dy,
155 self.eye_r,
156 self.eye_r,
157 (self.segments / 2).max(8),
158 )
159 }
160}
161
162/// Convenience: the default mascot's outline loops. See [`Rabbit::outline`].
163pub fn rabbit_outline() -> Vec<Loop> {
164 Rabbit::default().outline()
165}
166
167// ───────────────────────── 3D extruded mesh ──────────────────────────────────
168
169/// A triangle-soup mesh of the extruded rabbit logo: interleaved positions +
170/// normals, indexed. Coordinates are in the `[-1, 1]` design box (y up) with the
171/// extrusion along **z** (`±depth/2`). Pure data — the renderer (wgpu or the CPU
172/// fallback) projects + lights it.
173#[derive(Clone, Debug, Default)]
174pub struct RabbitMesh {
175 /// `(x, y, z)` per vertex.
176 pub positions: Vec<[f32; 3]>,
177 /// Unit normal per vertex (parallel to `positions`).
178 pub normals: Vec<[f32; 3]>,
179 /// Triangle indices (3 per face) into `positions`.
180 pub indices: Vec<u32>,
181}
182
183impl RabbitMesh {
184 pub fn vertex_count(&self) -> usize {
185 self.positions.len()
186 }
187 pub fn triangle_count(&self) -> usize {
188 self.indices.len() / 3
189 }
190}
191
192/// Extrude the rabbit silhouette into a solid 3D logo: a **front** face at
193/// `z = +depth/2`, a **back** face at `z = -depth/2`, and the **side walls**
194/// joining their rims. Each silhouette loop becomes its own extruded shell
195/// (body, head, two ears) — they read as one fused logo when lit. `depth` is the
196/// total thickness in design units (~0.3 looks like a chunky logo).
197///
198/// Front/back faces are triangle-fanned from the loop centroid (the loops are
199/// convex-ish ellipses/petals, so a fan is watertight enough for a lit logo) and
200/// get axial normals (`+z` / `-z`); the side walls get outward normals derived
201/// from the rim edge, so the logo catches the light around its edge.
202pub fn rabbit_mesh(depth: f32) -> RabbitMesh {
203 let r = Rabbit::default();
204 let mut mesh = RabbitMesh::default();
205 let hz = depth * 0.5;
206
207 for loop_pts in r.silhouette() {
208 let n = loop_pts.len();
209 if n < 3 {
210 continue;
211 }
212 // Centroid for the fan + side-wall outward direction.
213 let (mut cx, mut cy) = (0.0f64, 0.0f64);
214 for &(x, y) in &loop_pts {
215 cx += x;
216 cy += y;
217 }
218 cx /= n as f64;
219 cy /= n as f64;
220
221 // ── front face (z = +hz), normal +z ──
222 let front_centre = mesh.positions.len() as u32;
223 mesh.positions.push([cx as f32, cy as f32, hz]);
224 mesh.normals.push([0.0, 0.0, 1.0]);
225 let front_rim0 = mesh.positions.len() as u32;
226 for &(x, y) in &loop_pts {
227 mesh.positions.push([x as f32, y as f32, hz]);
228 mesh.normals.push([0.0, 0.0, 1.0]);
229 }
230 for i in 0..n as u32 {
231 let a = front_rim0 + i;
232 let b = front_rim0 + (i + 1) % n as u32;
233 // CCW when viewed from +z (front).
234 mesh.indices.extend_from_slice(&[front_centre, a, b]);
235 }
236
237 // ── back face (z = -hz), normal -z ──
238 let back_centre = mesh.positions.len() as u32;
239 mesh.positions.push([cx as f32, cy as f32, -hz]);
240 mesh.normals.push([0.0, 0.0, -1.0]);
241 let back_rim0 = mesh.positions.len() as u32;
242 for &(x, y) in &loop_pts {
243 mesh.positions.push([x as f32, y as f32, -hz]);
244 mesh.normals.push([0.0, 0.0, -1.0]);
245 }
246 for i in 0..n as u32 {
247 let a = back_rim0 + i;
248 let b = back_rim0 + (i + 1) % n as u32;
249 // reverse winding so the back face points -z
250 mesh.indices.extend_from_slice(&[back_centre, b, a]);
251 }
252
253 // ── side walls: quad per rim edge between front & back rims ──
254 let wall0 = mesh.positions.len() as u32;
255 for &(x, y) in &loop_pts {
256 // Outward normal in the xy-plane (from centroid toward the rim).
257 let (mut nx, mut ny) = ((x - cx) as f32, (y - cy) as f32);
258 let len = (nx * nx + ny * ny).sqrt().max(1e-6);
259 nx /= len;
260 ny /= len;
261 // front vertex then back vertex of this rim point.
262 mesh.positions.push([x as f32, y as f32, hz]);
263 mesh.normals.push([nx, ny, 0.0]);
264 mesh.positions.push([x as f32, y as f32, -hz]);
265 mesh.normals.push([nx, ny, 0.0]);
266 }
267 for i in 0..n as u32 {
268 let i0f = wall0 + i * 2;
269 let i0b = wall0 + i * 2 + 1;
270 let j = (i + 1) % n as u32;
271 let i1f = wall0 + j * 2;
272 let i1b = wall0 + j * 2 + 1;
273 // two triangles forming the wall quad (outward-facing)
274 mesh.indices.extend_from_slice(&[i0f, i0b, i1f]);
275 mesh.indices.extend_from_slice(&[i1f, i0b, i1b]);
276 }
277 }
278 mesh
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 /// Inject-assert: the default rabbit produces the expected loop structure —
286 /// body, head, two ears, eye = 5 loops; the silhouette drops the eye → 4.
287 #[test]
288 fn outline_has_body_head_two_ears_and_an_eye() {
289 let loops = rabbit_outline();
290 assert_eq!(loops.len(), 5, "body + head + 2 ears + eye");
291 assert_eq!(Rabbit::default().silhouette().len(), 4, "silhouette drops the eye");
292 // Every loop is a non-trivial closed ring.
293 for (i, l) in loops.iter().enumerate() {
294 assert!(l.len() >= 8, "loop {i} has enough vertices: {}", l.len());
295 }
296 }
297
298 /// Determinism (FC-7): two builds are byte-identical (no RNG / per-frame state).
299 #[test]
300 fn geometry_is_deterministic() {
301 assert_eq!(rabbit_outline(), rabbit_outline());
302 let a = rabbit_mesh(0.3);
303 let b = rabbit_mesh(0.3);
304 assert_eq!(a.positions, b.positions);
305 assert_eq!(a.indices, b.indices);
306 }
307
308 /// All geometry sits inside the normalised `[-1, 1]` design box, and the ears
309 /// rise ABOVE the head (the recognizable mascot, not a blob).
310 #[test]
311 fn geometry_fits_design_box_and_ears_stand_up() {
312 let r = Rabbit::default();
313 let mut max_y = f64::MIN;
314 for l in r.outline() {
315 for (x, y) in l {
316 assert!((-1.0..=1.0).contains(&x), "x in box: {x}");
317 assert!((-1.0..=1.0).contains(&y), "y in box: {y}");
318 max_y = max_y.max(y);
319 }
320 }
321 // The ear tips are the highest points and clearly above the head crown.
322 let head_top = r.head_cy + r.head_r;
323 assert!(max_y > head_top + 0.3, "ears stand well above the head: {max_y} vs {head_top}");
324 }
325
326 /// The extruded mesh is a solid: front + back + side-wall vertices, indexed
327 /// triangles, normals parallel to positions, and it has real depth in z.
328 #[test]
329 fn mesh_extrudes_with_depth_normals_and_triangles() {
330 let depth = 0.3f32;
331 let m = rabbit_mesh(depth);
332 assert!(m.vertex_count() > 100, "a real mesh, got {}", m.vertex_count());
333 assert_eq!(m.normals.len(), m.positions.len(), "one normal per vertex");
334 assert!(m.triangle_count() > 50, "front+back+walls tessellate to many tris");
335 assert_eq!(m.indices.len() % 3, 0, "indices are whole triangles");
336 // Every index is in range.
337 let vc = m.vertex_count() as u32;
338 assert!(m.indices.iter().all(|&i| i < vc), "indices in range");
339 // Real depth: z spans -depth/2 .. +depth/2.
340 let (mut zmin, mut zmax) = (f32::MAX, f32::MIN);
341 for p in &m.positions {
342 zmin = zmin.min(p[2]);
343 zmax = zmax.max(p[2]);
344 }
345 assert!((zmax - depth * 0.5).abs() < 1e-5 && (zmin + depth * 0.5).abs() < 1e-5, "z spans the full depth");
346 // Normals are unit-length.
347 for nml in &m.normals {
348 let l = (nml[0] * nml[0] + nml[1] * nml[1] + nml[2] * nml[2]).sqrt();
349 assert!((l - 1.0).abs() < 1e-4, "unit normal, got {l}");
350 }
351 }
352}