rlx_core/render/scenes/lines/hankin.rs
1//! Hankin star patterns: build an n-fold star rosette by the contact-angle
2//! method. `n` contact points sit symmetrically on a circle; from each, a ray
3//! leaves at the contact angle from the inward normal, and adjacent rays meet at
4//! the petal tips. Connecting each contact point to its two neighbouring tips
5//! traces the interlaced star.
6//!
7//! **This runs from `Scene::update`, not only from `configure`** (Plan 0054,
8//! ADR-0060). `variant` is a continuous contact angle, so a bound param
9//! reaches this construction during playback; `star.rs`'s hysteresis cache bounds
10//! the rate (one rebuild per `STEP_DEG` of travel, measured at 0.34 us for the
11//! reachable `n = 12`), but the call itself is on the hot path and the panic
12//! pragma below is load-bearing rather than precautionary.
13//!
14//! v1 scope (ADR-0007 / plan Risks): a small set of regular n-fold stars with a
15//! contact angle — not arbitrary tessellations. The construction is a pure
16//! deterministic function of `(n, contact_angle)` and, by building every petal
17//! from the same rotation-equivariant rule, its segment set is invariant under a
18//! `2*pi/n` rotation (directly unit-tested).
19
20// Hot-path panic-denial pragma: reachable from `Scene::update` since ADR-0060
21// (see the module docs). Written panic-free.
22#![deny(
23 clippy::unwrap_used,
24 clippy::expect_used,
25 clippy::indexing_slicing,
26 clippy::panic,
27 clippy::unreachable
28)]
29
30use std::f32::consts::TAU;
31
32use super::PLACEHOLDER_WIDTH;
33use super::renderer::{SegmentInstance, miter_extension};
34
35/// The `[generator] tiling` spellings, in roster order — every alias
36/// [`tiling_order`] accepts, plus the `none` that draws no interlace at all.
37///
38/// Three spellings per order because a preset author reaches for whichever they
39/// know: the polygon's name, its order as a number, or its vertex configuration.
40/// The list is here rather than in the schema export so there is one roster and
41/// the round-trip test can hold it to [`tiling_order`].
42pub const TILINGS: [&str; 13] = [
43 "square",
44 "4",
45 "4.4.4.4",
46 "hexagon",
47 "6",
48 "6.6.6",
49 "octagon",
50 "8",
51 "4.8.8",
52 "dodecagon",
53 "12",
54 "3.12.12",
55 "none",
56];
57
58/// Map a `tiling` name to its star order `n`. Accepts a few named/numeric
59/// regular tilings (the v1 set); returns `None` for anything else so the loader
60/// can reject it.
61///
62/// `none` is not answered here: it selects no interlace at all, and this
63/// function's answer is an order. The loader takes that case before asking.
64pub fn tiling_order(tiling: &str) -> Option<u32> {
65 Some(match tiling.trim() {
66 "square" | "4" | "4.4.4.4" => 4,
67 "hexagon" | "6" | "6.6.6" => 6,
68 "octagon" | "8" | "4.8.8" => 8,
69 "dodecagon" | "12" | "3.12.12" => 12,
70 _ => return None,
71 })
72}
73
74/// Intersect ray `p + t*d` with ray `q + s*e` (t, s unbounded — infinite lines).
75/// `None` if near-parallel.
76fn line_intersect(p: [f32; 2], d: [f32; 2], q: [f32; 2], e: [f32; 2]) -> Option<[f32; 2]> {
77 let denom = d[0] * e[1] - d[1] * e[0];
78 if denom.abs() < 1e-6 {
79 return None;
80 }
81 let t = ((q[0] - p[0]) * e[1] - (q[1] - p[1]) * e[0]) / denom;
82 Some([p[0] + t * d[0], p[1] + t * d[1]])
83}
84
85/// Build an `n`-fold star rosette with the given `contact_angle` (radians) into
86/// `out` (cleared first). Produces `2 * n` segments when every petal tip
87/// resolves. Positions are in roughly the unit disc; the scene fit-normalizes.
88pub fn star_rosette(n: u32, contact_angle: f32, out: &mut Vec<SegmentInstance>) {
89 out.clear();
90 if n < 3 {
91 return;
92 }
93 let nf = n as f32;
94
95 // Contact point k, evenly spaced on the unit circle.
96 let contact = |k: i32| -> [f32; 2] {
97 let a = TAU * (k as f32) / nf;
98 [a.cos(), a.sin()]
99 };
100 // Rotate a vector by `ang` radians.
101 let rotate = |v: [f32; 2], ang: f32| -> [f32; 2] {
102 let (s, c) = ang.sin_cos();
103 [v[0] * c - v[1] * s, v[0] * s + v[1] * c]
104 };
105
106 // Petal `k`'s tip, as a function of `k` rather than a loop local, so a
107 // vertex's two neighbours are both reachable when its miter is computed.
108 // Petals are congruent rotations, so this is `tip(0)` turned by
109 // `k * TAU / n`; it is derived rather than rotated so that a petal whose
110 // rays fail to meet reports `None` on its own terms.
111 let tip_at = |k: i32| -> Option<[f32; 2]> {
112 let m0 = contact(k);
113 let m1 = contact(k + 1);
114 // Inward normals (toward the centre) — the contact points lie on the
115 // unit circle, so the inward normal is just the negated position.
116 let in0 = [-m0[0], -m0[1]];
117 let in1 = [-m1[0], -m1[1]];
118 // Adjacent rays lean toward each other at the contact angle and meet at
119 // the petal tip between the two contact points. m0's ray tilts toward
120 // m1 (clockwise off its inward normal); m1's tilts back toward m0.
121 let d0 = rotate(in0, -contact_angle);
122 let d1 = rotate(in1, contact_angle);
123 line_intersect(m0, d0, m1, d1)
124 };
125
126 for k in 0..n as i32 {
127 let m0 = contact(k);
128 let m1 = contact(k + 1);
129 if let Some(tip) = tip_at(k) {
130 // The rosette is a **closed chain**, so every one of its `2n`
131 // vertices is a joint (ADR-0041's Outcome note; Plan 0040 Phase 3).
132 // The `b` ends meet at this petal's tip — but the `a` ends are not
133 // free either: petal `k + 1` starts from `contact(k + 1)` again, the
134 // same point from the same closure, so each contact point is shared
135 // by two segments' `a` ends and the figure runs
136 // `contact(0) -> tip(0) -> contact(1) -> tip(1) -> …`.
137 //
138 // The contact points are the **sharper** half. The two rays leave
139 // one `2 * contact_angle` apart, so a stroke through a contact point
140 // turns by `pi - 2 * contact_angle` — its interior angle is
141 // `2 * contact_angle` and its miter is exactly
142 // `half_width / sin(contact_angle)`, which is the sharper of the two
143 // for any star pointier than 45 degrees, against `star.rs`'s
144 // `CONTACT_MIN_DEG = 8`.
145 //
146 // Both segments meeting at a vertex reach the same corner point, so
147 // one length serves the pair at each of the three.
148 let ext_tip = miter_extension(PLACEHOLDER_WIDTH, m0, tip, m1);
149 let ext_m0 = tip_at(k - 1).map_or(PLACEHOLDER_WIDTH, |before| {
150 miter_extension(PLACEHOLDER_WIDTH, before, m0, tip)
151 });
152 let ext_m1 = tip_at(k + 1).map_or(PLACEHOLDER_WIDTH, |after| {
153 miter_extension(PLACEHOLDER_WIDTH, tip, m1, after)
154 });
155 out.push(seg(m0, tip, ext_m0, ext_tip));
156 out.push(seg(m1, tip, ext_m1, ext_tip));
157 }
158 }
159}
160
161/// One rosette segment. `ext_a`/`ext_b` are miter lengths in units of
162/// [`PLACEHOLDER_WIDTH`], which is what the rosette is cached at; the star scene
163/// restyles it per frame, and the miter rescales with the width because it is
164/// homogeneous of degree 1 in it (see [`miter_extension`]).
165fn seg(a: [f32; 2], b: [f32; 2], ext_a: f32, ext_b: f32) -> SegmentInstance {
166 SegmentInstance {
167 a,
168 b,
169 color: [1.0, 1.0, 1.0],
170 width: PLACEHOLDER_WIDTH,
171 alpha: 1.0,
172 ext_a,
173 ext_b,
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 #![allow(clippy::indexing_slicing)]
180
181 use super::*;
182
183 #[test]
184 fn tiling_names_map_to_orders() {
185 assert_eq!(tiling_order("hexagon"), Some(6));
186 assert_eq!(tiling_order("6.6.6"), Some(6));
187 assert_eq!(tiling_order("8"), Some(8));
188 assert_eq!(tiling_order("nonsense"), None);
189 }
190
191 #[test]
192 fn rosette_has_the_expected_segment_count() {
193 let mut out = Vec::new();
194 star_rosette(6, 30f32.to_radians(), &mut out);
195 // Two segments per petal (contact -> tip -> next contact).
196 assert_eq!(out.len(), 12);
197
198 let mut oct = Vec::new();
199 star_rosette(8, 30f32.to_radians(), &mut oct);
200 assert_eq!(oct.len(), 16);
201 }
202
203 /// Plan 0040 Phase 3 (ADR-0041's Outcome note), replacing Plan 0039's
204 /// `the_star_joins_in_pairs_at_the_petal_tip`.
205 ///
206 /// **The shipped test could not see the defect it was meant to guard.** It
207 /// asserted `!close(pair[0].a, pair[1].a)` — that the two contact points
208 /// *within* one petal are distinct, which is true and stays true — and said
209 /// nothing about the sharing *across* petals. So it passed unchanged both
210 /// before and after this fix, which is exactly why it is gone.
211 ///
212 /// The rosette is a **closed chain**, not a set of pairs: petal `k` emits
213 /// segments from `contact(k)` and `contact(k + 1)`, and petal `k + 1` emits
214 /// one from `contact(k + 1)` again. All `2n` vertices are joints.
215 #[test]
216 fn the_star_is_a_closed_chain_extended_at_every_vertex() {
217 use crate::render::scenes::lines::MITER_SLACK;
218
219 let n = 5usize;
220 let mut out = Vec::new();
221 star_rosette(n as u32, 30f32.to_radians(), &mut out);
222 assert_eq!(out.len(), 2 * n, "two segments per petal on a 5-fold star");
223
224 for (i, seg) in out.iter().enumerate() {
225 assert!(
226 seg.ext_a > 0.0 && seg.ext_b > 0.0,
227 "segment {i} lies in a closed chain, so both its ends are joints"
228 );
229 }
230
231 // **The contact vertex has a closed form**, and the module's own comment
232 // is where it comes from: the two rays leave one `2 * contact_angle`
233 // apart, so a stroke through a contact point turns by
234 // `pi - 2 * contact_angle`, its interior angle is `2 * contact_angle`,
235 // and its miter is exactly `half_width / sin(contact_angle)`. At 30
236 // degrees that is `2.0` half-widths — a property of the construction,
237 // not a reading off this render (ADR-0071).
238 //
239 // Every segment's `a` end is a contact point, by the chain the module
240 // documents: `contact(0) -> tip(0) -> contact(1) -> ...`.
241 let want = PLACEHOLDER_WIDTH / 30f32.to_radians().sin();
242 assert!(
243 (want - 2.0 * PLACEHOLDER_WIDTH).abs() < 1e-6,
244 "the closed form itself: 1 / sin(30 deg) is 2"
245 );
246 for (i, seg) in out.iter().enumerate() {
247 assert!(
248 (seg.ext_a - want).abs() <= want * MITER_SLACK,
249 "segment {i}'s `a` end is a contact point, whose interior angle \
250 is twice the 30-degree contact angle: extension {} against \
251 the {want} that angle asks for",
252 seg.ext_a
253 );
254 }
255
256 // The tips are the blunter half — the whole point of the module comment
257 // that calls the contacts the sharper one — so they must reach less far.
258 // Checked as an inequality rather than a second closed form, because
259 // the tip angle is what `line_intersect` produces and not what the
260 // construction states.
261 let tip = out.first().map_or(f32::NAN, |s| s.ext_b);
262 assert!(
263 tip > PLACEHOLDER_WIDTH && tip < want,
264 "a petal tip must reach past the flat {PLACEHOLDER_WIDTH} and less \
265 far than a contact point's {want}, got {tip}"
266 );
267
268 // Within a petal: both rays end on the shared tip, and they start from
269 // two distinct contact points.
270 for (p, pair) in out.chunks_exact(2).enumerate() {
271 assert!(
272 close(pair[0].b, pair[1].b),
273 "petal {p}: both rays must end on the shared tip"
274 );
275 assert!(
276 !close(pair[0].a, pair[1].a),
277 "petal {p}: a petal spans two distinct contact points"
278 );
279 }
280
281 // Across petals — the half Plan 0039 missed. Segment `2k + 1` starts at
282 // `contact(k + 1)` and so does segment `2k + 2`.
283 //
284 // The wrap-around pair (`2n - 1` against `0`) is the reason this uses
285 // `close` rather than an exact compare: it is `contact(n)` against
286 // `contact(0)`, the same point reached as `cos(TAU)` and `cos(0)`, which
287 // are not bit-identical in f32.
288 for k in 0..n {
289 let (i, j) = (2 * k + 1, (2 * k + 2) % (2 * n));
290 assert!(
291 close(out[i].a, out[j].a),
292 "segments {i} and {j} must meet at contact point {}",
293 k + 1
294 );
295 assert!(
296 out[i].ext_a > 0.0 && out[j].ext_a > 0.0,
297 "both segments at contact point {} must extend that end, \
298 or the sharper half of the rosette keeps the notch",
299 k + 1
300 );
301 }
302 }
303
304 #[test]
305 fn rosette_is_invariant_under_a_2pi_over_n_rotation() {
306 let n = 6u32;
307 let mut out = Vec::new();
308 star_rosette(n, 32f32.to_radians(), &mut out);
309 assert!(!out.is_empty());
310
311 let ang = TAU / n as f32;
312 let (s, c) = ang.sin_cos();
313 let rot = |p: [f32; 2]| [p[0] * c - p[1] * s, p[0] * s + p[1] * c];
314
315 // Every segment, rotated by 2*pi/n, must match some original segment
316 // (as an unordered endpoint pair) — the pattern has n-fold symmetry.
317 for seg in &out {
318 let ra = rot(seg.a);
319 let rb = rot(seg.b);
320 let matched = out.iter().any(|other| {
321 (close(other.a, ra) && close(other.b, rb))
322 || (close(other.a, rb) && close(other.b, ra))
323 });
324 assert!(matched, "rotated segment has no image in the pattern");
325 }
326 }
327
328 fn close(a: [f32; 2], b: [f32; 2]) -> bool {
329 (a[0] - b[0]).abs() < 1e-3 && (a[1] - b[1]).abs() < 1e-3
330 }
331}