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            let top_w = (eave_w - 2.0 * clip_run).max(0.0);
1309
1310            let slope_len = (depth / 2.0 + o) / cos_a;
1311
1312            let slope_profile = if top_w > 1e-9 {
1313                FaceProfile::Trapezoid {
1314                    top_width: top_w / eave_w,
1315                    offset_x: clip_run / eave_w,
1316                }
1317            } else {
1318                FaceProfile::Triangle { peak_offset: 0.5 }
1319            };
1320
1321            let true_h = (depth / 2.0) * tan_a;
1322            let wall_h = (true_h - clip_run * tan_a).max(0.0);
1323            let wall_profile = if clip_run > 1e-9 {
1324                FaceProfile::Trapezoid {
1325                    top_width: (2.0 * clip_run) / depth,
1326                    offset_x: (depth / 2.0 - clip_run) / depth,
1327                }
1328            } else {
1329                FaceProfile::Triangle { peak_offset: 0.5 }
1330            };
1331
1332            let hip_base_w = 2.0 * clip_run + 2.0 * o;
1333            let hip_slope_len = (clip_run + o) / cos_a;
1334            let hip_profile = FaceProfile::Triangle { peak_offset: 0.5 };
1335
1336            if orient_z {
1337                let left_hip_origin = Vec3::new(-o, wall_h - o * tan_a, sz / 2.0 - clip_run - o);
1338                let right_hip_origin =
1339                    Vec3::new(sx + o, wall_h - o * tan_a, sz / 2.0 + clip_run + o);
1340                vec![
1341                    (
1342                        Vec3::new(sx + o, y_anchor, -o),
1343                        Vec3::new(eave_w, slope_len, 0.0),
1344                        front_rot,
1345                        RoofFaceSelector::Slope,
1346                        slope_profile.clone(),
1347                    ),
1348                    (
1349                        Vec3::new(-o, y_anchor, sz + o),
1350                        Vec3::new(eave_w, slope_len, 0.0),
1351                        back_rot,
1352                        RoofFaceSelector::Slope,
1353                        slope_profile,
1354                    ),
1355                    (
1356                        Vec3::new(0.0, 0.0, 0.0),
1357                        Vec3::new(sz, wall_h, 0.0),
1358                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
1359                        RoofFaceSelector::GableEnd,
1360                        wall_profile.clone(),
1361                    ),
1362                    (
1363                        Vec3::new(sx, 0.0, sz),
1364                        Vec3::new(sz, wall_h, 0.0),
1365                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
1366                        RoofFaceSelector::GableEnd,
1367                        wall_profile,
1368                    ),
1369                    (
1370                        left_hip_origin,
1371                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1372                        left_rot,
1373                        RoofFaceSelector::HipEnd,
1374                        hip_profile.clone(),
1375                    ),
1376                    (
1377                        right_hip_origin,
1378                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1379                        right_rot,
1380                        RoofFaceSelector::HipEnd,
1381                        hip_profile,
1382                    ),
1383                ]
1384            } else {
1385                let front_hip_origin = Vec3::new(sx / 2.0 + clip_run + o, wall_h - o * tan_a, -o);
1386                let back_hip_origin =
1387                    Vec3::new(sx / 2.0 - clip_run - o, wall_h - o * tan_a, sz + o);
1388                vec![
1389                    (
1390                        Vec3::new(-o, y_anchor, -o),
1391                        Vec3::new(eave_w, slope_len, 0.0),
1392                        left_rot,
1393                        RoofFaceSelector::Slope,
1394                        slope_profile.clone(),
1395                    ),
1396                    (
1397                        Vec3::new(sx + o, y_anchor, sz + o),
1398                        Vec3::new(eave_w, slope_len, 0.0),
1399                        right_rot,
1400                        RoofFaceSelector::Slope,
1401                        slope_profile,
1402                    ),
1403                    (
1404                        Vec3::new(sx, 0.0, 0.0),
1405                        Vec3::new(sx, wall_h, 0.0),
1406                        Quat::from_axis_angle(Vec3::Y, PI),
1407                        RoofFaceSelector::GableEnd,
1408                        wall_profile.clone(),
1409                    ),
1410                    (
1411                        Vec3::new(0.0, 0.0, sz),
1412                        Vec3::new(sx, wall_h, 0.0),
1413                        Quat::IDENTITY,
1414                        RoofFaceSelector::GableEnd,
1415                        wall_profile,
1416                    ),
1417                    (
1418                        front_hip_origin,
1419                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1420                        front_rot,
1421                        RoofFaceSelector::HipEnd,
1422                        hip_profile.clone(),
1423                    ),
1424                    (
1425                        back_hip_origin,
1426                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1427                        back_rot,
1428                        RoofFaceSelector::HipEnd,
1429                        hip_profile,
1430                    ),
1431                ]
1432            }
1433        }
1434
1435        // ── DutchGable ────────────────────────────────────────────────────────
1436        // Hip roof with a small gable rising from the ridge centre.
1437        // `tier_height` controls the fraction of the horizontal run used for the lower Hip portion.
1438        RoofType::DutchGable => {
1439            let tier = config.tier_height_or(0.7).clamp(0.01, 0.99);
1440            let orient_z = ridge_x;
1441            let (width, depth) = if orient_z { (sx, sz) } else { (sz, sx) };
1442
1443            let max_run = width.min(depth) / 2.0 + o;
1444            let break_run = (tier * max_run).clamp(o, max_run - 1e-3);
1445
1446            if !break_run.is_finite() {
1447                return Err(ShapeError::InvalidNumericValue);
1448            }
1449
1450            let y_break = y_anchor + break_run * tan_a;
1451            let eave_w = width + 2.0 * o;
1452            let eave_d = depth + 2.0 * o;
1453
1454            let top_w = (eave_w - 2.0 * break_run).max(0.0);
1455            let top_d = (eave_d - 2.0 * break_run).max(0.0);
1456
1457            let lower_slope_len = break_run / cos_a;
1458
1459            let fb_profile = if top_w > 1e-9 {
1460                FaceProfile::Trapezoid {
1461                    top_width: top_w / eave_w,
1462                    offset_x: break_run / eave_w,
1463                }
1464            } else {
1465                FaceProfile::Triangle { peak_offset: 0.5 }
1466            };
1467
1468            let lr_profile = if top_d > 1e-9 {
1469                FaceProfile::Trapezoid {
1470                    top_width: top_d / eave_d,
1471                    offset_x: break_run / eave_d,
1472                }
1473            } else {
1474                FaceProfile::Triangle { peak_offset: 0.5 }
1475            };
1476
1477            let upper_run = (depth / 2.0 + o) - break_run;
1478            let upper_slope_len = upper_run / cos_a;
1479            let upper_h = upper_run * tan_a;
1480
1481            if orient_z {
1482                vec![
1483                    // Lower Hip front/back
1484                    (
1485                        Vec3::new(sx + o, y_anchor, -o),
1486                        Vec3::new(eave_w, lower_slope_len, 0.0),
1487                        front_rot,
1488                        RoofFaceSelector::Slope,
1489                        fb_profile.clone(),
1490                    ),
1491                    (
1492                        Vec3::new(-o, y_anchor, sz + o),
1493                        Vec3::new(eave_w, lower_slope_len, 0.0),
1494                        back_rot,
1495                        RoofFaceSelector::Slope,
1496                        fb_profile,
1497                    ),
1498                    // Lower Hip left/right
1499                    (
1500                        Vec3::new(-o, y_anchor, -o),
1501                        Vec3::new(eave_d, lower_slope_len, 0.0),
1502                        left_rot,
1503                        RoofFaceSelector::Slope,
1504                        lr_profile.clone(),
1505                    ),
1506                    (
1507                        Vec3::new(sx + o, y_anchor, sz + o),
1508                        Vec3::new(eave_d, lower_slope_len, 0.0),
1509                        right_rot,
1510                        RoofFaceSelector::Slope,
1511                        lr_profile,
1512                    ),
1513                    // Upper Gable front/back
1514                    (
1515                        Vec3::new(sx + o - break_run, y_break, -o + break_run),
1516                        Vec3::new(top_w, upper_slope_len, 0.0),
1517                        front_rot,
1518                        RoofFaceSelector::Slope,
1519                        FaceProfile::Rectangle,
1520                    ),
1521                    (
1522                        Vec3::new(-o + break_run, y_break, sz + o - break_run),
1523                        Vec3::new(top_w, upper_slope_len, 0.0),
1524                        back_rot,
1525                        RoofFaceSelector::Slope,
1526                        FaceProfile::Rectangle,
1527                    ),
1528                    // Small gable ends (Left/Right)
1529                    (
1530                        Vec3::new(-o + break_run, y_break, -o + break_run),
1531                        Vec3::new(top_d, upper_h, 0.0),
1532                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
1533                        RoofFaceSelector::GableEnd,
1534                        FaceProfile::Triangle { peak_offset: 0.5 },
1535                    ),
1536                    (
1537                        Vec3::new(sx + o - break_run, y_break, sz + o - break_run),
1538                        Vec3::new(top_d, upper_h, 0.0),
1539                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
1540                        RoofFaceSelector::GableEnd,
1541                        FaceProfile::Triangle { peak_offset: 0.5 },
1542                    ),
1543                ]
1544            } else {
1545                vec![
1546                    // Lower Hip left/right (which are the main slopes now)
1547                    (
1548                        Vec3::new(-o, y_anchor, -o),
1549                        Vec3::new(eave_w, lower_slope_len, 0.0),
1550                        left_rot,
1551                        RoofFaceSelector::Slope,
1552                        fb_profile.clone(),
1553                    ),
1554                    (
1555                        Vec3::new(sx + o, y_anchor, sz + o),
1556                        Vec3::new(eave_w, lower_slope_len, 0.0),
1557                        right_rot,
1558                        RoofFaceSelector::Slope,
1559                        fb_profile,
1560                    ),
1561                    // Lower Hip front/back (which are the gable ends now)
1562                    (
1563                        Vec3::new(sx + o, y_anchor, -o),
1564                        Vec3::new(eave_d, lower_slope_len, 0.0),
1565                        front_rot,
1566                        RoofFaceSelector::Slope,
1567                        lr_profile.clone(),
1568                    ),
1569                    (
1570                        Vec3::new(-o, y_anchor, sz + o),
1571                        Vec3::new(eave_d, lower_slope_len, 0.0),
1572                        back_rot,
1573                        RoofFaceSelector::Slope,
1574                        lr_profile,
1575                    ),
1576                    // Upper Gable left/right
1577                    (
1578                        Vec3::new(-o + break_run, y_break, -o + break_run),
1579                        Vec3::new(top_w, upper_slope_len, 0.0),
1580                        left_rot,
1581                        RoofFaceSelector::Slope,
1582                        FaceProfile::Rectangle,
1583                    ),
1584                    (
1585                        Vec3::new(sx + o - break_run, y_break, sz + o - break_run),
1586                        Vec3::new(top_w, upper_slope_len, 0.0),
1587                        right_rot,
1588                        RoofFaceSelector::Slope,
1589                        FaceProfile::Rectangle,
1590                    ),
1591                    // Small gable ends (Front/Back)
1592                    (
1593                        Vec3::new(sx + o - break_run, y_break, -o + break_run),
1594                        Vec3::new(top_d, upper_h, 0.0),
1595                        Quat::from_axis_angle(Vec3::Y, PI),
1596                        RoofFaceSelector::GableEnd,
1597                        FaceProfile::Triangle { peak_offset: 0.5 },
1598                    ),
1599                    (
1600                        Vec3::new(-o + break_run, y_break, sz + o - break_run),
1601                        Vec3::new(top_d, upper_h, 0.0),
1602                        Quat::IDENTITY,
1603                        RoofFaceSelector::GableEnd,
1604                        FaceProfile::Triangle { peak_offset: 0.5 },
1605                    ),
1606                ]
1607            }
1608        }
1609    };
1610
1611    // Append fascia bands hanging below each perimeter eave when fascia_depth > 0.
1612    // A fascia is generated for slope panels whose lower edge sits at the perimeter
1613    // (local Y ≈ y_anchor) and whose outward normal has a horizontal component.
1614    if config.fascia_depth.is_finite() && config.fascia_depth > 0.0 {
1615        let fascia_depth = config.fascia_depth;
1616        let mut fascia_panels: Vec<Panel> = Vec::new();
1617        for (local_off, face_size, rot_delta, selector, _profile) in &panels {
1618            let eave_bearing = matches!(
1619                selector,
1620                RoofFaceSelector::Slope
1621                    | RoofFaceSelector::LowerSlope
1622                    | RoofFaceSelector::OuterSlope
1623            );
1624            if !eave_bearing {
1625                continue;
1626            }
1627            if (local_off.y - y_anchor).abs() > 1e-6 {
1628                continue;
1629            }
1630            let Some(wall_rot) = slope_to_wall_rot(*rot_delta) else {
1631                continue;
1632            };
1633            fascia_panels.push((
1634                Vec3::new(local_off.x, local_off.y - fascia_depth, local_off.z),
1635                Vec3::new(face_size.x, fascia_depth, 0.0),
1636                wall_rot,
1637                RoofFaceSelector::Fascia,
1638                FaceProfile::Rectangle,
1639            ));
1640        }
1641        panels.extend(fascia_panels);
1642    }
1643
1644    if queue.len() + panels.len() > MAX_QUEUE {
1645        return Err(ShapeError::CapacityOverflow);
1646    }
1647    let mut child_ordinal: u64 = 0;
1648    for (local_off, face_size, rot_delta, selector, profile) in panels {
1649        let Some(rule) = find_roof_rule(selector, cases) else {
1650            continue;
1651        };
1652        // Skip degenerate panels (zero-area).
1653        if face_size.x < 1e-9 || face_size.y < 1e-9 {
1654            continue;
1655        }
1656        let face_pos = scope.position + scope.rotation * local_off;
1657        let face_rot = (scope.rotation * rot_delta).normalize();
1658        let face_scope = Scope::new(face_pos, face_rot, face_size);
1659        face_scope.validate()?;
1660        if model.len() + queue.len() >= max_terminals {
1661            return Err(ShapeError::CapacityOverflow);
1662        }
1663        queue.push_back(WorkItem {
1664            scope: face_scope,
1665            rule: rule.name.clone(),
1666            args: rule.args.clone(),
1667            depth: depth + 1,
1668            taper: 0.0,
1669            face_profile_override: Some(profile),
1670            material: material.clone(),
1671            split_i,
1672            split_n,
1673            rng_state: fork_state(rng_state, child_ordinal),
1674            label: label.clone(),
1675        });
1676        child_ordinal += 1;
1677    }
1678
1679    Ok(())
1680}
1681
1682// ── Stochastic selection ──────────────────────────────────────────────────────
1683
1684/// Selects a variant: weighted rules draw from the shape's stream; guarded
1685/// rules evaluate `when` conditions top-down in the shape's context and take
1686/// the first true guard (or the trailing `else`). A guarded rule with no
1687/// matching guard and no `else` selects nothing — the shape vanishes, the
1688/// same semantics as an empty successor.
1689#[allow(clippy::too_many_arguments)]
1690fn select_variant<'a>(
1691    variants: &'a [RuleVariant],
1692    scope: &Scope,
1693    split_i: usize,
1694    split_n: usize,
1695    depth: usize,
1696    params: &[(String, f64)],
1697    globals: &HashMap<String, f64>,
1698    rng: &mut Pcg64,
1699) -> Result<&'a [ShapeOp], ShapeError> {
1700    if variants.is_empty() {
1701        return Ok(&[]);
1702    }
1703    let guarded = variants
1704        .iter()
1705        .any(|v| matches!(v.selector, VariantSelector::When(_) | VariantSelector::Else));
1706    if guarded {
1707        for v in variants {
1708            match &v.selector {
1709                VariantSelector::When(cond) => {
1710                    let hit =
1711                        eval_expr(cond, scope, split_i, split_n, depth, params, globals, rng)?;
1712                    if hit != 0.0 {
1713                        return Ok(&v.ops);
1714                    }
1715                }
1716                VariantSelector::Else => return Ok(&v.ops),
1717                VariantSelector::Weight(_) => {
1718                    return Err(ShapeError::ParseError(
1719                        "rule mixes weighted and guarded variants".to_string(),
1720                    ));
1721                }
1722            }
1723        }
1724        return Ok(&[]);
1725    }
1726    if variants.len() == 1 {
1727        return Ok(&variants[0].ops);
1728    }
1729    let total: f64 = variants
1730        .iter()
1731        .map(|v| match v.selector {
1732            VariantSelector::Weight(w) => w,
1733            _ => 0.0,
1734        })
1735        .sum();
1736    use rand::Rng;
1737    let r: f64 = rng.random::<f64>() * total;
1738    let mut acc = 0.0;
1739    for v in variants {
1740        if let VariantSelector::Weight(w) = v.selector {
1741            acc += w;
1742            if r < acc {
1743                return Ok(&v.ops);
1744            }
1745        }
1746    }
1747    Ok(&variants.last().unwrap().ops)
1748}
1749
1750// ── Interpreter ───────────────────────────────────────────────────────────────
1751
1752/// The CGA Shape Grammar derivation engine.
1753///
1754/// Rules are registered by name, then `derive` is called with a root scope and
1755/// root rule name. The engine expands rules breadth-first until every branch
1756/// terminates with an `I(mesh)` terminal.
1757///
1758/// Stochastic rules with multiple weighted variants use the engine's `seed` for
1759/// reproducible randomness — the same seed always yields the same building.
1760/// A registered rule: declared parameter names plus its weighted variants.
1761#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1762pub struct RuleDef {
1763    /// Parameter names bound to call-argument values at invocation. Empty
1764    /// for parameterless rules.
1765    pub params: Vec<String>,
1766    pub variants: Vec<RuleVariant>,
1767}
1768
1769pub struct Interpreter {
1770    rules: HashMap<String, RuleDef>,
1771    /// Host-set attribute overrides (`set_attr`) — the strongest external
1772    /// knob, applied over declarations and styles.
1773    attrs: HashMap<String, f64>,
1774    /// `const Name = value` declarations (not externally overridable).
1775    consts: HashMap<String, f64>,
1776    /// `attr Name = value` declaration defaults (overridable by style and
1777    /// host, in that order).
1778    attr_defaults: HashMap<String, f64>,
1779    /// Named attr-override sets (`style` declarations), flattened over
1780    /// their `extends` base at registration time.
1781    styles: HashMap<String, Vec<(String, f64)>>,
1782    /// The style selected via `set_style`, applied at derivation.
1783    active_style: Option<String>,
1784    /// Hard cap on rule-derivation recursion depth. Defaults to `MAX_DEPTH`
1785    /// (64). Exceeding it returns `ShapeError::DepthLimitExceeded`.
1786    pub max_depth: usize,
1787    /// Hard cap on the number of terminals a single derivation may emit.
1788    /// Defaults to `MAX_TERMINALS` (100 000). Exceeding it returns
1789    /// `ShapeError::CapacityOverflow`.
1790    pub max_terminals: usize,
1791    /// Seed for stochastic rule selection. Each call to [`Interpreter::derive`]
1792    /// constructs a fresh `Pcg64` from this seed, so re-running with the same
1793    /// `seed` produces a bit-identical [`ShapeModel`]. Default `0`.
1794    pub seed: u64,
1795}
1796
1797impl Default for Interpreter {
1798    fn default() -> Self {
1799        Self::new()
1800    }
1801}
1802
1803impl Interpreter {
1804    pub fn new() -> Self {
1805        Self {
1806            rules: HashMap::new(),
1807            attrs: HashMap::new(),
1808            consts: HashMap::new(),
1809            attr_defaults: HashMap::new(),
1810            styles: HashMap::new(),
1811            active_style: None,
1812            max_depth: MAX_DEPTH,
1813            max_terminals: MAX_TERMINALS,
1814            seed: 0,
1815        }
1816    }
1817
1818    /// Registers one parsed grammar statement — rule, `attr`, `const`, or
1819    /// `style`. The one-stop text loading path:
1820    ///
1821    /// ```
1822    /// use symbios_shape::Interpreter;
1823    /// use symbios_shape::grammar::parse_statement;
1824    /// let mut interp = Interpreter::new();
1825    /// for line in ["attr Floors = 3", r#"Lot --> Extrude(Floors * 3.2) I("Mass")"#] {
1826    ///     interp.add_statement(parse_statement(line).unwrap()).unwrap();
1827    /// }
1828    /// ```
1829    pub fn add_statement(&mut self, stmt: crate::grammar::Statement) -> Result<(), ShapeError> {
1830        use crate::grammar::Statement;
1831        match stmt {
1832            Statement::Rule(rule) => self.add_grammar_rule(rule),
1833            Statement::Attr { name, value } => {
1834                self.attr_defaults.insert(name, value);
1835                Ok(())
1836            }
1837            Statement::Const { name, value } => {
1838                self.consts.insert(name, value);
1839                Ok(())
1840            }
1841            Statement::Style {
1842                name,
1843                extends,
1844                overrides,
1845            } => {
1846                let mut flat = match extends {
1847                    Some(base) => self.styles.get(&base).cloned().ok_or_else(|| {
1848                        ShapeError::ParseError(format!(
1849                            "style `{name}` extends unknown style `{base}`"
1850                        ))
1851                    })?,
1852                    None => Vec::new(),
1853                };
1854                flat.extend(overrides);
1855                self.styles.insert(name, flat);
1856                Ok(())
1857            }
1858        }
1859    }
1860
1861    /// Selects a declared style; its attr overrides apply at derivation
1862    /// (below host `set_attr` overrides, above `attr` defaults).
1863    pub fn set_style(&mut self, name: impl Into<String>) -> Result<(), ShapeError> {
1864        let name = name.into();
1865        if !self.styles.contains_key(&name) {
1866            return Err(ShapeError::ParseError(format!("unknown style `{name}`")));
1867        }
1868        self.active_style = Some(name);
1869        Ok(())
1870    }
1871
1872    /// Clears any active style.
1873    pub fn clear_style(&mut self) {
1874        self.active_style = None;
1875    }
1876
1877    /// The effective expression-visible name table for a derivation:
1878    /// consts, then `attr` defaults, then the active style's overrides,
1879    /// then host `set_attr` values — later layers win.
1880    fn effective_globals(&self) -> HashMap<String, f64> {
1881        let mut g = self.consts.clone();
1882        for (k, v) in &self.attr_defaults {
1883            g.insert(k.clone(), *v);
1884        }
1885        if let Some(style) = &self.active_style
1886            && let Some(overrides) = self.styles.get(style)
1887        {
1888            for (k, v) in overrides {
1889                g.insert(k.clone(), *v);
1890            }
1891        }
1892        for (k, v) in &self.attrs {
1893            g.insert(k.clone(), *v);
1894        }
1895        g
1896    }
1897
1898    /// Returns a reference to the full rule table (rule name → definition).
1899    pub fn rules(&self) -> &HashMap<String, RuleDef> {
1900        &self.rules
1901    }
1902
1903    /// Sets a named attribute readable from grammar expressions. The host's
1904    /// override channel: call before `derive` to parameterize a grammar from
1905    /// outside (per-lot floor counts, prosperity knobs, …).
1906    pub fn set_attr(&mut self, name: impl Into<String>, value: f64) {
1907        self.attrs.insert(name.into(), value);
1908    }
1909
1910    /// Returns the attribute table (host overrides + grammar declarations).
1911    pub fn attrs(&self) -> &HashMap<String, f64> {
1912        &self.attrs
1913    }
1914
1915    /// Directly inserts a pre-built variant list for `name`, bypassing weight validation.
1916    /// The rule's declared parameters are preserved when it already exists.
1917    ///
1918    /// Intended for restoring snapshots produced by
1919    /// [`crate::genetics::ShapeGenotype::to_interpreter`].
1920    pub fn set_variants(&mut self, name: impl Into<String>, variants: Vec<RuleVariant>) {
1921        let name = name.into();
1922        let params = self
1923            .rules
1924            .get(&name)
1925            .map(|def| def.params.clone())
1926            .unwrap_or_default();
1927        self.rules.insert(name, RuleDef { params, variants });
1928    }
1929
1930    /// Registers a deterministic production rule.
1931    pub fn add_rule(&mut self, name: impl Into<String>, ops: Vec<ShapeOp>) {
1932        self.rules.insert(
1933            name.into(),
1934            RuleDef {
1935                params: Vec::new(),
1936                variants: vec![RuleVariant::weighted(1.0, ops)],
1937            },
1938        );
1939    }
1940
1941    /// Registers a rule parsed from grammar text — parameters, weighted or
1942    /// guarded variants and all. The one-stop registration path for
1943    /// text-driven hosts.
1944    pub fn add_grammar_rule(
1945        &mut self,
1946        rule: crate::grammar::GrammarRule,
1947    ) -> Result<(), ShapeError> {
1948        if rule.name == "NIL" {
1949            return Err(ShapeError::ParseError(
1950                "`NIL` is reserved and cannot be defined as a rule".to_string(),
1951            ));
1952        }
1953        self.add_rule_variants(rule.name, rule.params, rule.variants)
1954    }
1955
1956    /// Registers a rule from pre-built variants, validating selector shape:
1957    /// all-weighted, or `when(..)` guards with at most one trailing `else`.
1958    pub fn add_rule_variants(
1959        &mut self,
1960        name: impl Into<String>,
1961        params: Vec<String>,
1962        variants: Vec<RuleVariant>,
1963    ) -> Result<(), ShapeError> {
1964        if params.len() > crate::grammar::MAX_RULE_ARGS {
1965            return Err(ShapeError::ParseError(format!(
1966                "rule declares {} parameters (max {})",
1967                params.len(),
1968                crate::grammar::MAX_RULE_ARGS
1969            )));
1970        }
1971        for (i, p) in params.iter().enumerate() {
1972            if params[..i].contains(p) {
1973                return Err(ShapeError::ParseError(format!(
1974                    "duplicate rule parameter name: {p}"
1975                )));
1976            }
1977        }
1978        let n_weight = variants
1979            .iter()
1980            .filter(|v| matches!(v.selector, VariantSelector::Weight(_)))
1981            .count();
1982        let n_guard = variants.len() - n_weight;
1983        if n_weight > 0 && n_guard > 0 {
1984            return Err(ShapeError::ParseError(
1985                "rule mixes weighted and guarded variants".to_string(),
1986            ));
1987        }
1988        for (i, v) in variants.iter().enumerate() {
1989            match &v.selector {
1990                VariantSelector::Weight(w) => {
1991                    if !w.is_finite() || *w < 0.0 {
1992                        return Err(ShapeError::InvalidNumericValue);
1993                    }
1994                }
1995                VariantSelector::Else => {
1996                    if i + 1 != variants.len() {
1997                        return Err(ShapeError::ParseError(
1998                            "`else:` must be the last variant".to_string(),
1999                        ));
2000                    }
2001                }
2002                VariantSelector::When(_) => {}
2003            }
2004        }
2005        self.rules.insert(name.into(), RuleDef { params, variants });
2006        Ok(())
2007    }
2008
2009    /// Registers a rule with declared parameter names and weighted variants —
2010    /// the full-fidelity registration path for parameterized rules.
2011    ///
2012    /// Returns `Err(InvalidNumericValue)` for bad weights and
2013    /// `Err(ParseError)` for duplicate or over-long parameter lists.
2014    pub fn add_rule_def(
2015        &mut self,
2016        name: impl Into<String>,
2017        params: Vec<String>,
2018        variants: Vec<(f64, Vec<ShapeOp>)>,
2019    ) -> Result<(), ShapeError> {
2020        if params.len() > crate::grammar::MAX_RULE_ARGS {
2021            return Err(ShapeError::ParseError(format!(
2022                "rule declares {} parameters (max {})",
2023                params.len(),
2024                crate::grammar::MAX_RULE_ARGS
2025            )));
2026        }
2027        for (i, p) in params.iter().enumerate() {
2028            if params[..i].contains(p) {
2029                return Err(ShapeError::ParseError(format!(
2030                    "duplicate rule parameter name: {p}"
2031                )));
2032            }
2033        }
2034        for (weight, _) in &variants {
2035            if !weight.is_finite() || *weight < 0.0 {
2036                return Err(ShapeError::InvalidNumericValue);
2037            }
2038        }
2039        let variants = variants
2040            .into_iter()
2041            .map(|(weight, ops)| RuleVariant::weighted(weight, ops))
2042            .collect();
2043        self.rules.insert(name.into(), RuleDef { params, variants });
2044        Ok(())
2045    }
2046
2047    /// Registers a stochastic rule with multiple weighted alternatives.
2048    ///
2049    /// `variants` is a list of `(relative_weight, ops)` pairs. Weights need not
2050    /// sum to 1.0 — they are normalised internally during selection.
2051    ///
2052    /// Returns `Err(InvalidNumericValue)` if any weight is non-finite or negative.
2053    pub fn add_weighted_rules(
2054        &mut self,
2055        name: impl Into<String>,
2056        variants: Vec<(f64, Vec<ShapeOp>)>,
2057    ) -> Result<(), ShapeError> {
2058        for (weight, _) in &variants {
2059            if !weight.is_finite() || *weight < 0.0 {
2060                return Err(ShapeError::InvalidNumericValue);
2061            }
2062        }
2063        let wvs = variants
2064            .into_iter()
2065            .map(|(weight, ops)| RuleVariant::weighted(weight, ops))
2066            .collect();
2067        self.rules.insert(
2068            name.into(),
2069            RuleDef {
2070                params: Vec::new(),
2071                variants: wvs,
2072            },
2073        );
2074        Ok(())
2075    }
2076
2077    /// Returns true if a rule with `name` is registered.
2078    pub fn has_rule(&self, name: &str) -> bool {
2079        self.rules.contains_key(name)
2080    }
2081
2082    /// Derives the shape model starting from `root_scope` and `root_rule`.
2083    ///
2084    /// Uses a breadth-first work queue to expand rules until all branches
2085    /// terminate via `I(mesh_id)` or an unknown rule name (implicit terminal).
2086    /// A fresh RNG seeded from `self.seed` is created for each call, making
2087    /// derivations reproducible for the same `seed` value.
2088    pub fn derive(
2089        &self,
2090        root_scope: Scope,
2091        root_rule: impl Into<String>,
2092    ) -> Result<ShapeModel, ShapeError> {
2093        root_scope.validate()?;
2094
2095        let mut model = ShapeModel::new();
2096        let mut queue: VecDeque<WorkItem> = VecDeque::new();
2097        let globals = self.effective_globals();
2098
2099        queue.push_back(WorkItem {
2100            scope: root_scope,
2101            rule: root_rule.into(),
2102            args: Vec::new(),
2103            depth: 0,
2104            taper: 0.0,
2105            face_profile_override: None,
2106            material: None,
2107            split_i: 0,
2108            split_n: 1,
2109            rng_state: splitmix64(self.seed),
2110            label: None,
2111        });
2112
2113        while let Some(item) = queue.pop_front() {
2114            if queue.len() > MAX_QUEUE {
2115                return Err(ShapeError::CapacityOverflow);
2116            }
2117            if item.depth > self.max_depth {
2118                return Err(ShapeError::DepthLimitExceeded(self.max_depth));
2119            }
2120
2121            // `NIL` is the reserved vanish rule: the shape is dropped without
2122            // emitting a terminal. Usable in any successor position,
2123            // including split slots (`0.4: NIL`) and stochastic variants.
2124            if item.rule == "NIL" {
2125                continue;
2126            }
2127
2128            // One stream per shape: variant choice and every rand() draw in
2129            // this rule body pull from it in body order.
2130            let mut item_rng = Pcg64::seed_from_u64(item.rng_state);
2131            let def = match self.rules.get(&item.rule) {
2132                Some(def) => def,
2133                None => {
2134                    // Unknown rule → implicit I(rule_name) terminal.
2135                    if model.len() >= self.max_terminals {
2136                        return Err(ShapeError::CapacityOverflow);
2137                    }
2138                    let profile = item
2139                        .face_profile_override
2140                        .unwrap_or_else(|| taper_to_profile(item.taper));
2141                    let mut terminal =
2142                        Terminal::new_profiled(item.scope, &item.rule, profile, item.material);
2143                    terminal.label = item.label;
2144                    model.push(terminal);
2145                    continue;
2146                }
2147            };
2148
2149            // Bind call-argument values to the callee's declared parameters.
2150            if def.params.len() != item.args.len() {
2151                return Err(ShapeError::ArityMismatch(format!(
2152                    "rule `{}` expects {} argument(s), got {}",
2153                    item.rule,
2154                    def.params.len(),
2155                    item.args.len()
2156                )));
2157            }
2158            let params: Vec<(String, f64)> = def
2159                .params
2160                .iter()
2161                .cloned()
2162                .zip(item.args.iter().copied())
2163                .collect();
2164
2165            let ops = select_variant(
2166                &def.variants,
2167                &item.scope,
2168                item.split_i,
2169                item.split_n,
2170                item.depth,
2171                &params,
2172                &globals,
2173                &mut item_rng,
2174            )?;
2175
2176            self.apply_ops(
2177                item.scope,
2178                item.taper,
2179                item.face_profile_override,
2180                item.material,
2181                item.label.clone(),
2182                ops,
2183                item.depth,
2184                &params,
2185                item.split_i,
2186                item.split_n,
2187                item.rng_state,
2188                &globals,
2189                &mut item_rng,
2190                &mut queue,
2191                &mut model,
2192            )?;
2193        }
2194
2195        Ok(model)
2196    }
2197
2198    /// Processes the ops sequence for a single rule invocation.
2199    ///
2200    /// Transformation ops (`Extrude`, `Scale`, etc.) mutate `scope` in place.
2201    /// The first branching op (`Split`, `Comp`, `Repeat`) or terminal op
2202    /// (`I`, `Rule`) ends the sequence by pushing new work items.
2203    #[allow(clippy::too_many_arguments)]
2204    // The child-ordinal counter's final increment before an arm returns is
2205    // intentionally unread (see the fork! macro).
2206    #[allow(unused_assignments)]
2207    fn apply_ops(
2208        &self,
2209        initial_scope: Scope,
2210        initial_taper: f64,
2211        initial_face_profile: Option<FaceProfile>,
2212        initial_material: Option<Material>,
2213        initial_label: Option<String>,
2214        ops: &[ShapeOp],
2215        depth: usize,
2216        params: &[(String, f64)],
2217        split_i: usize,
2218        split_n: usize,
2219        rng_state: u64,
2220        globals: &HashMap<String, f64>,
2221        rng: &mut Pcg64,
2222        queue: &mut VecDeque<WorkItem>,
2223        model: &mut ShapeModel,
2224    ) -> Result<(), ShapeError> {
2225        let mut scope = initial_scope;
2226        // Rule-entry scope, kept for `Center` (recentre within entry bounds).
2227        let entry_scope = initial_scope;
2228        let mut taper = initial_taper;
2229        let mut face_profile = initial_face_profile;
2230        let mut material = initial_material;
2231        let mut label = initial_label;
2232
2233        // Evaluates one argument expression in the current shape's context.
2234        // A macro (not a closure) so it can borrow `scope` and `rng` afresh
2235        // at each use site while both are also mutated between uses.
2236        macro_rules! ev {
2237            ($e:expr) => {
2238                eval_expr($e, &scope, split_i, split_n, depth, params, globals, rng)?
2239            };
2240        }
2241        // Sequential child seed states: at most one branching op runs per
2242        // body, so ordinals are stable push-order indices within it.
2243        let mut child_ordinal: u64 = 0;
2244        macro_rules! fork {
2245            () => {{
2246                let s = fork_state(rng_state, child_ordinal);
2247                child_ordinal += 1;
2248                s
2249            }};
2250        }
2251        // Evaluates a `RuleCall`'s argument list into values.
2252        macro_rules! ev_args {
2253            ($call:expr) => {{
2254                let mut argv = Vec::with_capacity($call.args.len());
2255                for a in &$call.args {
2256                    argv.push(ev!(a));
2257                }
2258                argv
2259            }};
2260        }
2261
2262        for op in ops {
2263            match op {
2264                // ── Transformations ───────────────────────────────────────
2265                ShapeOp::Extrude(h) => {
2266                    let h = ev!(h);
2267                    if h <= 0.0 {
2268                        return Err(ShapeError::InvalidNumericValue);
2269                    }
2270                    // Face scopes from Comp(Faces) have size.z == 0 (the outward-normal
2271                    // direction) and a non-zero size.y (the face height).  Extruding a
2272                    // face scope should push it outward along the normal (local Z), not
2273                    // collapse the height by overwriting size.y.
2274                    // Footprint scopes have size.y == 0; Extrude gives them their height.
2275                    if scope.size.z.abs() < 1e-9 && scope.size.y.abs() > 1e-9 {
2276                        scope.size.z = h;
2277                    } else {
2278                        scope.size.y = h;
2279                    }
2280                }
2281
2282                ShapeOp::Taper(amount) => {
2283                    taper = ev!(amount).clamp(0.0, 1.0);
2284                }
2285
2286                ShapeOp::Rotate([w, x, y, z]) => {
2287                    let (w, x, y, z) = (ev!(w), ev!(x), ev!(y), ev!(z));
2288                    // Reject degenerate (near-zero) quaternions that cannot represent a
2289                    // rotation. Normalize non-unit inputs so that glam's fast-path
2290                    // `rotation * vec` (which assumes a unit quaternion) is correct.
2291                    let len_sq = w * w + x * x + y * y + z * z;
2292                    if !len_sq.is_finite() || len_sq < 1e-12 {
2293                        return Err(ShapeError::InvalidNumericValue);
2294                    }
2295                    let q = Quat::from_xyzw(x, y, z, w);
2296                    scope.rotation = (scope.rotation * q.normalize()).normalize();
2297                }
2298
2299                ShapeOp::Translate([x, y, z]) => {
2300                    let v = Vec3::new(ev!(x), ev!(y), ev!(z));
2301                    scope.position += scope.rotation * v;
2302                    // Two individually-finite values can add to INFINITY
2303                    // (e.g. f64::MAX/2 + f64::MAX/2). Catch the overflow here.
2304                    if !scope.position.is_finite() {
2305                        return Err(ShapeError::InvalidNumericValue);
2306                    }
2307                }
2308
2309                ShapeOp::Scale([x, y, z]) => {
2310                    let v = Vec3::new(ev!(x), ev!(y), ev!(z));
2311                    if v.x <= 0.0 || v.y <= 0.0 || v.z <= 0.0 {
2312                        return Err(ShapeError::InvalidNumericValue);
2313                    }
2314                    scope.size *= v;
2315                    // Two individually-finite scale values can multiply to INFINITY
2316                    // (e.g. 1e200 * 1e200). Catch the overflow here before it
2317                    // propagates into Split/Repeat and causes NaN via ∞ − ∞.
2318                    if !scope.size.is_finite() {
2319                        return Err(ShapeError::InvalidNumericValue);
2320                    }
2321                }
2322
2323                ShapeOp::Mat(mat) => {
2324                    material = Some(mat.clone());
2325                }
2326
2327                ShapeOp::Label(name) => {
2328                    label = Some(name.clone());
2329                }
2330
2331                ShapeOp::Size([x, y, z]) => {
2332                    let v = Vec3::new(ev!(x), ev!(y), ev!(z));
2333                    if v.x < 0.0 || v.y < 0.0 || v.z < 0.0 {
2334                        return Err(ShapeError::InvalidNumericValue);
2335                    }
2336                    scope.size = v;
2337                }
2338
2339                ShapeOp::Center { x, y, z } => {
2340                    // Current local offset relative to the rule-entry frame.
2341                    let mut local = entry_scope
2342                        .rotation
2343                        .inverse()
2344                        .mul_vec3(scope.position - entry_scope.position);
2345                    if *x {
2346                        local.x = (entry_scope.size.x - scope.size.x) / 2.0;
2347                    }
2348                    if *y {
2349                        local.y = (entry_scope.size.y - scope.size.y) / 2.0;
2350                    }
2351                    if *z {
2352                        local.z = (entry_scope.size.z - scope.size.z) / 2.0;
2353                    }
2354                    scope.position = entry_scope.position + entry_scope.rotation * local;
2355                    if !scope.position.is_finite() {
2356                        return Err(ShapeError::InvalidNumericValue);
2357                    }
2358                }
2359
2360                ShapeOp::Mirror => {
2361                    if let Some(profile) = &mut face_profile {
2362                        *profile = mirror_profile(profile);
2363                    }
2364                }
2365
2366                // ── Branching: ShapeL / ShapeU footprint carving ─────────
2367                ShapeOp::ShapeL { front, side, cases } => {
2368                    let d = ev!(front);
2369                    let w = ev!(side);
2370                    let (sx, sz) = (scope.size.x, scope.size.z);
2371                    if d <= 0.0 || w <= 0.0 || d >= sz - 1e-9 || w >= sx - 1e-9 {
2372                        return Err(ShapeError::InvalidNumericValue);
2373                    }
2374                    // Front bar (full width), side leg (remaining depth),
2375                    // remainder rectangle.
2376                    let parts: [(CarveSelector, Vec3, Vec3); 3] = [
2377                        (
2378                            CarveSelector::Shape,
2379                            Vec3::ZERO,
2380                            Vec3::new(sx, scope.size.y, d),
2381                        ),
2382                        (
2383                            CarveSelector::Shape,
2384                            Vec3::new(0.0, 0.0, d),
2385                            Vec3::new(w, scope.size.y, sz - d),
2386                        ),
2387                        (
2388                            CarveSelector::Remainder,
2389                            Vec3::new(w, 0.0, d),
2390                            Vec3::new(sx - w, scope.size.y, sz - d),
2391                        ),
2392                    ];
2393                    if queue.len() + parts.len() > MAX_QUEUE {
2394                        return Err(ShapeError::CapacityOverflow);
2395                    }
2396                    for (selector, local_off, size) in parts {
2397                        let Some(case) = find_carve_rule(selector, cases) else {
2398                            continue;
2399                        };
2400                        let pos = scope.position + scope.rotation * local_off;
2401                        let child = Scope::new(pos, scope.rotation, size);
2402                        child.validate()?;
2403                        let args = ev_args!(&case.rule);
2404                        queue.push_back(WorkItem {
2405                            scope: child,
2406                            rule: case.rule.name.clone(),
2407                            args,
2408                            depth: depth + 1,
2409                            taper: 0.0,
2410                            face_profile_override: None,
2411                            material: material.clone(),
2412                            split_i,
2413                            split_n,
2414                            rng_state: fork!(),
2415                            label: label.clone(),
2416                        });
2417                    }
2418                    return Ok(());
2419                }
2420
2421                ShapeOp::ShapeU {
2422                    front,
2423                    left,
2424                    right,
2425                    cases,
2426                } => {
2427                    let d = ev!(front);
2428                    let wl = ev!(left);
2429                    let wr = ev!(right);
2430                    let (sx, sz) = (scope.size.x, scope.size.z);
2431                    if d <= 0.0 || wl <= 0.0 || wr <= 0.0 || d >= sz - 1e-9 || wl + wr >= sx - 1e-9
2432                    {
2433                        return Err(ShapeError::InvalidNumericValue);
2434                    }
2435                    let parts: [(CarveSelector, Vec3, Vec3); 4] = [
2436                        (
2437                            CarveSelector::Shape,
2438                            Vec3::ZERO,
2439                            Vec3::new(sx, scope.size.y, d),
2440                        ),
2441                        (
2442                            CarveSelector::Shape,
2443                            Vec3::new(0.0, 0.0, d),
2444                            Vec3::new(wl, scope.size.y, sz - d),
2445                        ),
2446                        (
2447                            CarveSelector::Shape,
2448                            Vec3::new(sx - wr, 0.0, d),
2449                            Vec3::new(wr, scope.size.y, sz - d),
2450                        ),
2451                        (
2452                            CarveSelector::Remainder,
2453                            Vec3::new(wl, 0.0, d),
2454                            Vec3::new(sx - wl - wr, scope.size.y, sz - d),
2455                        ),
2456                    ];
2457                    if queue.len() + parts.len() > MAX_QUEUE {
2458                        return Err(ShapeError::CapacityOverflow);
2459                    }
2460                    for (selector, local_off, size) in parts {
2461                        let Some(case) = find_carve_rule(selector, cases) else {
2462                            continue;
2463                        };
2464                        let pos = scope.position + scope.rotation * local_off;
2465                        let child = Scope::new(pos, scope.rotation, size);
2466                        child.validate()?;
2467                        let args = ev_args!(&case.rule);
2468                        queue.push_back(WorkItem {
2469                            scope: child,
2470                            rule: case.rule.name.clone(),
2471                            args,
2472                            depth: depth + 1,
2473                            taper: 0.0,
2474                            face_profile_override: None,
2475                            material: material.clone(),
2476                            split_i,
2477                            split_n,
2478                            rng_state: fork!(),
2479                            label: label.clone(),
2480                        });
2481                    }
2482                    return Ok(());
2483                }
2484
2485                ShapeOp::Polygon(verts) => {
2486                    if verts.len() < 3 {
2487                        return Err(ShapeError::InvalidNumericValue);
2488                    }
2489                    for v in verts {
2490                        if !v.is_finite() {
2491                            return Err(ShapeError::InvalidNumericValue);
2492                        }
2493                    }
2494                    face_profile = Some(FaceProfile::Polygon(verts.clone()));
2495                }
2496
2497                // ── Snap-plane registration ──────────────────────────────
2498                ShapeOp::RegSnap(label) => {
2499                    register_scope_snap_planes(&scope, label, &mut model.snap_planes);
2500                }
2501
2502                // ── Conditional: IfClear / IfOccluded ────────────────────
2503                // Both consult the model-so-far. IfClear pushes the rule only
2504                // when no already-emitted terminal overlaps the current scope;
2505                // IfOccluded is the inverse. The current rule body terminates
2506                // either way (these are branching ops).
2507                ShapeOp::IfClear { rule, label: filt } => {
2508                    let occluded = model
2509                        .terminals
2510                        .iter()
2511                        .filter(|t| filt.is_none() || t.label.as_deref() == filt.as_deref())
2512                        .any(|t| scope_obb_overlaps_terminal(&scope, t));
2513                    if !occluded {
2514                        if queue.len() >= MAX_QUEUE {
2515                            return Err(ShapeError::CapacityOverflow);
2516                        }
2517                        let args = ev_args!(rule);
2518                        queue.push_back(WorkItem {
2519                            scope,
2520                            rule: rule.name.clone(),
2521                            args,
2522                            depth: depth + 1,
2523                            taper,
2524                            face_profile_override: face_profile.take(),
2525                            material: material.clone(),
2526                            split_i,
2527                            split_n,
2528                            rng_state: fork!(),
2529                            label: label.clone(),
2530                        });
2531                    }
2532                    return Ok(());
2533                }
2534                ShapeOp::IfOccluded { rule, label: filt } => {
2535                    let occluded = model
2536                        .terminals
2537                        .iter()
2538                        .filter(|t| filt.is_none() || t.label.as_deref() == filt.as_deref())
2539                        .any(|t| scope_obb_overlaps_terminal(&scope, t));
2540                    if occluded {
2541                        if queue.len() >= MAX_QUEUE {
2542                            return Err(ShapeError::CapacityOverflow);
2543                        }
2544                        let args = ev_args!(rule);
2545                        queue.push_back(WorkItem {
2546                            scope,
2547                            rule: rule.name.clone(),
2548                            args,
2549                            depth: depth + 1,
2550                            taper,
2551                            face_profile_override: face_profile.take(),
2552                            material: material.clone(),
2553                            split_i,
2554                            split_n,
2555                            rng_state: fork!(),
2556                            label: label.clone(),
2557                        });
2558                    }
2559                    return Ok(());
2560                }
2561
2562                ShapeOp::IfInside { rule, label: filt } => {
2563                    let inside = model
2564                        .terminals
2565                        .iter()
2566                        .filter(|t| filt.is_none() || t.label.as_deref() == filt.as_deref())
2567                        .any(|t| crate::query::scope_inside_terminal(&scope, t));
2568                    if inside {
2569                        if queue.len() >= MAX_QUEUE {
2570                            return Err(ShapeError::CapacityOverflow);
2571                        }
2572                        let args = ev_args!(rule);
2573                        queue.push_back(WorkItem {
2574                            scope,
2575                            rule: rule.name.clone(),
2576                            args,
2577                            depth: depth + 1,
2578                            taper,
2579                            face_profile_override: face_profile.take(),
2580                            material: material.clone(),
2581                            split_i,
2582                            split_n,
2583                            rng_state: fork!(),
2584                            label: label.clone(),
2585                        });
2586                    }
2587                    return Ok(());
2588                }
2589
2590                ShapeOp::IfTouches { rule, label: filt } => {
2591                    let touches = model
2592                        .terminals
2593                        .iter()
2594                        .filter(|t| filt.is_none() || t.label.as_deref() == filt.as_deref())
2595                        .any(|t| crate::query::scope_touches_terminal(&scope, t));
2596                    if touches {
2597                        if queue.len() >= MAX_QUEUE {
2598                            return Err(ShapeError::CapacityOverflow);
2599                        }
2600                        let args = ev_args!(rule);
2601                        queue.push_back(WorkItem {
2602                            scope,
2603                            rule: rule.name.clone(),
2604                            args,
2605                            depth: depth + 1,
2606                            taper,
2607                            face_profile_override: face_profile.take(),
2608                            material: material.clone(),
2609                            split_i,
2610                            split_n,
2611                            rng_state: fork!(),
2612                            label: label.clone(),
2613                        });
2614                    }
2615                    return Ok(());
2616                }
2617
2618                // ── Coordination: Pick ───────────────────────────────────
2619                //
2620                // The winning index is a pure function of (seed, key): every
2621                // Pick with this key, anywhere in the derivation, agrees.
2622                ShapeOp::Pick { key, choices } => {
2623                    if choices.is_empty() {
2624                        return Ok(());
2625                    }
2626                    let mut pick_rng = Pcg64::seed_from_u64(splitmix64(self.seed ^ fnv1a(key)));
2627                    use rand::Rng as _;
2628                    let total: f64 = choices.iter().map(|(w, _)| w).sum();
2629                    let chosen = if total <= 0.0 {
2630                        &choices[0].1
2631                    } else {
2632                        let r = pick_rng.random::<f64>() * total;
2633                        let mut acc = 0.0;
2634                        let mut sel = &choices[choices.len() - 1].1;
2635                        for (w, call) in choices {
2636                            acc += w;
2637                            if r < acc {
2638                                sel = call;
2639                                break;
2640                            }
2641                        }
2642                        sel
2643                    };
2644                    if queue.len() >= MAX_QUEUE {
2645                        return Err(ShapeError::CapacityOverflow);
2646                    }
2647                    let args = ev_args!(chosen);
2648                    queue.push_back(WorkItem {
2649                        scope,
2650                        rule: chosen.name.clone(),
2651                        args,
2652                        depth: depth + 1,
2653                        taper,
2654                        face_profile_override: face_profile.take(),
2655                        material: material.clone(),
2656                        split_i,
2657                        split_n,
2658                        rng_state: fork!(),
2659                        label: label.clone(),
2660                    });
2661                    return Ok(());
2662                }
2663
2664                // ── Branching: Scatter ───────────────────────────────────
2665                ShapeOp::Scatter {
2666                    volume,
2667                    count,
2668                    rule,
2669                } => {
2670                    let n_f = ev!(count);
2671                    if n_f < 0.0 {
2672                        return Err(ShapeError::InvalidNumericValue);
2673                    }
2674                    let n = (n_f.floor() as usize).min(MAX_SCATTER_POINTS);
2675                    if queue.len() + n > MAX_QUEUE {
2676                        return Err(ShapeError::CapacityOverflow);
2677                    }
2678                    use rand::Rng as _;
2679                    for i in 0..n {
2680                        let px = rng.random::<f64>() * scope.size.x;
2681                        let py = if *volume {
2682                            rng.random::<f64>() * scope.size.y
2683                        } else {
2684                            scope.size.y
2685                        };
2686                        let pz = rng.random::<f64>() * scope.size.z;
2687                        let pos = scope.position + scope.rotation * Vec3::new(px, py, pz);
2688                        let child = Scope::new(pos, scope.rotation, Vec3::ZERO);
2689                        child.validate()?;
2690                        let args = ev_args!(rule);
2691                        queue.push_back(WorkItem {
2692                            scope: child,
2693                            rule: rule.name.clone(),
2694                            args,
2695                            depth: depth + 1,
2696                            taper: 0.0,
2697                            face_profile_override: None,
2698                            material: material.clone(),
2699                            split_i: i,
2700                            split_n: n,
2701                            rng_state: fork!(),
2702                            label: label.clone(),
2703                        });
2704                    }
2705                    return Ok(());
2706                }
2707
2708                // ── Transform: Align ─────────────────────────────────────
2709                ShapeOp::Align { local_axis, target } => {
2710                    // length_squared() can overflow to INFINITY for large-but-finite
2711                    // vectors (e.g. (1e200, 1e200, 1e200)); INFINITY > 1e-12 so the
2712                    // naive check would pass, then normalize() divides by INFINITY
2713                    // yielding a zero vector and a silent no-op rotation.
2714                    let len_sq = target.length_squared();
2715                    if !target.is_finite() || !len_sq.is_finite() || len_sq < 1e-12 {
2716                        return Err(ShapeError::InvalidAlignTarget);
2717                    }
2718                    let target_norm = target.normalize();
2719                    let current = scope.rotation * axis_vec(*local_axis);
2720                    // from_rotation_arc gives the shortest-arc rotation; it degenerates
2721                    // when vectors are antiparallel — handle that with a fallback 180°.
2722                    let dot = current.dot(target_norm);
2723                    let q = if (dot + 1.0).abs() < 1e-9 {
2724                        // Choose the cardinal axis least parallel to `current` (smallest
2725                        // absolute component) to form the cross product. This avoids the
2726                        // discontinuous snap caused by a hard threshold: the selection
2727                        // only changes when two components are exactly equal, which is
2728                        // rare and well-conditioned.
2729                        let perp = if current.x.abs() <= current.y.abs()
2730                            && current.x.abs() <= current.z.abs()
2731                        {
2732                            current.cross(Vec3::X).normalize()
2733                        } else if current.y.abs() <= current.z.abs() {
2734                            current.cross(Vec3::Y).normalize()
2735                        } else {
2736                            current.cross(Vec3::Z).normalize()
2737                        };
2738                        Quat::from_axis_angle(perp, PI)
2739                    } else {
2740                        Quat::from_rotation_arc(current, target_norm)
2741                    };
2742                    scope.rotation = (q * scope.rotation).normalize();
2743                }
2744
2745                // ── Branching: Offset ─────────────────────────────────────
2746                ShapeOp::Offset { distance, cases } => {
2747                    let distance = ev!(distance);
2748                    // Negative distance = inset; positive = outset (0.3):
2749                    // the Inside region grows past the face and the Border
2750                    // ring lies outside the original boundary.
2751                    if distance == 0.0 {
2752                        return Err(ShapeError::InvalidNumericValue);
2753                    }
2754                    let inset = -distance;
2755                    let sx = scope.size.x;
2756                    let sy = scope.size.y;
2757                    let inside_w = sx - 2.0 * inset;
2758                    let inside_h = sy - 2.0 * inset;
2759                    // Explicit NaN guard: if sx/sy are non-finite (e.g. leaked
2760                    // Infinity from an upstream op), the subtraction produces NaN,
2761                    // which compares false for `< 0.0` and would bypass the check.
2762                    if !inside_w.is_finite()
2763                        || !inside_h.is_finite()
2764                        || inside_w < 0.0
2765                        || inside_h < 0.0
2766                    {
2767                        return Err(ShapeError::OffsetTooLarge);
2768                    }
2769                    if let Some(rule) = find_offset_rule(OffsetSelector::Inside, cases) {
2770                        if queue.len() >= MAX_QUEUE {
2771                            return Err(ShapeError::CapacityOverflow);
2772                        }
2773                        let pos = scope.position + scope.rotation * Vec3::new(inset, inset, 0.0);
2774                        let child_scope =
2775                            Scope::new(pos, scope.rotation, Vec3::new(inside_w, inside_h, 0.0));
2776                        child_scope.validate()?;
2777                        let args = ev_args!(rule);
2778                        queue.push_back(WorkItem {
2779                            scope: child_scope,
2780                            rule: rule.name.clone(),
2781                            args,
2782                            depth: depth + 1,
2783                            taper: 0.0,
2784                            face_profile_override: None,
2785                            material: material.clone(),
2786                            split_i,
2787                            split_n,
2788                            rng_state: fork!(),
2789                            label: label.clone(),
2790                        });
2791                    }
2792                    if let Some(rule) = find_offset_rule(OffsetSelector::Border, cases) {
2793                        // 4 surrounding strips: bottom, top, left, right.
2794                        let strips = [
2795                            (Vec3::new(0.0, 0.0, 0.0), Vec3::new(sx, inset, 0.0)),
2796                            (Vec3::new(0.0, sy - inset, 0.0), Vec3::new(sx, inset, 0.0)),
2797                            (
2798                                Vec3::new(0.0, inset, 0.0),
2799                                Vec3::new(inset, sy - 2.0 * inset, 0.0),
2800                            ),
2801                            (
2802                                Vec3::new(sx - inset, inset, 0.0),
2803                                Vec3::new(inset, sy - 2.0 * inset, 0.0),
2804                            ),
2805                        ];
2806                        if queue.len() + strips.len() > MAX_QUEUE {
2807                            return Err(ShapeError::CapacityOverflow);
2808                        }
2809                        for (local_off, strip_size) in strips {
2810                            let pos = scope.position + scope.rotation * local_off;
2811                            let child_scope = Scope::new(pos, scope.rotation, strip_size);
2812                            child_scope.validate()?;
2813                            let args = ev_args!(rule);
2814                            queue.push_back(WorkItem {
2815                                scope: child_scope,
2816                                rule: rule.name.clone(),
2817                                args,
2818                                depth: depth + 1,
2819                                taper: 0.0,
2820                                face_profile_override: None,
2821                                material: material.clone(),
2822                                split_i,
2823                                split_n,
2824                                rng_state: fork!(),
2825                                label: label.clone(),
2826                            });
2827                        }
2828                    }
2829                    return Ok(());
2830                }
2831
2832                // ── Branching: Roof ───────────────────────────────────────
2833                ShapeOp::Roof { spec, cases } => {
2834                    // Resolve the expression-valued spec into a numeric config.
2835                    // `height=` overrides pitch: the rise is measured over half
2836                    // the narrower footprint axis, so mixed-width wings sharing
2837                    // one target height meet at the same ridge line.
2838                    let pitch = if let Some(h_expr) = &spec.height {
2839                        let h = ev!(h_expr);
2840                        if h <= 0.0 {
2841                            return Err(ShapeError::InvalidNumericValue);
2842                        }
2843                        // Shed rises over its full depth; ridge types over
2844                        // half the narrower span.
2845                        let run = if spec.roof_type == RoofType::Shed {
2846                            scope.size.z.max(1e-9)
2847                        } else {
2848                            (scope.size.x.min(scope.size.z) / 2.0).max(1e-9)
2849                        };
2850                        (h / run).atan().to_degrees()
2851                    } else {
2852                        ev!(&spec.pitch)
2853                    };
2854                    let secondary_pitch = match &spec.secondary_pitch {
2855                        Some(e) => Some(ev!(e)),
2856                        None => None,
2857                    };
2858                    let tier_height = match &spec.tier_height {
2859                        Some(e) => Some(ev!(e)),
2860                        None => None,
2861                    };
2862                    let config = RoofConfig {
2863                        roof_type: spec.roof_type,
2864                        pitch,
2865                        secondary_pitch,
2866                        overhang: ev!(&spec.overhang),
2867                        ridge_offset: ev!(&spec.ridge_offset),
2868                        fascia_depth: ev!(&spec.fascia_depth),
2869                        tier_height,
2870                    };
2871                    let resolved_cases = {
2872                        let mut rcs = Vec::with_capacity(cases.len());
2873                        for c in cases {
2874                            let args = ev_args!(&c.rule);
2875                            rcs.push(ResolvedRoofCase {
2876                                selector: c.selector,
2877                                name: c.rule.name.clone(),
2878                                args,
2879                            });
2880                        }
2881                        rcs
2882                    };
2883                    apply_roof(
2884                        &config,
2885                        &resolved_cases,
2886                        &scope,
2887                        depth,
2888                        &material,
2889                        queue,
2890                        model,
2891                        self.max_terminals,
2892                        split_i,
2893                        split_n,
2894                        rng_state,
2895                        spec.ridge_axis,
2896                        &label,
2897                    )?;
2898                    return Ok(());
2899                }
2900
2901                // ── Branching: Attach ─────────────────────────────────────
2902                ShapeOp::Attach { world_axis, cases } => {
2903                    let len_sq = world_axis.length_squared();
2904                    if !world_axis.is_finite() || !len_sq.is_finite() || len_sq < 1e-12 {
2905                        return Err(ShapeError::InvalidAlignTarget);
2906                    }
2907                    let axis_norm = world_axis.normalize();
2908                    // Build a new scope whose Y axis = world_axis.
2909                    // The new scope sits at the same corner as the current scope,
2910                    // has X = scope.size.x, Y = scope.size.y, Z = 0 (flat surface).
2911                    let rot = Quat::from_rotation_arc(Vec3::Y, axis_norm);
2912                    let attach_scope = Scope::new(
2913                        scope.position,
2914                        rot.normalize(),
2915                        Vec3::new(scope.size.x, scope.size.y, 0.0),
2916                    );
2917                    attach_scope.validate()?;
2918                    if let Some(rule) = find_attach_rule(crate::ops::AttachSelector::Surface, cases)
2919                    {
2920                        if queue.len() >= MAX_QUEUE {
2921                            return Err(ShapeError::CapacityOverflow);
2922                        }
2923                        let args = ev_args!(rule);
2924                        queue.push_back(WorkItem {
2925                            scope: attach_scope,
2926                            rule: rule.name.clone(),
2927                            args,
2928                            depth: depth + 1,
2929                            taper: 0.0,
2930                            face_profile_override: None,
2931                            material: material.clone(),
2932                            split_i,
2933                            split_n,
2934                            rng_state: fork!(),
2935                            label: label.clone(),
2936                        });
2937                    }
2938                    return Ok(());
2939                }
2940
2941                // ── Branching: Split ──────────────────────────────────────
2942                ShapeOp::Split {
2943                    axis,
2944                    entries,
2945                    snap,
2946                } => {
2947                    let total = match axis {
2948                        Axis::X => scope.size.x,
2949                        Axis::Y => scope.size.y,
2950                        Axis::Z => scope.size.z,
2951                    };
2952                    // Flatten entries (rhythm groups tile to fill) into an
2953                    // ordered slot list with resolved sizes.
2954                    let mut flat: Vec<(&SplitSlot, ResolvedSize)> = Vec::new();
2955                    {
2956                        macro_rules! resolve_size {
2957                            ($slot:expr) => {
2958                                match &$slot.size {
2959                                    SplitSize::Absolute(e) => ResolvedSize::Absolute(ev!(e)),
2960                                    SplitSize::Relative(e) => ResolvedSize::Relative(ev!(e)),
2961                                    SplitSize::Floating(e) => ResolvedSize::Floating(ev!(e)),
2962                                }
2963                            };
2964                        }
2965                        // Pass 1: resolve every size; compute outside fixed
2966                        // sum, outside float weight, and the group's nominal
2967                        // copy width (floats inside count at their weight).
2968                        let mut resolved_entries: Vec<(usize, Vec<ResolvedSize>)> =
2969                            Vec::with_capacity(entries.len());
2970                        let mut outside_fixed = 0.0_f64;
2971                        let mut outside_float_w = 0.0_f64;
2972                        let mut group_nominal = 0.0_f64;
2973                        let mut group_len = 0usize;
2974                        for (ei, entry) in entries.iter().enumerate() {
2975                            match entry {
2976                                SplitEntry::Slot(slot) => {
2977                                    let r = resolve_size!(slot);
2978                                    match r {
2979                                        ResolvedSize::Absolute(v) => outside_fixed += v,
2980                                        ResolvedSize::Relative(t) => outside_fixed += t * total,
2981                                        ResolvedSize::Floating(w) => outside_float_w += w,
2982                                    }
2983                                    resolved_entries.push((ei, vec![r]));
2984                                }
2985                                SplitEntry::Group(slots) => {
2986                                    let mut rs = Vec::with_capacity(slots.len());
2987                                    for slot in slots {
2988                                        let r = resolve_size!(slot);
2989                                        group_nominal += match r {
2990                                            ResolvedSize::Absolute(v) => v,
2991                                            ResolvedSize::Relative(t) => t * total,
2992                                            ResolvedSize::Floating(w) => w,
2993                                        };
2994                                        rs.push(r);
2995                                    }
2996                                    group_len = slots.len();
2997                                    resolved_entries.push((ei, rs));
2998                                }
2999                            }
3000                        }
3001                        if !outside_fixed.is_finite()
3002                            || !outside_float_w.is_finite()
3003                            || !group_nominal.is_finite()
3004                        {
3005                            return Err(ShapeError::InvalidNumericValue);
3006                        }
3007                        if outside_fixed > total + 1e-9 {
3008                            return Err(ShapeError::SplitOverflow(total));
3009                        }
3010                        let remaining = (total - outside_fixed).max(0.0);
3011                        // Copies of the whole pattern that fit the remainder.
3012                        let k = if group_len > 0 && group_nominal > 1e-12 {
3013                            ((remaining + 1e-9) / group_nominal).floor() as usize
3014                        } else {
3015                            0
3016                        };
3017                        let singles = entries.len() - usize::from(group_len > 0);
3018                        if singles + k * group_len > MAX_SPLIT_CHILDREN {
3019                            return Err(ShapeError::CapacityOverflow);
3020                        }
3021                        let leftover = remaining - k as f64 * group_nominal;
3022                        // Leftover space: outside floats absorb it; with no
3023                        // floats the copies stretch uniformly; a remainder
3024                        // nothing can absorb is an authoring error.
3025                        let copy_scale = if group_len > 0 && outside_float_w <= 0.0 {
3026                            if k == 0 {
3027                                if remaining > 1e-9 {
3028                                    return Err(ShapeError::SplitOverflow(total));
3029                                }
3030                                1.0
3031                            } else {
3032                                remaining / (k as f64 * group_nominal)
3033                            }
3034                        } else {
3035                            1.0
3036                        };
3037                        for (ei, rs) in &resolved_entries {
3038                            match &entries[*ei] {
3039                                SplitEntry::Slot(slot) => {
3040                                    let r = match rs[0] {
3041                                        ResolvedSize::Floating(w) => {
3042                                            // Convert to an absolute share of
3043                                            // the float pool now that the
3044                                            // group has taken its copies.
3045                                            if outside_float_w <= 0.0 {
3046                                                return Err(ShapeError::NoFloatingSlots);
3047                                            }
3048                                            ResolvedSize::Absolute(leftover * (w / outside_float_w))
3049                                        }
3050                                        other => other,
3051                                    };
3052                                    flat.push((slot, r));
3053                                }
3054                                SplitEntry::Group(slots) => {
3055                                    for _copy in 0..k {
3056                                        for (slot, r) in slots.iter().zip(rs.iter()) {
3057                                            let v = match *r {
3058                                                ResolvedSize::Absolute(v) => v,
3059                                                ResolvedSize::Relative(t) => t * total,
3060                                                ResolvedSize::Floating(w) => w,
3061                                            };
3062                                            flat.push((
3063                                                slot,
3064                                                ResolvedSize::Absolute(v * copy_scale),
3065                                            ));
3066                                        }
3067                                    }
3068                                }
3069                            }
3070                        }
3071                    }
3072                    let resolved: Vec<ResolvedSize> = flat.iter().map(|(_, r)| *r).collect();
3073                    let mut sizes = resolve_split_sizes(&resolved, total)?;
3074                    // Snap-aware adjustment: shift interior boundaries to the
3075                    // nearest registered snap-plane along `axis` if within
3076                    // tolerance, then redistribute the offset across the two
3077                    // adjacent slot widths.
3078                    if let Some(binding) = snap {
3079                        let tol = binding.tolerance.unwrap_or(0.05 * total);
3080                        snap_split_boundaries(
3081                            &scope,
3082                            *axis,
3083                            &mut sizes,
3084                            &binding.label,
3085                            tol,
3086                            &model.snap_planes,
3087                        );
3088                    }
3089                    if queue.len() + flat.len() > MAX_QUEUE {
3090                        return Err(ShapeError::CapacityOverflow);
3091                    }
3092                    let child_n = flat.len();
3093                    let mut offset = 0.0;
3094                    for (i, ((slot, _), size)) in flat.iter().zip(sizes.iter()).enumerate() {
3095                        let child = slice_scope(&scope, *axis, offset, *size);
3096                        child.validate()?;
3097                        let args = ev_args!(&slot.rule);
3098                        queue.push_back(WorkItem {
3099                            scope: child,
3100                            rule: slot.rule.name.clone(),
3101                            args,
3102                            depth: depth + 1,
3103                            taper: 0.0,
3104                            face_profile_override: None,
3105                            material: material.clone(),
3106                            split_i: i,
3107                            split_n: child_n,
3108                            rng_state: fork!(),
3109                            label: label.clone(),
3110                        });
3111                        offset += size;
3112                    }
3113                    return Ok(());
3114                }
3115
3116                // ── Branching: SplitArea ──────────────────────────────────
3117                //
3118                // Slot sizes are target areas; lengths are recovered through
3119                // the cross-axis extent, then the normal split solver runs.
3120                ShapeOp::SplitArea { axis, slots } => {
3121                    let (total, cross) = match axis {
3122                        Axis::X => (scope.size.x, scope.size.z),
3123                        Axis::Z => (scope.size.z, scope.size.x),
3124                        Axis::Y => return Err(ShapeError::InvalidNumericValue),
3125                    };
3126                    if !cross.is_finite() || cross <= 1e-12 {
3127                        return Err(ShapeError::InvalidNumericValue);
3128                    }
3129                    let resolved: Vec<ResolvedSize> = {
3130                        let mut v = Vec::with_capacity(slots.len());
3131                        for slot in slots {
3132                            v.push(match &slot.size {
3133                                // Absolute areas become lengths; relative and
3134                                // floating shares are scale-free.
3135                                SplitSize::Absolute(e) => ResolvedSize::Absolute(ev!(e) / cross),
3136                                SplitSize::Relative(e) => ResolvedSize::Relative(ev!(e)),
3137                                SplitSize::Floating(e) => ResolvedSize::Floating(ev!(e)),
3138                            });
3139                        }
3140                        v
3141                    };
3142                    let sizes = resolve_split_sizes(&resolved, total)?;
3143                    if queue.len() + slots.len() > MAX_QUEUE {
3144                        return Err(ShapeError::CapacityOverflow);
3145                    }
3146                    let child_n = slots.len();
3147                    let mut offset = 0.0;
3148                    for (i, (slot, size)) in slots.iter().zip(sizes.iter()).enumerate() {
3149                        let child = slice_scope(&scope, *axis, offset, *size);
3150                        child.validate()?;
3151                        let args = ev_args!(&slot.rule);
3152                        queue.push_back(WorkItem {
3153                            scope: child,
3154                            rule: slot.rule.name.clone(),
3155                            args,
3156                            depth: depth + 1,
3157                            taper: 0.0,
3158                            face_profile_override: None,
3159                            material: material.clone(),
3160                            split_i: i,
3161                            split_n: child_n,
3162                            rng_state: fork!(),
3163                            label: label.clone(),
3164                        });
3165                        offset += size;
3166                    }
3167                    return Ok(());
3168                }
3169
3170                // ── Branching: Fit ────────────────────────────────────────
3171                //
3172                // First candidate whose minimum extent fits the scope wins
3173                // the whole scope; none fitting vanishes the shape.
3174                ShapeOp::Fit { axis, candidates } => {
3175                    let extent = match axis {
3176                        Axis::X => scope.size.x,
3177                        Axis::Y => scope.size.y,
3178                        Axis::Z => scope.size.z,
3179                    };
3180                    for cand in candidates {
3181                        let min = ev!(&cand.min_size);
3182                        if min <= extent + 1e-9 {
3183                            if queue.len() >= MAX_QUEUE {
3184                                return Err(ShapeError::CapacityOverflow);
3185                            }
3186                            let args = ev_args!(&cand.rule);
3187                            queue.push_back(WorkItem {
3188                                scope,
3189                                rule: cand.rule.name.clone(),
3190                                args,
3191                                depth: depth + 1,
3192                                taper,
3193                                face_profile_override: face_profile.take(),
3194                                material: material.clone(),
3195                                split_i,
3196                                split_n,
3197                                rng_state: fork!(),
3198                                label: label.clone(),
3199                            });
3200                            return Ok(());
3201                        }
3202                    }
3203                    return Ok(());
3204                }
3205
3206                // ── Branching: Repeat ─────────────────────────────────────
3207                //
3208                // Uses `floor()` for tile count (never fewer tiles than fit),
3209                // then stretches actual tile size to fill the scope with no gaps.
3210                // Example: 10.5m scope / 2m target → 5 tiles × 2.1m each.
3211                ShapeOp::Repeat {
3212                    axis,
3213                    tile_sizes,
3214                    rule,
3215                } => {
3216                    if tile_sizes.is_empty() {
3217                        return Err(ShapeError::InvalidNumericValue);
3218                    }
3219                    let tile_sizes: Vec<f64> = {
3220                        let mut v = Vec::with_capacity(tile_sizes.len());
3221                        for ts in tile_sizes {
3222                            let t = ev!(ts);
3223                            if t <= 0.0 {
3224                                return Err(ShapeError::InvalidNumericValue);
3225                            }
3226                            v.push(t);
3227                        }
3228                        v
3229                    };
3230                    let total = match axis {
3231                        Axis::X => scope.size.x,
3232                        Axis::Y => scope.size.y,
3233                        Axis::Z => scope.size.z,
3234                    };
3235                    // Defensive: scope.size should always be finite after earlier
3236                    // checks, but if an Infinity scope size ever sneaks through
3237                    // (e.g. from a Roof child), `0.0 * Infinity = NaN` at i=0.
3238                    if !total.is_finite() || total <= 0.0 {
3239                        return Err(ShapeError::InvalidNumericValue);
3240                    }
3241                    let pattern_min = tile_sizes.iter().cloned().fold(f64::INFINITY, f64::min);
3242                    if pattern_min <= 0.0 {
3243                        return Err(ShapeError::InvalidNumericValue);
3244                    }
3245                    // Upper bound on tile count: even if every tile were the
3246                    // smallest in the pattern, this caps it. Mirrors the
3247                    // single-size guard against tiny-tile-size-induced
3248                    // n_tiles → usize::MAX overflow.
3249                    let n_max_f = (total / pattern_min).floor();
3250                    if !n_max_f.is_finite() {
3251                        return Err(ShapeError::CapacityOverflow);
3252                    }
3253                    let n_max = n_max_f as usize;
3254                    if queue.len().saturating_add(n_max) > MAX_QUEUE {
3255                        return Err(ShapeError::CapacityOverflow);
3256                    }
3257                    // Cycle the pattern, appending tiles greedily while the
3258                    // next tile still fits, then scale all placed tiles by
3259                    // `total / acc` so they fill the scope exactly.
3260                    let mut placed: Vec<f64> = Vec::new();
3261                    let mut acc = 0.0_f64;
3262                    loop {
3263                        let next = tile_sizes[placed.len() % tile_sizes.len()];
3264                        if acc + next > total + 1e-12 {
3265                            break;
3266                        }
3267                        placed.push(next);
3268                        acc += next;
3269                    }
3270                    if !placed.is_empty() {
3271                        let scale = total / acc;
3272                        let child_n = placed.len();
3273                        let mut offset = 0.0_f64;
3274                        for (i, tile) in placed.iter().enumerate() {
3275                            let actual = tile * scale;
3276                            let child = slice_scope(&scope, *axis, offset, actual);
3277                            child.validate()?;
3278                            // Args re-evaluate per tile: `Bay(rand(0, 3))` rolls
3279                            // once per placed tile, not once for the whole row.
3280                            let args = ev_args!(rule);
3281                            queue.push_back(WorkItem {
3282                                scope: child,
3283                                rule: rule.name.clone(),
3284                                args,
3285                                depth: depth + 1,
3286                                taper: 0.0,
3287                                face_profile_override: None,
3288                                material: material.clone(),
3289                                split_i: i,
3290                                split_n: child_n,
3291                                rng_state: fork!(),
3292                                label: label.clone(),
3293                            });
3294                            offset += actual;
3295                        }
3296                    }
3297                    return Ok(());
3298                }
3299
3300                // ── Branching: Comp ───────────────────────────────────────
3301                //
3302                // Each face scope is properly oriented so that local Z points
3303                // along the outward face normal. Rules can then use Split(X/Y)
3304                // or Repeat(X) naturally on any face of the parent volume.
3305                ShapeOp::Comp(CompTarget::Edges(cases)) => {
3306                    let descs = edge_descs(scope.size);
3307                    if queue.len() + descs.len() > MAX_QUEUE {
3308                        return Err(ShapeError::CapacityOverflow);
3309                    }
3310                    for (class, origin, dir, len) in descs {
3311                        let Some(rule) = find_edge_rule(class, cases) else {
3312                            continue;
3313                        };
3314                        if len <= 1e-9 {
3315                            continue;
3316                        }
3317                        let pos = scope.position + scope.rotation * origin;
3318                        // Local X runs along the edge (deterministic frame).
3319                        let rot =
3320                            (scope.rotation * Quat::from_rotation_arc(Vec3::X, dir)).normalize();
3321                        let child = Scope::new(pos, rot, Vec3::new(len, 0.0, 0.0));
3322                        child.validate()?;
3323                        let args = ev_args!(rule);
3324                        queue.push_back(WorkItem {
3325                            scope: child,
3326                            rule: rule.name.clone(),
3327                            args,
3328                            depth: depth + 1,
3329                            taper: 0.0,
3330                            face_profile_override: None,
3331                            material: material.clone(),
3332                            split_i,
3333                            split_n,
3334                            rng_state: fork!(),
3335                            label: label.clone(),
3336                        });
3337                    }
3338                    return Ok(());
3339                }
3340
3341                ShapeOp::Comp(CompTarget::Faces(cases)) => {
3342                    // face_descs always returns exactly 6 faces; guard before any push.
3343                    if queue.len() + 6 > MAX_QUEUE {
3344                        return Err(ShapeError::CapacityOverflow);
3345                    }
3346                    for (selector, offset_local, face_size, rot_delta) in face_descs(scope.size) {
3347                        let rule = match find_face_rule(selector, cases) {
3348                            Some(r) => r,
3349                            None => continue,
3350                        };
3351                        let face_pos = scope.position + scope.rotation * offset_local;
3352                        let face_rotation = scope.rotation * rot_delta;
3353                        let face_scope = Scope::new(face_pos, face_rotation, face_size);
3354                        face_scope.validate()?;
3355                        let args = ev_args!(&rule);
3356                        queue.push_back(WorkItem {
3357                            scope: face_scope,
3358                            rule: rule.name.clone(),
3359                            args,
3360                            depth: depth + 1,
3361                            taper: 0.0,
3362                            face_profile_override: None,
3363                            material: material.clone(),
3364                            split_i,
3365                            split_n,
3366                            rng_state: fork!(),
3367                            label: label.clone(),
3368                        });
3369                    }
3370                    return Ok(());
3371                }
3372
3373                // ── Terminal: mesh instance ───────────────────────────────
3374                ShapeOp::I(mesh_id) => {
3375                    if model.len() >= self.max_terminals {
3376                        return Err(ShapeError::CapacityOverflow);
3377                    }
3378                    let profile = face_profile
3379                        .take()
3380                        .unwrap_or_else(|| taper_to_profile(taper));
3381                    let mut terminal = Terminal::new_profiled(scope, mesh_id, profile, material);
3382                    terminal.label = label;
3383                    model.push(terminal);
3384                    return Ok(());
3385                }
3386
3387                // ── Delegate: named sub-rule ──────────────────────────────
3388                ShapeOp::Rule(call) => {
3389                    let args = ev_args!(call);
3390                    queue.push_back(WorkItem {
3391                        scope,
3392                        rule: call.name.clone(),
3393                        args,
3394                        depth: depth + 1,
3395                        taper,
3396                        face_profile_override: face_profile,
3397                        material,
3398                        split_i,
3399                        split_n,
3400                        rng_state: fork!(),
3401                        label: label.clone(),
3402                    });
3403                    return Ok(());
3404                }
3405            }
3406        }
3407
3408        // Ops exhausted without a terminal — scope is silently discarded
3409        // (matches CGA "delete this shape" semantics for empty successors).
3410        Ok(())
3411    }
3412}
3413
3414#[cfg(test)]
3415mod tests {
3416    use super::*;
3417    use crate::ops::{Axis, SplitSize, SplitSlot};
3418    use crate::scope::{Quat, Vec3};
3419
3420    fn slot(size: SplitSize, rule: &str) -> SplitSlot {
3421        SplitSlot {
3422            size,
3423            rule: rule.into(),
3424        }
3425    }
3426
3427    /// Wraps a quaternion into literal (w, x, y, z) rotation arguments.
3428    fn qexpr(q: Quat) -> [Expr; 4] {
3429        [
3430            Expr::lit(q.w),
3431            Expr::lit(q.x),
3432            Expr::lit(q.y),
3433            Expr::lit(q.z),
3434        ]
3435    }
3436
3437    /// Evaluates literal-only slot sizes for the resolver tests.
3438    fn resolved(slots: &[SplitSlot]) -> Vec<ResolvedSize> {
3439        slots
3440            .iter()
3441            .map(|s| match &s.size {
3442                SplitSize::Absolute(e) => ResolvedSize::Absolute(e.as_lit().unwrap()),
3443                SplitSize::Relative(e) => ResolvedSize::Relative(e.as_lit().unwrap()),
3444                SplitSize::Floating(e) => ResolvedSize::Floating(e.as_lit().unwrap()),
3445            })
3446            .collect()
3447    }
3448
3449    #[test]
3450    fn test_resolve_split_absolute() {
3451        let slots = vec![
3452            slot(SplitSize::abs(3.0), "A"),
3453            slot(SplitSize::abs(7.0), "B"),
3454        ];
3455        let sizes = resolve_split_sizes(&resolved(&slots), 10.0).unwrap();
3456        assert!((sizes[0] - 3.0).abs() < 1e-9);
3457        assert!((sizes[1] - 7.0).abs() < 1e-9);
3458    }
3459
3460    #[test]
3461    fn test_resolve_split_floating_equal() {
3462        let slots = vec![
3463            slot(SplitSize::float(1.0), "A"),
3464            slot(SplitSize::float(1.0), "B"),
3465        ];
3466        let sizes = resolve_split_sizes(&resolved(&slots), 10.0).unwrap();
3467        assert!((sizes[0] - 5.0).abs() < 1e-9);
3468        assert!((sizes[1] - 5.0).abs() < 1e-9);
3469    }
3470
3471    #[test]
3472    fn test_resolve_split_mixed() {
3473        let slots = vec![
3474            slot(SplitSize::abs(2.0), "Base"),
3475            slot(SplitSize::float(1.0), "A"),
3476            slot(SplitSize::float(1.0), "B"),
3477        ];
3478        let sizes = resolve_split_sizes(&resolved(&slots), 10.0).unwrap();
3479        assert!((sizes[0] - 2.0).abs() < 1e-9);
3480        assert!((sizes[1] - 4.0).abs() < 1e-9);
3481        assert!((sizes[2] - 4.0).abs() < 1e-9);
3482    }
3483
3484    #[test]
3485    fn test_resolve_split_overflow_rejected() {
3486        let slots = vec![
3487            slot(SplitSize::abs(6.0), "A"),
3488            slot(SplitSize::abs(6.0), "B"),
3489        ];
3490        assert!(matches!(
3491            resolve_split_sizes(&resolved(&slots), 10.0),
3492            Err(ShapeError::SplitOverflow(_))
3493        ));
3494    }
3495
3496    #[test]
3497    fn test_derive_extrude_then_terminal() {
3498        let mut interp = Interpreter::new();
3499        interp.add_rule(
3500            "Lot",
3501            vec![
3502                ShapeOp::Extrude(Expr::lit(10.0)),
3503                ShapeOp::I("Building".to_string()),
3504            ],
3505        );
3506        let scope = Scope::unit();
3507        let model = interp.derive(scope, "Lot").unwrap();
3508        assert_eq!(model.len(), 1);
3509        assert_eq!(model.terminals[0].mesh_id, "Building");
3510        assert!((model.terminals[0].scope.size.y - 10.0).abs() < 1e-9);
3511    }
3512
3513    #[test]
3514    fn test_derive_split_y_three_floors() {
3515        let mut interp = Interpreter::new();
3516        interp.add_rule(
3517            "Building",
3518            vec![ShapeOp::Split {
3519                axis: Axis::Y,
3520                entries: vec![
3521                    slot(SplitSize::abs(2.0), "Ground").into(),
3522                    slot(SplitSize::float(1.0), "Upper").into(),
3523                    slot(SplitSize::abs(1.5), "Roof").into(),
3524                ],
3525                snap: None,
3526            }],
3527        );
3528        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 10.0, 10.0));
3529        let model = interp.derive(scope, "Building").unwrap();
3530        assert_eq!(model.len(), 3);
3531        assert!((model.terminals[0].scope.size.y - 2.0).abs() < 1e-9);
3532        assert!((model.terminals[1].scope.size.y - 6.5).abs() < 1e-9);
3533        assert!((model.terminals[2].scope.size.y - 1.5).abs() < 1e-9);
3534    }
3535
3536    #[test]
3537    fn test_derive_depth_limit() {
3538        let mut interp = Interpreter::new();
3539        interp.add_rule("A", vec![ShapeOp::Rule("A".into())]);
3540        interp.max_depth = 5;
3541        let model = interp.derive(Scope::unit(), "A");
3542        assert!(matches!(model, Err(ShapeError::DepthLimitExceeded(_))));
3543    }
3544
3545    #[test]
3546    fn test_derive_comp_faces() {
3547        let mut interp = Interpreter::new();
3548        interp.add_rule(
3549            "Box",
3550            vec![ShapeOp::Comp(CompTarget::Faces(vec![
3551                crate::ops::CompFaceCase {
3552                    selector: FaceSelector::Top,
3553                    rule: "Roof".into(),
3554                },
3555                crate::ops::CompFaceCase {
3556                    selector: FaceSelector::Side,
3557                    rule: "Wall".into(),
3558                },
3559                crate::ops::CompFaceCase {
3560                    selector: FaceSelector::Bottom,
3561                    rule: "Base".into(),
3562                },
3563            ]))],
3564        );
3565        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(5.0, 3.0, 5.0));
3566        let model = interp.derive(scope, "Box").unwrap();
3567        assert_eq!(model.len(), 6);
3568    }
3569
3570    #[test]
3571    fn test_derive_repeat() {
3572        let mut interp = Interpreter::new();
3573        interp.add_rule(
3574            "Facade",
3575            vec![ShapeOp::Repeat {
3576                axis: Axis::X,
3577                tile_sizes: vec![Expr::lit(2.0)],
3578                rule: "Window".into(),
3579            }],
3580        );
3581        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 4.0, 0.0));
3582        let model = interp.derive(scope, "Facade").unwrap();
3583        // 10 / 2 = 5 tiles, each stretched to exactly 2.0m (no remainder here)
3584        assert_eq!(model.len(), 5);
3585    }
3586
3587    #[test]
3588    fn test_derive_mat_propagates() {
3589        let mut interp = Interpreter::new();
3590        interp.add_rule(
3591            "R",
3592            vec![
3593                ShapeOp::Mat(Material::new("Brick")),
3594                ShapeOp::I("Wall".to_string()),
3595            ],
3596        );
3597        let model = interp.derive(Scope::unit(), "R").unwrap();
3598        assert_eq!(model.terminals[0].material, Some(Material::new("Brick")));
3599    }
3600
3601    // ── Issue 1: empty-variants panic ─────────────────────────────────────────
3602
3603    #[test]
3604    fn test_empty_variants_discards_shape() {
3605        let mut interp = Interpreter::new();
3606        // add_weighted_rules with an empty vec must not panic; the scope is
3607        // silently discarded (consistent with CGA "delete shape" semantics).
3608        interp.add_weighted_rules("Empty", vec![]).unwrap();
3609        let model = interp.derive(Scope::unit(), "Empty").unwrap();
3610        assert_eq!(model.len(), 0);
3611    }
3612
3613    // ── Issue 1 (review #10): n_tiles INFINITY cast ───────────────────────────
3614
3615    #[test]
3616    fn test_repeat_tiny_tile_size_rejected() {
3617        // tile_size = f64::MIN_POSITIVE is finite and > 0, passes validation.
3618        // But total / f64::MIN_POSITIVE overflows to INFINITY, and
3619        // INFINITY as usize saturates to usize::MAX, causing overflow in the
3620        // queue length arithmetic. Must be caught as CapacityOverflow.
3621        let mut interp = Interpreter::new();
3622        interp.add_rule(
3623            "R",
3624            vec![ShapeOp::Repeat {
3625                axis: Axis::X,
3626                tile_sizes: vec![Expr::lit(f64::MIN_POSITIVE)],
3627                rule: "Tile".into(),
3628            }],
3629        );
3630        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(1.0, 1.0, 1.0));
3631        assert!(matches!(
3632            interp.derive(scope, "R"),
3633            Err(ShapeError::CapacityOverflow)
3634        ));
3635    }
3636
3637    // ── Issue 3 (review #10): Scale multiplication overflow ───────────────────
3638
3639    #[test]
3640    fn test_scale_multiply_overflow_to_infinity_rejected() {
3641        // Each Scale value is individually finite and positive, but scope.size *= v
3642        // can overflow to INFINITY. Must be caught after the multiplication.
3643        let mut interp = Interpreter::new();
3644        interp.add_rule(
3645            "R",
3646            vec![
3647                ShapeOp::Scale([Expr::lit(1e200), Expr::lit(1.0), Expr::lit(1.0)]),
3648                ShapeOp::Scale([Expr::lit(1e200), Expr::lit(1.0), Expr::lit(1.0)]), // 1e200*1e200=INFINITY
3649                ShapeOp::I("Mesh".to_string()),
3650            ],
3651        );
3652        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(1.0, 1.0, 1.0));
3653        assert!(matches!(
3654            interp.derive(scope, "R"),
3655            Err(ShapeError::InvalidNumericValue)
3656        ));
3657    }
3658
3659    #[test]
3660    fn test_split_absolute_sum_overflow_rejected() {
3661        // Absolute slot sizes whose sum overflows to INFINITY must be rejected.
3662        let slots = vec![
3663            slot(SplitSize::abs(f64::MAX), "A"),
3664            slot(SplitSize::abs(f64::MAX), "B"),
3665        ];
3666        assert!(matches!(
3667            resolve_split_sizes(&resolved(&slots), f64::MAX),
3668            Err(ShapeError::InvalidNumericValue)
3669        ));
3670    }
3671
3672    // ── Issue 3: queue capacity accounting ────────────────────────────────────
3673
3674    #[test]
3675    fn test_repeat_respects_combined_queue_limit() {
3676        // A Repeat whose tile count alone is fine (< MAX_QUEUE) but combined with
3677        // the existing queue would exceed MAX_QUEUE should be rejected.
3678        // We can't easily fill the queue to 99_999 in a unit test, so we use
3679        // the public max_depth / max_terminals to drive overflow indirectly.
3680        // Instead, verify the guard fires for a very large n_tiles (> MAX_QUEUE).
3681        let mut interp = Interpreter::new();
3682        // tile_size so small that n_tiles >> MAX_QUEUE (scope is 1e10, tile = 1e-1 → 1e11 tiles)
3683        interp.add_rule(
3684            "Big",
3685            vec![ShapeOp::Repeat {
3686                axis: Axis::X,
3687                tile_sizes: vec![Expr::lit(1e-1)],
3688                rule: "Tile".into(),
3689            }],
3690        );
3691        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(1e10, 1.0, 1.0));
3692        assert!(matches!(
3693            interp.derive(scope, "Big"),
3694            Err(ShapeError::CapacityOverflow)
3695        ));
3696    }
3697
3698    // ── Issue 4: negative scale via API ──────────────────────────────────────
3699
3700    #[test]
3701    fn test_api_negative_scale_rejected() {
3702        let mut interp = Interpreter::new();
3703        interp.add_rule(
3704            "R",
3705            vec![
3706                ShapeOp::Scale([Expr::lit(-1.0), Expr::lit(1.0), Expr::lit(1.0)]),
3707                ShapeOp::I("Mesh".to_string()),
3708            ],
3709        );
3710        assert!(matches!(
3711            interp.derive(Scope::unit(), "R"),
3712            Err(ShapeError::InvalidNumericValue)
3713        ));
3714    }
3715
3716    #[test]
3717    fn test_api_zero_scale_rejected() {
3718        let mut interp = Interpreter::new();
3719        interp.add_rule(
3720            "R",
3721            vec![
3722                ShapeOp::Scale([Expr::lit(0.0), Expr::lit(1.0), Expr::lit(1.0)]),
3723                ShapeOp::I("Mesh".to_string()),
3724            ],
3725        );
3726        assert!(matches!(
3727            interp.derive(Scope::unit(), "R"),
3728            Err(ShapeError::InvalidNumericValue)
3729        ));
3730    }
3731
3732    // ── Issue 1 (review #14): intermediate product overflow in floating split ──
3733
3734    #[test]
3735    fn test_split_floating_large_remaining_no_overflow() {
3736        // remaining ≈ 1e308, w = 2.0, float_weight_total = 3.0.
3737        // Old code: (1e308 * 2.0) / 3.0 = INFINITY / 3.0 = INFINITY.
3738        // Fixed:    1e308 * (2.0 / 3.0) = finite.
3739        let slots = vec![
3740            slot(SplitSize::float(2.0), "A"),
3741            slot(SplitSize::float(1.0), "B"),
3742        ];
3743        let sizes = resolve_split_sizes(&resolved(&slots), 1e308).unwrap();
3744        assert!(sizes[0].is_finite(), "size[0] overflowed to {}", sizes[0]);
3745        assert!(sizes[1].is_finite(), "size[1] overflowed to {}", sizes[1]);
3746        // Proportions must be 2/3 and 1/3.
3747        assert!((sizes[0] / sizes[1] - 2.0).abs() < 1e-6);
3748    }
3749
3750    // ── Issue 5: float_weight_total overflow ──────────────────────────────────
3751
3752    #[test]
3753    fn test_split_floating_weight_overflow_rejected() {
3754        // Two floating slots each with weight near f64::MAX; their sum overflows
3755        // to INFINITY in float_weight_total, which should be caught and rejected.
3756        let slots = vec![
3757            slot(SplitSize::float(f64::MAX), "A"),
3758            slot(SplitSize::float(f64::MAX), "B"),
3759        ];
3760        assert!(matches!(
3761            resolve_split_sizes(&resolved(&slots), 10.0),
3762            Err(ShapeError::InvalidNumericValue)
3763        ));
3764    }
3765
3766    #[test]
3767    fn test_stochastic_rule_deterministic_with_seed() {
3768        let mut interp = Interpreter::new();
3769        interp
3770            .add_weighted_rules(
3771                "Facade",
3772                vec![
3773                    (70.0, vec![ShapeOp::I("Brick".to_string())]),
3774                    (30.0, vec![ShapeOp::I("Glass".to_string())]),
3775                ],
3776            )
3777            .unwrap();
3778        interp.seed = 42;
3779        // Same seed → same result
3780        let m1 = interp.derive(Scope::unit(), "Facade").unwrap();
3781        let m2 = interp.derive(Scope::unit(), "Facade").unwrap();
3782        assert_eq!(m1.terminals[0].mesh_id, m2.terminals[0].mesh_id);
3783    }
3784
3785    #[test]
3786    fn test_face_comp_orientations() {
3787        // After Comp, each face scope should have local Z pointing along its outward normal.
3788        // We verify by checking the rotation: applying the face rotation to (0,0,1) should
3789        // give the expected world-space normal direction.
3790        let mut interp = Interpreter::new();
3791        interp.add_rule(
3792            "Box",
3793            vec![ShapeOp::Comp(CompTarget::Faces(vec![
3794                crate::ops::CompFaceCase {
3795                    selector: FaceSelector::All,
3796                    rule: "Face".into(),
3797                },
3798            ]))],
3799        );
3800        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 2.0));
3801        let model = interp.derive(scope, "Box").unwrap();
3802        assert_eq!(model.len(), 6);
3803
3804        // Collect the outward normals by rotating (0,0,1) with each face's rotation
3805        let normals: Vec<Vec3> = model
3806            .terminals
3807            .iter()
3808            .map(|t| t.scope.rotation * Vec3::Z)
3809            .collect();
3810
3811        // We expect exactly one terminal pointing in each of the 6 cardinal directions
3812        let expected = [
3813            Vec3::NEG_Y, // Bottom
3814            Vec3::Y,     // Top
3815            Vec3::NEG_Z, // Front
3816            Vec3::Z,     // Back
3817            Vec3::NEG_X, // Left
3818            Vec3::X,     // Right
3819        ];
3820        for exp in &expected {
3821            assert!(
3822                normals.iter().any(|n| (*n - *exp).length() < 1e-6),
3823                "missing normal {:?}, got {:?}",
3824                exp,
3825                normals
3826            );
3827        }
3828
3829        // face_descs order is deterministic: Bottom, Top, Front, Back, Left, Right.
3830        // Verify that the face origin positions lie on the correct parent faces.
3831        // scope: position=(0,0,0), size sx=4, sy=3, sz=2.
3832        let pos = |i: usize| model.terminals[i].scope.position;
3833        assert!(
3834            (pos(0) - Vec3::new(0.0, 0.0, 0.0)).length() < 1e-6,
3835            "Bottom pos"
3836        ); // at y=0
3837        assert!(
3838            (pos(1) - Vec3::new(0.0, 3.0, 2.0)).length() < 1e-6,
3839            "Top pos"
3840        ); // at y=sy, origin shifted to (0,sy,sz)
3841        assert!(
3842            (pos(2) - Vec3::new(4.0, 0.0, 0.0)).length() < 1e-6,
3843            "Front pos"
3844        ); // at z=0, origin shifted to (sx,0,0)
3845        assert!(
3846            (pos(3) - Vec3::new(0.0, 0.0, 2.0)).length() < 1e-6,
3847            "Back pos"
3848        ); // at z=sz
3849        assert!(
3850            (pos(4) - Vec3::new(0.0, 0.0, 0.0)).length() < 1e-6,
3851            "Left pos"
3852        ); // at x=0
3853        assert!(
3854            (pos(5) - Vec3::new(4.0, 0.0, 2.0)).length() < 1e-6,
3855            "Right pos"
3856        ); // at x=sx, origin shifted to (sx,0,sz)
3857    }
3858
3859    // ── Issue 4 (review #14): negative scope size rejected by validate() ────────
3860
3861    #[test]
3862    fn test_negative_scope_size_rejected() {
3863        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(-1.0, 1.0, 1.0));
3864        let interp = Interpreter::new();
3865        assert!(matches!(
3866            interp.derive(scope, "Anything"),
3867            Err(ShapeError::InvalidNumericValue)
3868        ));
3869    }
3870
3871    #[test]
3872    fn test_zero_scope_size_accepted() {
3873        // Y=0 is a valid 2D footprint; derive should succeed (rule unknown → implicit terminal).
3874        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 0.0, 10.0));
3875        let interp = Interpreter::new();
3876        let model = interp.derive(scope, "Footprint").unwrap();
3877        assert_eq!(model.len(), 1);
3878    }
3879
3880    // ── Issue 1 (review #11): unnormalized quaternion in root scope ───────────
3881
3882    #[test]
3883    fn test_unnormalized_root_quat_rejected() {
3884        // DQuat::from_xyzw(2,0,0,0) is finite but has length 2 — not a unit quat.
3885        let bad_q = Quat::from_xyzw(0.0, 0.0, 0.0, 2.0);
3886        let scope = Scope::new(Vec3::ZERO, bad_q, Vec3::ONE);
3887        let interp = Interpreter::new();
3888        assert!(matches!(
3889            interp.derive(scope, "Anything"),
3890            Err(ShapeError::InvalidNumericValue)
3891        ));
3892    }
3893
3894    #[test]
3895    fn test_degenerate_rotate_op_rejected() {
3896        // A zero quaternion (len_sq < 1e-12) cannot represent a rotation — must reject.
3897        let zero = [
3898            Expr::lit(0.0),
3899            Expr::lit(0.0),
3900            Expr::lit(0.0),
3901            Expr::lit(0.0),
3902        ];
3903        let mut interp = Interpreter::new();
3904        interp.add_rule(
3905            "R",
3906            vec![ShapeOp::Rotate(zero), ShapeOp::I("M".to_string())],
3907        );
3908        assert!(matches!(
3909            interp.derive(Scope::unit(), "R"),
3910            Err(ShapeError::InvalidNumericValue)
3911        ));
3912    }
3913
3914    #[test]
3915    fn test_scaled_rotate_op_normalized() {
3916        // A quaternion with magnitude 2 (e.g. IDENTITY * 2) is non-unit but valid;
3917        // it must be normalised to IDENTITY rather than rejected.
3918        let scaled_q = Quat::from_xyzw(0.0, 0.0, 0.0, 2.0); // IDENTITY * 2
3919        let mut interp = Interpreter::new();
3920        interp.add_rule(
3921            "R",
3922            vec![
3923                ShapeOp::Rotate(qexpr(scaled_q)),
3924                ShapeOp::I("M".to_string()),
3925            ],
3926        );
3927        // Should succeed; the terminal scope rotation should be IDENTITY.
3928        let model = interp.derive(Scope::unit(), "R").unwrap();
3929        assert_eq!(model.len(), 1);
3930        let r = model.terminals[0].scope.rotation;
3931        assert!(
3932            (r.length_squared() - 1.0).abs() < 1e-9,
3933            "rotation should be unit"
3934        );
3935    }
3936
3937    // ── Issue 2 (review #12): invalid weights in add_weighted_rules ──────────
3938
3939    #[test]
3940    fn test_nan_weight_rejected() {
3941        let mut interp = Interpreter::new();
3942        assert!(matches!(
3943            interp.add_weighted_rules("R", vec![(f64::NAN, vec![ShapeOp::I("M".to_string())])]),
3944            Err(ShapeError::InvalidNumericValue)
3945        ));
3946    }
3947
3948    #[test]
3949    fn test_infinite_weight_rejected() {
3950        let mut interp = Interpreter::new();
3951        assert!(matches!(
3952            interp.add_weighted_rules(
3953                "R",
3954                vec![(f64::INFINITY, vec![ShapeOp::I("M".to_string())])]
3955            ),
3956            Err(ShapeError::InvalidNumericValue)
3957        ));
3958    }
3959
3960    #[test]
3961    fn test_negative_weight_rejected() {
3962        let mut interp = Interpreter::new();
3963        assert!(matches!(
3964            interp.add_weighted_rules("R", vec![(-1.0, vec![ShapeOp::I("M".to_string())])]),
3965            Err(ShapeError::InvalidNumericValue)
3966        ));
3967    }
3968
3969    // ── Feature: Align ───────────────────────────────────────────────────────
3970
3971    #[test]
3972    fn test_align_y_to_world_up_when_rotated() {
3973        // Rotate 90° around Z (Y → -X), then Align(Y, Up) should restore Y = +Y.
3974        let mut interp = Interpreter::new();
3975        let ninety_z = Quat::from_axis_angle(Vec3::Z, std::f64::consts::FRAC_PI_2);
3976        interp.add_rule(
3977            "R",
3978            vec![
3979                ShapeOp::Rotate(qexpr(ninety_z)),
3980                ShapeOp::Align {
3981                    local_axis: Axis::Y,
3982                    target: Vec3::Y,
3983                },
3984                ShapeOp::I("M".to_string()),
3985            ],
3986        );
3987        let model = interp.derive(Scope::unit(), "R").unwrap();
3988        assert_eq!(model.len(), 1);
3989        let world_y = model.terminals[0].scope.rotation * Vec3::Y;
3990        assert!(
3991            (world_y - Vec3::Y).length() < 1e-6,
3992            "expected Y=(0,1,0), got {:?}",
3993            world_y
3994        );
3995    }
3996
3997    #[test]
3998    fn test_align_already_aligned_is_noop() {
3999        let mut interp = Interpreter::new();
4000        interp.add_rule(
4001            "R",
4002            vec![
4003                ShapeOp::Align {
4004                    local_axis: Axis::Y,
4005                    target: Vec3::Y,
4006                },
4007                ShapeOp::I("M".to_string()),
4008            ],
4009        );
4010        let model = interp.derive(Scope::unit(), "R").unwrap();
4011        let rot = model.terminals[0].scope.rotation;
4012        assert!((rot.length_squared() - 1.0).abs() < 1e-9);
4013        // Rotation should still be unit (identity-like for already-aligned)
4014        let world_y = rot * Vec3::Y;
4015        assert!((world_y - Vec3::Y).length() < 1e-6);
4016    }
4017
4018    #[test]
4019    fn test_align_zero_target_rejected() {
4020        let mut interp = Interpreter::new();
4021        interp.add_rule(
4022            "R",
4023            vec![
4024                ShapeOp::Align {
4025                    local_axis: Axis::Y,
4026                    target: Vec3::ZERO,
4027                },
4028                ShapeOp::I("M".to_string()),
4029            ],
4030        );
4031        assert!(matches!(
4032            interp.derive(Scope::unit(), "R"),
4033            Err(ShapeError::InvalidAlignTarget)
4034        ));
4035    }
4036
4037    // ── Feature: Offset ──────────────────────────────────────────────────────
4038
4039    #[test]
4040    fn test_offset_inset_produces_inside_and_border() {
4041        let mut interp = Interpreter::new();
4042        interp.add_rule(
4043            "R",
4044            vec![ShapeOp::Offset {
4045                distance: Expr::lit(-0.5),
4046                cases: vec![
4047                    crate::ops::OffsetCase {
4048                        selector: crate::ops::OffsetSelector::Inside,
4049                        rule: "Glass".into(),
4050                    },
4051                    crate::ops::OffsetCase {
4052                        selector: crate::ops::OffsetSelector::Border,
4053                        rule: "Frame".into(),
4054                    },
4055                ],
4056            }],
4057        );
4058        // 4×3 face scope (z=0)
4059        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 0.0));
4060        let model = interp.derive(scope, "R").unwrap();
4061        // 1 Inside + 4 Border strips = 5 terminals
4062        assert_eq!(model.len(), 5);
4063        // Inside scope: size = (3.0, 2.0, 0.0), positioned at (0.5, 0.5, 0.0)
4064        let inside = model
4065            .terminals
4066            .iter()
4067            .find(|t| t.mesh_id == "Glass")
4068            .unwrap();
4069        assert!((inside.scope.size.x - 3.0).abs() < 1e-9);
4070        assert!((inside.scope.size.y - 2.0).abs() < 1e-9);
4071        assert!((inside.scope.position - Vec3::new(0.5, 0.5, 0.0)).length() < 1e-9);
4072    }
4073
4074    #[test]
4075    fn test_offset_too_large_rejected() {
4076        let mut interp = Interpreter::new();
4077        interp.add_rule(
4078            "R",
4079            vec![ShapeOp::Offset {
4080                distance: Expr::lit(-2.0), // 2*2.0 = 4 > 3 (sy)
4081                cases: vec![crate::ops::OffsetCase {
4082                    selector: crate::ops::OffsetSelector::Inside,
4083                    rule: "A".into(),
4084                }],
4085            }],
4086        );
4087        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 0.0));
4088        assert!(matches!(
4089            interp.derive(scope, "R"),
4090            Err(ShapeError::OffsetTooLarge)
4091        ));
4092    }
4093
4094    #[test]
4095    fn test_offset_positive_distance_is_an_outset() {
4096        // Positive distances grow the face (0.3): the Inside region extends
4097        // 0.2 past every edge of a 4×3 face scope.
4098        let mut interp = Interpreter::new();
4099        interp.add_rule(
4100            "R",
4101            vec![ShapeOp::Offset {
4102                distance: Expr::lit(0.2),
4103                cases: vec![crate::ops::OffsetCase {
4104                    selector: crate::ops::OffsetSelector::Inside,
4105                    rule: "A".into(),
4106                }],
4107            }],
4108        );
4109        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 0.0));
4110        let model = interp.derive(scope, "R").unwrap();
4111        assert_eq!(model.len(), 1);
4112        let t = &model.terminals[0];
4113        assert!((t.scope.size.x - 4.4).abs() < 1e-9);
4114        assert!((t.scope.size.y - 3.4).abs() < 1e-9);
4115        assert!((t.scope.position.x - -0.2).abs() < 1e-9);
4116        assert!((t.scope.position.y - -0.2).abs() < 1e-9);
4117        // Zero distance stays rejected.
4118        let mut i2 = Interpreter::new();
4119        i2.add_rule(
4120            "R",
4121            vec![ShapeOp::Offset {
4122                distance: Expr::lit(0.0),
4123                cases: vec![crate::ops::OffsetCase {
4124                    selector: crate::ops::OffsetSelector::Inside,
4125                    rule: "A".into(),
4126                }],
4127            }],
4128        );
4129        assert!(matches!(
4130            i2.derive(Scope::unit(), "R"),
4131            Err(ShapeError::InvalidNumericValue)
4132        ));
4133    }
4134
4135    // ── Feature: Roof ────────────────────────────────────────────────────────
4136
4137    #[test]
4138    fn test_roof_shed_produces_one_slope() {
4139        let mut interp = Interpreter::new();
4140        interp.add_rule(
4141            "R",
4142            vec![ShapeOp::Roof {
4143                spec: RoofConfig::new(RoofType::Shed, 30.0).into(),
4144                cases: vec![crate::ops::RoofCase {
4145                    selector: RoofFaceSelector::Slope,
4146                    rule: "Tiles".into(),
4147                }],
4148            }],
4149        );
4150        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 5.0, 8.0));
4151        let model = interp.derive(scope, "R").unwrap();
4152        assert_eq!(model.len(), 1);
4153        assert_eq!(model.terminals[0].mesh_id, "Tiles");
4154        // Panel positioned at the base of the roof scope (local Y = 0.0)
4155        assert!((model.terminals[0].scope.position.y - 0.0).abs() < 1e-6);
4156    }
4157
4158    #[test]
4159    fn test_roof_gable_produces_four_panels() {
4160        let mut interp = Interpreter::new();
4161        interp.add_rule(
4162            "R",
4163            vec![ShapeOp::Roof {
4164                spec: RoofConfig::new(RoofType::Gable, 30.0).into(),
4165                cases: vec![
4166                    crate::ops::RoofCase {
4167                        selector: RoofFaceSelector::Slope,
4168                        rule: "Tiles".into(),
4169                    },
4170                    crate::ops::RoofCase {
4171                        selector: RoofFaceSelector::GableEnd,
4172                        rule: "Bricks".into(),
4173                    },
4174                ],
4175            }],
4176        );
4177        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 5.0, 8.0));
4178        let model = interp.derive(scope, "R").unwrap();
4179        // 2 slope + 2 gable-end panels
4180        assert_eq!(model.len(), 4);
4181        let tiles: Vec<_> = model
4182            .terminals
4183            .iter()
4184            .filter(|t| t.mesh_id == "Tiles")
4185            .collect();
4186        let bricks: Vec<_> = model
4187            .terminals
4188            .iter()
4189            .filter(|t| t.mesh_id == "Bricks")
4190            .collect();
4191        assert_eq!(tiles.len(), 2);
4192        assert_eq!(bricks.len(), 2);
4193    }
4194
4195    #[test]
4196    fn test_roof_hip_produces_four_slopes() {
4197        let mut interp = Interpreter::new();
4198        interp.add_rule(
4199            "R",
4200            vec![ShapeOp::Roof {
4201                spec: crate::ops::RoofSpec::from({
4202                    let mut c = RoofConfig::new(RoofType::Hip, 45.0);
4203                    c.overhang = 0.3;
4204                    c
4205                }),
4206                cases: vec![crate::ops::RoofCase {
4207                    selector: RoofFaceSelector::Slope,
4208                    rule: "Tiles".into(),
4209                }],
4210            }],
4211        );
4212        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 4.0, 8.0));
4213        let model = interp.derive(scope, "R").unwrap();
4214        assert_eq!(model.len(), 4);
4215    }
4216
4217    #[test]
4218    fn test_roof_pyramid_produces_four_tapered_slopes() {
4219        let mut interp = Interpreter::new();
4220        interp.add_rule(
4221            "R",
4222            vec![ShapeOp::Roof {
4223                spec: RoofConfig::new(RoofType::Pyramid, 40.0).into(),
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(6.0, 3.0, 6.0));
4231        let model = interp.derive(scope, "R").unwrap();
4232        assert_eq!(model.len(), 4);
4233        // All pyramid panels carry Triangle face profile
4234        for t in &model.terminals {
4235            assert!(
4236                matches!(t.face_profile, FaceProfile::Triangle { peak_offset } if (peak_offset - 0.5).abs() < 1e-9),
4237                "expected Triangle{{peak_offset=0.5}}, got {:?}",
4238                t.face_profile
4239            );
4240        }
4241    }
4242
4243    #[test]
4244    fn test_roof_slope_normals_outward() {
4245        // All four Hip slopes must have Local Z (= scope.rotation * Z) pointing
4246        // AWAY from the building:
4247        //   front  → (0,  cos α, −sin α)   back  → (0, cos α, +sin α)
4248        //   left   → (−sin α, cos α,  0)   right → (+sin α, cos α,  0)
4249        let alpha: f64 = 30_f64.to_radians();
4250        let cos_a = alpha.cos();
4251        let sin_a = alpha.sin();
4252        let mut interp = Interpreter::new();
4253        interp.add_rule(
4254            "R",
4255            vec![ShapeOp::Roof {
4256                spec: RoofConfig::new(RoofType::Hip, 30.0).into(),
4257                cases: vec![crate::ops::RoofCase {
4258                    selector: RoofFaceSelector::Slope,
4259                    rule: "S".into(),
4260                }],
4261            }],
4262        );
4263        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 4.0, 8.0));
4264        let model = interp.derive(scope, "R").unwrap();
4265        assert_eq!(model.len(), 4);
4266        let normals: Vec<Vec3> = model
4267            .terminals
4268            .iter()
4269            .map(|t| t.scope.rotation * Vec3::Z)
4270            .collect();
4271        let expected = [
4272            Vec3::new(0.0, cos_a, -sin_a), // front: up & forward
4273            Vec3::new(0.0, cos_a, sin_a),  // back:  up & backward
4274            Vec3::new(-sin_a, cos_a, 0.0), // left:  up & left
4275            Vec3::new(sin_a, cos_a, 0.0),  // right: up & right
4276        ];
4277        for exp in &expected {
4278            assert!(
4279                normals.iter().any(|n| (*n - *exp).length() < 1e-6),
4280                "missing outward normal {:?}; got {:?}",
4281                exp,
4282                normals
4283            );
4284        }
4285        // All normals must have a positive Y component (point upward).
4286        for n in &normals {
4287            assert!(n.y > 0.0, "normal pointing downward: {:?}", n);
4288        }
4289    }
4290
4291    #[test]
4292    fn test_align_antiparallel_fallback_no_nan() {
4293        // When the local axis is exactly anti-parallel to the target, the fallback
4294        // 180° rotation must produce a unit quaternion, not NaN.
4295        // Rotate scope so local Y = −Y (anti-parallel to world Up), then Align(Y, Up).
4296        let flip_y = Quat::from_axis_angle(Vec3::Z, PI);
4297        let mut interp = Interpreter::new();
4298        interp.add_rule(
4299            "R",
4300            vec![
4301                ShapeOp::Rotate(qexpr(flip_y)),
4302                ShapeOp::Align {
4303                    local_axis: Axis::Y,
4304                    target: Vec3::Y,
4305                },
4306                ShapeOp::I("M".to_string()),
4307            ],
4308        );
4309        let model = interp.derive(Scope::unit(), "R").unwrap();
4310        let world_y = model.terminals[0].scope.rotation * Vec3::Y;
4311        assert!(
4312            (world_y - Vec3::Y).length() < 1e-6,
4313            "anti-parallel Align should point Y to world up, got {:?}",
4314            world_y
4315        );
4316        // Quaternion must remain unit.
4317        let r = model.terminals[0].scope.rotation;
4318        assert!(
4319            (r.length_squared() - 1.0).abs() < 1e-9,
4320            "rotation not unit: length_sq={}",
4321            r.length_squared()
4322        );
4323    }
4324
4325    #[test]
4326    fn test_roof_invalid_angle_rejected() {
4327        let mut interp = Interpreter::new();
4328        interp.add_rule(
4329            "R",
4330            vec![ShapeOp::Roof {
4331                spec: RoofConfig::new(RoofType::Shed, 0.0).into(),
4332                cases: vec![],
4333            }],
4334        );
4335        assert!(matches!(
4336            interp.derive(Scope::unit(), "R"),
4337            Err(ShapeError::InvalidRoofAngle(_))
4338        ));
4339    }
4340}