Skip to main content

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}