Skip to main content

thread_manifest/
arch.rs

1//! # arch — the shared architecture math of the Thread
2//!
3//! One geometry, every builder: the markup `<room>` compiler, the museum
4//! generator, and anything else that raises a wall all call THIS module, so
5//! a ring of wall segments meets its columns the same way everywhere. Walls
6//! looked "off" precisely when three builders each hand-rolled the same
7//! trigonometry; the cure is one copy, tested once.
8//!
9//! **Convention** (matches [`crate::markup`]'s room element): azimuths in
10//! degrees, `0` faces −z ("north", away from the default spawn), `180` faces
11//! +z (toward arrivals). A point at azimuth `az` and radius `r` sits at
12//! `(sin(az)·r, −cos(az)·r)`.
13
14/// Direction of an azimuth: unit `(x, z)`.
15pub fn az_dir(az_deg: f32) -> (f32, f32) {
16    let a = az_deg.to_radians();
17    (a.sin(), -a.cos())
18}
19
20/// One straight wall piece of a ring: where it stands, how it turns, how
21/// long it is. Height/thickness are the caller's voice.
22#[derive(Debug, Clone, Copy)]
23pub struct RingSegment {
24    pub x: f32,
25    pub z: f32,
26    pub yaw_deg: f32,
27    pub len: f32,
28}
29
30/// The gate threshold that actually opens a gate: at least half a segment
31/// plus grace, whatever was requested — a gate azimuth landing exactly on a
32/// column joint must still clear both straddling segments.
33pub fn gate_reach(n_segments: usize, requested_deg: f32) -> f32 {
34    requested_deg.max(0.55 * 360.0 / n_segments.max(1) as f32)
35}
36
37/// A circular wall of `n` chord segments at radius `r`, with openings cut at
38/// each azimuth in `gates` (segments whose midpoint is within
39/// [`gate_reach`] degrees of a gate are omitted). Segments overlap joints
40/// slightly (`len + 0.2` is the caller's usual scale) so rings read sealed.
41pub fn ring_segments(r: f32, n: usize, gates: &[f32], gate_reach_deg: f32) -> Vec<RingSegment> {
42    let seg_len = 2.0 * r * (std::f32::consts::PI / n.max(1) as f32).sin();
43    (0..n)
44        .filter_map(|k| {
45            let az_mid = (k as f32 + 0.5) / n as f32 * 360.0;
46            if gates.iter().any(|g| ang_dist(az_mid, *g) <= gate_reach_deg) {
47                return None;
48            }
49            let (dx, dz) = az_dir(az_mid);
50            Some(RingSegment {
51                x: dx * r,
52                z: dz * r,
53                yaw_deg: -az_mid,
54                len: seg_len,
55            })
56        })
57        .collect()
58}
59
60/// The ring's column positions — one at every joint between segments.
61pub fn ring_joints(r: f32, n: usize) -> Vec<(f32, f32)> {
62    (0..n)
63        .map(|k| {
64            let (dx, dz) = az_dir(k as f32 / n as f32 * 360.0);
65            (dx * r, dz * r)
66        })
67        .collect()
68}
69
70/// A corridor radiating from a center at azimuth `az` — the frame every
71/// wing-like structure positions and TURNS its pieces in. The yaw helpers
72/// are the whole point: wall/beam skew bugs come from hand-deriving these
73/// signs per call site. Derive once, test once, reuse everywhere.
74///
75/// Frame: `dir` runs outward along the corridor; `side` is the lateral axis
76/// (positive = to the right when walking outward). Placement yaw convention:
77/// a yaw of ψ maps local +X to `(cos ψ, −sin ψ)` and local +Z (a quad's
78/// normal) to `(sin ψ, cos ψ)`.
79#[derive(Debug, Clone, Copy)]
80pub struct Corridor {
81    pub az: f32,
82}
83
84impl Corridor {
85    pub fn new(az: f32) -> Self {
86        Corridor { az }
87    }
88
89    /// Outward direction of the corridor.
90    pub fn dir(&self) -> (f32, f32) {
91        az_dir(self.az)
92    }
93
94    /// The lateral axis (`side > 0` is this way).
95    pub fn side_axis(&self) -> (f32, f32) {
96        let a = self.az.to_radians();
97        (a.cos(), a.sin())
98    }
99
100    /// A point `r` out along the corridor, `side` across it, `lift` up.
101    pub fn at(&self, r: f32, side: f32, lift: f32) -> [f32; 3] {
102        let (dx, dz) = self.dir();
103        let (sx, sz) = self.side_axis();
104        [dx * r + sx * side, lift, dz * r + sz * side]
105    }
106
107    /// Yaw for an X-long element RUNNING DOWN the corridor (side walls).
108    pub fn yaw_along(&self) -> f32 {
109        90.0 - self.az
110    }
111
112    /// Yaw for an X-long element SPANNING the corridor (lintels, end walls) —
113    /// also the yaw for a quad FACING BACK toward the corridor's origin
114    /// (gate titles, end paintings: the visitor walks outward into them).
115    pub fn yaw_across(&self) -> f32 {
116        -self.az
117    }
118
119    /// Yaw for a quad on the corridor's `side`, FACING the centerline —
120    /// reading boards mounted on a side wall.
121    pub fn yaw_face_in(&self, side: f32) -> f32 {
122        if side < 0.0 {
123            90.0 - self.az
124        } else {
125            270.0 - self.az
126        }
127    }
128}
129
130/// Minimal angular distance between two azimuths, degrees.
131pub fn ang_dist(a: f32, b: f32) -> f32 {
132    let mut d = (a - b).abs() % 360.0;
133    if d > 180.0 {
134        d = 360.0 - d;
135    }
136    d
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn segments_sit_on_the_circle_and_face_tangent() {
145        let ring = ring_segments(10.0, 12, &[], 0.0);
146        assert_eq!(ring.len(), 12);
147        for s in &ring {
148            let r = (s.x * s.x + s.z * s.z).sqrt();
149            assert!((r - 10.0).abs() < 1e-4, "on the circle ({r})");
150            // Tangent: the wall's local X (rotated by yaw) ⊥ the radial dir.
151            let yaw = s.yaw_deg.to_radians();
152            let tangent = (yaw.cos(), -yaw.sin());
153            let radial = (s.x / r, s.z / r);
154            let dot = tangent.0 * radial.0 + tangent.1 * radial.1;
155            assert!(dot.abs() < 1e-4, "tangent ⊥ radial (dot {dot})");
156        }
157        // Chord length closes the polygon.
158        let expect = 2.0 * 10.0 * (std::f32::consts::PI / 12.0).sin();
159        assert!((ring[0].len - expect).abs() < 1e-4);
160    }
161
162    #[test]
163    fn gates_open_even_on_joints() {
164        // Gate at 0° lands exactly on a joint of a 12-ring: both straddling
165        // segments must go.
166        let reach = gate_reach(12, 0.0);
167        let ring = ring_segments(10.0, 12, &[0.0], reach);
168        assert_eq!(ring.len(), 10, "two segments removed at the joint gate");
169        // South entrance (180°) with a mid-segment gate: 12-ring mids are at
170        // 15°+30k → 165 and 195 straddle 180 → also two segments.
171        let ring = ring_segments(10.0, 12, &[180.0], reach);
172        assert_eq!(ring.len(), 10);
173    }
174
175    #[test]
176    fn corridor_frame_points_and_turns_correctly() {
177        // A north corridor (az 0): out = −z, side = +x.
178        let c = Corridor::new(0.0);
179        let p = c.at(10.0, 2.0, 1.5);
180        assert!(
181            (p[0] - 2.0).abs() < 1e-5 && (p[1] - 1.5).abs() < 1e-5 && (p[2] + 10.0).abs() < 1e-5
182        );
183        // Along: X-long piece runs down the corridor (maps X to ±dir).
184        let yaw = c.yaw_along().to_radians();
185        let x_axis = (yaw.cos(), -yaw.sin());
186        let dot_dir = x_axis.0 * c.dir().0 + x_axis.1 * c.dir().1;
187        assert!(dot_dir.abs() > 0.999, "along maps X onto dir ({dot_dir})");
188        // Across: X-long piece spans it (maps X to ±side).
189        let yaw = c.yaw_across().to_radians();
190        let x_axis = (yaw.cos(), -yaw.sin());
191        let dot_side = x_axis.0 * c.side_axis().0 + x_axis.1 * c.side_axis().1;
192        assert!(
193            dot_side.abs() > 0.999,
194            "across maps X onto side ({dot_side})"
195        );
196        // Face-in: a board on the left (side < 0) faces +side; right faces −side.
197        for (side, sign) in [(-3.0f32, 1.0f32), (3.0, -1.0)] {
198            let yaw = c.yaw_face_in(side).to_radians();
199            let normal = (yaw.sin(), yaw.cos());
200            let dot = normal.0 * c.side_axis().0 + normal.1 * c.side_axis().1;
201            assert!(
202                (dot - sign).abs() < 1e-3,
203                "board at {side} faces in ({dot})"
204            );
205        }
206        // And the same invariants hold at an arbitrary azimuth.
207        let c = Corridor::new(-55.0);
208        let yaw = c.yaw_along().to_radians();
209        let x_axis = (yaw.cos(), -yaw.sin());
210        let dot = x_axis.0 * c.dir().0 + x_axis.1 * c.dir().1;
211        assert!(dot.abs() > 0.999, "along holds at −55° ({dot})");
212    }
213
214    #[test]
215    fn joints_count_and_convention() {
216        let joints = ring_joints(5.0, 8);
217        assert_eq!(joints.len(), 8);
218        // Azimuth 0 = north (−z).
219        assert!((joints[0].0).abs() < 1e-5 && (joints[0].1 + 5.0).abs() < 1e-5);
220    }
221}