Skip to main content

symbios_shape/
interpreter.rs

1/// Queue-based CGA Shape Grammar interpreter.
2///
3/// The interpreter owns a set of named rules. Each rule has one or more
4/// weighted variants — for deterministic rules there is exactly one variant.
5/// Derivation starts from a root `Scope` and root rule name, expanding rules
6/// breadth-first until every branch terminates. Branches terminate either via
7/// an explicit `I(mesh_id)` op or by referencing an unknown rule name, which
8/// is treated as an implicit `I(rule_name)` terminal ("leaf shorthand").
9use std::collections::{HashMap, VecDeque};
10use std::f64::consts::{FRAC_PI_2, PI};
11
12use rand::SeedableRng;
13use rand_pcg::Pcg64;
14use serde::{Deserialize, Serialize};
15
16use crate::error::ShapeError;
17use crate::expr::{EvalCtx, Expr};
18use crate::model::{FaceProfile, Material, ShapeModel, Terminal, taper_to_profile};
19use crate::ops::{
20    AttachCase, AttachSelector, Axis, CarveSelector, CompTarget, FaceSelector, OffsetCase,
21    OffsetSelector, RoofConfig, RoofFaceSelector, RoofType, ShapeOp, SplitEntry, SplitSize,
22    SplitSlot,
23};
24use crate::query::{
25    register_scope_snap_planes, scope_obb_overlaps_terminal, snap_split_boundaries,
26};
27use crate::scope::{Quat, Scope, Vec3};
28
29/// Safety caps (DoS protection).
30const MAX_DEPTH: usize = 64;
31const MAX_QUEUE: usize = 100_000;
32const MAX_TERMINALS: usize = 100_000;
33/// Hard cap on the flattened child count of one `Split` (rhythm groups can
34/// multiply entries well past the parse-time slot cap).
35const MAX_SPLIT_CHILDREN: usize = 4096;
36/// Hard cap on points from a single `Scatter` op.
37const MAX_SCATTER_POINTS: usize = 1024;
38
39// Rule variants (selector + ops) live in `crate::ops` as `RuleVariant`; the
40// interpreter re-exports them for downstream convenience.
41pub use crate::ops::{RuleVariant, VariantSelector};
42
43// ── Work queue item ───────────────────────────────────────────────────────────
44
45struct WorkItem {
46    scope: Scope,
47    rule: String,
48    /// Call-argument values, evaluated in the *calling* shape's context at
49    /// push time. Bound to the callee's declared parameter names when the
50    /// item is popped (see [`RuleDef::params`]).
51    args: Vec<f64>,
52    depth: usize,
53    /// Taper value set by `ShapeOp::Taper` within this rule invocation; propagated
54    /// to the terminal. Branching ops (Split/Comp/Repeat) reset it to 0.0 for
55    /// children — taper is not accumulated across rule boundaries.
56    taper: f64,
57    /// Explicit face profile set by `Roof` panel generation; overrides `taper`
58    /// when computing the terminal's `face_profile`.
59    face_profile_override: Option<FaceProfile>,
60    /// Material set by `Mat("...")` ops; propagates to child scopes.
61    material: Option<Material>,
62    /// Zero-based index of this shape within the most recent `Split` /
63    /// `Repeat` on its derivation path (`split.i` in expressions). Children
64    /// of non-split branching ops inherit the parent's value — CityEngine's
65    /// "last split wins" semantics.
66    split_i: usize,
67    /// Sibling count of that same `Split` / `Repeat` (`split.n`). `1` at the root.
68    split_n: usize,
69    /// Per-shape RNG seed state (see the module's per-shape streams notes).
70    rng_state: u64,
71    /// Occlusion label stamped by `Label("...")`; propagates like `material`.
72    label: Option<String>,
73}
74
75/// Evaluates one argument expression against the current shape's context.
76#[allow(clippy::too_many_arguments)]
77fn eval_expr(
78    e: &Expr,
79    scope: &Scope,
80    split_i: usize,
81    split_n: usize,
82    depth: usize,
83    params: &[(String, f64)],
84    globals: &HashMap<String, f64>,
85    rng: &mut Pcg64,
86) -> Result<f64, ShapeError> {
87    let mut ctx = EvalCtx {
88        scope_size: scope.size,
89        split_i: split_i as f64,
90        split_n: split_n as f64,
91        depth: depth as f64,
92        params,
93        globals,
94        rng,
95    };
96    e.eval(&mut ctx)
97}
98
99// ── Per-shape RNG streams ─────────────────────────────────────────────────────
100//
101// Every shape owns a seed state derived from its parent's state and its
102// ordinal among the parent's children (CityEngine's *seedian* model). All
103// stochastic behaviour for a shape — variant choice and `rand(..)` draws —
104// comes from a stream seeded with that state, so a derivation is a pure
105// function of `(grammar, root scope, interpreter seed)` regardless of queue
106// order, and localized edits re-roll only the affected subtree.
107
108/// SplitMix64 finalizer — cheap, well-distributed state mixing.
109fn splitmix64(mut z: u64) -> u64 {
110    z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
111    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
112    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
113    z ^ (z >> 31)
114}
115
116/// The seed state for the `ordinal`-th child of a shape with `parent` state.
117fn fork_state(parent: u64, ordinal: u64) -> u64 {
118    splitmix64(parent ^ ordinal.wrapping_add(1).wrapping_mul(0x9E37_79B9_7F4A_7C15))
119}
120
121/// FNV-1a hash of a string — stable key hashing for `Pick`.
122fn fnv1a(s: &str) -> u64 {
123    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
124    for b in s.as_bytes() {
125        h ^= u64::from(*b);
126        h = h.wrapping_mul(0x0000_0100_0000_01B3);
127    }
128    h
129}
130
131// ── Split size resolution ─────────────────────────────────────────────────────
132
133/// A slot size whose expression has already been evaluated.
134#[derive(Clone, Copy, Debug, PartialEq)]
135pub(crate) enum ResolvedSize {
136    Absolute(f64),
137    Relative(f64),
138    Floating(f64),
139}
140
141impl ResolvedSize {
142    fn value(self) -> f64 {
143        match self {
144            ResolvedSize::Absolute(v) | ResolvedSize::Relative(v) | ResolvedSize::Floating(v) => v,
145        }
146    }
147}
148
149/// Resolves evaluated slot sizes against `total_dim`, returning absolute sizes.
150fn resolve_split_sizes(slots: &[ResolvedSize], total_dim: f64) -> Result<Vec<f64>, ShapeError> {
151    if slots.is_empty() {
152        return Err(ShapeError::EmptySplit);
153    }
154    for slot in slots {
155        let v = slot.value();
156        if !v.is_finite() || v <= 0.0 {
157            return match slot {
158                ResolvedSize::Floating(v) => Err(ShapeError::InvalidFloatingSize(*v)),
159                _ => Err(ShapeError::InvalidNumericValue),
160            };
161        }
162    }
163
164    let mut fixed: Vec<Option<f64>> = Vec::with_capacity(slots.len());
165    let mut used = 0.0_f64;
166    let mut float_weight_total = 0.0_f64;
167
168    for slot in slots {
169        match *slot {
170            ResolvedSize::Absolute(v) => {
171                fixed.push(Some(v));
172                used += v;
173            }
174            ResolvedSize::Relative(t) => {
175                let s = total_dim * t;
176                fixed.push(Some(s));
177                used += s;
178            }
179            ResolvedSize::Floating(w) => {
180                fixed.push(None);
181                float_weight_total += w;
182            }
183        }
184    }
185
186    // Guard against absolute-size sum overflow (e.g. 256 slots each with 1e307).
187    if !used.is_finite() {
188        return Err(ShapeError::InvalidNumericValue);
189    }
190
191    if used > total_dim + 1e-9 {
192        return Err(ShapeError::SplitOverflow(total_dim));
193    }
194
195    let remaining = (total_dim - used).max(0.0);
196
197    // Guard against weight sum overflow (e.g. 256 slots each with weight 1e307).
198    if !float_weight_total.is_finite() {
199        return Err(ShapeError::InvalidNumericValue);
200    }
201
202    let mut result = Vec::with_capacity(slots.len());
203    for (i, slot) in slots.iter().enumerate() {
204        match fixed[i] {
205            Some(v) => result.push(v),
206            None => {
207                let w = match *slot {
208                    ResolvedSize::Floating(w) => w,
209                    _ => unreachable!(),
210                };
211                if float_weight_total <= 0.0 {
212                    return Err(ShapeError::NoFloatingSlots);
213                }
214                // Compute the ratio first (≤ 1.0) to avoid an intermediate
215                // product overflow when both `remaining` and `w` are large.
216                result.push(remaining * (w / float_weight_total));
217            }
218        }
219    }
220
221    Ok(result)
222}
223
224// ── Scope slicing helpers ─────────────────────────────────────────────────────
225
226/// Creates a child scope that is a sub-interval `[offset, offset+size]` of the
227/// parent scope along `axis`. All measurements are in local (scope) units.
228fn slice_scope(parent: &Scope, axis: Axis, offset: f64, size: f64) -> Scope {
229    let offset_vec = match axis {
230        Axis::X => Vec3::new(offset, 0.0, 0.0),
231        Axis::Y => Vec3::new(0.0, offset, 0.0),
232        Axis::Z => Vec3::new(0.0, 0.0, offset),
233    };
234
235    let child_position = parent.position + parent.rotation * offset_vec;
236
237    let child_size = match axis {
238        Axis::X => Vec3::new(size, parent.size.y, parent.size.z),
239        Axis::Y => Vec3::new(parent.size.x, size, parent.size.z),
240        Axis::Z => Vec3::new(parent.size.x, parent.size.y, size),
241    };
242
243    Scope::new(child_position, parent.rotation, child_size)
244}
245
246// ── Face decomposition ────────────────────────────────────────────────────────
247
248/// All six canonical faces of an OBB, each with a proper outward-facing orientation.
249///
250/// **Convention:** Local **Z** points along the outward normal.  Local **X** is
251/// world-horizontal along the face; Local **Y** is world-up for vertical faces.
252///
253/// This means `Split(X)` tiles a wall horizontally and `Split(Y)` divides it
254/// into floors — the same grammar rule works on any vertical face without manual
255/// rotation hacks.
256///
257/// Each entry: `(selector, local_offset, face_size, rotation_delta)`.
258/// `face_size` uses Z=0 (flattened 2-D canvas).
259fn face_descs(scope_size: Vec3) -> [(FaceSelector, Vec3, Vec3, Quat); 6] {
260    let sx = scope_size.x;
261    let sy = scope_size.y;
262    let sz = scope_size.z;
263
264    [
265        // Bottom: outward = -Y.  Local X=+X, Local Y=+Z, Local Z=-Y.
266        // Rotation: from_axis_angle(X, +π/2) → X→X, Y→+Z, Z→-Y.
267        (
268            FaceSelector::Bottom,
269            Vec3::new(0.0, 0.0, 0.0),
270            Vec3::new(sx, sz, 0.0),
271            Quat::from_axis_angle(Vec3::X, FRAC_PI_2),
272        ),
273        // Top: outward = +Y.  Local X=+X, Local Y=-Z, Local Z=+Y.
274        // Rotation: from_axis_angle(X, -π/2) → X→X, Y→-Z, Z→+Y.
275        // Origin at (0, sy, sz) so local-Y tiles from back to front.
276        (
277            FaceSelector::Top,
278            Vec3::new(0.0, sy, sz),
279            Vec3::new(sx, sz, 0.0),
280            Quat::from_axis_angle(Vec3::X, -FRAC_PI_2),
281        ),
282        // Front: outward = -Z.  Local X=-X, Local Y=+Y, Local Z=-Z.
283        // Rotation: from_axis_angle(Y, π) → X→-X, Y→Y, Z→-Z.
284        // Origin at (sx, 0, 0) so local-X tiles from right to left in world.
285        (
286            FaceSelector::Front,
287            Vec3::new(sx, 0.0, 0.0),
288            Vec3::new(sx, sy, 0.0),
289            Quat::from_axis_angle(Vec3::Y, PI),
290        ),
291        // Back: outward = +Z.  Local X=+X, Local Y=+Y, Local Z=+Z.
292        // Rotation: identity.
293        // Origin at (0, 0, sz).
294        (
295            FaceSelector::Back,
296            Vec3::new(0.0, 0.0, sz),
297            Vec3::new(sx, sy, 0.0),
298            Quat::IDENTITY,
299        ),
300        // Left: outward = -X.  Local X=+Z, Local Y=+Y, Local Z=-X.
301        // Rotation: from_axis_angle(Y, -π/2) → X→+Z, Y→Y, Z→-X.
302        // Origin at (0, 0, 0).
303        (
304            FaceSelector::Left,
305            Vec3::new(0.0, 0.0, 0.0),
306            Vec3::new(sz, sy, 0.0),
307            Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
308        ),
309        // Right: outward = +X.  Local X=-Z, Local Y=+Y, Local Z=+X.
310        // Rotation: from_axis_angle(Y, +π/2) → X→-Z, Y→Y, Z→+X.
311        // Origin at (sx, 0, sz).
312        (
313            FaceSelector::Right,
314            Vec3::new(sx, 0.0, sz),
315            Vec3::new(sz, sy, 0.0),
316            Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
317        ),
318    ]
319}
320
321/// Finds the rule to apply to a face given a list of `CompFaceCase`s.
322fn find_face_rule(
323    selector: FaceSelector,
324    cases: &[crate::ops::CompFaceCase],
325) -> Option<&crate::ops::RuleCall> {
326    if let Some(case) = cases.iter().find(|c| c.selector == selector) {
327        return Some(&case.rule);
328    }
329    let is_side = matches!(
330        selector,
331        FaceSelector::Front | FaceSelector::Back | FaceSelector::Left | FaceSelector::Right
332    );
333    if is_side && let Some(case) = cases.iter().find(|c| c.selector == FaceSelector::Side) {
334        return Some(&case.rule);
335    }
336    cases
337        .iter()
338        .find(|c| c.selector == FaceSelector::All)
339        .map(|c| &c.rule)
340}
341
342/// Returns the local-space unit vector for the given axis.
343fn axis_vec(axis: Axis) -> Vec3 {
344    match axis {
345        Axis::X => Vec3::X,
346        Axis::Y => Vec3::Y,
347        Axis::Z => Vec3::Z,
348    }
349}
350
351/// Rule look-up for `Offset` cases.
352fn find_offset_rule(
353    selector: OffsetSelector,
354    cases: &[OffsetCase],
355) -> Option<&crate::ops::RuleCall> {
356    cases
357        .iter()
358        .find(|c| c.selector == selector)
359        .or_else(|| cases.iter().find(|c| c.selector == OffsetSelector::All))
360        .map(|c| &c.rule)
361}
362
363/// A roof case whose call arguments were evaluated in the parent shape's
364/// context before roof construction (apply_roof has no evaluation context).
365pub(crate) struct ResolvedRoofCase {
366    selector: RoofFaceSelector,
367    name: String,
368    args: Vec<f64>,
369}
370
371/// Rule look-up for `Roof` cases (argument values pre-evaluated).
372fn find_roof_rule(
373    selector: RoofFaceSelector,
374    cases: &[ResolvedRoofCase],
375) -> Option<&ResolvedRoofCase> {
376    cases
377        .iter()
378        .find(|c| c.selector == selector)
379        .or_else(|| cases.iter().find(|c| c.selector == RoofFaceSelector::All))
380}
381
382/// Edge descriptions of an OBB or face scope: `(class, origin, direction,
383/// length)` in scope-local coordinates. Volumes yield 12 edges (4 vertical +
384/// two 4-edge horizontal rings); face scopes (`size.z == 0`) yield their 4
385/// boundary edges.
386fn edge_descs(size: Vec3) -> Vec<(crate::ops::EdgeSelector, Vec3, Vec3, f64)> {
387    use crate::ops::EdgeSelector as E;
388    let (sx, sy, sz) = (size.x, size.y, size.z);
389    if sz.abs() < 1e-9 && sy.abs() > 1e-9 {
390        // Face scope: 4 boundary edges in the XY plane.
391        return vec![
392            (E::Bottom, Vec3::new(0.0, 0.0, 0.0), Vec3::X, sx),
393            (E::Top, Vec3::new(0.0, sy, 0.0), Vec3::X, sx),
394            (E::Vertical, Vec3::new(0.0, 0.0, 0.0), Vec3::Y, sy),
395            (E::Vertical, Vec3::new(sx, 0.0, 0.0), Vec3::Y, sy),
396        ];
397    }
398    let mut v = Vec::with_capacity(12);
399    // 4 vertical corner posts.
400    for (cx, cz) in [(0.0, 0.0), (sx, 0.0), (sx, sz), (0.0, sz)] {
401        v.push((E::Vertical, Vec3::new(cx, 0.0, cz), Vec3::Y, sy));
402    }
403    // Horizontal rings at y = 0 (Bottom) and y = sy (Top).
404    for (class, y) in [(E::Bottom, 0.0), (E::Top, sy)] {
405        v.push((class, Vec3::new(0.0, y, 0.0), Vec3::X, sx));
406        v.push((class, Vec3::new(0.0, y, sz), Vec3::X, sx));
407        v.push((class, Vec3::new(0.0, y, 0.0), Vec3::Z, sz));
408        v.push((class, Vec3::new(sx, y, 0.0), Vec3::Z, sz));
409    }
410    v
411}
412
413/// Rule look-up for `Comp(Edges)` cases: exact class first, then
414/// `Horizontal` for the top/bottom rings, then `All`.
415fn find_edge_rule(
416    class: crate::ops::EdgeSelector,
417    cases: &[crate::ops::CompEdgeCase],
418) -> Option<&crate::ops::RuleCall> {
419    use crate::ops::EdgeSelector as E;
420    if let Some(c) = cases.iter().find(|c| c.selector == class) {
421        return Some(&c.rule);
422    }
423    if matches!(class, E::Top | E::Bottom)
424        && let Some(c) = cases.iter().find(|c| c.selector == E::Horizontal)
425    {
426        return Some(&c.rule);
427    }
428    cases.iter().find(|c| c.selector == E::All).map(|c| &c.rule)
429}
430
431/// Rule look-up for `ShapeL` / `ShapeU` cases.
432fn find_carve_rule(
433    selector: crate::ops::CarveSelector,
434    cases: &[crate::ops::CarveCase],
435) -> Option<&crate::ops::CarveCase> {
436    cases.iter().find(|c| c.selector == selector).or_else(|| {
437        cases
438            .iter()
439            .find(|c| c.selector == crate::ops::CarveSelector::All)
440    })
441}
442
443/// Mirrors a 2-D face profile across its vertical centre line.
444fn mirror_profile(p: &FaceProfile) -> FaceProfile {
445    match p {
446        FaceProfile::Rectangle => FaceProfile::Rectangle,
447        FaceProfile::Taper(t) => FaceProfile::Taper(*t),
448        FaceProfile::Triangle { peak_offset } => FaceProfile::Triangle {
449            peak_offset: 1.0 - peak_offset,
450        },
451        FaceProfile::Trapezoid {
452            top_width,
453            offset_x,
454        } => FaceProfile::Trapezoid {
455            top_width: *top_width,
456            offset_x: 1.0 - top_width - offset_x,
457        },
458        FaceProfile::Polygon(pts) => FaceProfile::Polygon(
459            pts.iter()
460                .map(|v| glam::DVec2::new(1.0 - v.x, v.y))
461                .collect(),
462        ),
463    }
464}
465
466/// Rule look-up for `Attach` cases.
467fn find_attach_rule(
468    selector: AttachSelector,
469    cases: &[AttachCase],
470) -> Option<&crate::ops::RuleCall> {
471    cases
472        .iter()
473        .find(|c| c.selector == selector)
474        .or_else(|| cases.iter().find(|c| c.selector == AttachSelector::All))
475        .map(|c| &c.rule)
476}
477
478/// Rotations for the four cardinal slope directions used by `Roof`.
479///
480/// All rotations are expressed as deltas in the **parent scope's local frame**
481/// (composed as `scope.rotation * delta` to obtain the world-space rotation).
482///
483/// Convention: Local Z = outward normal (away from building), Local Y = up the slope,
484/// matching the `Comp(Faces)` face convention extended to tilted surfaces.
485///
486/// - `front_rot`: outward normal = (0, cos α, −sin α) — up & forward (−Z world)
487/// - `back_rot`:  outward normal = (0, cos α, +sin α) — up & backward (+Z world)
488/// - `left_rot`:  outward normal = (−sin α, cos α, 0) — up & left (−X world)
489/// - `right_rot`: outward normal = (+sin α, cos α, 0) — up & right (+X world)
490fn roof_slope_rotations(alpha: f64) -> (Quat, Quat, Quat, Quat) {
491    // front: mirror the Comp-Front face (flip X via Y-π rotation), then tilt the slope.
492    //   Local X = (−1, 0, 0), Local Z = (0, cos α, −sin α) — outward up & forward.
493    let front_rot =
494        Quat::from_axis_angle(Vec3::X, FRAC_PI_2 - alpha) * Quat::from_axis_angle(Vec3::Y, PI);
495    // back: identity orientation tilted by (α − π/2).
496    //   Local X = (+1, 0, 0), Local Z = (0, cos α, +sin α) — outward up & backward.
497    let back_rot = Quat::from_axis_angle(Vec3::X, alpha - FRAC_PI_2);
498    // left: Comp-Left face orientation (Y, −π/2) then tilt.
499    //   Local X = (0, 0, +1), Local Z = (−sin α, cos α, 0) — outward up & left.
500    let left_rot = Quat::from_axis_angle(Vec3::Z, alpha - FRAC_PI_2)
501        * Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2);
502    // right: Comp-Right face orientation (Y, +π/2) then tilt.
503    //   Local X = (0, 0, −1), Local Z = (+sin α, cos α, 0) — outward up & right.
504    let right_rot = Quat::from_axis_angle(Vec3::Z, FRAC_PI_2 - alpha)
505        * Quat::from_axis_angle(Vec3::Y, FRAC_PI_2);
506    (front_rot, back_rot, left_rot, right_rot)
507}
508
509/// Derives the vertical "wall" rotation that matches a slope panel's eave direction.
510///
511/// Used by the `Fascia` band generation: the fascia hangs vertically from the eave,
512/// so its outward normal is the horizontal projection of the slope's outward normal,
513/// while keeping the slope's eave direction as the fascia's local X axis.
514///
515/// Returns `None` when the slope has no horizontal component (e.g. a flat roof's
516/// purely-vertical normal) — in that case no horizontal fascia direction exists.
517fn slope_to_wall_rot(slope_rot: Quat) -> Option<Quat> {
518    let eave_x = slope_rot * Vec3::X;
519    let slope_z = slope_rot * Vec3::Z;
520    let mut wall_z = Vec3::new(slope_z.x, 0.0, slope_z.z);
521    if wall_z.length_squared() < 1e-12 {
522        return None;
523    }
524    wall_z = wall_z.normalize();
525    let mut wall_x = Vec3::new(eave_x.x, 0.0, eave_x.z);
526    if wall_x.length_squared() < 1e-12 {
527        return None;
528    }
529    wall_x = wall_x.normalize();
530    let wall_y = wall_z.cross(wall_x);
531    let mat = glam::DMat3::from_cols(wall_x, wall_y, wall_z);
532    Some(Quat::from_mat3(&mat).normalize())
533}
534
535// ── Roof geometry ─────────────────────────────────────────────────────────────
536
537/// Generates roof panel scopes for all supported `RoofType` variants.
538///
539/// Each panel is a flat scope (size.z = 0) with an orientation that places
540/// local Z along the outward normal and local Y up the slope, consistent with
541/// the `Comp(Faces)` convention. The `face_profile_override` field on each
542/// child `WorkItem` carries the exact 2D cross-section shape.
543#[allow(clippy::too_many_arguments)]
544fn apply_roof(
545    config: &RoofConfig,
546    cases: &[ResolvedRoofCase],
547    scope: &Scope,
548    depth: usize,
549    material: &Option<Material>,
550    queue: &mut VecDeque<WorkItem>,
551    model: &mut ShapeModel,
552    max_terminals: usize,
553    split_i: usize,
554    split_n: usize,
555    rng_state: u64,
556    ridge_axis: Option<Axis>,
557    label: &Option<String>,
558) -> Result<(), ShapeError> {
559    if !config.pitch.is_finite() || config.pitch <= 0.0 || config.pitch >= 90.0 {
560        return Err(ShapeError::InvalidRoofAngle(config.pitch));
561    }
562    if !config.overhang.is_finite() || config.overhang < 0.0 {
563        return Err(ShapeError::InvalidNumericValue);
564    }
565
566    let sx = scope.size.x;
567    let sz = scope.size.z;
568    // Ridge orientation: honoured by the gable family (Gable/OpenGable/
569    // BoxGable), Gambrel, Saltbox, Jerkinhead and DutchGable; the hip family
570    // derives its ridge from the footprint and ignores the override.
571    let ridge_x = match ridge_axis {
572        Some(Axis::X) => true,
573        Some(_) => false,
574        None => sx >= sz,
575    };
576    let o = config.overhang;
577    let alpha = config.pitch.to_radians();
578    let cos_a = alpha.cos();
579    let tan_a = alpha.tan();
580    let (front_rot, back_rot, left_rot, right_rot) = roof_slope_rotations(alpha);
581    // y_anchor: Y offset below scope.position due to eave overhang projection.
582    let y_anchor = -o * tan_a;
583    // Slope lengths from eave to ridge centre (including overhang).
584    let fb_len = (sz / 2.0 + o) / cos_a;
585    let lr_len = (sx / 2.0 + o) / cos_a;
586    // Ridge height above eave (driven by depth, no overhang contribution to height).
587    let h = (sz / 2.0) * tan_a;
588
589    if !fb_len.is_finite() || !lr_len.is_finite() || !h.is_finite() {
590        return Err(ShapeError::InvalidNumericValue);
591    }
592
593    // Panel tuple: (local_offset, face_size, rot_delta, selector, face_profile)
594    type Panel = (Vec3, Vec3, Quat, RoofFaceSelector, FaceProfile);
595
596    let mut panels: Vec<Panel> = match config.roof_type {
597        // ── Flat ─────────────────────────────────────────────────────────────
598        // One horizontal panel covering the scope top (same geometry as Comp Top).
599        RoofType::Flat => vec![(
600            Vec3::new(0.0, 0.0, sz),
601            Vec3::new(sx, sz, 0.0),
602            Quat::from_axis_angle(Vec3::X, -FRAC_PI_2),
603            RoofFaceSelector::Slope,
604            FaceProfile::Rectangle,
605        )],
606
607        // ── Shed ─────────────────────────────────────────────────────────────
608        // One slope from front eave to back eave (front_rot convention).
609        RoofType::Shed => {
610            let shed_h = sz * tan_a;
611            vec![
612                (
613                    Vec3::new(sx + o, y_anchor, -o),
614                    Vec3::new(sx + 2.0 * o, (sz + 2.0 * o) / cos_a, 0.0),
615                    front_rot,
616                    RoofFaceSelector::Slope,
617                    FaceProfile::Rectangle,
618                ),
619                // The high back wall under the raised eave — the glazed
620                // "northlight" face of a sawtooth factory roof.
621                (
622                    Vec3::new(0.0, 0.0, sz),
623                    Vec3::new(sx, shed_h, 0.0),
624                    Quat::IDENTITY,
625                    RoofFaceSelector::Back,
626                    FaceProfile::Rectangle,
627                ),
628                (
629                    Vec3::new(0.0, 0.0, 0.0),
630                    Vec3::new(sz, shed_h, 0.0),
631                    Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
632                    RoofFaceSelector::GableEnd,
633                    FaceProfile::Triangle { peak_offset: 1.0 },
634                ),
635                (
636                    Vec3::new(sx, 0.0, sz),
637                    Vec3::new(sz, shed_h, 0.0),
638                    Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
639                    RoofFaceSelector::GableEnd,
640                    FaceProfile::Triangle { peak_offset: 0.0 },
641                ),
642            ]
643        }
644
645        // ── Gable / OpenGable / BoxGable ──────────────────────────────────────
646        RoofType::Gable | RoofType::OpenGable | RoofType::BoxGable => {
647            let (slope_len, eave_len, ridge_h) = if ridge_x {
648                ((sz / 2.0 + o) / cos_a, sx + 2.0 * o, (sz / 2.0) * tan_a)
649            } else {
650                ((sx / 2.0 + o) / cos_a, sz + 2.0 * o, (sx / 2.0) * tan_a)
651            };
652
653            let mut panels = if ridge_x {
654                vec![
655                    (
656                        Vec3::new(sx + o, y_anchor, -o),
657                        Vec3::new(eave_len, slope_len, 0.0),
658                        front_rot,
659                        RoofFaceSelector::Slope,
660                        FaceProfile::Rectangle,
661                    ),
662                    (
663                        Vec3::new(-o, y_anchor, sz + o),
664                        Vec3::new(eave_len, slope_len, 0.0),
665                        back_rot,
666                        RoofFaceSelector::Slope,
667                        FaceProfile::Rectangle,
668                    ),
669                ]
670            } else {
671                vec![
672                    (
673                        Vec3::new(-o, y_anchor, -o),
674                        Vec3::new(eave_len, slope_len, 0.0),
675                        left_rot,
676                        RoofFaceSelector::Slope,
677                        FaceProfile::Rectangle,
678                    ),
679                    (
680                        Vec3::new(sx + o, y_anchor, sz + o),
681                        Vec3::new(eave_len, slope_len, 0.0),
682                        right_rot,
683                        RoofFaceSelector::Slope,
684                        FaceProfile::Rectangle,
685                    ),
686                ]
687            };
688
689            if config.roof_type != RoofType::OpenGable {
690                let profile = if config.roof_type == RoofType::BoxGable {
691                    FaceProfile::Rectangle
692                } else {
693                    FaceProfile::Triangle { peak_offset: 0.5 }
694                };
695
696                if ridge_x {
697                    panels.extend(vec![
698                        (
699                            Vec3::new(0.0, 0.0, 0.0),
700                            Vec3::new(sz, ridge_h, 0.0),
701                            Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
702                            RoofFaceSelector::GableEnd,
703                            profile.clone(),
704                        ),
705                        (
706                            Vec3::new(sx, 0.0, sz),
707                            Vec3::new(sz, ridge_h, 0.0),
708                            Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
709                            RoofFaceSelector::GableEnd,
710                            profile,
711                        ),
712                    ]);
713                } else {
714                    panels.extend(vec![
715                        (
716                            Vec3::new(sx, 0.0, 0.0),
717                            Vec3::new(sx, ridge_h, 0.0),
718                            Quat::from_axis_angle(Vec3::Y, PI),
719                            RoofFaceSelector::GableEnd,
720                            profile.clone(),
721                        ),
722                        (
723                            Vec3::new(0.0, 0.0, sz),
724                            Vec3::new(sx, ridge_h, 0.0),
725                            Quat::IDENTITY,
726                            RoofFaceSelector::GableEnd,
727                            profile,
728                        ),
729                    ]);
730                }
731            }
732            panels
733        }
734
735        // ── Pyramid / PyramidHip / Hip ─────────────────────────────────────────
736        // For non-square bases, true pyramids with equal pitch are mathematically
737        // impossible; they correctly degenerate into a Hip roof with a ridge.
738        RoofType::Pyramid | RoofType::PyramidHip | RoofType::Hip => {
739            let eave_w = sx + 2.0 * o;
740            let eave_d = sz + 2.0 * o;
741            let max_run = sx.min(sz) / 2.0 + o;
742            let slope_len = max_run / cos_a;
743
744            let (fb_profile, lr_profile) = if sx > sz + 1e-5 {
745                let top_w = (sx - sz) / eave_w;
746                let off_x = (sz / 2.0 + o) / eave_w;
747                (
748                    FaceProfile::Trapezoid {
749                        top_width: top_w,
750                        offset_x: off_x,
751                    },
752                    FaceProfile::Triangle { peak_offset: 0.5 },
753                )
754            } else if sz > sx + 1e-5 {
755                let top_w = (sz - sx) / eave_d;
756                let off_x = (sx / 2.0 + o) / eave_d;
757                (
758                    FaceProfile::Triangle { peak_offset: 0.5 },
759                    FaceProfile::Trapezoid {
760                        top_width: top_w,
761                        offset_x: off_x,
762                    },
763                )
764            } else {
765                (
766                    FaceProfile::Triangle { peak_offset: 0.5 },
767                    FaceProfile::Triangle { peak_offset: 0.5 },
768                )
769            };
770
771            vec![
772                (
773                    Vec3::new(sx + o, y_anchor, -o),
774                    Vec3::new(eave_w, slope_len, 0.0),
775                    front_rot,
776                    RoofFaceSelector::Slope,
777                    fb_profile.clone(),
778                ),
779                (
780                    Vec3::new(-o, y_anchor, sz + o),
781                    Vec3::new(eave_w, slope_len, 0.0),
782                    back_rot,
783                    RoofFaceSelector::Slope,
784                    fb_profile,
785                ),
786                (
787                    Vec3::new(-o, y_anchor, -o),
788                    Vec3::new(eave_d, slope_len, 0.0),
789                    left_rot,
790                    RoofFaceSelector::Slope,
791                    lr_profile.clone(),
792                ),
793                (
794                    Vec3::new(sx + o, y_anchor, sz + o),
795                    Vec3::new(eave_d, slope_len, 0.0),
796                    right_rot,
797                    RoofFaceSelector::Slope,
798                    lr_profile,
799                ),
800            ]
801        }
802
803        // ── Butterfly ─────────────────────────────────────────────────────────
804        // Two inward-tilting slopes with a valley at centre (z = sz/2).
805        // Panels run FROM the valley TOWARD each eave using back_rot/front_rot.
806        RoofType::Butterfly => {
807            // The valley sits (sz/2 + o)*tan_a below eave level.
808            let y_valley = y_anchor - (sz / 2.0 + o) * tan_a;
809            if !y_valley.is_finite() {
810                return Err(ShapeError::InvalidNumericValue);
811            }
812            vec![
813                // Front valley slope: valley → front eave (back_rot points toward -Z = front)
814                (
815                    Vec3::new(-o, y_valley, sz / 2.0),
816                    Vec3::new(sx + 2.0 * o, fb_len, 0.0),
817                    back_rot,
818                    RoofFaceSelector::ValleySlope,
819                    FaceProfile::Rectangle,
820                ),
821                // Back valley slope: valley → back eave (front_rot points toward +Z = back)
822                (
823                    Vec3::new(sx + o, y_valley, sz / 2.0),
824                    Vec3::new(sx + 2.0 * o, fb_len, 0.0),
825                    front_rot,
826                    RoofFaceSelector::ValleySlope,
827                    FaceProfile::Rectangle,
828                ),
829            ]
830        }
831
832        // ── MShaped ───────────────────────────────────────────────────────────
833        // Two ridges (at z = sz/4 and z = 3*sz/4) with a valley at z = sz/2.
834        // Four slopes: outer-front, inner-front (valley), inner-back (valley), outer-back.
835        RoofType::MShaped => {
836            let quarter = sz / 4.0;
837            let h_m = quarter * tan_a;
838            if !h_m.is_finite() {
839                return Err(ShapeError::InvalidNumericValue);
840            }
841            let slope_m = quarter / cos_a;
842            let y_valley = y_anchor - h_m; // valley is h_m below the outer ridges
843            vec![
844                // Outer front: eave (z=-o) → front ridge (z=sz/4)
845                (
846                    Vec3::new(sx + o, y_anchor, -o),
847                    Vec3::new(sx + 2.0 * o, slope_m, 0.0),
848                    front_rot,
849                    RoofFaceSelector::OuterSlope,
850                    FaceProfile::Rectangle,
851                ),
852                // Inner front: valley (z=sz/2) → front ridge (z=sz/4), using back_rot
853                (
854                    Vec3::new(-o, y_valley, sz / 2.0),
855                    Vec3::new(sx + 2.0 * o, slope_m, 0.0),
856                    back_rot,
857                    RoofFaceSelector::InnerSlope,
858                    FaceProfile::Rectangle,
859                ),
860                // Inner back: valley (z=sz/2) → back ridge (z=3*sz/4), using front_rot
861                (
862                    Vec3::new(sx + o, y_valley, sz / 2.0),
863                    Vec3::new(sx + 2.0 * o, slope_m, 0.0),
864                    front_rot,
865                    RoofFaceSelector::InnerSlope,
866                    FaceProfile::Rectangle,
867                ),
868                // Outer back: eave (z=sz+o) → back ridge (z=3*sz/4)
869                (
870                    Vec3::new(-o, y_anchor, sz + o),
871                    Vec3::new(sx + 2.0 * o, slope_m, 0.0),
872                    back_rot,
873                    RoofFaceSelector::OuterSlope,
874                    FaceProfile::Rectangle,
875                ),
876            ]
877        }
878
879        // ── Gambrel ───────────────────────────────────────────────────────────
880        // Two-pitch front/back barn roof: steep lower zone + shallow upper zone.
881        RoofType::Gambrel => {
882            let alpha2 = config.secondary_pitch_or_default().to_radians();
883            if !alpha2.is_finite() || alpha2 <= 0.0 || alpha2 >= FRAC_PI_2 {
884                return Err(ShapeError::InvalidNumericValue);
885            }
886            let cos_a2 = alpha2.cos();
887            let tan_a2 = alpha2.tan();
888            let tier = config.tier_height_or(0.5).clamp(0.01, 0.99);
889            let (ufr, ubr, ulr, urr) = roof_slope_rotations(alpha2);
890
891            if ridge_x {
892                let run_z = sz / 2.0 + o;
893                let break_run = (tier * run_z).clamp(o, run_z - 1e-3);
894                let h_break = break_run * tan_a;
895                if !h_break.is_finite() {
896                    return Err(ShapeError::InvalidNumericValue);
897                }
898                let lower_slope = break_run / cos_a;
899                let upper_run = run_z - break_run;
900                let upper_slope = upper_run / cos_a2;
901                let upper_h = upper_run * tan_a2;
902                let y_break = y_anchor + h_break;
903                let eave_w = sx + 2.0 * o;
904                let wall_y_break = h_break - o * tan_a;
905                let wall_break_run = break_run - o;
906                let mid_w = (sz - 2.0 * wall_break_run).max(0.0);
907
908                let lower_gable_profile = if mid_w > 1e-9 {
909                    FaceProfile::Trapezoid {
910                        top_width: mid_w / sz,
911                        offset_x: wall_break_run / sz,
912                    }
913                } else {
914                    FaceProfile::Triangle { peak_offset: 0.5 }
915                };
916
917                vec![
918                    (
919                        Vec3::new(sx + o, y_anchor, -o),
920                        Vec3::new(eave_w, lower_slope, 0.0),
921                        front_rot,
922                        RoofFaceSelector::LowerSlope,
923                        FaceProfile::Rectangle,
924                    ),
925                    (
926                        Vec3::new(-o, y_anchor, sz + o),
927                        Vec3::new(eave_w, lower_slope, 0.0),
928                        back_rot,
929                        RoofFaceSelector::LowerSlope,
930                        FaceProfile::Rectangle,
931                    ),
932                    (
933                        Vec3::new(sx + o, y_break, -o + break_run),
934                        Vec3::new(eave_w, upper_slope, 0.0),
935                        ufr,
936                        RoofFaceSelector::UpperSlope,
937                        FaceProfile::Rectangle,
938                    ),
939                    (
940                        Vec3::new(-o, y_break, sz + o - break_run),
941                        Vec3::new(eave_w, upper_slope, 0.0),
942                        ubr,
943                        RoofFaceSelector::UpperSlope,
944                        FaceProfile::Rectangle,
945                    ),
946                    (
947                        Vec3::new(0.0, 0.0, 0.0),
948                        Vec3::new(sz, wall_y_break, 0.0),
949                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
950                        RoofFaceSelector::GableEnd,
951                        lower_gable_profile.clone(),
952                    ),
953                    (
954                        Vec3::new(sx, 0.0, sz),
955                        Vec3::new(sz, wall_y_break, 0.0),
956                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
957                        RoofFaceSelector::GableEnd,
958                        lower_gable_profile,
959                    ),
960                    (
961                        Vec3::new(0.0, wall_y_break, wall_break_run),
962                        Vec3::new(mid_w, upper_h, 0.0),
963                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
964                        RoofFaceSelector::GableEnd,
965                        FaceProfile::Triangle { peak_offset: 0.5 },
966                    ),
967                    (
968                        Vec3::new(sx, wall_y_break, sz - wall_break_run),
969                        Vec3::new(mid_w, upper_h, 0.0),
970                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
971                        RoofFaceSelector::GableEnd,
972                        FaceProfile::Triangle { peak_offset: 0.5 },
973                    ),
974                ]
975            } else {
976                let run_x = sx / 2.0 + o;
977                let break_run = (tier * run_x).clamp(o, run_x - 1e-3);
978                let h_break = break_run * tan_a;
979                if !h_break.is_finite() {
980                    return Err(ShapeError::InvalidNumericValue);
981                }
982                let lower_slope = break_run / cos_a;
983                let upper_run = run_x - break_run;
984                let upper_slope = upper_run / cos_a2;
985                let upper_h = upper_run * tan_a2;
986                let y_break = y_anchor + h_break;
987                let eave_d = sz + 2.0 * o;
988                let wall_y_break = h_break - o * tan_a;
989                let wall_break_run = break_run - o;
990                let mid_d = (sx - 2.0 * wall_break_run).max(0.0);
991
992                let lower_gable_profile = if mid_d > 1e-9 {
993                    FaceProfile::Trapezoid {
994                        top_width: mid_d / sx,
995                        offset_x: wall_break_run / sx,
996                    }
997                } else {
998                    FaceProfile::Triangle { peak_offset: 0.5 }
999                };
1000
1001                vec![
1002                    (
1003                        Vec3::new(-o, y_anchor, -o),
1004                        Vec3::new(eave_d, lower_slope, 0.0),
1005                        left_rot,
1006                        RoofFaceSelector::LowerSlope,
1007                        FaceProfile::Rectangle,
1008                    ),
1009                    (
1010                        Vec3::new(sx + o, y_anchor, sz + o),
1011                        Vec3::new(eave_d, lower_slope, 0.0),
1012                        right_rot,
1013                        RoofFaceSelector::LowerSlope,
1014                        FaceProfile::Rectangle,
1015                    ),
1016                    (
1017                        Vec3::new(-o + break_run, y_break, -o),
1018                        Vec3::new(eave_d, upper_slope, 0.0),
1019                        ulr,
1020                        RoofFaceSelector::UpperSlope,
1021                        FaceProfile::Rectangle,
1022                    ),
1023                    (
1024                        Vec3::new(sx + o - break_run, y_break, sz + o),
1025                        Vec3::new(eave_d, upper_slope, 0.0),
1026                        urr,
1027                        RoofFaceSelector::UpperSlope,
1028                        FaceProfile::Rectangle,
1029                    ),
1030                    (
1031                        Vec3::new(sx, 0.0, 0.0),
1032                        Vec3::new(sx, wall_y_break, 0.0),
1033                        Quat::from_axis_angle(Vec3::Y, PI),
1034                        RoofFaceSelector::GableEnd,
1035                        lower_gable_profile.clone(),
1036                    ),
1037                    (
1038                        Vec3::new(0.0, 0.0, sz),
1039                        Vec3::new(sx, wall_y_break, 0.0),
1040                        Quat::IDENTITY,
1041                        RoofFaceSelector::GableEnd,
1042                        lower_gable_profile,
1043                    ),
1044                    (
1045                        Vec3::new(sx - wall_break_run, wall_y_break, 0.0),
1046                        Vec3::new(mid_d, upper_h, 0.0),
1047                        Quat::from_axis_angle(Vec3::Y, PI),
1048                        RoofFaceSelector::GableEnd,
1049                        FaceProfile::Triangle { peak_offset: 0.5 },
1050                    ),
1051                    (
1052                        Vec3::new(wall_break_run, wall_y_break, sz),
1053                        Vec3::new(mid_d, upper_h, 0.0),
1054                        Quat::IDENTITY,
1055                        RoofFaceSelector::GableEnd,
1056                        FaceProfile::Triangle { peak_offset: 0.5 },
1057                    ),
1058                ]
1059            }
1060        }
1061
1062        // ── Mansard ───────────────────────────────────────────────────────────
1063        // Gambrel applied to all four sides: 4 steep lower + 4 shallow upper panels.
1064        RoofType::Mansard => {
1065            let alpha2 = config.secondary_pitch_or_default().to_radians();
1066            if !alpha2.is_finite() || alpha2 <= 0.0 || alpha2 >= FRAC_PI_2 {
1067                return Err(ShapeError::InvalidNumericValue);
1068            }
1069            let cos_a2 = alpha2.cos();
1070            let tier = config.tier_height_or(0.5).clamp(0.01, 0.99);
1071
1072            let max_run = sx.min(sz) / 2.0 + o;
1073            let break_run = (tier * max_run).clamp(o, max_run - 1e-3);
1074            let h_break = break_run * tan_a;
1075
1076            if !h_break.is_finite() {
1077                return Err(ShapeError::InvalidNumericValue);
1078            }
1079
1080            let lower_slope = break_run / cos_a;
1081            let y_break = y_anchor + h_break;
1082
1083            let eave_w = sx + 2.0 * o;
1084            let eave_d = sz + 2.0 * o;
1085
1086            let mid_w = (eave_w - 2.0 * break_run).max(0.0);
1087            let mid_d = (eave_d - 2.0 * break_run).max(0.0);
1088
1089            let lower_fb_profile = if mid_w > 1e-9 {
1090                FaceProfile::Trapezoid {
1091                    top_width: mid_w / eave_w,
1092                    offset_x: break_run / eave_w,
1093                }
1094            } else {
1095                FaceProfile::Triangle { peak_offset: 0.5 }
1096            };
1097
1098            let lower_lr_profile = if mid_d > 1e-9 {
1099                FaceProfile::Trapezoid {
1100                    top_width: mid_d / eave_d,
1101                    offset_x: break_run / eave_d,
1102                }
1103            } else {
1104                FaceProfile::Triangle { peak_offset: 0.5 }
1105            };
1106
1107            let (ufr, ubr, ulr, urr) = roof_slope_rotations(alpha2);
1108
1109            let upper_run = mid_w.min(mid_d) / 2.0;
1110            let upper_slope = upper_run / cos_a2;
1111
1112            let top_w = (mid_w - 2.0 * upper_run).max(0.0);
1113            let top_d = (mid_d - 2.0 * upper_run).max(0.0);
1114
1115            let upper_fb_profile = if top_w > 1e-9 {
1116                FaceProfile::Trapezoid {
1117                    top_width: top_w / mid_w,
1118                    offset_x: upper_run / mid_w,
1119                }
1120            } else {
1121                FaceProfile::Triangle { peak_offset: 0.5 }
1122            };
1123
1124            let upper_lr_profile = if top_d > 1e-9 {
1125                FaceProfile::Trapezoid {
1126                    top_width: top_d / mid_d,
1127                    offset_x: upper_run / mid_d,
1128                }
1129            } else {
1130                FaceProfile::Triangle { peak_offset: 0.5 }
1131            };
1132
1133            vec![
1134                // Lower steep slopes
1135                (
1136                    Vec3::new(sx + o, y_anchor, -o),
1137                    Vec3::new(eave_w, lower_slope, 0.0),
1138                    front_rot,
1139                    RoofFaceSelector::LowerSlope,
1140                    lower_fb_profile.clone(),
1141                ),
1142                (
1143                    Vec3::new(-o, y_anchor, sz + o),
1144                    Vec3::new(eave_w, lower_slope, 0.0),
1145                    back_rot,
1146                    RoofFaceSelector::LowerSlope,
1147                    lower_fb_profile,
1148                ),
1149                (
1150                    Vec3::new(-o, y_anchor, -o),
1151                    Vec3::new(eave_d, lower_slope, 0.0),
1152                    left_rot,
1153                    RoofFaceSelector::LowerSlope,
1154                    lower_lr_profile.clone(),
1155                ),
1156                (
1157                    Vec3::new(sx + o, y_anchor, sz + o),
1158                    Vec3::new(eave_d, lower_slope, 0.0),
1159                    right_rot,
1160                    RoofFaceSelector::LowerSlope,
1161                    lower_lr_profile,
1162                ),
1163                // Upper shallow slopes
1164                (
1165                    Vec3::new(sx + o - break_run, y_break, -o + break_run),
1166                    Vec3::new(mid_w, upper_slope, 0.0),
1167                    ufr,
1168                    RoofFaceSelector::UpperSlope,
1169                    upper_fb_profile.clone(),
1170                ),
1171                (
1172                    Vec3::new(-o + break_run, y_break, sz + o - break_run),
1173                    Vec3::new(mid_w, upper_slope, 0.0),
1174                    ubr,
1175                    RoofFaceSelector::UpperSlope,
1176                    upper_fb_profile,
1177                ),
1178                (
1179                    Vec3::new(-o + break_run, y_break, -o + break_run),
1180                    Vec3::new(mid_d, upper_slope, 0.0),
1181                    ulr,
1182                    RoofFaceSelector::UpperSlope,
1183                    upper_lr_profile.clone(),
1184                ),
1185                (
1186                    Vec3::new(sx + o - break_run, y_break, sz + o - break_run),
1187                    Vec3::new(mid_d, upper_slope, 0.0),
1188                    urr,
1189                    RoofFaceSelector::UpperSlope,
1190                    upper_lr_profile,
1191                ),
1192            ]
1193        }
1194
1195        // ── Saltbox ───────────────────────────────────────────────────────────
1196        // Asymmetric Gable: ridge offset from front by `ridge_offset` fraction of depth.
1197        // Front slope is steeper (pitch = alpha); back slope angle derived from h and depth.
1198        RoofType::Saltbox => {
1199            let (orient_z, _width, depth) = if ridge_x {
1200                (true, sx, sz)
1201            } else {
1202                (false, sz, sx)
1203            };
1204            let ridge_d = depth * config.ridge_offset;
1205            if !ridge_d.is_finite() || ridge_d <= 0.0 || ridge_d >= depth {
1206                return Err(ShapeError::InvalidNumericValue);
1207            }
1208            let h_s = ridge_d * tan_a;
1209            let back_depth = depth - ridge_d;
1210            let alpha_back = ((h_s) / (back_depth + o)).atan();
1211            let cos_ab = alpha_back.cos();
1212            if !h_s.is_finite() || !alpha_back.is_finite() || cos_ab < 1e-9 {
1213                return Err(ShapeError::InvalidNumericValue);
1214            }
1215            let front_len = (ridge_d + o) / cos_a;
1216            let back_len = (back_depth + o) / cos_ab;
1217            if !front_len.is_finite() || !back_len.is_finite() {
1218                return Err(ShapeError::InvalidNumericValue);
1219            }
1220            let (_, back_rot_s, _, right_rot_s) = roof_slope_rotations(alpha_back);
1221            let peak_fwd = ridge_d / depth;
1222
1223            if orient_z {
1224                vec![
1225                    (
1226                        Vec3::new(sx + o, y_anchor, -o),
1227                        Vec3::new(sx + 2.0 * o, front_len, 0.0),
1228                        front_rot,
1229                        RoofFaceSelector::Slope,
1230                        FaceProfile::Rectangle,
1231                    ),
1232                    (
1233                        Vec3::new(-o, y_anchor, sz + o),
1234                        Vec3::new(sx + 2.0 * o, back_len, 0.0),
1235                        back_rot_s,
1236                        RoofFaceSelector::Slope,
1237                        FaceProfile::Rectangle,
1238                    ),
1239                    (
1240                        Vec3::new(0.0, 0.0, 0.0),
1241                        Vec3::new(sz, h_s, 0.0),
1242                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
1243                        RoofFaceSelector::GableEnd,
1244                        FaceProfile::Triangle {
1245                            peak_offset: peak_fwd,
1246                        },
1247                    ),
1248                    (
1249                        Vec3::new(sx, 0.0, sz),
1250                        Vec3::new(sz, h_s, 0.0),
1251                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
1252                        RoofFaceSelector::GableEnd,
1253                        FaceProfile::Triangle {
1254                            peak_offset: 1.0 - peak_fwd,
1255                        },
1256                    ),
1257                ]
1258            } else {
1259                vec![
1260                    (
1261                        Vec3::new(-o, y_anchor, -o),
1262                        Vec3::new(sz + 2.0 * o, front_len, 0.0),
1263                        left_rot,
1264                        RoofFaceSelector::Slope,
1265                        FaceProfile::Rectangle,
1266                    ),
1267                    (
1268                        Vec3::new(sx + o, y_anchor, sz + o),
1269                        Vec3::new(sz + 2.0 * o, back_len, 0.0),
1270                        right_rot_s,
1271                        RoofFaceSelector::Slope,
1272                        FaceProfile::Rectangle,
1273                    ),
1274                    (
1275                        Vec3::new(sx, 0.0, 0.0),
1276                        Vec3::new(sx, h_s, 0.0),
1277                        Quat::from_axis_angle(Vec3::Y, PI),
1278                        RoofFaceSelector::GableEnd,
1279                        FaceProfile::Triangle {
1280                            peak_offset: 1.0 - peak_fwd,
1281                        },
1282                    ),
1283                    (
1284                        Vec3::new(0.0, 0.0, sz),
1285                        Vec3::new(sx, h_s, 0.0),
1286                        Quat::IDENTITY,
1287                        RoofFaceSelector::GableEnd,
1288                        FaceProfile::Triangle {
1289                            peak_offset: peak_fwd,
1290                        },
1291                    ),
1292                ]
1293            }
1294        }
1295
1296        // ── Jerkinhead ────────────────────────────────────────────────────────
1297        // Gable with clipped-hip corners: main slopes are Trapezoid; small HipEnd triangles
1298        // fill the clipped gable-end corners.
1299        RoofType::Jerkinhead => {
1300            let tier = config.tier_height_or(0.25).clamp(0.01, 0.99);
1301            let orient_z = ridge_x;
1302            let (width, depth) = if orient_z { (sx, sz) } else { (sz, sx) };
1303
1304            let max_clip = (width / 2.0 + o).min(depth / 2.0);
1305            let clip_run = (tier * depth / 2.0).clamp(0.0, max_clip - 1e-3);
1306
1307            let eave_w = width + 2.0 * o;
1308
1309            let slope_len = (depth / 2.0 + o) / cos_a;
1310
1311            // The clipped slope is a HEXAGON, not a trapezoid. The clip
1312            // diagonal does not run to the eave corner: it runs from the
1313            // ridge end down to the hip-let's base corner, which sits on
1314            // the verge (`o` outboard of the end wall) at the height where
1315            // the hip-let eave springs. A trapezoid whose clip is measured
1316            // from the overhung panel edge — correct for DutchGable, whose
1317            // hip diagonals really do start at the overhung eave corner —
1318            // overshoots the ridge end by `o` and opens an `o`-wide sliver
1319            // gap along each clip diagonal (#39). Outline, panel-local and
1320            // normalised: the full eave edge, a short verge edge up each
1321            // side to the hip-let corner level, a clip diagonal up to each
1322            // ridge end, and the ridge-span top edge between them.
1323            let ridge_span = width - 2.0 * clip_run;
1324            let slope_profile = if ridge_span > 1e-9 {
1325                let verge_y = (depth / 2.0 - clip_run) / (depth / 2.0 + o);
1326                let top_inset = (clip_run + o) / eave_w;
1327                FaceProfile::Polygon(vec![
1328                    glam::DVec2::new(0.0, 0.0),
1329                    glam::DVec2::new(1.0, 0.0),
1330                    glam::DVec2::new(1.0, verge_y),
1331                    glam::DVec2::new(1.0 - top_inset, 1.0),
1332                    glam::DVec2::new(top_inset, 1.0),
1333                    glam::DVec2::new(0.0, verge_y),
1334                ])
1335            } else {
1336                FaceProfile::Triangle { peak_offset: 0.5 }
1337            };
1338
1339            let true_h = (depth / 2.0) * tan_a;
1340            let wall_h = (true_h - clip_run * tan_a).max(0.0);
1341            let wall_profile = if clip_run > 1e-9 {
1342                FaceProfile::Trapezoid {
1343                    top_width: (2.0 * clip_run) / depth,
1344                    offset_x: (depth / 2.0 - clip_run) / depth,
1345                }
1346            } else {
1347                FaceProfile::Triangle { peak_offset: 0.5 }
1348            };
1349
1350            let hip_base_w = 2.0 * clip_run + 2.0 * o;
1351            let hip_slope_len = (clip_run + o) / cos_a;
1352            let hip_profile = FaceProfile::Triangle { peak_offset: 0.5 };
1353
1354            if orient_z {
1355                let left_hip_origin = Vec3::new(-o, wall_h - o * tan_a, sz / 2.0 - clip_run - o);
1356                let right_hip_origin =
1357                    Vec3::new(sx + o, wall_h - o * tan_a, sz / 2.0 + clip_run + o);
1358                vec![
1359                    (
1360                        Vec3::new(sx + o, y_anchor, -o),
1361                        Vec3::new(eave_w, slope_len, 0.0),
1362                        front_rot,
1363                        RoofFaceSelector::Slope,
1364                        slope_profile.clone(),
1365                    ),
1366                    (
1367                        Vec3::new(-o, y_anchor, sz + o),
1368                        Vec3::new(eave_w, slope_len, 0.0),
1369                        back_rot,
1370                        RoofFaceSelector::Slope,
1371                        slope_profile,
1372                    ),
1373                    (
1374                        Vec3::new(0.0, 0.0, 0.0),
1375                        Vec3::new(sz, wall_h, 0.0),
1376                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
1377                        RoofFaceSelector::GableEnd,
1378                        wall_profile.clone(),
1379                    ),
1380                    (
1381                        Vec3::new(sx, 0.0, sz),
1382                        Vec3::new(sz, wall_h, 0.0),
1383                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
1384                        RoofFaceSelector::GableEnd,
1385                        wall_profile,
1386                    ),
1387                    (
1388                        left_hip_origin,
1389                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1390                        left_rot,
1391                        RoofFaceSelector::HipEnd,
1392                        hip_profile.clone(),
1393                    ),
1394                    (
1395                        right_hip_origin,
1396                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1397                        right_rot,
1398                        RoofFaceSelector::HipEnd,
1399                        hip_profile,
1400                    ),
1401                ]
1402            } else {
1403                let front_hip_origin = Vec3::new(sx / 2.0 + clip_run + o, wall_h - o * tan_a, -o);
1404                let back_hip_origin =
1405                    Vec3::new(sx / 2.0 - clip_run - o, wall_h - o * tan_a, sz + o);
1406                vec![
1407                    (
1408                        Vec3::new(-o, y_anchor, -o),
1409                        Vec3::new(eave_w, slope_len, 0.0),
1410                        left_rot,
1411                        RoofFaceSelector::Slope,
1412                        slope_profile.clone(),
1413                    ),
1414                    (
1415                        Vec3::new(sx + o, y_anchor, sz + o),
1416                        Vec3::new(eave_w, slope_len, 0.0),
1417                        right_rot,
1418                        RoofFaceSelector::Slope,
1419                        slope_profile,
1420                    ),
1421                    (
1422                        Vec3::new(sx, 0.0, 0.0),
1423                        Vec3::new(sx, wall_h, 0.0),
1424                        Quat::from_axis_angle(Vec3::Y, PI),
1425                        RoofFaceSelector::GableEnd,
1426                        wall_profile.clone(),
1427                    ),
1428                    (
1429                        Vec3::new(0.0, 0.0, sz),
1430                        Vec3::new(sx, wall_h, 0.0),
1431                        Quat::IDENTITY,
1432                        RoofFaceSelector::GableEnd,
1433                        wall_profile,
1434                    ),
1435                    (
1436                        front_hip_origin,
1437                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1438                        front_rot,
1439                        RoofFaceSelector::HipEnd,
1440                        hip_profile.clone(),
1441                    ),
1442                    (
1443                        back_hip_origin,
1444                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1445                        back_rot,
1446                        RoofFaceSelector::HipEnd,
1447                        hip_profile,
1448                    ),
1449                ]
1450            }
1451        }
1452
1453        // ── DutchGable ────────────────────────────────────────────────────────
1454        // Hip roof with a small gable rising from the ridge centre.
1455        // `tier_height` controls the fraction of the horizontal run used for the lower Hip portion.
1456        RoofType::DutchGable => {
1457            let tier = config.tier_height_or(0.7).clamp(0.01, 0.99);
1458            let orient_z = ridge_x;
1459            let (width, depth) = if orient_z { (sx, sz) } else { (sz, sx) };
1460
1461            let max_run = width.min(depth) / 2.0 + o;
1462            let break_run = (tier * max_run).clamp(o, max_run - 1e-3);
1463
1464            if !break_run.is_finite() {
1465                return Err(ShapeError::InvalidNumericValue);
1466            }
1467
1468            let y_break = y_anchor + break_run * tan_a;
1469            let eave_w = width + 2.0 * o;
1470            let eave_d = depth + 2.0 * o;
1471
1472            let top_w = (eave_w - 2.0 * break_run).max(0.0);
1473            let top_d = (eave_d - 2.0 * break_run).max(0.0);
1474
1475            let lower_slope_len = break_run / cos_a;
1476
1477            let fb_profile = if top_w > 1e-9 {
1478                FaceProfile::Trapezoid {
1479                    top_width: top_w / eave_w,
1480                    offset_x: break_run / eave_w,
1481                }
1482            } else {
1483                FaceProfile::Triangle { peak_offset: 0.5 }
1484            };
1485
1486            let lr_profile = if top_d > 1e-9 {
1487                FaceProfile::Trapezoid {
1488                    top_width: top_d / eave_d,
1489                    offset_x: break_run / eave_d,
1490                }
1491            } else {
1492                FaceProfile::Triangle { peak_offset: 0.5 }
1493            };
1494
1495            let upper_run = (depth / 2.0 + o) - break_run;
1496            let upper_slope_len = upper_run / cos_a;
1497            let upper_h = upper_run * tan_a;
1498
1499            if orient_z {
1500                vec![
1501                    // Lower Hip front/back
1502                    (
1503                        Vec3::new(sx + o, y_anchor, -o),
1504                        Vec3::new(eave_w, lower_slope_len, 0.0),
1505                        front_rot,
1506                        RoofFaceSelector::Slope,
1507                        fb_profile.clone(),
1508                    ),
1509                    (
1510                        Vec3::new(-o, y_anchor, sz + o),
1511                        Vec3::new(eave_w, lower_slope_len, 0.0),
1512                        back_rot,
1513                        RoofFaceSelector::Slope,
1514                        fb_profile,
1515                    ),
1516                    // Lower Hip left/right
1517                    (
1518                        Vec3::new(-o, y_anchor, -o),
1519                        Vec3::new(eave_d, lower_slope_len, 0.0),
1520                        left_rot,
1521                        RoofFaceSelector::Slope,
1522                        lr_profile.clone(),
1523                    ),
1524                    (
1525                        Vec3::new(sx + o, y_anchor, sz + o),
1526                        Vec3::new(eave_d, lower_slope_len, 0.0),
1527                        right_rot,
1528                        RoofFaceSelector::Slope,
1529                        lr_profile,
1530                    ),
1531                    // Upper Gable front/back
1532                    (
1533                        Vec3::new(sx + o - break_run, y_break, -o + break_run),
1534                        Vec3::new(top_w, upper_slope_len, 0.0),
1535                        front_rot,
1536                        RoofFaceSelector::Slope,
1537                        FaceProfile::Rectangle,
1538                    ),
1539                    (
1540                        Vec3::new(-o + break_run, y_break, sz + o - break_run),
1541                        Vec3::new(top_w, upper_slope_len, 0.0),
1542                        back_rot,
1543                        RoofFaceSelector::Slope,
1544                        FaceProfile::Rectangle,
1545                    ),
1546                    // Small gable ends (Left/Right)
1547                    (
1548                        Vec3::new(-o + break_run, y_break, -o + break_run),
1549                        Vec3::new(top_d, upper_h, 0.0),
1550                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
1551                        RoofFaceSelector::GableEnd,
1552                        FaceProfile::Triangle { peak_offset: 0.5 },
1553                    ),
1554                    (
1555                        Vec3::new(sx + o - break_run, y_break, sz + o - break_run),
1556                        Vec3::new(top_d, upper_h, 0.0),
1557                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
1558                        RoofFaceSelector::GableEnd,
1559                        FaceProfile::Triangle { peak_offset: 0.5 },
1560                    ),
1561                ]
1562            } else {
1563                vec![
1564                    // Lower Hip left/right (which are the main slopes now)
1565                    (
1566                        Vec3::new(-o, y_anchor, -o),
1567                        Vec3::new(eave_w, lower_slope_len, 0.0),
1568                        left_rot,
1569                        RoofFaceSelector::Slope,
1570                        fb_profile.clone(),
1571                    ),
1572                    (
1573                        Vec3::new(sx + o, y_anchor, sz + o),
1574                        Vec3::new(eave_w, lower_slope_len, 0.0),
1575                        right_rot,
1576                        RoofFaceSelector::Slope,
1577                        fb_profile,
1578                    ),
1579                    // Lower Hip front/back (which are the gable ends now)
1580                    (
1581                        Vec3::new(sx + o, y_anchor, -o),
1582                        Vec3::new(eave_d, lower_slope_len, 0.0),
1583                        front_rot,
1584                        RoofFaceSelector::Slope,
1585                        lr_profile.clone(),
1586                    ),
1587                    (
1588                        Vec3::new(-o, y_anchor, sz + o),
1589                        Vec3::new(eave_d, lower_slope_len, 0.0),
1590                        back_rot,
1591                        RoofFaceSelector::Slope,
1592                        lr_profile,
1593                    ),
1594                    // Upper Gable left/right
1595                    (
1596                        Vec3::new(-o + break_run, y_break, -o + break_run),
1597                        Vec3::new(top_w, upper_slope_len, 0.0),
1598                        left_rot,
1599                        RoofFaceSelector::Slope,
1600                        FaceProfile::Rectangle,
1601                    ),
1602                    (
1603                        Vec3::new(sx + o - break_run, y_break, sz + o - break_run),
1604                        Vec3::new(top_w, upper_slope_len, 0.0),
1605                        right_rot,
1606                        RoofFaceSelector::Slope,
1607                        FaceProfile::Rectangle,
1608                    ),
1609                    // Small gable ends (Front/Back)
1610                    (
1611                        Vec3::new(sx + o - break_run, y_break, -o + break_run),
1612                        Vec3::new(top_d, upper_h, 0.0),
1613                        Quat::from_axis_angle(Vec3::Y, PI),
1614                        RoofFaceSelector::GableEnd,
1615                        FaceProfile::Triangle { peak_offset: 0.5 },
1616                    ),
1617                    (
1618                        Vec3::new(-o + break_run, y_break, sz + o - break_run),
1619                        Vec3::new(top_d, upper_h, 0.0),
1620                        Quat::IDENTITY,
1621                        RoofFaceSelector::GableEnd,
1622                        FaceProfile::Triangle { peak_offset: 0.5 },
1623                    ),
1624                ]
1625            }
1626        }
1627    };
1628
1629    // Append fascia bands hanging below each perimeter eave when fascia_depth > 0.
1630    // A fascia is generated for slope panels whose lower edge sits at the perimeter
1631    // (local Y ≈ y_anchor) and whose outward normal has a horizontal component.
1632    if config.fascia_depth.is_finite() && config.fascia_depth > 0.0 {
1633        let fascia_depth = config.fascia_depth;
1634        let mut fascia_panels: Vec<Panel> = Vec::new();
1635        for (local_off, face_size, rot_delta, selector, _profile) in &panels {
1636            let eave_bearing = matches!(
1637                selector,
1638                RoofFaceSelector::Slope
1639                    | RoofFaceSelector::LowerSlope
1640                    | RoofFaceSelector::OuterSlope
1641            );
1642            if !eave_bearing {
1643                continue;
1644            }
1645            if (local_off.y - y_anchor).abs() > 1e-6 {
1646                continue;
1647            }
1648            let Some(wall_rot) = slope_to_wall_rot(*rot_delta) else {
1649                continue;
1650            };
1651            fascia_panels.push((
1652                Vec3::new(local_off.x, local_off.y - fascia_depth, local_off.z),
1653                Vec3::new(face_size.x, fascia_depth, 0.0),
1654                wall_rot,
1655                RoofFaceSelector::Fascia,
1656                FaceProfile::Rectangle,
1657            ));
1658        }
1659        panels.extend(fascia_panels);
1660    }
1661
1662    if queue.len() + panels.len() > MAX_QUEUE {
1663        return Err(ShapeError::CapacityOverflow);
1664    }
1665    let mut child_ordinal: u64 = 0;
1666    for (local_off, face_size, rot_delta, selector, profile) in panels {
1667        let Some(rule) = find_roof_rule(selector, cases) else {
1668            continue;
1669        };
1670        // Skip degenerate panels (zero-area).
1671        if face_size.x < 1e-9 || face_size.y < 1e-9 {
1672            continue;
1673        }
1674        let face_pos = scope.position + scope.rotation * local_off;
1675        let face_rot = (scope.rotation * rot_delta).normalize();
1676        let face_scope = Scope::new(face_pos, face_rot, face_size);
1677        face_scope.validate()?;
1678        if model.len() + queue.len() >= max_terminals {
1679            return Err(ShapeError::CapacityOverflow);
1680        }
1681        queue.push_back(WorkItem {
1682            scope: face_scope,
1683            rule: rule.name.clone(),
1684            args: rule.args.clone(),
1685            depth: depth + 1,
1686            taper: 0.0,
1687            face_profile_override: Some(profile),
1688            material: material.clone(),
1689            split_i,
1690            split_n,
1691            rng_state: fork_state(rng_state, child_ordinal),
1692            label: label.clone(),
1693        });
1694        child_ordinal += 1;
1695    }
1696
1697    Ok(())
1698}
1699
1700// ── Stochastic selection ──────────────────────────────────────────────────────
1701
1702/// Selects a variant: weighted rules draw from the shape's stream; guarded
1703/// rules evaluate `when` conditions top-down in the shape's context and take
1704/// the first true guard (or the trailing `else`). A guarded rule with no
1705/// matching guard and no `else` selects nothing — the shape vanishes, the
1706/// same semantics as an empty successor.
1707#[allow(clippy::too_many_arguments)]
1708fn select_variant<'a>(
1709    variants: &'a [RuleVariant],
1710    scope: &Scope,
1711    split_i: usize,
1712    split_n: usize,
1713    depth: usize,
1714    params: &[(String, f64)],
1715    globals: &HashMap<String, f64>,
1716    rng: &mut Pcg64,
1717) -> Result<&'a [ShapeOp], ShapeError> {
1718    if variants.is_empty() {
1719        return Ok(&[]);
1720    }
1721    let guarded = variants
1722        .iter()
1723        .any(|v| matches!(v.selector, VariantSelector::When(_) | VariantSelector::Else));
1724    if guarded {
1725        for v in variants {
1726            match &v.selector {
1727                VariantSelector::When(cond) => {
1728                    let hit =
1729                        eval_expr(cond, scope, split_i, split_n, depth, params, globals, rng)?;
1730                    if hit != 0.0 {
1731                        return Ok(&v.ops);
1732                    }
1733                }
1734                VariantSelector::Else => return Ok(&v.ops),
1735                VariantSelector::Weight(_) => {
1736                    return Err(ShapeError::ParseError(
1737                        "rule mixes weighted and guarded variants".to_string(),
1738                    ));
1739                }
1740            }
1741        }
1742        return Ok(&[]);
1743    }
1744    if variants.len() == 1 {
1745        return Ok(&variants[0].ops);
1746    }
1747    let total: f64 = variants
1748        .iter()
1749        .map(|v| match v.selector {
1750            VariantSelector::Weight(w) => w,
1751            _ => 0.0,
1752        })
1753        .sum();
1754    use rand::Rng;
1755    let r: f64 = rng.random::<f64>() * total;
1756    let mut acc = 0.0;
1757    for v in variants {
1758        if let VariantSelector::Weight(w) = v.selector {
1759            acc += w;
1760            if r < acc {
1761                return Ok(&v.ops);
1762            }
1763        }
1764    }
1765    Ok(&variants.last().unwrap().ops)
1766}
1767
1768// ── Interpreter ───────────────────────────────────────────────────────────────
1769
1770/// The CGA Shape Grammar derivation engine.
1771///
1772/// Rules are registered by name, then `derive` is called with a root scope and
1773/// root rule name. The engine expands rules breadth-first until every branch
1774/// terminates with an `I(mesh)` terminal.
1775///
1776/// Stochastic rules with multiple weighted variants use the engine's `seed` for
1777/// reproducible randomness — the same seed always yields the same building.
1778/// A registered rule: declared parameter names plus its weighted variants.
1779#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1780pub struct RuleDef {
1781    /// Parameter names bound to call-argument values at invocation. Empty
1782    /// for parameterless rules.
1783    pub params: Vec<String>,
1784    pub variants: Vec<RuleVariant>,
1785}
1786
1787pub struct Interpreter {
1788    rules: HashMap<String, RuleDef>,
1789    /// Host-set attribute overrides (`set_attr`) — the strongest external
1790    /// knob, applied over declarations and styles.
1791    attrs: HashMap<String, f64>,
1792    /// `const Name = value` declarations (not externally overridable).
1793    consts: HashMap<String, f64>,
1794    /// `attr Name = value` declaration defaults (overridable by style and
1795    /// host, in that order).
1796    attr_defaults: HashMap<String, f64>,
1797    /// Named attr-override sets (`style` declarations), flattened over
1798    /// their `extends` base at registration time.
1799    styles: HashMap<String, Vec<(String, f64)>>,
1800    /// The style selected via `set_style`, applied at derivation.
1801    active_style: Option<String>,
1802    /// Hard cap on rule-derivation recursion depth. Defaults to `MAX_DEPTH`
1803    /// (64). Exceeding it returns `ShapeError::DepthLimitExceeded`.
1804    pub max_depth: usize,
1805    /// Hard cap on the number of terminals a single derivation may emit.
1806    /// Defaults to `MAX_TERMINALS` (100 000). Exceeding it returns
1807    /// `ShapeError::CapacityOverflow`.
1808    pub max_terminals: usize,
1809    /// Seed for stochastic rule selection. Each call to [`Interpreter::derive`]
1810    /// constructs a fresh `Pcg64` from this seed, so re-running with the same
1811    /// `seed` produces a bit-identical [`ShapeModel`]. Default `0`.
1812    pub seed: u64,
1813}
1814
1815impl Default for Interpreter {
1816    fn default() -> Self {
1817        Self::new()
1818    }
1819}
1820
1821impl Interpreter {
1822    pub fn new() -> Self {
1823        Self {
1824            rules: HashMap::new(),
1825            attrs: HashMap::new(),
1826            consts: HashMap::new(),
1827            attr_defaults: HashMap::new(),
1828            styles: HashMap::new(),
1829            active_style: None,
1830            max_depth: MAX_DEPTH,
1831            max_terminals: MAX_TERMINALS,
1832            seed: 0,
1833        }
1834    }
1835
1836    /// Registers one parsed grammar statement — rule, `attr`, `const`, or
1837    /// `style`. The one-stop text loading path:
1838    ///
1839    /// ```
1840    /// use symbios_shape::Interpreter;
1841    /// use symbios_shape::grammar::parse_statement;
1842    /// let mut interp = Interpreter::new();
1843    /// for line in ["attr Floors = 3", r#"Lot --> Extrude(Floors * 3.2) I("Mass")"#] {
1844    ///     interp.add_statement(parse_statement(line).unwrap()).unwrap();
1845    /// }
1846    /// ```
1847    pub fn add_statement(&mut self, stmt: crate::grammar::Statement) -> Result<(), ShapeError> {
1848        use crate::grammar::Statement;
1849        match stmt {
1850            Statement::Rule(rule) => self.add_grammar_rule(rule),
1851            Statement::Attr { name, value } => {
1852                self.attr_defaults.insert(name, value);
1853                Ok(())
1854            }
1855            Statement::Const { name, value } => {
1856                self.consts.insert(name, value);
1857                Ok(())
1858            }
1859            Statement::Style {
1860                name,
1861                extends,
1862                overrides,
1863            } => {
1864                let mut flat = match extends {
1865                    Some(base) => self.styles.get(&base).cloned().ok_or_else(|| {
1866                        ShapeError::ParseError(format!(
1867                            "style `{name}` extends unknown style `{base}`"
1868                        ))
1869                    })?,
1870                    None => Vec::new(),
1871                };
1872                flat.extend(overrides);
1873                self.styles.insert(name, flat);
1874                Ok(())
1875            }
1876        }
1877    }
1878
1879    /// Selects a declared style; its attr overrides apply at derivation
1880    /// (below host `set_attr` overrides, above `attr` defaults).
1881    pub fn set_style(&mut self, name: impl Into<String>) -> Result<(), ShapeError> {
1882        let name = name.into();
1883        if !self.styles.contains_key(&name) {
1884            return Err(ShapeError::ParseError(format!("unknown style `{name}`")));
1885        }
1886        self.active_style = Some(name);
1887        Ok(())
1888    }
1889
1890    /// Clears any active style.
1891    pub fn clear_style(&mut self) {
1892        self.active_style = None;
1893    }
1894
1895    /// The effective expression-visible name table for a derivation:
1896    /// consts, then `attr` defaults, then the active style's overrides,
1897    /// then host `set_attr` values — later layers win.
1898    fn effective_globals(&self) -> HashMap<String, f64> {
1899        let mut g = self.consts.clone();
1900        for (k, v) in &self.attr_defaults {
1901            g.insert(k.clone(), *v);
1902        }
1903        if let Some(style) = &self.active_style
1904            && let Some(overrides) = self.styles.get(style)
1905        {
1906            for (k, v) in overrides {
1907                g.insert(k.clone(), *v);
1908            }
1909        }
1910        for (k, v) in &self.attrs {
1911            g.insert(k.clone(), *v);
1912        }
1913        g
1914    }
1915
1916    /// Returns a reference to the full rule table (rule name → definition).
1917    pub fn rules(&self) -> &HashMap<String, RuleDef> {
1918        &self.rules
1919    }
1920
1921    /// Sets a named attribute readable from grammar expressions. The host's
1922    /// override channel: call before `derive` to parameterize a grammar from
1923    /// outside (per-lot floor counts, prosperity knobs, …).
1924    pub fn set_attr(&mut self, name: impl Into<String>, value: f64) {
1925        self.attrs.insert(name.into(), value);
1926    }
1927
1928    /// Returns the attribute table (host overrides + grammar declarations).
1929    pub fn attrs(&self) -> &HashMap<String, f64> {
1930        &self.attrs
1931    }
1932
1933    /// Directly inserts a pre-built variant list for `name`, bypassing weight validation.
1934    /// The rule's declared parameters are preserved when it already exists.
1935    ///
1936    /// Intended for restoring snapshots produced by
1937    /// [`crate::genetics::ShapeGenotype::to_interpreter`].
1938    pub fn set_variants(&mut self, name: impl Into<String>, variants: Vec<RuleVariant>) {
1939        let name = name.into();
1940        let params = self
1941            .rules
1942            .get(&name)
1943            .map(|def| def.params.clone())
1944            .unwrap_or_default();
1945        self.rules.insert(name, RuleDef { params, variants });
1946    }
1947
1948    /// Registers a deterministic production rule.
1949    pub fn add_rule(&mut self, name: impl Into<String>, ops: Vec<ShapeOp>) {
1950        self.rules.insert(
1951            name.into(),
1952            RuleDef {
1953                params: Vec::new(),
1954                variants: vec![RuleVariant::weighted(1.0, ops)],
1955            },
1956        );
1957    }
1958
1959    /// Registers a rule parsed from grammar text — parameters, weighted or
1960    /// guarded variants and all. The one-stop registration path for
1961    /// text-driven hosts.
1962    pub fn add_grammar_rule(
1963        &mut self,
1964        rule: crate::grammar::GrammarRule,
1965    ) -> Result<(), ShapeError> {
1966        if rule.name == "NIL" {
1967            return Err(ShapeError::ParseError(
1968                "`NIL` is reserved and cannot be defined as a rule".to_string(),
1969            ));
1970        }
1971        self.add_rule_variants(rule.name, rule.params, rule.variants)
1972    }
1973
1974    /// Registers a rule from pre-built variants, validating selector shape:
1975    /// all-weighted, or `when(..)` guards with at most one trailing `else`.
1976    pub fn add_rule_variants(
1977        &mut self,
1978        name: impl Into<String>,
1979        params: Vec<String>,
1980        variants: Vec<RuleVariant>,
1981    ) -> Result<(), ShapeError> {
1982        if params.len() > crate::grammar::MAX_RULE_ARGS {
1983            return Err(ShapeError::ParseError(format!(
1984                "rule declares {} parameters (max {})",
1985                params.len(),
1986                crate::grammar::MAX_RULE_ARGS
1987            )));
1988        }
1989        for (i, p) in params.iter().enumerate() {
1990            if params[..i].contains(p) {
1991                return Err(ShapeError::ParseError(format!(
1992                    "duplicate rule parameter name: {p}"
1993                )));
1994            }
1995        }
1996        let n_weight = variants
1997            .iter()
1998            .filter(|v| matches!(v.selector, VariantSelector::Weight(_)))
1999            .count();
2000        let n_guard = variants.len() - n_weight;
2001        if n_weight > 0 && n_guard > 0 {
2002            return Err(ShapeError::ParseError(
2003                "rule mixes weighted and guarded variants".to_string(),
2004            ));
2005        }
2006        for (i, v) in variants.iter().enumerate() {
2007            match &v.selector {
2008                VariantSelector::Weight(w) => {
2009                    if !w.is_finite() || *w < 0.0 {
2010                        return Err(ShapeError::InvalidNumericValue);
2011                    }
2012                }
2013                VariantSelector::Else => {
2014                    if i + 1 != variants.len() {
2015                        return Err(ShapeError::ParseError(
2016                            "`else:` must be the last variant".to_string(),
2017                        ));
2018                    }
2019                }
2020                VariantSelector::When(_) => {}
2021            }
2022        }
2023        self.rules.insert(name.into(), RuleDef { params, variants });
2024        Ok(())
2025    }
2026
2027    /// Registers a rule with declared parameter names and weighted variants —
2028    /// the full-fidelity registration path for parameterized rules.
2029    ///
2030    /// Returns `Err(InvalidNumericValue)` for bad weights and
2031    /// `Err(ParseError)` for duplicate or over-long parameter lists.
2032    pub fn add_rule_def(
2033        &mut self,
2034        name: impl Into<String>,
2035        params: Vec<String>,
2036        variants: Vec<(f64, Vec<ShapeOp>)>,
2037    ) -> Result<(), ShapeError> {
2038        if params.len() > crate::grammar::MAX_RULE_ARGS {
2039            return Err(ShapeError::ParseError(format!(
2040                "rule declares {} parameters (max {})",
2041                params.len(),
2042                crate::grammar::MAX_RULE_ARGS
2043            )));
2044        }
2045        for (i, p) in params.iter().enumerate() {
2046            if params[..i].contains(p) {
2047                return Err(ShapeError::ParseError(format!(
2048                    "duplicate rule parameter name: {p}"
2049                )));
2050            }
2051        }
2052        for (weight, _) in &variants {
2053            if !weight.is_finite() || *weight < 0.0 {
2054                return Err(ShapeError::InvalidNumericValue);
2055            }
2056        }
2057        let variants = variants
2058            .into_iter()
2059            .map(|(weight, ops)| RuleVariant::weighted(weight, ops))
2060            .collect();
2061        self.rules.insert(name.into(), RuleDef { params, variants });
2062        Ok(())
2063    }
2064
2065    /// Registers a stochastic rule with multiple weighted alternatives.
2066    ///
2067    /// `variants` is a list of `(relative_weight, ops)` pairs. Weights need not
2068    /// sum to 1.0 — they are normalised internally during selection.
2069    ///
2070    /// Returns `Err(InvalidNumericValue)` if any weight is non-finite or negative.
2071    pub fn add_weighted_rules(
2072        &mut self,
2073        name: impl Into<String>,
2074        variants: Vec<(f64, Vec<ShapeOp>)>,
2075    ) -> Result<(), ShapeError> {
2076        for (weight, _) in &variants {
2077            if !weight.is_finite() || *weight < 0.0 {
2078                return Err(ShapeError::InvalidNumericValue);
2079            }
2080        }
2081        let wvs = variants
2082            .into_iter()
2083            .map(|(weight, ops)| RuleVariant::weighted(weight, ops))
2084            .collect();
2085        self.rules.insert(
2086            name.into(),
2087            RuleDef {
2088                params: Vec::new(),
2089                variants: wvs,
2090            },
2091        );
2092        Ok(())
2093    }
2094
2095    /// Returns true if a rule with `name` is registered.
2096    pub fn has_rule(&self, name: &str) -> bool {
2097        self.rules.contains_key(name)
2098    }
2099
2100    /// Derives the shape model starting from `root_scope` and `root_rule`.
2101    ///
2102    /// Uses a breadth-first work queue to expand rules until all branches
2103    /// terminate via `I(mesh_id)` or an unknown rule name (implicit terminal).
2104    /// A fresh RNG seeded from `self.seed` is created for each call, making
2105    /// derivations reproducible for the same `seed` value.
2106    pub fn derive(
2107        &self,
2108        root_scope: Scope,
2109        root_rule: impl Into<String>,
2110    ) -> Result<ShapeModel, ShapeError> {
2111        root_scope.validate()?;
2112
2113        let mut model = ShapeModel::new();
2114        let mut queue: VecDeque<WorkItem> = VecDeque::new();
2115        let globals = self.effective_globals();
2116
2117        queue.push_back(WorkItem {
2118            scope: root_scope,
2119            rule: root_rule.into(),
2120            args: Vec::new(),
2121            depth: 0,
2122            taper: 0.0,
2123            face_profile_override: None,
2124            material: None,
2125            split_i: 0,
2126            split_n: 1,
2127            rng_state: splitmix64(self.seed),
2128            label: None,
2129        });
2130
2131        while let Some(item) = queue.pop_front() {
2132            if queue.len() > MAX_QUEUE {
2133                return Err(ShapeError::CapacityOverflow);
2134            }
2135            if item.depth > self.max_depth {
2136                return Err(ShapeError::DepthLimitExceeded(self.max_depth));
2137            }
2138
2139            // `NIL` is the reserved vanish rule: the shape is dropped without
2140            // emitting a terminal. Usable in any successor position,
2141            // including split slots (`0.4: NIL`) and stochastic variants.
2142            if item.rule == "NIL" {
2143                continue;
2144            }
2145
2146            // One stream per shape: variant choice and every rand() draw in
2147            // this rule body pull from it in body order.
2148            let mut item_rng = Pcg64::seed_from_u64(item.rng_state);
2149            let def = match self.rules.get(&item.rule) {
2150                Some(def) => def,
2151                None => {
2152                    // Unknown rule → implicit I(rule_name) terminal.
2153                    if model.len() >= self.max_terminals {
2154                        return Err(ShapeError::CapacityOverflow);
2155                    }
2156                    let profile = item
2157                        .face_profile_override
2158                        .unwrap_or_else(|| taper_to_profile(item.taper));
2159                    let mut terminal =
2160                        Terminal::new_profiled(item.scope, &item.rule, profile, item.material);
2161                    terminal.label = item.label;
2162                    model.push(terminal);
2163                    continue;
2164                }
2165            };
2166
2167            // Bind call-argument values to the callee's declared parameters.
2168            if def.params.len() != item.args.len() {
2169                return Err(ShapeError::ArityMismatch(format!(
2170                    "rule `{}` expects {} argument(s), got {}",
2171                    item.rule,
2172                    def.params.len(),
2173                    item.args.len()
2174                )));
2175            }
2176            let params: Vec<(String, f64)> = def
2177                .params
2178                .iter()
2179                .cloned()
2180                .zip(item.args.iter().copied())
2181                .collect();
2182
2183            let ops = select_variant(
2184                &def.variants,
2185                &item.scope,
2186                item.split_i,
2187                item.split_n,
2188                item.depth,
2189                &params,
2190                &globals,
2191                &mut item_rng,
2192            )?;
2193
2194            self.apply_ops(
2195                item.scope,
2196                item.taper,
2197                item.face_profile_override,
2198                item.material,
2199                item.label.clone(),
2200                ops,
2201                item.depth,
2202                &params,
2203                item.split_i,
2204                item.split_n,
2205                item.rng_state,
2206                &globals,
2207                &mut item_rng,
2208                &mut queue,
2209                &mut model,
2210            )?;
2211        }
2212
2213        Ok(model)
2214    }
2215
2216    /// Processes the ops sequence for a single rule invocation.
2217    ///
2218    /// Transformation ops (`Extrude`, `Scale`, etc.) mutate `scope` in place.
2219    /// The first branching op (`Split`, `Comp`, `Repeat`) or terminal op
2220    /// (`I`, `Rule`) ends the sequence by pushing new work items.
2221    #[allow(clippy::too_many_arguments)]
2222    // The child-ordinal counter's final increment before an arm returns is
2223    // intentionally unread (see the fork! macro).
2224    #[allow(unused_assignments)]
2225    fn apply_ops(
2226        &self,
2227        initial_scope: Scope,
2228        initial_taper: f64,
2229        initial_face_profile: Option<FaceProfile>,
2230        initial_material: Option<Material>,
2231        initial_label: Option<String>,
2232        ops: &[ShapeOp],
2233        depth: usize,
2234        params: &[(String, f64)],
2235        split_i: usize,
2236        split_n: usize,
2237        rng_state: u64,
2238        globals: &HashMap<String, f64>,
2239        rng: &mut Pcg64,
2240        queue: &mut VecDeque<WorkItem>,
2241        model: &mut ShapeModel,
2242    ) -> Result<(), ShapeError> {
2243        let mut scope = initial_scope;
2244        // Rule-entry scope, kept for `Center` (recentre within entry bounds).
2245        let entry_scope = initial_scope;
2246        let mut taper = initial_taper;
2247        let mut face_profile = initial_face_profile;
2248        let mut material = initial_material;
2249        let mut label = initial_label;
2250
2251        // Evaluates one argument expression in the current shape's context.
2252        // A macro (not a closure) so it can borrow `scope` and `rng` afresh
2253        // at each use site while both are also mutated between uses.
2254        macro_rules! ev {
2255            ($e:expr) => {
2256                eval_expr($e, &scope, split_i, split_n, depth, params, globals, rng)?
2257            };
2258        }
2259        // Sequential child seed states: at most one branching op runs per
2260        // body, so ordinals are stable push-order indices within it.
2261        let mut child_ordinal: u64 = 0;
2262        macro_rules! fork {
2263            () => {{
2264                let s = fork_state(rng_state, child_ordinal);
2265                child_ordinal += 1;
2266                s
2267            }};
2268        }
2269        // Evaluates a `RuleCall`'s argument list into values.
2270        macro_rules! ev_args {
2271            ($call:expr) => {{
2272                let mut argv = Vec::with_capacity($call.args.len());
2273                for a in &$call.args {
2274                    argv.push(ev!(a));
2275                }
2276                argv
2277            }};
2278        }
2279
2280        for op in ops {
2281            match op {
2282                // ── Transformations ───────────────────────────────────────
2283                ShapeOp::Extrude(h) => {
2284                    let h = ev!(h);
2285                    if h <= 0.0 {
2286                        return Err(ShapeError::InvalidNumericValue);
2287                    }
2288                    // Face scopes from Comp(Faces) have size.z == 0 (the outward-normal
2289                    // direction) and a non-zero size.y (the face height).  Extruding a
2290                    // face scope should push it outward along the normal (local Z), not
2291                    // collapse the height by overwriting size.y.
2292                    // Footprint scopes have size.y == 0; Extrude gives them their height.
2293                    if scope.size.z.abs() < 1e-9 && scope.size.y.abs() > 1e-9 {
2294                        scope.size.z = h;
2295                    } else {
2296                        scope.size.y = h;
2297                    }
2298                }
2299
2300                ShapeOp::Taper(amount) => {
2301                    taper = ev!(amount).clamp(0.0, 1.0);
2302                }
2303
2304                ShapeOp::Rotate([w, x, y, z]) => {
2305                    let (w, x, y, z) = (ev!(w), ev!(x), ev!(y), ev!(z));
2306                    // Reject degenerate (near-zero) quaternions that cannot represent a
2307                    // rotation. Normalize non-unit inputs so that glam's fast-path
2308                    // `rotation * vec` (which assumes a unit quaternion) is correct.
2309                    let len_sq = w * w + x * x + y * y + z * z;
2310                    if !len_sq.is_finite() || len_sq < 1e-12 {
2311                        return Err(ShapeError::InvalidNumericValue);
2312                    }
2313                    let q = Quat::from_xyzw(x, y, z, w);
2314                    scope.rotation = (scope.rotation * q.normalize()).normalize();
2315                }
2316
2317                ShapeOp::Translate([x, y, z]) => {
2318                    let v = Vec3::new(ev!(x), ev!(y), ev!(z));
2319                    scope.position += scope.rotation * v;
2320                    // Two individually-finite values can add to INFINITY
2321                    // (e.g. f64::MAX/2 + f64::MAX/2). Catch the overflow here.
2322                    if !scope.position.is_finite() {
2323                        return Err(ShapeError::InvalidNumericValue);
2324                    }
2325                }
2326
2327                ShapeOp::Scale([x, y, z]) => {
2328                    let v = Vec3::new(ev!(x), ev!(y), ev!(z));
2329                    if v.x <= 0.0 || v.y <= 0.0 || v.z <= 0.0 {
2330                        return Err(ShapeError::InvalidNumericValue);
2331                    }
2332                    scope.size *= v;
2333                    // Two individually-finite scale values can multiply to INFINITY
2334                    // (e.g. 1e200 * 1e200). Catch the overflow here before it
2335                    // propagates into Split/Repeat and causes NaN via ∞ − ∞.
2336                    if !scope.size.is_finite() {
2337                        return Err(ShapeError::InvalidNumericValue);
2338                    }
2339                }
2340
2341                ShapeOp::Mat(mat) => {
2342                    material = Some(mat.clone());
2343                }
2344
2345                ShapeOp::Label(name) => {
2346                    label = Some(name.clone());
2347                }
2348
2349                ShapeOp::Size([x, y, z]) => {
2350                    let v = Vec3::new(ev!(x), ev!(y), ev!(z));
2351                    if v.x < 0.0 || v.y < 0.0 || v.z < 0.0 {
2352                        return Err(ShapeError::InvalidNumericValue);
2353                    }
2354                    scope.size = v;
2355                }
2356
2357                ShapeOp::Center { x, y, z } => {
2358                    // Current local offset relative to the rule-entry frame.
2359                    let mut local = entry_scope
2360                        .rotation
2361                        .inverse()
2362                        .mul_vec3(scope.position - entry_scope.position);
2363                    if *x {
2364                        local.x = (entry_scope.size.x - scope.size.x) / 2.0;
2365                    }
2366                    if *y {
2367                        local.y = (entry_scope.size.y - scope.size.y) / 2.0;
2368                    }
2369                    if *z {
2370                        local.z = (entry_scope.size.z - scope.size.z) / 2.0;
2371                    }
2372                    scope.position = entry_scope.position + entry_scope.rotation * local;
2373                    if !scope.position.is_finite() {
2374                        return Err(ShapeError::InvalidNumericValue);
2375                    }
2376                }
2377
2378                ShapeOp::Mirror => {
2379                    if let Some(profile) = &mut face_profile {
2380                        *profile = mirror_profile(profile);
2381                    }
2382                }
2383
2384                // ── Branching: ShapeL / ShapeU footprint carving ─────────
2385                ShapeOp::ShapeL { front, side, cases } => {
2386                    let d = ev!(front);
2387                    let w = ev!(side);
2388                    let (sx, sz) = (scope.size.x, scope.size.z);
2389                    if d <= 0.0 || w <= 0.0 || d >= sz - 1e-9 || w >= sx - 1e-9 {
2390                        return Err(ShapeError::InvalidNumericValue);
2391                    }
2392                    // Front bar (full width), side leg (remaining depth),
2393                    // remainder rectangle.
2394                    let parts: [(CarveSelector, Vec3, Vec3); 3] = [
2395                        (
2396                            CarveSelector::Shape,
2397                            Vec3::ZERO,
2398                            Vec3::new(sx, scope.size.y, d),
2399                        ),
2400                        (
2401                            CarveSelector::Shape,
2402                            Vec3::new(0.0, 0.0, d),
2403                            Vec3::new(w, scope.size.y, sz - d),
2404                        ),
2405                        (
2406                            CarveSelector::Remainder,
2407                            Vec3::new(w, 0.0, d),
2408                            Vec3::new(sx - w, scope.size.y, sz - d),
2409                        ),
2410                    ];
2411                    if queue.len() + parts.len() > MAX_QUEUE {
2412                        return Err(ShapeError::CapacityOverflow);
2413                    }
2414                    for (selector, local_off, size) in parts {
2415                        let Some(case) = find_carve_rule(selector, cases) else {
2416                            continue;
2417                        };
2418                        let pos = scope.position + scope.rotation * local_off;
2419                        let child = Scope::new(pos, scope.rotation, size);
2420                        child.validate()?;
2421                        let args = ev_args!(&case.rule);
2422                        queue.push_back(WorkItem {
2423                            scope: child,
2424                            rule: case.rule.name.clone(),
2425                            args,
2426                            depth: depth + 1,
2427                            taper: 0.0,
2428                            face_profile_override: None,
2429                            material: material.clone(),
2430                            split_i,
2431                            split_n,
2432                            rng_state: fork!(),
2433                            label: label.clone(),
2434                        });
2435                    }
2436                    return Ok(());
2437                }
2438
2439                ShapeOp::ShapeU {
2440                    front,
2441                    left,
2442                    right,
2443                    cases,
2444                } => {
2445                    let d = ev!(front);
2446                    let wl = ev!(left);
2447                    let wr = ev!(right);
2448                    let (sx, sz) = (scope.size.x, scope.size.z);
2449                    if d <= 0.0 || wl <= 0.0 || wr <= 0.0 || d >= sz - 1e-9 || wl + wr >= sx - 1e-9
2450                    {
2451                        return Err(ShapeError::InvalidNumericValue);
2452                    }
2453                    let parts: [(CarveSelector, Vec3, Vec3); 4] = [
2454                        (
2455                            CarveSelector::Shape,
2456                            Vec3::ZERO,
2457                            Vec3::new(sx, scope.size.y, d),
2458                        ),
2459                        (
2460                            CarveSelector::Shape,
2461                            Vec3::new(0.0, 0.0, d),
2462                            Vec3::new(wl, scope.size.y, sz - d),
2463                        ),
2464                        (
2465                            CarveSelector::Shape,
2466                            Vec3::new(sx - wr, 0.0, d),
2467                            Vec3::new(wr, scope.size.y, sz - d),
2468                        ),
2469                        (
2470                            CarveSelector::Remainder,
2471                            Vec3::new(wl, 0.0, d),
2472                            Vec3::new(sx - wl - wr, scope.size.y, sz - d),
2473                        ),
2474                    ];
2475                    if queue.len() + parts.len() > MAX_QUEUE {
2476                        return Err(ShapeError::CapacityOverflow);
2477                    }
2478                    for (selector, local_off, size) in parts {
2479                        let Some(case) = find_carve_rule(selector, cases) else {
2480                            continue;
2481                        };
2482                        let pos = scope.position + scope.rotation * local_off;
2483                        let child = Scope::new(pos, scope.rotation, size);
2484                        child.validate()?;
2485                        let args = ev_args!(&case.rule);
2486                        queue.push_back(WorkItem {
2487                            scope: child,
2488                            rule: case.rule.name.clone(),
2489                            args,
2490                            depth: depth + 1,
2491                            taper: 0.0,
2492                            face_profile_override: None,
2493                            material: material.clone(),
2494                            split_i,
2495                            split_n,
2496                            rng_state: fork!(),
2497                            label: label.clone(),
2498                        });
2499                    }
2500                    return Ok(());
2501                }
2502
2503                ShapeOp::Polygon(verts) => {
2504                    if verts.len() < 3 {
2505                        return Err(ShapeError::InvalidNumericValue);
2506                    }
2507                    for v in verts {
2508                        if !v.is_finite() {
2509                            return Err(ShapeError::InvalidNumericValue);
2510                        }
2511                    }
2512                    face_profile = Some(FaceProfile::Polygon(verts.clone()));
2513                }
2514
2515                // ── Snap-plane registration ──────────────────────────────
2516                ShapeOp::RegSnap(label) => {
2517                    register_scope_snap_planes(&scope, label, &mut model.snap_planes);
2518                }
2519
2520                // ── Conditional: IfClear / IfOccluded ────────────────────
2521                // Both consult the model-so-far. IfClear pushes the rule only
2522                // when no already-emitted terminal overlaps the current scope;
2523                // IfOccluded is the inverse. The current rule body terminates
2524                // either way (these are branching ops).
2525                ShapeOp::IfClear { rule, label: filt } => {
2526                    let occluded = model
2527                        .terminals
2528                        .iter()
2529                        .filter(|t| filt.is_none() || t.label.as_deref() == filt.as_deref())
2530                        .any(|t| scope_obb_overlaps_terminal(&scope, t));
2531                    if !occluded {
2532                        if queue.len() >= MAX_QUEUE {
2533                            return Err(ShapeError::CapacityOverflow);
2534                        }
2535                        let args = ev_args!(rule);
2536                        queue.push_back(WorkItem {
2537                            scope,
2538                            rule: rule.name.clone(),
2539                            args,
2540                            depth: depth + 1,
2541                            taper,
2542                            face_profile_override: face_profile.take(),
2543                            material: material.clone(),
2544                            split_i,
2545                            split_n,
2546                            rng_state: fork!(),
2547                            label: label.clone(),
2548                        });
2549                    }
2550                    return Ok(());
2551                }
2552                ShapeOp::IfOccluded { rule, label: filt } => {
2553                    let occluded = model
2554                        .terminals
2555                        .iter()
2556                        .filter(|t| filt.is_none() || t.label.as_deref() == filt.as_deref())
2557                        .any(|t| scope_obb_overlaps_terminal(&scope, t));
2558                    if occluded {
2559                        if queue.len() >= MAX_QUEUE {
2560                            return Err(ShapeError::CapacityOverflow);
2561                        }
2562                        let args = ev_args!(rule);
2563                        queue.push_back(WorkItem {
2564                            scope,
2565                            rule: rule.name.clone(),
2566                            args,
2567                            depth: depth + 1,
2568                            taper,
2569                            face_profile_override: face_profile.take(),
2570                            material: material.clone(),
2571                            split_i,
2572                            split_n,
2573                            rng_state: fork!(),
2574                            label: label.clone(),
2575                        });
2576                    }
2577                    return Ok(());
2578                }
2579
2580                ShapeOp::IfInside { rule, label: filt } => {
2581                    let inside = model
2582                        .terminals
2583                        .iter()
2584                        .filter(|t| filt.is_none() || t.label.as_deref() == filt.as_deref())
2585                        .any(|t| crate::query::scope_inside_terminal(&scope, t));
2586                    if inside {
2587                        if queue.len() >= MAX_QUEUE {
2588                            return Err(ShapeError::CapacityOverflow);
2589                        }
2590                        let args = ev_args!(rule);
2591                        queue.push_back(WorkItem {
2592                            scope,
2593                            rule: rule.name.clone(),
2594                            args,
2595                            depth: depth + 1,
2596                            taper,
2597                            face_profile_override: face_profile.take(),
2598                            material: material.clone(),
2599                            split_i,
2600                            split_n,
2601                            rng_state: fork!(),
2602                            label: label.clone(),
2603                        });
2604                    }
2605                    return Ok(());
2606                }
2607
2608                ShapeOp::IfTouches { rule, label: filt } => {
2609                    let touches = model
2610                        .terminals
2611                        .iter()
2612                        .filter(|t| filt.is_none() || t.label.as_deref() == filt.as_deref())
2613                        .any(|t| crate::query::scope_touches_terminal(&scope, t));
2614                    if touches {
2615                        if queue.len() >= MAX_QUEUE {
2616                            return Err(ShapeError::CapacityOverflow);
2617                        }
2618                        let args = ev_args!(rule);
2619                        queue.push_back(WorkItem {
2620                            scope,
2621                            rule: rule.name.clone(),
2622                            args,
2623                            depth: depth + 1,
2624                            taper,
2625                            face_profile_override: face_profile.take(),
2626                            material: material.clone(),
2627                            split_i,
2628                            split_n,
2629                            rng_state: fork!(),
2630                            label: label.clone(),
2631                        });
2632                    }
2633                    return Ok(());
2634                }
2635
2636                // ── Coordination: Pick ───────────────────────────────────
2637                //
2638                // The winning index is a pure function of (seed, key): every
2639                // Pick with this key, anywhere in the derivation, agrees.
2640                ShapeOp::Pick { key, choices } => {
2641                    if choices.is_empty() {
2642                        return Ok(());
2643                    }
2644                    let mut pick_rng = Pcg64::seed_from_u64(splitmix64(self.seed ^ fnv1a(key)));
2645                    use rand::Rng as _;
2646                    let total: f64 = choices.iter().map(|(w, _)| w).sum();
2647                    let chosen = if total <= 0.0 {
2648                        &choices[0].1
2649                    } else {
2650                        let r = pick_rng.random::<f64>() * total;
2651                        let mut acc = 0.0;
2652                        let mut sel = &choices[choices.len() - 1].1;
2653                        for (w, call) in choices {
2654                            acc += w;
2655                            if r < acc {
2656                                sel = call;
2657                                break;
2658                            }
2659                        }
2660                        sel
2661                    };
2662                    if queue.len() >= MAX_QUEUE {
2663                        return Err(ShapeError::CapacityOverflow);
2664                    }
2665                    let args = ev_args!(chosen);
2666                    queue.push_back(WorkItem {
2667                        scope,
2668                        rule: chosen.name.clone(),
2669                        args,
2670                        depth: depth + 1,
2671                        taper,
2672                        face_profile_override: face_profile.take(),
2673                        material: material.clone(),
2674                        split_i,
2675                        split_n,
2676                        rng_state: fork!(),
2677                        label: label.clone(),
2678                    });
2679                    return Ok(());
2680                }
2681
2682                // ── Branching: Scatter ───────────────────────────────────
2683                ShapeOp::Scatter {
2684                    volume,
2685                    count,
2686                    rule,
2687                } => {
2688                    let n_f = ev!(count);
2689                    if n_f < 0.0 {
2690                        return Err(ShapeError::InvalidNumericValue);
2691                    }
2692                    let n = (n_f.floor() as usize).min(MAX_SCATTER_POINTS);
2693                    if queue.len() + n > MAX_QUEUE {
2694                        return Err(ShapeError::CapacityOverflow);
2695                    }
2696                    use rand::Rng as _;
2697                    for i in 0..n {
2698                        let px = rng.random::<f64>() * scope.size.x;
2699                        let py = if *volume {
2700                            rng.random::<f64>() * scope.size.y
2701                        } else {
2702                            scope.size.y
2703                        };
2704                        let pz = rng.random::<f64>() * scope.size.z;
2705                        let pos = scope.position + scope.rotation * Vec3::new(px, py, pz);
2706                        let child = Scope::new(pos, scope.rotation, Vec3::ZERO);
2707                        child.validate()?;
2708                        let args = ev_args!(rule);
2709                        queue.push_back(WorkItem {
2710                            scope: child,
2711                            rule: rule.name.clone(),
2712                            args,
2713                            depth: depth + 1,
2714                            taper: 0.0,
2715                            face_profile_override: None,
2716                            material: material.clone(),
2717                            split_i: i,
2718                            split_n: n,
2719                            rng_state: fork!(),
2720                            label: label.clone(),
2721                        });
2722                    }
2723                    return Ok(());
2724                }
2725
2726                // ── Transform: Align ─────────────────────────────────────
2727                ShapeOp::Align { local_axis, target } => {
2728                    // length_squared() can overflow to INFINITY for large-but-finite
2729                    // vectors (e.g. (1e200, 1e200, 1e200)); INFINITY > 1e-12 so the
2730                    // naive check would pass, then normalize() divides by INFINITY
2731                    // yielding a zero vector and a silent no-op rotation.
2732                    let len_sq = target.length_squared();
2733                    if !target.is_finite() || !len_sq.is_finite() || len_sq < 1e-12 {
2734                        return Err(ShapeError::InvalidAlignTarget);
2735                    }
2736                    let target_norm = target.normalize();
2737                    let current = scope.rotation * axis_vec(*local_axis);
2738                    // from_rotation_arc gives the shortest-arc rotation; it degenerates
2739                    // when vectors are antiparallel — handle that with a fallback 180°.
2740                    let dot = current.dot(target_norm);
2741                    let q = if (dot + 1.0).abs() < 1e-9 {
2742                        // Choose the cardinal axis least parallel to `current` (smallest
2743                        // absolute component) to form the cross product. This avoids the
2744                        // discontinuous snap caused by a hard threshold: the selection
2745                        // only changes when two components are exactly equal, which is
2746                        // rare and well-conditioned.
2747                        let perp = if current.x.abs() <= current.y.abs()
2748                            && current.x.abs() <= current.z.abs()
2749                        {
2750                            current.cross(Vec3::X).normalize()
2751                        } else if current.y.abs() <= current.z.abs() {
2752                            current.cross(Vec3::Y).normalize()
2753                        } else {
2754                            current.cross(Vec3::Z).normalize()
2755                        };
2756                        Quat::from_axis_angle(perp, PI)
2757                    } else {
2758                        Quat::from_rotation_arc(current, target_norm)
2759                    };
2760                    scope.rotation = (q * scope.rotation).normalize();
2761                }
2762
2763                // ── Branching: Offset ─────────────────────────────────────
2764                ShapeOp::Offset { distance, cases } => {
2765                    let distance = ev!(distance);
2766                    // Negative distance = inset; positive = outset (0.3):
2767                    // the Inside region grows past the face and the Border
2768                    // ring lies outside the original boundary.
2769                    if distance == 0.0 {
2770                        return Err(ShapeError::InvalidNumericValue);
2771                    }
2772                    let inset = -distance;
2773                    let sx = scope.size.x;
2774                    let sy = scope.size.y;
2775                    let inside_w = sx - 2.0 * inset;
2776                    let inside_h = sy - 2.0 * inset;
2777                    // Explicit NaN guard: if sx/sy are non-finite (e.g. leaked
2778                    // Infinity from an upstream op), the subtraction produces NaN,
2779                    // which compares false for `< 0.0` and would bypass the check.
2780                    if !inside_w.is_finite()
2781                        || !inside_h.is_finite()
2782                        || inside_w < 0.0
2783                        || inside_h < 0.0
2784                    {
2785                        return Err(ShapeError::OffsetTooLarge);
2786                    }
2787                    if let Some(rule) = find_offset_rule(OffsetSelector::Inside, cases) {
2788                        if queue.len() >= MAX_QUEUE {
2789                            return Err(ShapeError::CapacityOverflow);
2790                        }
2791                        let pos = scope.position + scope.rotation * Vec3::new(inset, inset, 0.0);
2792                        let child_scope =
2793                            Scope::new(pos, scope.rotation, Vec3::new(inside_w, inside_h, 0.0));
2794                        child_scope.validate()?;
2795                        let args = ev_args!(rule);
2796                        queue.push_back(WorkItem {
2797                            scope: child_scope,
2798                            rule: rule.name.clone(),
2799                            args,
2800                            depth: depth + 1,
2801                            taper: 0.0,
2802                            face_profile_override: None,
2803                            material: material.clone(),
2804                            split_i,
2805                            split_n,
2806                            rng_state: fork!(),
2807                            label: label.clone(),
2808                        });
2809                    }
2810                    if let Some(rule) = find_offset_rule(OffsetSelector::Border, cases) {
2811                        // 4 surrounding strips: bottom, top, left, right.
2812                        let strips = [
2813                            (Vec3::new(0.0, 0.0, 0.0), Vec3::new(sx, inset, 0.0)),
2814                            (Vec3::new(0.0, sy - inset, 0.0), Vec3::new(sx, inset, 0.0)),
2815                            (
2816                                Vec3::new(0.0, inset, 0.0),
2817                                Vec3::new(inset, sy - 2.0 * inset, 0.0),
2818                            ),
2819                            (
2820                                Vec3::new(sx - inset, inset, 0.0),
2821                                Vec3::new(inset, sy - 2.0 * inset, 0.0),
2822                            ),
2823                        ];
2824                        if queue.len() + strips.len() > MAX_QUEUE {
2825                            return Err(ShapeError::CapacityOverflow);
2826                        }
2827                        for (local_off, strip_size) in strips {
2828                            let pos = scope.position + scope.rotation * local_off;
2829                            let child_scope = Scope::new(pos, scope.rotation, strip_size);
2830                            child_scope.validate()?;
2831                            let args = ev_args!(rule);
2832                            queue.push_back(WorkItem {
2833                                scope: child_scope,
2834                                rule: rule.name.clone(),
2835                                args,
2836                                depth: depth + 1,
2837                                taper: 0.0,
2838                                face_profile_override: None,
2839                                material: material.clone(),
2840                                split_i,
2841                                split_n,
2842                                rng_state: fork!(),
2843                                label: label.clone(),
2844                            });
2845                        }
2846                    }
2847                    return Ok(());
2848                }
2849
2850                // ── Branching: Roof ───────────────────────────────────────
2851                ShapeOp::Roof { spec, cases } => {
2852                    // Resolve the expression-valued spec into a numeric config.
2853                    // `height=` overrides pitch: the rise is measured over half
2854                    // the narrower footprint axis, so mixed-width wings sharing
2855                    // one target height meet at the same ridge line.
2856                    let pitch = if let Some(h_expr) = &spec.height {
2857                        let h = ev!(h_expr);
2858                        if h <= 0.0 {
2859                            return Err(ShapeError::InvalidNumericValue);
2860                        }
2861                        // Shed rises over its full depth; ridge types over
2862                        // half the narrower span.
2863                        let run = if spec.roof_type == RoofType::Shed {
2864                            scope.size.z.max(1e-9)
2865                        } else {
2866                            (scope.size.x.min(scope.size.z) / 2.0).max(1e-9)
2867                        };
2868                        (h / run).atan().to_degrees()
2869                    } else {
2870                        ev!(&spec.pitch)
2871                    };
2872                    let secondary_pitch = match &spec.secondary_pitch {
2873                        Some(e) => Some(ev!(e)),
2874                        None => None,
2875                    };
2876                    let tier_height = match &spec.tier_height {
2877                        Some(e) => Some(ev!(e)),
2878                        None => None,
2879                    };
2880                    let config = RoofConfig {
2881                        roof_type: spec.roof_type,
2882                        pitch,
2883                        secondary_pitch,
2884                        overhang: ev!(&spec.overhang),
2885                        ridge_offset: ev!(&spec.ridge_offset),
2886                        fascia_depth: ev!(&spec.fascia_depth),
2887                        tier_height,
2888                    };
2889                    let resolved_cases = {
2890                        let mut rcs = Vec::with_capacity(cases.len());
2891                        for c in cases {
2892                            let args = ev_args!(&c.rule);
2893                            rcs.push(ResolvedRoofCase {
2894                                selector: c.selector,
2895                                name: c.rule.name.clone(),
2896                                args,
2897                            });
2898                        }
2899                        rcs
2900                    };
2901                    apply_roof(
2902                        &config,
2903                        &resolved_cases,
2904                        &scope,
2905                        depth,
2906                        &material,
2907                        queue,
2908                        model,
2909                        self.max_terminals,
2910                        split_i,
2911                        split_n,
2912                        rng_state,
2913                        spec.ridge_axis,
2914                        &label,
2915                    )?;
2916                    return Ok(());
2917                }
2918
2919                // ── Branching: Attach ─────────────────────────────────────
2920                ShapeOp::Attach { world_axis, cases } => {
2921                    let len_sq = world_axis.length_squared();
2922                    if !world_axis.is_finite() || !len_sq.is_finite() || len_sq < 1e-12 {
2923                        return Err(ShapeError::InvalidAlignTarget);
2924                    }
2925                    let axis_norm = world_axis.normalize();
2926                    // Build a new scope whose Y axis = world_axis.
2927                    // The new scope sits at the same corner as the current scope,
2928                    // has X = scope.size.x, Y = scope.size.y, Z = 0 (flat surface).
2929                    let rot = Quat::from_rotation_arc(Vec3::Y, axis_norm);
2930                    let attach_scope = Scope::new(
2931                        scope.position,
2932                        rot.normalize(),
2933                        Vec3::new(scope.size.x, scope.size.y, 0.0),
2934                    );
2935                    attach_scope.validate()?;
2936                    if let Some(rule) = find_attach_rule(crate::ops::AttachSelector::Surface, cases)
2937                    {
2938                        if queue.len() >= MAX_QUEUE {
2939                            return Err(ShapeError::CapacityOverflow);
2940                        }
2941                        let args = ev_args!(rule);
2942                        queue.push_back(WorkItem {
2943                            scope: attach_scope,
2944                            rule: rule.name.clone(),
2945                            args,
2946                            depth: depth + 1,
2947                            taper: 0.0,
2948                            face_profile_override: None,
2949                            material: material.clone(),
2950                            split_i,
2951                            split_n,
2952                            rng_state: fork!(),
2953                            label: label.clone(),
2954                        });
2955                    }
2956                    return Ok(());
2957                }
2958
2959                // ── Branching: Split ──────────────────────────────────────
2960                ShapeOp::Split {
2961                    axis,
2962                    entries,
2963                    snap,
2964                } => {
2965                    let total = match axis {
2966                        Axis::X => scope.size.x,
2967                        Axis::Y => scope.size.y,
2968                        Axis::Z => scope.size.z,
2969                    };
2970                    // Flatten entries (rhythm groups tile to fill) into an
2971                    // ordered slot list with resolved sizes.
2972                    let mut flat: Vec<(&SplitSlot, ResolvedSize)> = Vec::new();
2973                    {
2974                        macro_rules! resolve_size {
2975                            ($slot:expr) => {
2976                                match &$slot.size {
2977                                    SplitSize::Absolute(e) => ResolvedSize::Absolute(ev!(e)),
2978                                    SplitSize::Relative(e) => ResolvedSize::Relative(ev!(e)),
2979                                    SplitSize::Floating(e) => ResolvedSize::Floating(ev!(e)),
2980                                }
2981                            };
2982                        }
2983                        // Pass 1: resolve every size; compute outside fixed
2984                        // sum, outside float weight, and the group's nominal
2985                        // copy width (floats inside count at their weight).
2986                        let mut resolved_entries: Vec<(usize, Vec<ResolvedSize>)> =
2987                            Vec::with_capacity(entries.len());
2988                        let mut outside_fixed = 0.0_f64;
2989                        let mut outside_float_w = 0.0_f64;
2990                        let mut group_nominal = 0.0_f64;
2991                        let mut group_len = 0usize;
2992                        for (ei, entry) in entries.iter().enumerate() {
2993                            match entry {
2994                                SplitEntry::Slot(slot) => {
2995                                    let r = resolve_size!(slot);
2996                                    match r {
2997                                        ResolvedSize::Absolute(v) => outside_fixed += v,
2998                                        ResolvedSize::Relative(t) => outside_fixed += t * total,
2999                                        ResolvedSize::Floating(w) => outside_float_w += w,
3000                                    }
3001                                    resolved_entries.push((ei, vec![r]));
3002                                }
3003                                SplitEntry::Group(slots) => {
3004                                    let mut rs = Vec::with_capacity(slots.len());
3005                                    for slot in slots {
3006                                        let r = resolve_size!(slot);
3007                                        group_nominal += match r {
3008                                            ResolvedSize::Absolute(v) => v,
3009                                            ResolvedSize::Relative(t) => t * total,
3010                                            ResolvedSize::Floating(w) => w,
3011                                        };
3012                                        rs.push(r);
3013                                    }
3014                                    group_len = slots.len();
3015                                    resolved_entries.push((ei, rs));
3016                                }
3017                            }
3018                        }
3019                        if !outside_fixed.is_finite()
3020                            || !outside_float_w.is_finite()
3021                            || !group_nominal.is_finite()
3022                        {
3023                            return Err(ShapeError::InvalidNumericValue);
3024                        }
3025                        if outside_fixed > total + 1e-9 {
3026                            return Err(ShapeError::SplitOverflow(total));
3027                        }
3028                        let remaining = (total - outside_fixed).max(0.0);
3029                        // Copies of the whole pattern that fit the remainder.
3030                        let k = if group_len > 0 && group_nominal > 1e-12 {
3031                            ((remaining + 1e-9) / group_nominal).floor() as usize
3032                        } else {
3033                            0
3034                        };
3035                        let singles = entries.len() - usize::from(group_len > 0);
3036                        if singles + k * group_len > MAX_SPLIT_CHILDREN {
3037                            return Err(ShapeError::CapacityOverflow);
3038                        }
3039                        let leftover = remaining - k as f64 * group_nominal;
3040                        // Leftover space: outside floats absorb it; with no
3041                        // floats the copies stretch uniformly; a remainder
3042                        // nothing can absorb is an authoring error.
3043                        let copy_scale = if group_len > 0 && outside_float_w <= 0.0 {
3044                            if k == 0 {
3045                                if remaining > 1e-9 {
3046                                    return Err(ShapeError::SplitOverflow(total));
3047                                }
3048                                1.0
3049                            } else {
3050                                remaining / (k as f64 * group_nominal)
3051                            }
3052                        } else {
3053                            1.0
3054                        };
3055                        for (ei, rs) in &resolved_entries {
3056                            match &entries[*ei] {
3057                                SplitEntry::Slot(slot) => {
3058                                    let r = match rs[0] {
3059                                        ResolvedSize::Floating(w) => {
3060                                            // Convert to an absolute share of
3061                                            // the float pool now that the
3062                                            // group has taken its copies.
3063                                            if outside_float_w <= 0.0 {
3064                                                return Err(ShapeError::NoFloatingSlots);
3065                                            }
3066                                            ResolvedSize::Absolute(leftover * (w / outside_float_w))
3067                                        }
3068                                        other => other,
3069                                    };
3070                                    flat.push((slot, r));
3071                                }
3072                                SplitEntry::Group(slots) => {
3073                                    for _copy in 0..k {
3074                                        for (slot, r) in slots.iter().zip(rs.iter()) {
3075                                            let v = match *r {
3076                                                ResolvedSize::Absolute(v) => v,
3077                                                ResolvedSize::Relative(t) => t * total,
3078                                                ResolvedSize::Floating(w) => w,
3079                                            };
3080                                            flat.push((
3081                                                slot,
3082                                                ResolvedSize::Absolute(v * copy_scale),
3083                                            ));
3084                                        }
3085                                    }
3086                                }
3087                            }
3088                        }
3089                    }
3090                    let resolved: Vec<ResolvedSize> = flat.iter().map(|(_, r)| *r).collect();
3091                    let mut sizes = resolve_split_sizes(&resolved, total)?;
3092                    // Snap-aware adjustment: shift interior boundaries to the
3093                    // nearest registered snap-plane along `axis` if within
3094                    // tolerance, then redistribute the offset across the two
3095                    // adjacent slot widths.
3096                    if let Some(binding) = snap {
3097                        let tol = binding.tolerance.unwrap_or(0.05 * total);
3098                        snap_split_boundaries(
3099                            &scope,
3100                            *axis,
3101                            &mut sizes,
3102                            &binding.label,
3103                            tol,
3104                            &model.snap_planes,
3105                        );
3106                    }
3107                    if queue.len() + flat.len() > MAX_QUEUE {
3108                        return Err(ShapeError::CapacityOverflow);
3109                    }
3110                    let child_n = flat.len();
3111                    let mut offset = 0.0;
3112                    for (i, ((slot, _), size)) in flat.iter().zip(sizes.iter()).enumerate() {
3113                        let child = slice_scope(&scope, *axis, offset, *size);
3114                        child.validate()?;
3115                        let args = ev_args!(&slot.rule);
3116                        queue.push_back(WorkItem {
3117                            scope: child,
3118                            rule: slot.rule.name.clone(),
3119                            args,
3120                            depth: depth + 1,
3121                            taper: 0.0,
3122                            face_profile_override: None,
3123                            material: material.clone(),
3124                            split_i: i,
3125                            split_n: child_n,
3126                            rng_state: fork!(),
3127                            label: label.clone(),
3128                        });
3129                        offset += size;
3130                    }
3131                    return Ok(());
3132                }
3133
3134                // ── Branching: SplitArea ──────────────────────────────────
3135                //
3136                // Slot sizes are target areas; lengths are recovered through
3137                // the cross-axis extent, then the normal split solver runs.
3138                ShapeOp::SplitArea { axis, slots } => {
3139                    let (total, cross) = match axis {
3140                        Axis::X => (scope.size.x, scope.size.z),
3141                        Axis::Z => (scope.size.z, scope.size.x),
3142                        Axis::Y => return Err(ShapeError::InvalidNumericValue),
3143                    };
3144                    if !cross.is_finite() || cross <= 1e-12 {
3145                        return Err(ShapeError::InvalidNumericValue);
3146                    }
3147                    let resolved: Vec<ResolvedSize> = {
3148                        let mut v = Vec::with_capacity(slots.len());
3149                        for slot in slots {
3150                            v.push(match &slot.size {
3151                                // Absolute areas become lengths; relative and
3152                                // floating shares are scale-free.
3153                                SplitSize::Absolute(e) => ResolvedSize::Absolute(ev!(e) / cross),
3154                                SplitSize::Relative(e) => ResolvedSize::Relative(ev!(e)),
3155                                SplitSize::Floating(e) => ResolvedSize::Floating(ev!(e)),
3156                            });
3157                        }
3158                        v
3159                    };
3160                    let sizes = resolve_split_sizes(&resolved, total)?;
3161                    if queue.len() + slots.len() > MAX_QUEUE {
3162                        return Err(ShapeError::CapacityOverflow);
3163                    }
3164                    let child_n = slots.len();
3165                    let mut offset = 0.0;
3166                    for (i, (slot, size)) in slots.iter().zip(sizes.iter()).enumerate() {
3167                        let child = slice_scope(&scope, *axis, offset, *size);
3168                        child.validate()?;
3169                        let args = ev_args!(&slot.rule);
3170                        queue.push_back(WorkItem {
3171                            scope: child,
3172                            rule: slot.rule.name.clone(),
3173                            args,
3174                            depth: depth + 1,
3175                            taper: 0.0,
3176                            face_profile_override: None,
3177                            material: material.clone(),
3178                            split_i: i,
3179                            split_n: child_n,
3180                            rng_state: fork!(),
3181                            label: label.clone(),
3182                        });
3183                        offset += size;
3184                    }
3185                    return Ok(());
3186                }
3187
3188                // ── Branching: Fit ────────────────────────────────────────
3189                //
3190                // First candidate whose minimum extent fits the scope wins
3191                // the whole scope; none fitting vanishes the shape.
3192                ShapeOp::Fit { axis, candidates } => {
3193                    let extent = match axis {
3194                        Axis::X => scope.size.x,
3195                        Axis::Y => scope.size.y,
3196                        Axis::Z => scope.size.z,
3197                    };
3198                    for cand in candidates {
3199                        let min = ev!(&cand.min_size);
3200                        if min <= extent + 1e-9 {
3201                            if queue.len() >= MAX_QUEUE {
3202                                return Err(ShapeError::CapacityOverflow);
3203                            }
3204                            let args = ev_args!(&cand.rule);
3205                            queue.push_back(WorkItem {
3206                                scope,
3207                                rule: cand.rule.name.clone(),
3208                                args,
3209                                depth: depth + 1,
3210                                taper,
3211                                face_profile_override: face_profile.take(),
3212                                material: material.clone(),
3213                                split_i,
3214                                split_n,
3215                                rng_state: fork!(),
3216                                label: label.clone(),
3217                            });
3218                            return Ok(());
3219                        }
3220                    }
3221                    return Ok(());
3222                }
3223
3224                // ── Branching: Repeat ─────────────────────────────────────
3225                //
3226                // Uses `floor()` for tile count (never fewer tiles than fit),
3227                // then stretches actual tile size to fill the scope with no gaps.
3228                // Example: 10.5m scope / 2m target → 5 tiles × 2.1m each.
3229                ShapeOp::Repeat {
3230                    axis,
3231                    tile_sizes,
3232                    rule,
3233                } => {
3234                    if tile_sizes.is_empty() {
3235                        return Err(ShapeError::InvalidNumericValue);
3236                    }
3237                    let tile_sizes: Vec<f64> = {
3238                        let mut v = Vec::with_capacity(tile_sizes.len());
3239                        for ts in tile_sizes {
3240                            let t = ev!(ts);
3241                            if t <= 0.0 {
3242                                return Err(ShapeError::InvalidNumericValue);
3243                            }
3244                            v.push(t);
3245                        }
3246                        v
3247                    };
3248                    let total = match axis {
3249                        Axis::X => scope.size.x,
3250                        Axis::Y => scope.size.y,
3251                        Axis::Z => scope.size.z,
3252                    };
3253                    // Defensive: scope.size should always be finite after earlier
3254                    // checks, but if an Infinity scope size ever sneaks through
3255                    // (e.g. from a Roof child), `0.0 * Infinity = NaN` at i=0.
3256                    if !total.is_finite() || total <= 0.0 {
3257                        return Err(ShapeError::InvalidNumericValue);
3258                    }
3259                    let pattern_min = tile_sizes.iter().cloned().fold(f64::INFINITY, f64::min);
3260                    if pattern_min <= 0.0 {
3261                        return Err(ShapeError::InvalidNumericValue);
3262                    }
3263                    // Upper bound on tile count: even if every tile were the
3264                    // smallest in the pattern, this caps it. Mirrors the
3265                    // single-size guard against tiny-tile-size-induced
3266                    // n_tiles → usize::MAX overflow.
3267                    let n_max_f = (total / pattern_min).floor();
3268                    if !n_max_f.is_finite() {
3269                        return Err(ShapeError::CapacityOverflow);
3270                    }
3271                    let n_max = n_max_f as usize;
3272                    if queue.len().saturating_add(n_max) > MAX_QUEUE {
3273                        return Err(ShapeError::CapacityOverflow);
3274                    }
3275                    // Cycle the pattern, appending tiles greedily while the
3276                    // next tile still fits, then scale all placed tiles by
3277                    // `total / acc` so they fill the scope exactly.
3278                    let mut placed: Vec<f64> = Vec::new();
3279                    let mut acc = 0.0_f64;
3280                    loop {
3281                        let next = tile_sizes[placed.len() % tile_sizes.len()];
3282                        if acc + next > total + 1e-12 {
3283                            break;
3284                        }
3285                        placed.push(next);
3286                        acc += next;
3287                    }
3288                    if !placed.is_empty() {
3289                        let scale = total / acc;
3290                        let child_n = placed.len();
3291                        let mut offset = 0.0_f64;
3292                        for (i, tile) in placed.iter().enumerate() {
3293                            let actual = tile * scale;
3294                            let child = slice_scope(&scope, *axis, offset, actual);
3295                            child.validate()?;
3296                            // Args re-evaluate per tile: `Bay(rand(0, 3))` rolls
3297                            // once per placed tile, not once for the whole row.
3298                            let args = ev_args!(rule);
3299                            queue.push_back(WorkItem {
3300                                scope: child,
3301                                rule: rule.name.clone(),
3302                                args,
3303                                depth: depth + 1,
3304                                taper: 0.0,
3305                                face_profile_override: None,
3306                                material: material.clone(),
3307                                split_i: i,
3308                                split_n: child_n,
3309                                rng_state: fork!(),
3310                                label: label.clone(),
3311                            });
3312                            offset += actual;
3313                        }
3314                    }
3315                    return Ok(());
3316                }
3317
3318                // ── Branching: Comp ───────────────────────────────────────
3319                //
3320                // Each face scope is properly oriented so that local Z points
3321                // along the outward face normal. Rules can then use Split(X/Y)
3322                // or Repeat(X) naturally on any face of the parent volume.
3323                ShapeOp::Comp(CompTarget::Edges(cases)) => {
3324                    let descs = edge_descs(scope.size);
3325                    if queue.len() + descs.len() > MAX_QUEUE {
3326                        return Err(ShapeError::CapacityOverflow);
3327                    }
3328                    for (class, origin, dir, len) in descs {
3329                        let Some(rule) = find_edge_rule(class, cases) else {
3330                            continue;
3331                        };
3332                        if len <= 1e-9 {
3333                            continue;
3334                        }
3335                        let pos = scope.position + scope.rotation * origin;
3336                        // Local X runs along the edge (deterministic frame).
3337                        let rot =
3338                            (scope.rotation * Quat::from_rotation_arc(Vec3::X, dir)).normalize();
3339                        let child = Scope::new(pos, rot, Vec3::new(len, 0.0, 0.0));
3340                        child.validate()?;
3341                        let args = ev_args!(rule);
3342                        queue.push_back(WorkItem {
3343                            scope: child,
3344                            rule: rule.name.clone(),
3345                            args,
3346                            depth: depth + 1,
3347                            taper: 0.0,
3348                            face_profile_override: None,
3349                            material: material.clone(),
3350                            split_i,
3351                            split_n,
3352                            rng_state: fork!(),
3353                            label: label.clone(),
3354                        });
3355                    }
3356                    return Ok(());
3357                }
3358
3359                ShapeOp::Comp(CompTarget::Faces(cases)) => {
3360                    // face_descs always returns exactly 6 faces; guard before any push.
3361                    if queue.len() + 6 > MAX_QUEUE {
3362                        return Err(ShapeError::CapacityOverflow);
3363                    }
3364                    for (selector, offset_local, face_size, rot_delta) in face_descs(scope.size) {
3365                        let rule = match find_face_rule(selector, cases) {
3366                            Some(r) => r,
3367                            None => continue,
3368                        };
3369                        let face_pos = scope.position + scope.rotation * offset_local;
3370                        let face_rotation = scope.rotation * rot_delta;
3371                        let face_scope = Scope::new(face_pos, face_rotation, face_size);
3372                        face_scope.validate()?;
3373                        let args = ev_args!(&rule);
3374                        queue.push_back(WorkItem {
3375                            scope: face_scope,
3376                            rule: rule.name.clone(),
3377                            args,
3378                            depth: depth + 1,
3379                            taper: 0.0,
3380                            face_profile_override: None,
3381                            material: material.clone(),
3382                            split_i,
3383                            split_n,
3384                            rng_state: fork!(),
3385                            label: label.clone(),
3386                        });
3387                    }
3388                    return Ok(());
3389                }
3390
3391                // ── Terminal: mesh instance ───────────────────────────────
3392                ShapeOp::I(mesh_id) => {
3393                    if model.len() >= self.max_terminals {
3394                        return Err(ShapeError::CapacityOverflow);
3395                    }
3396                    let profile = face_profile
3397                        .take()
3398                        .unwrap_or_else(|| taper_to_profile(taper));
3399                    let mut terminal = Terminal::new_profiled(scope, mesh_id, profile, material);
3400                    terminal.label = label;
3401                    model.push(terminal);
3402                    return Ok(());
3403                }
3404
3405                // ── Delegate: named sub-rule ──────────────────────────────
3406                ShapeOp::Rule(call) => {
3407                    let args = ev_args!(call);
3408                    queue.push_back(WorkItem {
3409                        scope,
3410                        rule: call.name.clone(),
3411                        args,
3412                        depth: depth + 1,
3413                        taper,
3414                        face_profile_override: face_profile,
3415                        material,
3416                        split_i,
3417                        split_n,
3418                        rng_state: fork!(),
3419                        label: label.clone(),
3420                    });
3421                    return Ok(());
3422                }
3423            }
3424        }
3425
3426        // Ops exhausted without a terminal — scope is silently discarded
3427        // (matches CGA "delete this shape" semantics for empty successors).
3428        Ok(())
3429    }
3430}
3431
3432#[cfg(test)]
3433mod tests {
3434    use super::*;
3435    use crate::ops::{Axis, SplitSize, SplitSlot};
3436    use crate::scope::{Quat, Vec3};
3437
3438    fn slot(size: SplitSize, rule: &str) -> SplitSlot {
3439        SplitSlot {
3440            size,
3441            rule: rule.into(),
3442        }
3443    }
3444
3445    /// Wraps a quaternion into literal (w, x, y, z) rotation arguments.
3446    fn qexpr(q: Quat) -> [Expr; 4] {
3447        [
3448            Expr::lit(q.w),
3449            Expr::lit(q.x),
3450            Expr::lit(q.y),
3451            Expr::lit(q.z),
3452        ]
3453    }
3454
3455    /// Evaluates literal-only slot sizes for the resolver tests.
3456    fn resolved(slots: &[SplitSlot]) -> Vec<ResolvedSize> {
3457        slots
3458            .iter()
3459            .map(|s| match &s.size {
3460                SplitSize::Absolute(e) => ResolvedSize::Absolute(e.as_lit().unwrap()),
3461                SplitSize::Relative(e) => ResolvedSize::Relative(e.as_lit().unwrap()),
3462                SplitSize::Floating(e) => ResolvedSize::Floating(e.as_lit().unwrap()),
3463            })
3464            .collect()
3465    }
3466
3467    #[test]
3468    fn test_resolve_split_absolute() {
3469        let slots = vec![
3470            slot(SplitSize::abs(3.0), "A"),
3471            slot(SplitSize::abs(7.0), "B"),
3472        ];
3473        let sizes = resolve_split_sizes(&resolved(&slots), 10.0).unwrap();
3474        assert!((sizes[0] - 3.0).abs() < 1e-9);
3475        assert!((sizes[1] - 7.0).abs() < 1e-9);
3476    }
3477
3478    #[test]
3479    fn test_resolve_split_floating_equal() {
3480        let slots = vec![
3481            slot(SplitSize::float(1.0), "A"),
3482            slot(SplitSize::float(1.0), "B"),
3483        ];
3484        let sizes = resolve_split_sizes(&resolved(&slots), 10.0).unwrap();
3485        assert!((sizes[0] - 5.0).abs() < 1e-9);
3486        assert!((sizes[1] - 5.0).abs() < 1e-9);
3487    }
3488
3489    #[test]
3490    fn test_resolve_split_mixed() {
3491        let slots = vec![
3492            slot(SplitSize::abs(2.0), "Base"),
3493            slot(SplitSize::float(1.0), "A"),
3494            slot(SplitSize::float(1.0), "B"),
3495        ];
3496        let sizes = resolve_split_sizes(&resolved(&slots), 10.0).unwrap();
3497        assert!((sizes[0] - 2.0).abs() < 1e-9);
3498        assert!((sizes[1] - 4.0).abs() < 1e-9);
3499        assert!((sizes[2] - 4.0).abs() < 1e-9);
3500    }
3501
3502    #[test]
3503    fn test_resolve_split_overflow_rejected() {
3504        let slots = vec![
3505            slot(SplitSize::abs(6.0), "A"),
3506            slot(SplitSize::abs(6.0), "B"),
3507        ];
3508        assert!(matches!(
3509            resolve_split_sizes(&resolved(&slots), 10.0),
3510            Err(ShapeError::SplitOverflow(_))
3511        ));
3512    }
3513
3514    #[test]
3515    fn test_derive_extrude_then_terminal() {
3516        let mut interp = Interpreter::new();
3517        interp.add_rule(
3518            "Lot",
3519            vec![
3520                ShapeOp::Extrude(Expr::lit(10.0)),
3521                ShapeOp::I("Building".to_string()),
3522            ],
3523        );
3524        let scope = Scope::unit();
3525        let model = interp.derive(scope, "Lot").unwrap();
3526        assert_eq!(model.len(), 1);
3527        assert_eq!(model.terminals[0].mesh_id, "Building");
3528        assert!((model.terminals[0].scope.size.y - 10.0).abs() < 1e-9);
3529    }
3530
3531    #[test]
3532    fn test_derive_split_y_three_floors() {
3533        let mut interp = Interpreter::new();
3534        interp.add_rule(
3535            "Building",
3536            vec![ShapeOp::Split {
3537                axis: Axis::Y,
3538                entries: vec![
3539                    slot(SplitSize::abs(2.0), "Ground").into(),
3540                    slot(SplitSize::float(1.0), "Upper").into(),
3541                    slot(SplitSize::abs(1.5), "Roof").into(),
3542                ],
3543                snap: None,
3544            }],
3545        );
3546        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 10.0, 10.0));
3547        let model = interp.derive(scope, "Building").unwrap();
3548        assert_eq!(model.len(), 3);
3549        assert!((model.terminals[0].scope.size.y - 2.0).abs() < 1e-9);
3550        assert!((model.terminals[1].scope.size.y - 6.5).abs() < 1e-9);
3551        assert!((model.terminals[2].scope.size.y - 1.5).abs() < 1e-9);
3552    }
3553
3554    #[test]
3555    fn test_derive_depth_limit() {
3556        let mut interp = Interpreter::new();
3557        interp.add_rule("A", vec![ShapeOp::Rule("A".into())]);
3558        interp.max_depth = 5;
3559        let model = interp.derive(Scope::unit(), "A");
3560        assert!(matches!(model, Err(ShapeError::DepthLimitExceeded(_))));
3561    }
3562
3563    #[test]
3564    fn test_derive_comp_faces() {
3565        let mut interp = Interpreter::new();
3566        interp.add_rule(
3567            "Box",
3568            vec![ShapeOp::Comp(CompTarget::Faces(vec![
3569                crate::ops::CompFaceCase {
3570                    selector: FaceSelector::Top,
3571                    rule: "Roof".into(),
3572                },
3573                crate::ops::CompFaceCase {
3574                    selector: FaceSelector::Side,
3575                    rule: "Wall".into(),
3576                },
3577                crate::ops::CompFaceCase {
3578                    selector: FaceSelector::Bottom,
3579                    rule: "Base".into(),
3580                },
3581            ]))],
3582        );
3583        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(5.0, 3.0, 5.0));
3584        let model = interp.derive(scope, "Box").unwrap();
3585        assert_eq!(model.len(), 6);
3586    }
3587
3588    #[test]
3589    fn test_derive_repeat() {
3590        let mut interp = Interpreter::new();
3591        interp.add_rule(
3592            "Facade",
3593            vec![ShapeOp::Repeat {
3594                axis: Axis::X,
3595                tile_sizes: vec![Expr::lit(2.0)],
3596                rule: "Window".into(),
3597            }],
3598        );
3599        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 4.0, 0.0));
3600        let model = interp.derive(scope, "Facade").unwrap();
3601        // 10 / 2 = 5 tiles, each stretched to exactly 2.0m (no remainder here)
3602        assert_eq!(model.len(), 5);
3603    }
3604
3605    #[test]
3606    fn test_derive_mat_propagates() {
3607        let mut interp = Interpreter::new();
3608        interp.add_rule(
3609            "R",
3610            vec![
3611                ShapeOp::Mat(Material::new("Brick")),
3612                ShapeOp::I("Wall".to_string()),
3613            ],
3614        );
3615        let model = interp.derive(Scope::unit(), "R").unwrap();
3616        assert_eq!(model.terminals[0].material, Some(Material::new("Brick")));
3617    }
3618
3619    // ── Issue 1: empty-variants panic ─────────────────────────────────────────
3620
3621    #[test]
3622    fn test_empty_variants_discards_shape() {
3623        let mut interp = Interpreter::new();
3624        // add_weighted_rules with an empty vec must not panic; the scope is
3625        // silently discarded (consistent with CGA "delete shape" semantics).
3626        interp.add_weighted_rules("Empty", vec![]).unwrap();
3627        let model = interp.derive(Scope::unit(), "Empty").unwrap();
3628        assert_eq!(model.len(), 0);
3629    }
3630
3631    // ── Issue 1 (review #10): n_tiles INFINITY cast ───────────────────────────
3632
3633    #[test]
3634    fn test_repeat_tiny_tile_size_rejected() {
3635        // tile_size = f64::MIN_POSITIVE is finite and > 0, passes validation.
3636        // But total / f64::MIN_POSITIVE overflows to INFINITY, and
3637        // INFINITY as usize saturates to usize::MAX, causing overflow in the
3638        // queue length arithmetic. Must be caught as CapacityOverflow.
3639        let mut interp = Interpreter::new();
3640        interp.add_rule(
3641            "R",
3642            vec![ShapeOp::Repeat {
3643                axis: Axis::X,
3644                tile_sizes: vec![Expr::lit(f64::MIN_POSITIVE)],
3645                rule: "Tile".into(),
3646            }],
3647        );
3648        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(1.0, 1.0, 1.0));
3649        assert!(matches!(
3650            interp.derive(scope, "R"),
3651            Err(ShapeError::CapacityOverflow)
3652        ));
3653    }
3654
3655    // ── Issue 3 (review #10): Scale multiplication overflow ───────────────────
3656
3657    #[test]
3658    fn test_scale_multiply_overflow_to_infinity_rejected() {
3659        // Each Scale value is individually finite and positive, but scope.size *= v
3660        // can overflow to INFINITY. Must be caught after the multiplication.
3661        let mut interp = Interpreter::new();
3662        interp.add_rule(
3663            "R",
3664            vec![
3665                ShapeOp::Scale([Expr::lit(1e200), Expr::lit(1.0), Expr::lit(1.0)]),
3666                ShapeOp::Scale([Expr::lit(1e200), Expr::lit(1.0), Expr::lit(1.0)]), // 1e200*1e200=INFINITY
3667                ShapeOp::I("Mesh".to_string()),
3668            ],
3669        );
3670        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(1.0, 1.0, 1.0));
3671        assert!(matches!(
3672            interp.derive(scope, "R"),
3673            Err(ShapeError::InvalidNumericValue)
3674        ));
3675    }
3676
3677    #[test]
3678    fn test_split_absolute_sum_overflow_rejected() {
3679        // Absolute slot sizes whose sum overflows to INFINITY must be rejected.
3680        let slots = vec![
3681            slot(SplitSize::abs(f64::MAX), "A"),
3682            slot(SplitSize::abs(f64::MAX), "B"),
3683        ];
3684        assert!(matches!(
3685            resolve_split_sizes(&resolved(&slots), f64::MAX),
3686            Err(ShapeError::InvalidNumericValue)
3687        ));
3688    }
3689
3690    // ── Issue 3: queue capacity accounting ────────────────────────────────────
3691
3692    #[test]
3693    fn test_repeat_respects_combined_queue_limit() {
3694        // A Repeat whose tile count alone is fine (< MAX_QUEUE) but combined with
3695        // the existing queue would exceed MAX_QUEUE should be rejected.
3696        // We can't easily fill the queue to 99_999 in a unit test, so we use
3697        // the public max_depth / max_terminals to drive overflow indirectly.
3698        // Instead, verify the guard fires for a very large n_tiles (> MAX_QUEUE).
3699        let mut interp = Interpreter::new();
3700        // tile_size so small that n_tiles >> MAX_QUEUE (scope is 1e10, tile = 1e-1 → 1e11 tiles)
3701        interp.add_rule(
3702            "Big",
3703            vec![ShapeOp::Repeat {
3704                axis: Axis::X,
3705                tile_sizes: vec![Expr::lit(1e-1)],
3706                rule: "Tile".into(),
3707            }],
3708        );
3709        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(1e10, 1.0, 1.0));
3710        assert!(matches!(
3711            interp.derive(scope, "Big"),
3712            Err(ShapeError::CapacityOverflow)
3713        ));
3714    }
3715
3716    // ── Issue 4: negative scale via API ──────────────────────────────────────
3717
3718    #[test]
3719    fn test_api_negative_scale_rejected() {
3720        let mut interp = Interpreter::new();
3721        interp.add_rule(
3722            "R",
3723            vec![
3724                ShapeOp::Scale([Expr::lit(-1.0), Expr::lit(1.0), Expr::lit(1.0)]),
3725                ShapeOp::I("Mesh".to_string()),
3726            ],
3727        );
3728        assert!(matches!(
3729            interp.derive(Scope::unit(), "R"),
3730            Err(ShapeError::InvalidNumericValue)
3731        ));
3732    }
3733
3734    #[test]
3735    fn test_api_zero_scale_rejected() {
3736        let mut interp = Interpreter::new();
3737        interp.add_rule(
3738            "R",
3739            vec![
3740                ShapeOp::Scale([Expr::lit(0.0), Expr::lit(1.0), Expr::lit(1.0)]),
3741                ShapeOp::I("Mesh".to_string()),
3742            ],
3743        );
3744        assert!(matches!(
3745            interp.derive(Scope::unit(), "R"),
3746            Err(ShapeError::InvalidNumericValue)
3747        ));
3748    }
3749
3750    // ── Issue 1 (review #14): intermediate product overflow in floating split ──
3751
3752    #[test]
3753    fn test_split_floating_large_remaining_no_overflow() {
3754        // remaining ≈ 1e308, w = 2.0, float_weight_total = 3.0.
3755        // Old code: (1e308 * 2.0) / 3.0 = INFINITY / 3.0 = INFINITY.
3756        // Fixed:    1e308 * (2.0 / 3.0) = finite.
3757        let slots = vec![
3758            slot(SplitSize::float(2.0), "A"),
3759            slot(SplitSize::float(1.0), "B"),
3760        ];
3761        let sizes = resolve_split_sizes(&resolved(&slots), 1e308).unwrap();
3762        assert!(sizes[0].is_finite(), "size[0] overflowed to {}", sizes[0]);
3763        assert!(sizes[1].is_finite(), "size[1] overflowed to {}", sizes[1]);
3764        // Proportions must be 2/3 and 1/3.
3765        assert!((sizes[0] / sizes[1] - 2.0).abs() < 1e-6);
3766    }
3767
3768    // ── Issue 5: float_weight_total overflow ──────────────────────────────────
3769
3770    #[test]
3771    fn test_split_floating_weight_overflow_rejected() {
3772        // Two floating slots each with weight near f64::MAX; their sum overflows
3773        // to INFINITY in float_weight_total, which should be caught and rejected.
3774        let slots = vec![
3775            slot(SplitSize::float(f64::MAX), "A"),
3776            slot(SplitSize::float(f64::MAX), "B"),
3777        ];
3778        assert!(matches!(
3779            resolve_split_sizes(&resolved(&slots), 10.0),
3780            Err(ShapeError::InvalidNumericValue)
3781        ));
3782    }
3783
3784    #[test]
3785    fn test_stochastic_rule_deterministic_with_seed() {
3786        let mut interp = Interpreter::new();
3787        interp
3788            .add_weighted_rules(
3789                "Facade",
3790                vec![
3791                    (70.0, vec![ShapeOp::I("Brick".to_string())]),
3792                    (30.0, vec![ShapeOp::I("Glass".to_string())]),
3793                ],
3794            )
3795            .unwrap();
3796        interp.seed = 42;
3797        // Same seed → same result
3798        let m1 = interp.derive(Scope::unit(), "Facade").unwrap();
3799        let m2 = interp.derive(Scope::unit(), "Facade").unwrap();
3800        assert_eq!(m1.terminals[0].mesh_id, m2.terminals[0].mesh_id);
3801    }
3802
3803    #[test]
3804    fn test_face_comp_orientations() {
3805        // After Comp, each face scope should have local Z pointing along its outward normal.
3806        // We verify by checking the rotation: applying the face rotation to (0,0,1) should
3807        // give the expected world-space normal direction.
3808        let mut interp = Interpreter::new();
3809        interp.add_rule(
3810            "Box",
3811            vec![ShapeOp::Comp(CompTarget::Faces(vec![
3812                crate::ops::CompFaceCase {
3813                    selector: FaceSelector::All,
3814                    rule: "Face".into(),
3815                },
3816            ]))],
3817        );
3818        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 2.0));
3819        let model = interp.derive(scope, "Box").unwrap();
3820        assert_eq!(model.len(), 6);
3821
3822        // Collect the outward normals by rotating (0,0,1) with each face's rotation
3823        let normals: Vec<Vec3> = model
3824            .terminals
3825            .iter()
3826            .map(|t| t.scope.rotation * Vec3::Z)
3827            .collect();
3828
3829        // We expect exactly one terminal pointing in each of the 6 cardinal directions
3830        let expected = [
3831            Vec3::NEG_Y, // Bottom
3832            Vec3::Y,     // Top
3833            Vec3::NEG_Z, // Front
3834            Vec3::Z,     // Back
3835            Vec3::NEG_X, // Left
3836            Vec3::X,     // Right
3837        ];
3838        for exp in &expected {
3839            assert!(
3840                normals.iter().any(|n| (*n - *exp).length() < 1e-6),
3841                "missing normal {:?}, got {:?}",
3842                exp,
3843                normals
3844            );
3845        }
3846
3847        // face_descs order is deterministic: Bottom, Top, Front, Back, Left, Right.
3848        // Verify that the face origin positions lie on the correct parent faces.
3849        // scope: position=(0,0,0), size sx=4, sy=3, sz=2.
3850        let pos = |i: usize| model.terminals[i].scope.position;
3851        assert!(
3852            (pos(0) - Vec3::new(0.0, 0.0, 0.0)).length() < 1e-6,
3853            "Bottom pos"
3854        ); // at y=0
3855        assert!(
3856            (pos(1) - Vec3::new(0.0, 3.0, 2.0)).length() < 1e-6,
3857            "Top pos"
3858        ); // at y=sy, origin shifted to (0,sy,sz)
3859        assert!(
3860            (pos(2) - Vec3::new(4.0, 0.0, 0.0)).length() < 1e-6,
3861            "Front pos"
3862        ); // at z=0, origin shifted to (sx,0,0)
3863        assert!(
3864            (pos(3) - Vec3::new(0.0, 0.0, 2.0)).length() < 1e-6,
3865            "Back pos"
3866        ); // at z=sz
3867        assert!(
3868            (pos(4) - Vec3::new(0.0, 0.0, 0.0)).length() < 1e-6,
3869            "Left pos"
3870        ); // at x=0
3871        assert!(
3872            (pos(5) - Vec3::new(4.0, 0.0, 2.0)).length() < 1e-6,
3873            "Right pos"
3874        ); // at x=sx, origin shifted to (sx,0,sz)
3875    }
3876
3877    // ── Issue 4 (review #14): negative scope size rejected by validate() ────────
3878
3879    #[test]
3880    fn test_negative_scope_size_rejected() {
3881        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(-1.0, 1.0, 1.0));
3882        let interp = Interpreter::new();
3883        assert!(matches!(
3884            interp.derive(scope, "Anything"),
3885            Err(ShapeError::InvalidNumericValue)
3886        ));
3887    }
3888
3889    #[test]
3890    fn test_zero_scope_size_accepted() {
3891        // Y=0 is a valid 2D footprint; derive should succeed (rule unknown → implicit terminal).
3892        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 0.0, 10.0));
3893        let interp = Interpreter::new();
3894        let model = interp.derive(scope, "Footprint").unwrap();
3895        assert_eq!(model.len(), 1);
3896    }
3897
3898    // ── Issue 1 (review #11): unnormalized quaternion in root scope ───────────
3899
3900    #[test]
3901    fn test_unnormalized_root_quat_rejected() {
3902        // DQuat::from_xyzw(2,0,0,0) is finite but has length 2 — not a unit quat.
3903        let bad_q = Quat::from_xyzw(0.0, 0.0, 0.0, 2.0);
3904        let scope = Scope::new(Vec3::ZERO, bad_q, Vec3::ONE);
3905        let interp = Interpreter::new();
3906        assert!(matches!(
3907            interp.derive(scope, "Anything"),
3908            Err(ShapeError::InvalidNumericValue)
3909        ));
3910    }
3911
3912    #[test]
3913    fn test_degenerate_rotate_op_rejected() {
3914        // A zero quaternion (len_sq < 1e-12) cannot represent a rotation — must reject.
3915        let zero = [
3916            Expr::lit(0.0),
3917            Expr::lit(0.0),
3918            Expr::lit(0.0),
3919            Expr::lit(0.0),
3920        ];
3921        let mut interp = Interpreter::new();
3922        interp.add_rule(
3923            "R",
3924            vec![ShapeOp::Rotate(zero), ShapeOp::I("M".to_string())],
3925        );
3926        assert!(matches!(
3927            interp.derive(Scope::unit(), "R"),
3928            Err(ShapeError::InvalidNumericValue)
3929        ));
3930    }
3931
3932    #[test]
3933    fn test_scaled_rotate_op_normalized() {
3934        // A quaternion with magnitude 2 (e.g. IDENTITY * 2) is non-unit but valid;
3935        // it must be normalised to IDENTITY rather than rejected.
3936        let scaled_q = Quat::from_xyzw(0.0, 0.0, 0.0, 2.0); // IDENTITY * 2
3937        let mut interp = Interpreter::new();
3938        interp.add_rule(
3939            "R",
3940            vec![
3941                ShapeOp::Rotate(qexpr(scaled_q)),
3942                ShapeOp::I("M".to_string()),
3943            ],
3944        );
3945        // Should succeed; the terminal scope rotation should be IDENTITY.
3946        let model = interp.derive(Scope::unit(), "R").unwrap();
3947        assert_eq!(model.len(), 1);
3948        let r = model.terminals[0].scope.rotation;
3949        assert!(
3950            (r.length_squared() - 1.0).abs() < 1e-9,
3951            "rotation should be unit"
3952        );
3953    }
3954
3955    // ── Issue 2 (review #12): invalid weights in add_weighted_rules ──────────
3956
3957    #[test]
3958    fn test_nan_weight_rejected() {
3959        let mut interp = Interpreter::new();
3960        assert!(matches!(
3961            interp.add_weighted_rules("R", vec![(f64::NAN, vec![ShapeOp::I("M".to_string())])]),
3962            Err(ShapeError::InvalidNumericValue)
3963        ));
3964    }
3965
3966    #[test]
3967    fn test_infinite_weight_rejected() {
3968        let mut interp = Interpreter::new();
3969        assert!(matches!(
3970            interp.add_weighted_rules(
3971                "R",
3972                vec![(f64::INFINITY, vec![ShapeOp::I("M".to_string())])]
3973            ),
3974            Err(ShapeError::InvalidNumericValue)
3975        ));
3976    }
3977
3978    #[test]
3979    fn test_negative_weight_rejected() {
3980        let mut interp = Interpreter::new();
3981        assert!(matches!(
3982            interp.add_weighted_rules("R", vec![(-1.0, vec![ShapeOp::I("M".to_string())])]),
3983            Err(ShapeError::InvalidNumericValue)
3984        ));
3985    }
3986
3987    // ── Feature: Align ───────────────────────────────────────────────────────
3988
3989    #[test]
3990    fn test_align_y_to_world_up_when_rotated() {
3991        // Rotate 90° around Z (Y → -X), then Align(Y, Up) should restore Y = +Y.
3992        let mut interp = Interpreter::new();
3993        let ninety_z = Quat::from_axis_angle(Vec3::Z, std::f64::consts::FRAC_PI_2);
3994        interp.add_rule(
3995            "R",
3996            vec![
3997                ShapeOp::Rotate(qexpr(ninety_z)),
3998                ShapeOp::Align {
3999                    local_axis: Axis::Y,
4000                    target: Vec3::Y,
4001                },
4002                ShapeOp::I("M".to_string()),
4003            ],
4004        );
4005        let model = interp.derive(Scope::unit(), "R").unwrap();
4006        assert_eq!(model.len(), 1);
4007        let world_y = model.terminals[0].scope.rotation * Vec3::Y;
4008        assert!(
4009            (world_y - Vec3::Y).length() < 1e-6,
4010            "expected Y=(0,1,0), got {:?}",
4011            world_y
4012        );
4013    }
4014
4015    #[test]
4016    fn test_align_already_aligned_is_noop() {
4017        let mut interp = Interpreter::new();
4018        interp.add_rule(
4019            "R",
4020            vec![
4021                ShapeOp::Align {
4022                    local_axis: Axis::Y,
4023                    target: Vec3::Y,
4024                },
4025                ShapeOp::I("M".to_string()),
4026            ],
4027        );
4028        let model = interp.derive(Scope::unit(), "R").unwrap();
4029        let rot = model.terminals[0].scope.rotation;
4030        assert!((rot.length_squared() - 1.0).abs() < 1e-9);
4031        // Rotation should still be unit (identity-like for already-aligned)
4032        let world_y = rot * Vec3::Y;
4033        assert!((world_y - Vec3::Y).length() < 1e-6);
4034    }
4035
4036    #[test]
4037    fn test_align_zero_target_rejected() {
4038        let mut interp = Interpreter::new();
4039        interp.add_rule(
4040            "R",
4041            vec![
4042                ShapeOp::Align {
4043                    local_axis: Axis::Y,
4044                    target: Vec3::ZERO,
4045                },
4046                ShapeOp::I("M".to_string()),
4047            ],
4048        );
4049        assert!(matches!(
4050            interp.derive(Scope::unit(), "R"),
4051            Err(ShapeError::InvalidAlignTarget)
4052        ));
4053    }
4054
4055    // ── Feature: Offset ──────────────────────────────────────────────────────
4056
4057    #[test]
4058    fn test_offset_inset_produces_inside_and_border() {
4059        let mut interp = Interpreter::new();
4060        interp.add_rule(
4061            "R",
4062            vec![ShapeOp::Offset {
4063                distance: Expr::lit(-0.5),
4064                cases: vec![
4065                    crate::ops::OffsetCase {
4066                        selector: crate::ops::OffsetSelector::Inside,
4067                        rule: "Glass".into(),
4068                    },
4069                    crate::ops::OffsetCase {
4070                        selector: crate::ops::OffsetSelector::Border,
4071                        rule: "Frame".into(),
4072                    },
4073                ],
4074            }],
4075        );
4076        // 4×3 face scope (z=0)
4077        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 0.0));
4078        let model = interp.derive(scope, "R").unwrap();
4079        // 1 Inside + 4 Border strips = 5 terminals
4080        assert_eq!(model.len(), 5);
4081        // Inside scope: size = (3.0, 2.0, 0.0), positioned at (0.5, 0.5, 0.0)
4082        let inside = model
4083            .terminals
4084            .iter()
4085            .find(|t| t.mesh_id == "Glass")
4086            .unwrap();
4087        assert!((inside.scope.size.x - 3.0).abs() < 1e-9);
4088        assert!((inside.scope.size.y - 2.0).abs() < 1e-9);
4089        assert!((inside.scope.position - Vec3::new(0.5, 0.5, 0.0)).length() < 1e-9);
4090    }
4091
4092    #[test]
4093    fn test_offset_too_large_rejected() {
4094        let mut interp = Interpreter::new();
4095        interp.add_rule(
4096            "R",
4097            vec![ShapeOp::Offset {
4098                distance: Expr::lit(-2.0), // 2*2.0 = 4 > 3 (sy)
4099                cases: vec![crate::ops::OffsetCase {
4100                    selector: crate::ops::OffsetSelector::Inside,
4101                    rule: "A".into(),
4102                }],
4103            }],
4104        );
4105        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 0.0));
4106        assert!(matches!(
4107            interp.derive(scope, "R"),
4108            Err(ShapeError::OffsetTooLarge)
4109        ));
4110    }
4111
4112    #[test]
4113    fn test_offset_positive_distance_is_an_outset() {
4114        // Positive distances grow the face (0.3): the Inside region extends
4115        // 0.2 past every edge of a 4×3 face scope.
4116        let mut interp = Interpreter::new();
4117        interp.add_rule(
4118            "R",
4119            vec![ShapeOp::Offset {
4120                distance: Expr::lit(0.2),
4121                cases: vec![crate::ops::OffsetCase {
4122                    selector: crate::ops::OffsetSelector::Inside,
4123                    rule: "A".into(),
4124                }],
4125            }],
4126        );
4127        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 0.0));
4128        let model = interp.derive(scope, "R").unwrap();
4129        assert_eq!(model.len(), 1);
4130        let t = &model.terminals[0];
4131        assert!((t.scope.size.x - 4.4).abs() < 1e-9);
4132        assert!((t.scope.size.y - 3.4).abs() < 1e-9);
4133        assert!((t.scope.position.x - -0.2).abs() < 1e-9);
4134        assert!((t.scope.position.y - -0.2).abs() < 1e-9);
4135        // Zero distance stays rejected.
4136        let mut i2 = Interpreter::new();
4137        i2.add_rule(
4138            "R",
4139            vec![ShapeOp::Offset {
4140                distance: Expr::lit(0.0),
4141                cases: vec![crate::ops::OffsetCase {
4142                    selector: crate::ops::OffsetSelector::Inside,
4143                    rule: "A".into(),
4144                }],
4145            }],
4146        );
4147        assert!(matches!(
4148            i2.derive(Scope::unit(), "R"),
4149            Err(ShapeError::InvalidNumericValue)
4150        ));
4151    }
4152
4153    // ── Feature: Roof ────────────────────────────────────────────────────────
4154
4155    #[test]
4156    fn test_roof_shed_produces_one_slope() {
4157        let mut interp = Interpreter::new();
4158        interp.add_rule(
4159            "R",
4160            vec![ShapeOp::Roof {
4161                spec: RoofConfig::new(RoofType::Shed, 30.0).into(),
4162                cases: vec![crate::ops::RoofCase {
4163                    selector: RoofFaceSelector::Slope,
4164                    rule: "Tiles".into(),
4165                }],
4166            }],
4167        );
4168        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 5.0, 8.0));
4169        let model = interp.derive(scope, "R").unwrap();
4170        assert_eq!(model.len(), 1);
4171        assert_eq!(model.terminals[0].mesh_id, "Tiles");
4172        // Panel positioned at the base of the roof scope (local Y = 0.0)
4173        assert!((model.terminals[0].scope.position.y - 0.0).abs() < 1e-6);
4174    }
4175
4176    #[test]
4177    fn test_roof_gable_produces_four_panels() {
4178        let mut interp = Interpreter::new();
4179        interp.add_rule(
4180            "R",
4181            vec![ShapeOp::Roof {
4182                spec: RoofConfig::new(RoofType::Gable, 30.0).into(),
4183                cases: vec![
4184                    crate::ops::RoofCase {
4185                        selector: RoofFaceSelector::Slope,
4186                        rule: "Tiles".into(),
4187                    },
4188                    crate::ops::RoofCase {
4189                        selector: RoofFaceSelector::GableEnd,
4190                        rule: "Bricks".into(),
4191                    },
4192                ],
4193            }],
4194        );
4195        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 5.0, 8.0));
4196        let model = interp.derive(scope, "R").unwrap();
4197        // 2 slope + 2 gable-end panels
4198        assert_eq!(model.len(), 4);
4199        let tiles: Vec<_> = model
4200            .terminals
4201            .iter()
4202            .filter(|t| t.mesh_id == "Tiles")
4203            .collect();
4204        let bricks: Vec<_> = model
4205            .terminals
4206            .iter()
4207            .filter(|t| t.mesh_id == "Bricks")
4208            .collect();
4209        assert_eq!(tiles.len(), 2);
4210        assert_eq!(bricks.len(), 2);
4211    }
4212
4213    #[test]
4214    fn test_roof_hip_produces_four_slopes() {
4215        let mut interp = Interpreter::new();
4216        interp.add_rule(
4217            "R",
4218            vec![ShapeOp::Roof {
4219                spec: crate::ops::RoofSpec::from({
4220                    let mut c = RoofConfig::new(RoofType::Hip, 45.0);
4221                    c.overhang = 0.3;
4222                    c
4223                }),
4224                cases: vec![crate::ops::RoofCase {
4225                    selector: RoofFaceSelector::Slope,
4226                    rule: "Tiles".into(),
4227                }],
4228            }],
4229        );
4230        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 4.0, 8.0));
4231        let model = interp.derive(scope, "R").unwrap();
4232        assert_eq!(model.len(), 4);
4233    }
4234
4235    #[test]
4236    fn test_roof_pyramid_produces_four_tapered_slopes() {
4237        let mut interp = Interpreter::new();
4238        interp.add_rule(
4239            "R",
4240            vec![ShapeOp::Roof {
4241                spec: RoofConfig::new(RoofType::Pyramid, 40.0).into(),
4242                cases: vec![crate::ops::RoofCase {
4243                    selector: RoofFaceSelector::Slope,
4244                    rule: "Tiles".into(),
4245                }],
4246            }],
4247        );
4248        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(6.0, 3.0, 6.0));
4249        let model = interp.derive(scope, "R").unwrap();
4250        assert_eq!(model.len(), 4);
4251        // All pyramid panels carry Triangle face profile
4252        for t in &model.terminals {
4253            assert!(
4254                matches!(t.face_profile, FaceProfile::Triangle { peak_offset } if (peak_offset - 0.5).abs() < 1e-9),
4255                "expected Triangle{{peak_offset=0.5}}, got {:?}",
4256                t.face_profile
4257            );
4258        }
4259    }
4260
4261    #[test]
4262    fn test_roof_slope_normals_outward() {
4263        // All four Hip slopes must have Local Z (= scope.rotation * Z) pointing
4264        // AWAY from the building:
4265        //   front  → (0,  cos α, −sin α)   back  → (0, cos α, +sin α)
4266        //   left   → (−sin α, cos α,  0)   right → (+sin α, cos α,  0)
4267        let alpha: f64 = 30_f64.to_radians();
4268        let cos_a = alpha.cos();
4269        let sin_a = alpha.sin();
4270        let mut interp = Interpreter::new();
4271        interp.add_rule(
4272            "R",
4273            vec![ShapeOp::Roof {
4274                spec: RoofConfig::new(RoofType::Hip, 30.0).into(),
4275                cases: vec![crate::ops::RoofCase {
4276                    selector: RoofFaceSelector::Slope,
4277                    rule: "S".into(),
4278                }],
4279            }],
4280        );
4281        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 4.0, 8.0));
4282        let model = interp.derive(scope, "R").unwrap();
4283        assert_eq!(model.len(), 4);
4284        let normals: Vec<Vec3> = model
4285            .terminals
4286            .iter()
4287            .map(|t| t.scope.rotation * Vec3::Z)
4288            .collect();
4289        let expected = [
4290            Vec3::new(0.0, cos_a, -sin_a), // front: up & forward
4291            Vec3::new(0.0, cos_a, sin_a),  // back:  up & backward
4292            Vec3::new(-sin_a, cos_a, 0.0), // left:  up & left
4293            Vec3::new(sin_a, cos_a, 0.0),  // right: up & right
4294        ];
4295        for exp in &expected {
4296            assert!(
4297                normals.iter().any(|n| (*n - *exp).length() < 1e-6),
4298                "missing outward normal {:?}; got {:?}",
4299                exp,
4300                normals
4301            );
4302        }
4303        // All normals must have a positive Y component (point upward).
4304        for n in &normals {
4305            assert!(n.y > 0.0, "normal pointing downward: {:?}", n);
4306        }
4307    }
4308
4309    #[test]
4310    fn test_align_antiparallel_fallback_no_nan() {
4311        // When the local axis is exactly anti-parallel to the target, the fallback
4312        // 180° rotation must produce a unit quaternion, not NaN.
4313        // Rotate scope so local Y = −Y (anti-parallel to world Up), then Align(Y, Up).
4314        let flip_y = Quat::from_axis_angle(Vec3::Z, PI);
4315        let mut interp = Interpreter::new();
4316        interp.add_rule(
4317            "R",
4318            vec![
4319                ShapeOp::Rotate(qexpr(flip_y)),
4320                ShapeOp::Align {
4321                    local_axis: Axis::Y,
4322                    target: Vec3::Y,
4323                },
4324                ShapeOp::I("M".to_string()),
4325            ],
4326        );
4327        let model = interp.derive(Scope::unit(), "R").unwrap();
4328        let world_y = model.terminals[0].scope.rotation * Vec3::Y;
4329        assert!(
4330            (world_y - Vec3::Y).length() < 1e-6,
4331            "anti-parallel Align should point Y to world up, got {:?}",
4332            world_y
4333        );
4334        // Quaternion must remain unit.
4335        let r = model.terminals[0].scope.rotation;
4336        assert!(
4337            (r.length_squared() - 1.0).abs() < 1e-9,
4338            "rotation not unit: length_sq={}",
4339            r.length_squared()
4340        );
4341    }
4342
4343    #[test]
4344    fn test_roof_invalid_angle_rejected() {
4345        let mut interp = Interpreter::new();
4346        interp.add_rule(
4347            "R",
4348            vec![ShapeOp::Roof {
4349                spec: RoofConfig::new(RoofType::Shed, 0.0).into(),
4350                cases: vec![],
4351            }],
4352        );
4353        assert!(matches!(
4354            interp.derive(Scope::unit(), "R"),
4355            Err(ShapeError::InvalidRoofAngle(_))
4356        ));
4357    }
4358}