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::model::{FaceProfile, ShapeModel, Terminal, taper_to_profile};
18use crate::ops::{
19    AttachCase, AttachSelector, Axis, CompTarget, FaceSelector, OffsetCase, OffsetSelector,
20    RoofCase, RoofConfig, RoofFaceSelector, RoofType, ShapeOp, SplitSize, SplitSlot,
21};
22use crate::scope::{Quat, Scope, Vec3};
23
24/// Safety caps (DoS protection).
25const MAX_DEPTH: usize = 64;
26const MAX_QUEUE: usize = 100_000;
27const MAX_TERMINALS: usize = 100_000;
28
29// ── Weighted rule variant ─────────────────────────────────────────────────────
30
31/// One alternative in a stochastic or deterministic rule.
32#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
33pub struct WeightedVariant {
34    /// Relative weight (need not sum to 1.0 across variants).
35    pub weight: f64,
36    pub ops: Vec<ShapeOp>,
37}
38
39// ── Work queue item ───────────────────────────────────────────────────────────
40
41struct WorkItem {
42    scope: Scope,
43    rule: String,
44    depth: usize,
45    /// Taper value set by `ShapeOp::Taper` within this rule invocation; propagated
46    /// to the terminal. Branching ops (Split/Comp/Repeat) reset it to 0.0 for
47    /// children — taper is not accumulated across rule boundaries.
48    taper: f64,
49    /// Explicit face profile set by `Roof` panel generation; overrides `taper`
50    /// when computing the terminal's `face_profile`.
51    face_profile_override: Option<FaceProfile>,
52    /// Material identifier set by `Mat("...")` ops; propagates to child scopes.
53    material: Option<String>,
54}
55
56// ── Split size resolution ─────────────────────────────────────────────────────
57
58/// Resolves `SplitSlot` sizes against `total_dim`, returning absolute sizes.
59fn resolve_split_sizes(slots: &[SplitSlot], total_dim: f64) -> Result<Vec<f64>, ShapeError> {
60    if slots.is_empty() {
61        return Err(ShapeError::EmptySplit);
62    }
63    for slot in slots {
64        if !slot.size.is_valid() {
65            return match &slot.size {
66                SplitSize::Floating(v) => Err(ShapeError::InvalidFloatingSize(*v)),
67                _ => Err(ShapeError::InvalidNumericValue),
68            };
69        }
70    }
71
72    let mut fixed: Vec<Option<f64>> = Vec::with_capacity(slots.len());
73    let mut used = 0.0_f64;
74    let mut float_weight_total = 0.0_f64;
75
76    for slot in slots {
77        match slot.size {
78            SplitSize::Absolute(v) => {
79                fixed.push(Some(v));
80                used += v;
81            }
82            SplitSize::Relative(t) => {
83                let s = total_dim * t;
84                fixed.push(Some(s));
85                used += s;
86            }
87            SplitSize::Floating(w) => {
88                fixed.push(None);
89                float_weight_total += w;
90            }
91        }
92    }
93
94    // Guard against absolute-size sum overflow (e.g. 256 slots each with 1e307).
95    if !used.is_finite() {
96        return Err(ShapeError::InvalidNumericValue);
97    }
98
99    if used > total_dim + 1e-9 {
100        return Err(ShapeError::SplitOverflow(total_dim));
101    }
102
103    let remaining = (total_dim - used).max(0.0);
104
105    // Guard against weight sum overflow (e.g. 256 slots each with weight 1e307).
106    if !float_weight_total.is_finite() {
107        return Err(ShapeError::InvalidNumericValue);
108    }
109
110    let mut result = Vec::with_capacity(slots.len());
111    for (i, slot) in slots.iter().enumerate() {
112        match fixed[i] {
113            Some(v) => result.push(v),
114            None => {
115                let w = match slot.size {
116                    SplitSize::Floating(w) => w,
117                    _ => unreachable!(),
118                };
119                if float_weight_total <= 0.0 {
120                    return Err(ShapeError::NoFloatingSlots);
121                }
122                // Compute the ratio first (≤ 1.0) to avoid an intermediate
123                // product overflow when both `remaining` and `w` are large.
124                result.push(remaining * (w / float_weight_total));
125            }
126        }
127    }
128
129    Ok(result)
130}
131
132// ── Scope slicing helpers ─────────────────────────────────────────────────────
133
134/// Creates a child scope that is a sub-interval `[offset, offset+size]` of the
135/// parent scope along `axis`. All measurements are in local (scope) units.
136fn slice_scope(parent: &Scope, axis: Axis, offset: f64, size: f64) -> Scope {
137    let offset_vec = match axis {
138        Axis::X => Vec3::new(offset, 0.0, 0.0),
139        Axis::Y => Vec3::new(0.0, offset, 0.0),
140        Axis::Z => Vec3::new(0.0, 0.0, offset),
141    };
142
143    let child_position = parent.position + parent.rotation * offset_vec;
144
145    let child_size = match axis {
146        Axis::X => Vec3::new(size, parent.size.y, parent.size.z),
147        Axis::Y => Vec3::new(parent.size.x, size, parent.size.z),
148        Axis::Z => Vec3::new(parent.size.x, parent.size.y, size),
149    };
150
151    Scope::new(child_position, parent.rotation, child_size)
152}
153
154// ── Face decomposition ────────────────────────────────────────────────────────
155
156/// All six canonical faces of an OBB, each with a proper outward-facing orientation.
157///
158/// **Convention:** Local **Z** points along the outward normal.  Local **X** is
159/// world-horizontal along the face; Local **Y** is world-up for vertical faces.
160///
161/// This means `Split(X)` tiles a wall horizontally and `Split(Y)` divides it
162/// into floors — the same grammar rule works on any vertical face without manual
163/// rotation hacks.
164///
165/// Each entry: `(selector, local_offset, face_size, rotation_delta)`.
166/// `face_size` uses Z=0 (flattened 2-D canvas).
167fn face_descs(scope_size: Vec3) -> [(FaceSelector, Vec3, Vec3, Quat); 6] {
168    let sx = scope_size.x;
169    let sy = scope_size.y;
170    let sz = scope_size.z;
171
172    [
173        // Bottom: outward = -Y.  Local X=+X, Local Y=+Z, Local Z=-Y.
174        // Rotation: from_axis_angle(X, +π/2) → X→X, Y→+Z, Z→-Y.
175        (
176            FaceSelector::Bottom,
177            Vec3::new(0.0, 0.0, 0.0),
178            Vec3::new(sx, sz, 0.0),
179            Quat::from_axis_angle(Vec3::X, FRAC_PI_2),
180        ),
181        // Top: outward = +Y.  Local X=+X, Local Y=-Z, Local Z=+Y.
182        // Rotation: from_axis_angle(X, -π/2) → X→X, Y→-Z, Z→+Y.
183        // Origin at (0, sy, sz) so local-Y tiles from back to front.
184        (
185            FaceSelector::Top,
186            Vec3::new(0.0, sy, sz),
187            Vec3::new(sx, sz, 0.0),
188            Quat::from_axis_angle(Vec3::X, -FRAC_PI_2),
189        ),
190        // Front: outward = -Z.  Local X=-X, Local Y=+Y, Local Z=-Z.
191        // Rotation: from_axis_angle(Y, π) → X→-X, Y→Y, Z→-Z.
192        // Origin at (sx, 0, 0) so local-X tiles from right to left in world.
193        (
194            FaceSelector::Front,
195            Vec3::new(sx, 0.0, 0.0),
196            Vec3::new(sx, sy, 0.0),
197            Quat::from_axis_angle(Vec3::Y, PI),
198        ),
199        // Back: outward = +Z.  Local X=+X, Local Y=+Y, Local Z=+Z.
200        // Rotation: identity.
201        // Origin at (0, 0, sz).
202        (
203            FaceSelector::Back,
204            Vec3::new(0.0, 0.0, sz),
205            Vec3::new(sx, sy, 0.0),
206            Quat::IDENTITY,
207        ),
208        // Left: outward = -X.  Local X=+Z, Local Y=+Y, Local Z=-X.
209        // Rotation: from_axis_angle(Y, -π/2) → X→+Z, Y→Y, Z→-X.
210        // Origin at (0, 0, 0).
211        (
212            FaceSelector::Left,
213            Vec3::new(0.0, 0.0, 0.0),
214            Vec3::new(sz, sy, 0.0),
215            Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
216        ),
217        // Right: outward = +X.  Local X=-Z, Local Y=+Y, Local Z=+X.
218        // Rotation: from_axis_angle(Y, +π/2) → X→-Z, Y→Y, Z→+X.
219        // Origin at (sx, 0, sz).
220        (
221            FaceSelector::Right,
222            Vec3::new(sx, 0.0, sz),
223            Vec3::new(sz, sy, 0.0),
224            Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
225        ),
226    ]
227}
228
229/// Finds the rule to apply to a face given a list of `CompFaceCase`s.
230fn find_face_rule(selector: FaceSelector, cases: &[crate::ops::CompFaceCase]) -> Option<&str> {
231    for case in cases {
232        if case.selector == selector {
233            return Some(&case.rule);
234        }
235    }
236    let is_side = matches!(
237        selector,
238        FaceSelector::Front | FaceSelector::Back | FaceSelector::Left | FaceSelector::Right
239    );
240    if is_side {
241        for case in cases {
242            if case.selector == FaceSelector::Side {
243                return Some(&case.rule);
244            }
245        }
246    }
247    for case in cases {
248        if case.selector == FaceSelector::All {
249            return Some(&case.rule);
250        }
251    }
252    None
253}
254
255/// Returns the local-space unit vector for the given axis.
256fn axis_vec(axis: Axis) -> Vec3 {
257    match axis {
258        Axis::X => Vec3::X,
259        Axis::Y => Vec3::Y,
260        Axis::Z => Vec3::Z,
261    }
262}
263
264/// Rule look-up for `Offset` cases.
265fn find_offset_rule(selector: OffsetSelector, cases: &[OffsetCase]) -> Option<&str> {
266    for c in cases {
267        if c.selector == selector {
268            return Some(&c.rule);
269        }
270    }
271    for c in cases {
272        if c.selector == OffsetSelector::All {
273            return Some(&c.rule);
274        }
275    }
276    None
277}
278
279/// Rule look-up for `Roof` cases.
280fn find_roof_rule(selector: RoofFaceSelector, cases: &[RoofCase]) -> Option<&str> {
281    for c in cases {
282        if c.selector == selector {
283            return Some(&c.rule);
284        }
285    }
286    for c in cases {
287        if c.selector == RoofFaceSelector::All {
288            return Some(&c.rule);
289        }
290    }
291    None
292}
293
294/// Rule look-up for `Attach` cases.
295fn find_attach_rule(selector: AttachSelector, cases: &[AttachCase]) -> Option<&str> {
296    for c in cases {
297        if c.selector == selector {
298            return Some(&c.rule);
299        }
300    }
301    for c in cases {
302        if c.selector == AttachSelector::All {
303            return Some(&c.rule);
304        }
305    }
306    None
307}
308
309/// Rotations for the four cardinal slope directions used by `Roof`.
310///
311/// All rotations are expressed as deltas in the **parent scope's local frame**
312/// (composed as `scope.rotation * delta` to obtain the world-space rotation).
313///
314/// Convention: Local Z = outward normal (away from building), Local Y = up the slope,
315/// matching the `Comp(Faces)` face convention extended to tilted surfaces.
316///
317/// - `front_rot`: outward normal = (0, cos α, −sin α) — up & forward (−Z world)
318/// - `back_rot`:  outward normal = (0, cos α, +sin α) — up & backward (+Z world)
319/// - `left_rot`:  outward normal = (−sin α, cos α, 0) — up & left (−X world)
320/// - `right_rot`: outward normal = (+sin α, cos α, 0) — up & right (+X world)
321fn roof_slope_rotations(alpha: f64) -> (Quat, Quat, Quat, Quat) {
322    // front: mirror the Comp-Front face (flip X via Y-π rotation), then tilt the slope.
323    //   Local X = (−1, 0, 0), Local Z = (0, cos α, −sin α) — outward up & forward.
324    let front_rot =
325        Quat::from_axis_angle(Vec3::X, FRAC_PI_2 - alpha) * Quat::from_axis_angle(Vec3::Y, PI);
326    // back: identity orientation tilted by (α − π/2).
327    //   Local X = (+1, 0, 0), Local Z = (0, cos α, +sin α) — outward up & backward.
328    let back_rot = Quat::from_axis_angle(Vec3::X, alpha - FRAC_PI_2);
329    // left: Comp-Left face orientation (Y, −π/2) then tilt.
330    //   Local X = (0, 0, +1), Local Z = (−sin α, cos α, 0) — outward up & left.
331    let left_rot = Quat::from_axis_angle(Vec3::Z, alpha - FRAC_PI_2)
332        * Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2);
333    // right: Comp-Right face orientation (Y, +π/2) then tilt.
334    //   Local X = (0, 0, −1), Local Z = (+sin α, cos α, 0) — outward up & right.
335    let right_rot = Quat::from_axis_angle(Vec3::Z, FRAC_PI_2 - alpha)
336        * Quat::from_axis_angle(Vec3::Y, FRAC_PI_2);
337    (front_rot, back_rot, left_rot, right_rot)
338}
339
340// ── Roof geometry ─────────────────────────────────────────────────────────────
341
342/// Generates roof panel scopes for all supported `RoofType` variants.
343///
344/// Each panel is a flat scope (size.z = 0) with an orientation that places
345/// local Z along the outward normal and local Y up the slope, consistent with
346/// the `Comp(Faces)` convention. The `face_profile_override` field on each
347/// child `WorkItem` carries the exact 2D cross-section shape.
348#[allow(clippy::too_many_arguments)]
349fn apply_roof(
350    config: &RoofConfig,
351    cases: &[RoofCase],
352    scope: &Scope,
353    depth: usize,
354    material: &Option<String>,
355    queue: &mut VecDeque<WorkItem>,
356    model: &mut ShapeModel,
357    max_terminals: usize,
358) -> Result<(), ShapeError> {
359    if !config.pitch.is_finite() || config.pitch <= 0.0 || config.pitch >= 90.0 {
360        return Err(ShapeError::InvalidRoofAngle(config.pitch));
361    }
362    if !config.overhang.is_finite() || config.overhang < 0.0 {
363        return Err(ShapeError::InvalidNumericValue);
364    }
365
366    let sx = scope.size.x;
367    let sz = scope.size.z;
368    let o = config.overhang;
369    let alpha = config.pitch.to_radians();
370    let cos_a = alpha.cos();
371    let tan_a = alpha.tan();
372    let (front_rot, back_rot, left_rot, right_rot) = roof_slope_rotations(alpha);
373    // y_anchor: Y offset below scope.position due to eave overhang projection.
374    let y_anchor = -o * tan_a;
375    // Slope lengths from eave to ridge centre (including overhang).
376    let fb_len = (sz / 2.0 + o) / cos_a;
377    let lr_len = (sx / 2.0 + o) / cos_a;
378    // Ridge height above eave (driven by depth, no overhang contribution to height).
379    let h = (sz / 2.0) * tan_a;
380
381    if !fb_len.is_finite() || !lr_len.is_finite() || !h.is_finite() {
382        return Err(ShapeError::InvalidNumericValue);
383    }
384
385    // Panel tuple: (local_offset, face_size, rot_delta, selector, face_profile)
386    type Panel = (Vec3, Vec3, Quat, RoofFaceSelector, FaceProfile);
387
388    let panels: Vec<Panel> = match config.roof_type {
389        // ── Flat ─────────────────────────────────────────────────────────────
390        // One horizontal panel covering the scope top (same geometry as Comp Top).
391        RoofType::Flat => vec![(
392            Vec3::new(0.0, 0.0, sz),
393            Vec3::new(sx, sz, 0.0),
394            Quat::from_axis_angle(Vec3::X, -FRAC_PI_2),
395            RoofFaceSelector::Slope,
396            FaceProfile::Rectangle,
397        )],
398
399        // ── Shed ─────────────────────────────────────────────────────────────
400        // One slope from front eave to back eave (front_rot convention).
401        RoofType::Shed => {
402            let shed_h = sz * tan_a;
403            vec![
404                (
405                    Vec3::new(sx + o, y_anchor, -o),
406                    Vec3::new(sx + 2.0 * o, (sz + 2.0 * o) / cos_a, 0.0),
407                    front_rot,
408                    RoofFaceSelector::Slope,
409                    FaceProfile::Rectangle,
410                ),
411                (
412                    Vec3::new(0.0, 0.0, 0.0),
413                    Vec3::new(sz, shed_h, 0.0),
414                    Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
415                    RoofFaceSelector::GableEnd,
416                    FaceProfile::Triangle { peak_offset: 1.0 },
417                ),
418                (
419                    Vec3::new(sx, 0.0, sz),
420                    Vec3::new(sz, shed_h, 0.0),
421                    Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
422                    RoofFaceSelector::GableEnd,
423                    FaceProfile::Triangle { peak_offset: 0.0 },
424                ),
425            ]
426        }
427
428        // ── Gable / OpenGable / BoxGable ──────────────────────────────────────
429        RoofType::Gable | RoofType::OpenGable | RoofType::BoxGable => {
430            let (slope_len, eave_len, ridge_h) = if sx >= sz {
431                ((sz / 2.0 + o) / cos_a, sx + 2.0 * o, (sz / 2.0) * tan_a)
432            } else {
433                ((sx / 2.0 + o) / cos_a, sz + 2.0 * o, (sx / 2.0) * tan_a)
434            };
435
436            let mut panels = if sx >= sz {
437                vec![
438                    (
439                        Vec3::new(sx + o, y_anchor, -o),
440                        Vec3::new(eave_len, slope_len, 0.0),
441                        front_rot,
442                        RoofFaceSelector::Slope,
443                        FaceProfile::Rectangle,
444                    ),
445                    (
446                        Vec3::new(-o, y_anchor, sz + o),
447                        Vec3::new(eave_len, slope_len, 0.0),
448                        back_rot,
449                        RoofFaceSelector::Slope,
450                        FaceProfile::Rectangle,
451                    ),
452                ]
453            } else {
454                vec![
455                    (
456                        Vec3::new(-o, y_anchor, -o),
457                        Vec3::new(eave_len, slope_len, 0.0),
458                        left_rot,
459                        RoofFaceSelector::Slope,
460                        FaceProfile::Rectangle,
461                    ),
462                    (
463                        Vec3::new(sx + o, y_anchor, sz + o),
464                        Vec3::new(eave_len, slope_len, 0.0),
465                        right_rot,
466                        RoofFaceSelector::Slope,
467                        FaceProfile::Rectangle,
468                    ),
469                ]
470            };
471
472            if config.roof_type != RoofType::OpenGable {
473                let profile = if config.roof_type == RoofType::BoxGable {
474                    FaceProfile::Rectangle
475                } else {
476                    FaceProfile::Triangle { peak_offset: 0.5 }
477                };
478
479                if sx >= sz {
480                    panels.extend(vec![
481                        (
482                            Vec3::new(0.0, 0.0, 0.0),
483                            Vec3::new(sz, ridge_h, 0.0),
484                            Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
485                            RoofFaceSelector::GableEnd,
486                            profile.clone(),
487                        ),
488                        (
489                            Vec3::new(sx, 0.0, sz),
490                            Vec3::new(sz, ridge_h, 0.0),
491                            Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
492                            RoofFaceSelector::GableEnd,
493                            profile,
494                        ),
495                    ]);
496                } else {
497                    panels.extend(vec![
498                        (
499                            Vec3::new(sx, 0.0, 0.0),
500                            Vec3::new(sx, ridge_h, 0.0),
501                            Quat::from_axis_angle(Vec3::Y, PI),
502                            RoofFaceSelector::GableEnd,
503                            profile.clone(),
504                        ),
505                        (
506                            Vec3::new(0.0, 0.0, sz),
507                            Vec3::new(sx, ridge_h, 0.0),
508                            Quat::IDENTITY,
509                            RoofFaceSelector::GableEnd,
510                            profile,
511                        ),
512                    ]);
513                }
514            }
515            panels
516        }
517
518        // ── Pyramid / PyramidHip / Hip ─────────────────────────────────────────
519        // For non-square bases, true pyramids with equal pitch are mathematically
520        // impossible; they correctly degenerate into a Hip roof with a ridge.
521        RoofType::Pyramid | RoofType::PyramidHip | RoofType::Hip => {
522            let eave_w = sx + 2.0 * o;
523            let eave_d = sz + 2.0 * o;
524            let max_run = sx.min(sz) / 2.0 + o;
525            let slope_len = max_run / cos_a;
526
527            let (fb_profile, lr_profile) = if sx > sz + 1e-5 {
528                let top_w = (sx - sz) / eave_w;
529                let off_x = (sz / 2.0 + o) / eave_w;
530                (
531                    FaceProfile::Trapezoid {
532                        top_width: top_w,
533                        offset_x: off_x,
534                    },
535                    FaceProfile::Triangle { peak_offset: 0.5 },
536                )
537            } else if sz > sx + 1e-5 {
538                let top_w = (sz - sx) / eave_d;
539                let off_x = (sx / 2.0 + o) / eave_d;
540                (
541                    FaceProfile::Triangle { peak_offset: 0.5 },
542                    FaceProfile::Trapezoid {
543                        top_width: top_w,
544                        offset_x: off_x,
545                    },
546                )
547            } else {
548                (
549                    FaceProfile::Triangle { peak_offset: 0.5 },
550                    FaceProfile::Triangle { peak_offset: 0.5 },
551                )
552            };
553
554            vec![
555                (
556                    Vec3::new(sx + o, y_anchor, -o),
557                    Vec3::new(eave_w, slope_len, 0.0),
558                    front_rot,
559                    RoofFaceSelector::Slope,
560                    fb_profile.clone(),
561                ),
562                (
563                    Vec3::new(-o, y_anchor, sz + o),
564                    Vec3::new(eave_w, slope_len, 0.0),
565                    back_rot,
566                    RoofFaceSelector::Slope,
567                    fb_profile,
568                ),
569                (
570                    Vec3::new(-o, y_anchor, -o),
571                    Vec3::new(eave_d, slope_len, 0.0),
572                    left_rot,
573                    RoofFaceSelector::Slope,
574                    lr_profile.clone(),
575                ),
576                (
577                    Vec3::new(sx + o, y_anchor, sz + o),
578                    Vec3::new(eave_d, slope_len, 0.0),
579                    right_rot,
580                    RoofFaceSelector::Slope,
581                    lr_profile,
582                ),
583            ]
584        }
585
586        // ── Butterfly ─────────────────────────────────────────────────────────
587        // Two inward-tilting slopes with a valley at centre (z = sz/2).
588        // Panels run FROM the valley TOWARD each eave using back_rot/front_rot.
589        RoofType::Butterfly => {
590            // The valley sits (sz/2 + o)*tan_a below eave level.
591            let y_valley = y_anchor - (sz / 2.0 + o) * tan_a;
592            if !y_valley.is_finite() {
593                return Err(ShapeError::InvalidNumericValue);
594            }
595            vec![
596                // Front valley slope: valley → front eave (back_rot points toward -Z = front)
597                (
598                    Vec3::new(-o, y_valley, sz / 2.0),
599                    Vec3::new(sx + 2.0 * o, fb_len, 0.0),
600                    back_rot,
601                    RoofFaceSelector::ValleySlope,
602                    FaceProfile::Rectangle,
603                ),
604                // Back valley slope: valley → back eave (front_rot points toward +Z = back)
605                (
606                    Vec3::new(sx + o, y_valley, sz / 2.0),
607                    Vec3::new(sx + 2.0 * o, fb_len, 0.0),
608                    front_rot,
609                    RoofFaceSelector::ValleySlope,
610                    FaceProfile::Rectangle,
611                ),
612            ]
613        }
614
615        // ── MShaped ───────────────────────────────────────────────────────────
616        // Two ridges (at z = sz/4 and z = 3*sz/4) with a valley at z = sz/2.
617        // Four slopes: outer-front, inner-front (valley), inner-back (valley), outer-back.
618        RoofType::MShaped => {
619            let quarter = sz / 4.0;
620            let h_m = quarter * tan_a;
621            if !h_m.is_finite() {
622                return Err(ShapeError::InvalidNumericValue);
623            }
624            let slope_m = quarter / cos_a;
625            let y_valley = y_anchor - h_m; // valley is h_m below the outer ridges
626            vec![
627                // Outer front: eave (z=-o) → front ridge (z=sz/4)
628                (
629                    Vec3::new(sx + o, y_anchor, -o),
630                    Vec3::new(sx + 2.0 * o, slope_m, 0.0),
631                    front_rot,
632                    RoofFaceSelector::OuterSlope,
633                    FaceProfile::Rectangle,
634                ),
635                // Inner front: valley (z=sz/2) → front ridge (z=sz/4), using back_rot
636                (
637                    Vec3::new(-o, y_valley, sz / 2.0),
638                    Vec3::new(sx + 2.0 * o, slope_m, 0.0),
639                    back_rot,
640                    RoofFaceSelector::InnerSlope,
641                    FaceProfile::Rectangle,
642                ),
643                // Inner back: valley (z=sz/2) → back ridge (z=3*sz/4), using front_rot
644                (
645                    Vec3::new(sx + o, y_valley, sz / 2.0),
646                    Vec3::new(sx + 2.0 * o, slope_m, 0.0),
647                    front_rot,
648                    RoofFaceSelector::InnerSlope,
649                    FaceProfile::Rectangle,
650                ),
651                // Outer back: eave (z=sz+o) → back ridge (z=3*sz/4)
652                (
653                    Vec3::new(-o, y_anchor, sz + o),
654                    Vec3::new(sx + 2.0 * o, slope_m, 0.0),
655                    back_rot,
656                    RoofFaceSelector::OuterSlope,
657                    FaceProfile::Rectangle,
658                ),
659            ]
660        }
661
662        // ── Gambrel ───────────────────────────────────────────────────────────
663        // Two-pitch front/back barn roof: steep lower zone + shallow upper zone.
664        RoofType::Gambrel => {
665            let alpha2 = config.secondary_pitch_or_default().to_radians();
666            if !alpha2.is_finite() || alpha2 <= 0.0 || alpha2 >= FRAC_PI_2 {
667                return Err(ShapeError::InvalidNumericValue);
668            }
669            let cos_a2 = alpha2.cos();
670            let tan_a2 = alpha2.tan();
671            let tier = config.tier_height_or(0.5).clamp(0.01, 0.99);
672            let (ufr, ubr, ulr, urr) = roof_slope_rotations(alpha2);
673
674            if sx >= sz {
675                let run_z = sz / 2.0 + o;
676                let break_run = (tier * run_z).clamp(o, run_z - 1e-3);
677                let h_break = break_run * tan_a;
678                if !h_break.is_finite() {
679                    return Err(ShapeError::InvalidNumericValue);
680                }
681                let lower_slope = break_run / cos_a;
682                let upper_run = run_z - break_run;
683                let upper_slope = upper_run / cos_a2;
684                let upper_h = upper_run * tan_a2;
685                let y_break = y_anchor + h_break;
686                let eave_w = sx + 2.0 * o;
687                let wall_y_break = h_break - o * tan_a;
688                let wall_break_run = break_run - o;
689                let mid_w = (sz - 2.0 * wall_break_run).max(0.0);
690
691                let lower_gable_profile = if mid_w > 1e-9 {
692                    FaceProfile::Trapezoid {
693                        top_width: mid_w / sz,
694                        offset_x: wall_break_run / sz,
695                    }
696                } else {
697                    FaceProfile::Triangle { peak_offset: 0.5 }
698                };
699
700                vec![
701                    (
702                        Vec3::new(sx + o, y_anchor, -o),
703                        Vec3::new(eave_w, lower_slope, 0.0),
704                        front_rot,
705                        RoofFaceSelector::LowerSlope,
706                        FaceProfile::Rectangle,
707                    ),
708                    (
709                        Vec3::new(-o, y_anchor, sz + o),
710                        Vec3::new(eave_w, lower_slope, 0.0),
711                        back_rot,
712                        RoofFaceSelector::LowerSlope,
713                        FaceProfile::Rectangle,
714                    ),
715                    (
716                        Vec3::new(sx + o, y_break, -o + break_run),
717                        Vec3::new(eave_w, upper_slope, 0.0),
718                        ufr,
719                        RoofFaceSelector::UpperSlope,
720                        FaceProfile::Rectangle,
721                    ),
722                    (
723                        Vec3::new(-o, y_break, sz + o - break_run),
724                        Vec3::new(eave_w, upper_slope, 0.0),
725                        ubr,
726                        RoofFaceSelector::UpperSlope,
727                        FaceProfile::Rectangle,
728                    ),
729                    (
730                        Vec3::new(0.0, 0.0, 0.0),
731                        Vec3::new(sz, wall_y_break, 0.0),
732                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
733                        RoofFaceSelector::GableEnd,
734                        lower_gable_profile.clone(),
735                    ),
736                    (
737                        Vec3::new(sx, 0.0, sz),
738                        Vec3::new(sz, wall_y_break, 0.0),
739                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
740                        RoofFaceSelector::GableEnd,
741                        lower_gable_profile,
742                    ),
743                    (
744                        Vec3::new(0.0, wall_y_break, wall_break_run),
745                        Vec3::new(mid_w, upper_h, 0.0),
746                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
747                        RoofFaceSelector::GableEnd,
748                        FaceProfile::Triangle { peak_offset: 0.5 },
749                    ),
750                    (
751                        Vec3::new(sx, wall_y_break, sz - wall_break_run),
752                        Vec3::new(mid_w, upper_h, 0.0),
753                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
754                        RoofFaceSelector::GableEnd,
755                        FaceProfile::Triangle { peak_offset: 0.5 },
756                    ),
757                ]
758            } else {
759                let run_x = sx / 2.0 + o;
760                let break_run = (tier * run_x).clamp(o, run_x - 1e-3);
761                let h_break = break_run * tan_a;
762                if !h_break.is_finite() {
763                    return Err(ShapeError::InvalidNumericValue);
764                }
765                let lower_slope = break_run / cos_a;
766                let upper_run = run_x - break_run;
767                let upper_slope = upper_run / cos_a2;
768                let upper_h = upper_run * tan_a2;
769                let y_break = y_anchor + h_break;
770                let eave_d = sz + 2.0 * o;
771                let wall_y_break = h_break - o * tan_a;
772                let wall_break_run = break_run - o;
773                let mid_d = (sx - 2.0 * wall_break_run).max(0.0);
774
775                let lower_gable_profile = if mid_d > 1e-9 {
776                    FaceProfile::Trapezoid {
777                        top_width: mid_d / sx,
778                        offset_x: wall_break_run / sx,
779                    }
780                } else {
781                    FaceProfile::Triangle { peak_offset: 0.5 }
782                };
783
784                vec![
785                    (
786                        Vec3::new(-o, y_anchor, -o),
787                        Vec3::new(eave_d, lower_slope, 0.0),
788                        left_rot,
789                        RoofFaceSelector::LowerSlope,
790                        FaceProfile::Rectangle,
791                    ),
792                    (
793                        Vec3::new(sx + o, y_anchor, sz + o),
794                        Vec3::new(eave_d, lower_slope, 0.0),
795                        right_rot,
796                        RoofFaceSelector::LowerSlope,
797                        FaceProfile::Rectangle,
798                    ),
799                    (
800                        Vec3::new(-o + break_run, y_break, -o),
801                        Vec3::new(eave_d, upper_slope, 0.0),
802                        ulr,
803                        RoofFaceSelector::UpperSlope,
804                        FaceProfile::Rectangle,
805                    ),
806                    (
807                        Vec3::new(sx + o - break_run, y_break, sz + o),
808                        Vec3::new(eave_d, upper_slope, 0.0),
809                        urr,
810                        RoofFaceSelector::UpperSlope,
811                        FaceProfile::Rectangle,
812                    ),
813                    (
814                        Vec3::new(sx, 0.0, 0.0),
815                        Vec3::new(sx, wall_y_break, 0.0),
816                        Quat::from_axis_angle(Vec3::Y, PI),
817                        RoofFaceSelector::GableEnd,
818                        lower_gable_profile.clone(),
819                    ),
820                    (
821                        Vec3::new(0.0, 0.0, sz),
822                        Vec3::new(sx, wall_y_break, 0.0),
823                        Quat::IDENTITY,
824                        RoofFaceSelector::GableEnd,
825                        lower_gable_profile,
826                    ),
827                    (
828                        Vec3::new(sx - wall_break_run, wall_y_break, 0.0),
829                        Vec3::new(mid_d, upper_h, 0.0),
830                        Quat::from_axis_angle(Vec3::Y, PI),
831                        RoofFaceSelector::GableEnd,
832                        FaceProfile::Triangle { peak_offset: 0.5 },
833                    ),
834                    (
835                        Vec3::new(wall_break_run, wall_y_break, sz),
836                        Vec3::new(mid_d, upper_h, 0.0),
837                        Quat::IDENTITY,
838                        RoofFaceSelector::GableEnd,
839                        FaceProfile::Triangle { peak_offset: 0.5 },
840                    ),
841                ]
842            }
843        }
844
845        // ── Mansard ───────────────────────────────────────────────────────────
846        // Gambrel applied to all four sides: 4 steep lower + 4 shallow upper panels.
847        RoofType::Mansard => {
848            let alpha2 = config.secondary_pitch_or_default().to_radians();
849            if !alpha2.is_finite() || alpha2 <= 0.0 || alpha2 >= FRAC_PI_2 {
850                return Err(ShapeError::InvalidNumericValue);
851            }
852            let cos_a2 = alpha2.cos();
853            let tier = config.tier_height_or(0.5).clamp(0.01, 0.99);
854
855            let max_run = sx.min(sz) / 2.0 + o;
856            let break_run = (tier * max_run).clamp(o, max_run - 1e-3);
857            let h_break = break_run * tan_a;
858
859            if !h_break.is_finite() {
860                return Err(ShapeError::InvalidNumericValue);
861            }
862
863            let lower_slope = break_run / cos_a;
864            let y_break = y_anchor + h_break;
865
866            let eave_w = sx + 2.0 * o;
867            let eave_d = sz + 2.0 * o;
868
869            let mid_w = (eave_w - 2.0 * break_run).max(0.0);
870            let mid_d = (eave_d - 2.0 * break_run).max(0.0);
871
872            let lower_fb_profile = if mid_w > 1e-9 {
873                FaceProfile::Trapezoid {
874                    top_width: mid_w / eave_w,
875                    offset_x: break_run / eave_w,
876                }
877            } else {
878                FaceProfile::Triangle { peak_offset: 0.5 }
879            };
880
881            let lower_lr_profile = if mid_d > 1e-9 {
882                FaceProfile::Trapezoid {
883                    top_width: mid_d / eave_d,
884                    offset_x: break_run / eave_d,
885                }
886            } else {
887                FaceProfile::Triangle { peak_offset: 0.5 }
888            };
889
890            let (ufr, ubr, ulr, urr) = roof_slope_rotations(alpha2);
891
892            let upper_run = mid_w.min(mid_d) / 2.0;
893            let upper_slope = upper_run / cos_a2;
894
895            let top_w = (mid_w - 2.0 * upper_run).max(0.0);
896            let top_d = (mid_d - 2.0 * upper_run).max(0.0);
897
898            let upper_fb_profile = if top_w > 1e-9 {
899                FaceProfile::Trapezoid {
900                    top_width: top_w / mid_w,
901                    offset_x: upper_run / mid_w,
902                }
903            } else {
904                FaceProfile::Triangle { peak_offset: 0.5 }
905            };
906
907            let upper_lr_profile = if top_d > 1e-9 {
908                FaceProfile::Trapezoid {
909                    top_width: top_d / mid_d,
910                    offset_x: upper_run / mid_d,
911                }
912            } else {
913                FaceProfile::Triangle { peak_offset: 0.5 }
914            };
915
916            vec![
917                // Lower steep slopes
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                    lower_fb_profile.clone(),
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                    lower_fb_profile,
931                ),
932                (
933                    Vec3::new(-o, y_anchor, -o),
934                    Vec3::new(eave_d, lower_slope, 0.0),
935                    left_rot,
936                    RoofFaceSelector::LowerSlope,
937                    lower_lr_profile.clone(),
938                ),
939                (
940                    Vec3::new(sx + o, y_anchor, sz + o),
941                    Vec3::new(eave_d, lower_slope, 0.0),
942                    right_rot,
943                    RoofFaceSelector::LowerSlope,
944                    lower_lr_profile,
945                ),
946                // Upper shallow slopes
947                (
948                    Vec3::new(sx + o - break_run, y_break, -o + break_run),
949                    Vec3::new(mid_w, upper_slope, 0.0),
950                    ufr,
951                    RoofFaceSelector::UpperSlope,
952                    upper_fb_profile.clone(),
953                ),
954                (
955                    Vec3::new(-o + break_run, y_break, sz + o - break_run),
956                    Vec3::new(mid_w, upper_slope, 0.0),
957                    ubr,
958                    RoofFaceSelector::UpperSlope,
959                    upper_fb_profile,
960                ),
961                (
962                    Vec3::new(-o + break_run, y_break, -o + break_run),
963                    Vec3::new(mid_d, upper_slope, 0.0),
964                    ulr,
965                    RoofFaceSelector::UpperSlope,
966                    upper_lr_profile.clone(),
967                ),
968                (
969                    Vec3::new(sx + o - break_run, y_break, sz + o - break_run),
970                    Vec3::new(mid_d, upper_slope, 0.0),
971                    urr,
972                    RoofFaceSelector::UpperSlope,
973                    upper_lr_profile,
974                ),
975            ]
976        }
977
978        // ── Saltbox ───────────────────────────────────────────────────────────
979        // Asymmetric Gable: ridge offset from front by `ridge_offset` fraction of depth.
980        // Front slope is steeper (pitch = alpha); back slope angle derived from h and depth.
981        RoofType::Saltbox => {
982            let (orient_z, _width, depth) = if sx >= sz {
983                (true, sx, sz)
984            } else {
985                (false, sz, sx)
986            };
987            let ridge_d = depth * config.ridge_offset;
988            if !ridge_d.is_finite() || ridge_d <= 0.0 || ridge_d >= depth {
989                return Err(ShapeError::InvalidNumericValue);
990            }
991            let h_s = ridge_d * tan_a;
992            let back_depth = depth - ridge_d;
993            let alpha_back = ((h_s) / (back_depth + o)).atan();
994            let cos_ab = alpha_back.cos();
995            if !h_s.is_finite() || !alpha_back.is_finite() || cos_ab < 1e-9 {
996                return Err(ShapeError::InvalidNumericValue);
997            }
998            let front_len = (ridge_d + o) / cos_a;
999            let back_len = (back_depth + o) / cos_ab;
1000            if !front_len.is_finite() || !back_len.is_finite() {
1001                return Err(ShapeError::InvalidNumericValue);
1002            }
1003            let (_, back_rot_s, _, right_rot_s) = roof_slope_rotations(alpha_back);
1004            let peak_fwd = ridge_d / depth;
1005
1006            if orient_z {
1007                vec![
1008                    (
1009                        Vec3::new(sx + o, y_anchor, -o),
1010                        Vec3::new(sx + 2.0 * o, front_len, 0.0),
1011                        front_rot,
1012                        RoofFaceSelector::Slope,
1013                        FaceProfile::Rectangle,
1014                    ),
1015                    (
1016                        Vec3::new(-o, y_anchor, sz + o),
1017                        Vec3::new(sx + 2.0 * o, back_len, 0.0),
1018                        back_rot_s,
1019                        RoofFaceSelector::Slope,
1020                        FaceProfile::Rectangle,
1021                    ),
1022                    (
1023                        Vec3::new(0.0, 0.0, 0.0),
1024                        Vec3::new(sz, h_s, 0.0),
1025                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
1026                        RoofFaceSelector::GableEnd,
1027                        FaceProfile::Triangle {
1028                            peak_offset: peak_fwd,
1029                        },
1030                    ),
1031                    (
1032                        Vec3::new(sx, 0.0, sz),
1033                        Vec3::new(sz, h_s, 0.0),
1034                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
1035                        RoofFaceSelector::GableEnd,
1036                        FaceProfile::Triangle {
1037                            peak_offset: 1.0 - peak_fwd,
1038                        },
1039                    ),
1040                ]
1041            } else {
1042                vec![
1043                    (
1044                        Vec3::new(-o, y_anchor, -o),
1045                        Vec3::new(sz + 2.0 * o, front_len, 0.0),
1046                        left_rot,
1047                        RoofFaceSelector::Slope,
1048                        FaceProfile::Rectangle,
1049                    ),
1050                    (
1051                        Vec3::new(sx + o, y_anchor, sz + o),
1052                        Vec3::new(sz + 2.0 * o, back_len, 0.0),
1053                        right_rot_s,
1054                        RoofFaceSelector::Slope,
1055                        FaceProfile::Rectangle,
1056                    ),
1057                    (
1058                        Vec3::new(sx, 0.0, 0.0),
1059                        Vec3::new(sx, h_s, 0.0),
1060                        Quat::from_axis_angle(Vec3::Y, PI),
1061                        RoofFaceSelector::GableEnd,
1062                        FaceProfile::Triangle {
1063                            peak_offset: 1.0 - peak_fwd,
1064                        },
1065                    ),
1066                    (
1067                        Vec3::new(0.0, 0.0, sz),
1068                        Vec3::new(sx, h_s, 0.0),
1069                        Quat::IDENTITY,
1070                        RoofFaceSelector::GableEnd,
1071                        FaceProfile::Triangle {
1072                            peak_offset: peak_fwd,
1073                        },
1074                    ),
1075                ]
1076            }
1077        }
1078
1079        // ── Jerkinhead ────────────────────────────────────────────────────────
1080        // Gable with clipped-hip corners: main slopes are Trapezoid; small HipEnd triangles
1081        // fill the clipped gable-end corners.
1082        RoofType::Jerkinhead => {
1083            let tier = config.tier_height_or(0.25).clamp(0.01, 0.99);
1084            let orient_z = sx >= sz;
1085            let (width, depth) = if orient_z { (sx, sz) } else { (sz, sx) };
1086
1087            let max_clip = (width / 2.0 + o).min(depth / 2.0);
1088            let clip_run = (tier * depth / 2.0).clamp(0.0, max_clip - 1e-3);
1089
1090            let eave_w = width + 2.0 * o;
1091            let top_w = (eave_w - 2.0 * clip_run).max(0.0);
1092
1093            let slope_len = (depth / 2.0 + o) / cos_a;
1094
1095            let slope_profile = if top_w > 1e-9 {
1096                FaceProfile::Trapezoid {
1097                    top_width: top_w / eave_w,
1098                    offset_x: clip_run / eave_w,
1099                }
1100            } else {
1101                FaceProfile::Triangle { peak_offset: 0.5 }
1102            };
1103
1104            let true_h = (depth / 2.0) * tan_a;
1105            let wall_h = (true_h - clip_run * tan_a).max(0.0);
1106            let wall_profile = if clip_run > 1e-9 {
1107                FaceProfile::Trapezoid {
1108                    top_width: (2.0 * clip_run) / depth,
1109                    offset_x: (depth / 2.0 - clip_run) / depth,
1110                }
1111            } else {
1112                FaceProfile::Triangle { peak_offset: 0.5 }
1113            };
1114
1115            let hip_base_w = 2.0 * clip_run + 2.0 * o;
1116            let hip_slope_len = (clip_run + o) / cos_a;
1117            let hip_profile = FaceProfile::Triangle { peak_offset: 0.5 };
1118
1119            if orient_z {
1120                let left_hip_origin = Vec3::new(-o, wall_h - o * tan_a, sz / 2.0 - clip_run - o);
1121                let right_hip_origin =
1122                    Vec3::new(sx + o, wall_h - o * tan_a, sz / 2.0 + clip_run + o);
1123                vec![
1124                    (
1125                        Vec3::new(sx + o, y_anchor, -o),
1126                        Vec3::new(eave_w, slope_len, 0.0),
1127                        front_rot,
1128                        RoofFaceSelector::Slope,
1129                        slope_profile.clone(),
1130                    ),
1131                    (
1132                        Vec3::new(-o, y_anchor, sz + o),
1133                        Vec3::new(eave_w, slope_len, 0.0),
1134                        back_rot,
1135                        RoofFaceSelector::Slope,
1136                        slope_profile,
1137                    ),
1138                    (
1139                        Vec3::new(0.0, 0.0, 0.0),
1140                        Vec3::new(sz, wall_h, 0.0),
1141                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
1142                        RoofFaceSelector::GableEnd,
1143                        wall_profile.clone(),
1144                    ),
1145                    (
1146                        Vec3::new(sx, 0.0, sz),
1147                        Vec3::new(sz, wall_h, 0.0),
1148                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
1149                        RoofFaceSelector::GableEnd,
1150                        wall_profile,
1151                    ),
1152                    (
1153                        left_hip_origin,
1154                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1155                        left_rot,
1156                        RoofFaceSelector::HipEnd,
1157                        hip_profile.clone(),
1158                    ),
1159                    (
1160                        right_hip_origin,
1161                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1162                        right_rot,
1163                        RoofFaceSelector::HipEnd,
1164                        hip_profile,
1165                    ),
1166                ]
1167            } else {
1168                let front_hip_origin = Vec3::new(sx / 2.0 + clip_run + o, wall_h - o * tan_a, -o);
1169                let back_hip_origin =
1170                    Vec3::new(sx / 2.0 - clip_run - o, wall_h - o * tan_a, sz + o);
1171                vec![
1172                    (
1173                        Vec3::new(-o, y_anchor, -o),
1174                        Vec3::new(eave_w, slope_len, 0.0),
1175                        left_rot,
1176                        RoofFaceSelector::Slope,
1177                        slope_profile.clone(),
1178                    ),
1179                    (
1180                        Vec3::new(sx + o, y_anchor, sz + o),
1181                        Vec3::new(eave_w, slope_len, 0.0),
1182                        right_rot,
1183                        RoofFaceSelector::Slope,
1184                        slope_profile,
1185                    ),
1186                    (
1187                        Vec3::new(sx, 0.0, 0.0),
1188                        Vec3::new(sx, wall_h, 0.0),
1189                        Quat::from_axis_angle(Vec3::Y, PI),
1190                        RoofFaceSelector::GableEnd,
1191                        wall_profile.clone(),
1192                    ),
1193                    (
1194                        Vec3::new(0.0, 0.0, sz),
1195                        Vec3::new(sx, wall_h, 0.0),
1196                        Quat::IDENTITY,
1197                        RoofFaceSelector::GableEnd,
1198                        wall_profile,
1199                    ),
1200                    (
1201                        front_hip_origin,
1202                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1203                        front_rot,
1204                        RoofFaceSelector::HipEnd,
1205                        hip_profile.clone(),
1206                    ),
1207                    (
1208                        back_hip_origin,
1209                        Vec3::new(hip_base_w, hip_slope_len, 0.0),
1210                        back_rot,
1211                        RoofFaceSelector::HipEnd,
1212                        hip_profile,
1213                    ),
1214                ]
1215            }
1216        }
1217
1218        // ── DutchGable ────────────────────────────────────────────────────────
1219        // Hip roof with a small gable rising from the ridge centre.
1220        // `tier_height` controls the fraction of the horizontal run used for the lower Hip portion.
1221        RoofType::DutchGable => {
1222            let tier = config.tier_height_or(0.7).clamp(0.01, 0.99);
1223            let orient_z = sx >= sz;
1224            let (width, depth) = if orient_z { (sx, sz) } else { (sz, sx) };
1225
1226            let max_run = width.min(depth) / 2.0 + o;
1227            let break_run = (tier * max_run).clamp(o, max_run - 1e-3);
1228
1229            if !break_run.is_finite() {
1230                return Err(ShapeError::InvalidNumericValue);
1231            }
1232
1233            let y_break = y_anchor + break_run * tan_a;
1234            let eave_w = width + 2.0 * o;
1235            let eave_d = depth + 2.0 * o;
1236
1237            let top_w = (eave_w - 2.0 * break_run).max(0.0);
1238            let top_d = (eave_d - 2.0 * break_run).max(0.0);
1239
1240            let lower_slope_len = break_run / cos_a;
1241
1242            let fb_profile = if top_w > 1e-9 {
1243                FaceProfile::Trapezoid {
1244                    top_width: top_w / eave_w,
1245                    offset_x: break_run / eave_w,
1246                }
1247            } else {
1248                FaceProfile::Triangle { peak_offset: 0.5 }
1249            };
1250
1251            let lr_profile = if top_d > 1e-9 {
1252                FaceProfile::Trapezoid {
1253                    top_width: top_d / eave_d,
1254                    offset_x: break_run / eave_d,
1255                }
1256            } else {
1257                FaceProfile::Triangle { peak_offset: 0.5 }
1258            };
1259
1260            let upper_run = (depth / 2.0 + o) - break_run;
1261            let upper_slope_len = upper_run / cos_a;
1262            let upper_h = upper_run * tan_a;
1263
1264            if orient_z {
1265                vec![
1266                    // Lower Hip front/back
1267                    (
1268                        Vec3::new(sx + o, y_anchor, -o),
1269                        Vec3::new(eave_w, lower_slope_len, 0.0),
1270                        front_rot,
1271                        RoofFaceSelector::Slope,
1272                        fb_profile.clone(),
1273                    ),
1274                    (
1275                        Vec3::new(-o, y_anchor, sz + o),
1276                        Vec3::new(eave_w, lower_slope_len, 0.0),
1277                        back_rot,
1278                        RoofFaceSelector::Slope,
1279                        fb_profile,
1280                    ),
1281                    // Lower Hip left/right
1282                    (
1283                        Vec3::new(-o, y_anchor, -o),
1284                        Vec3::new(eave_d, lower_slope_len, 0.0),
1285                        left_rot,
1286                        RoofFaceSelector::Slope,
1287                        lr_profile.clone(),
1288                    ),
1289                    (
1290                        Vec3::new(sx + o, y_anchor, sz + o),
1291                        Vec3::new(eave_d, lower_slope_len, 0.0),
1292                        right_rot,
1293                        RoofFaceSelector::Slope,
1294                        lr_profile,
1295                    ),
1296                    // Upper Gable front/back
1297                    (
1298                        Vec3::new(sx + o - break_run, y_break, -o + break_run),
1299                        Vec3::new(top_w, upper_slope_len, 0.0),
1300                        front_rot,
1301                        RoofFaceSelector::Slope,
1302                        FaceProfile::Rectangle,
1303                    ),
1304                    (
1305                        Vec3::new(-o + break_run, y_break, sz + o - break_run),
1306                        Vec3::new(top_w, upper_slope_len, 0.0),
1307                        back_rot,
1308                        RoofFaceSelector::Slope,
1309                        FaceProfile::Rectangle,
1310                    ),
1311                    // Small gable ends (Left/Right)
1312                    (
1313                        Vec3::new(-o + break_run, y_break, -o + break_run),
1314                        Vec3::new(top_d, upper_h, 0.0),
1315                        Quat::from_axis_angle(Vec3::Y, -FRAC_PI_2),
1316                        RoofFaceSelector::GableEnd,
1317                        FaceProfile::Triangle { peak_offset: 0.5 },
1318                    ),
1319                    (
1320                        Vec3::new(sx + o - break_run, y_break, sz + o - break_run),
1321                        Vec3::new(top_d, upper_h, 0.0),
1322                        Quat::from_axis_angle(Vec3::Y, FRAC_PI_2),
1323                        RoofFaceSelector::GableEnd,
1324                        FaceProfile::Triangle { peak_offset: 0.5 },
1325                    ),
1326                ]
1327            } else {
1328                vec![
1329                    // Lower Hip left/right (which are the main slopes now)
1330                    (
1331                        Vec3::new(-o, y_anchor, -o),
1332                        Vec3::new(eave_w, lower_slope_len, 0.0),
1333                        left_rot,
1334                        RoofFaceSelector::Slope,
1335                        fb_profile.clone(),
1336                    ),
1337                    (
1338                        Vec3::new(sx + o, y_anchor, sz + o),
1339                        Vec3::new(eave_w, lower_slope_len, 0.0),
1340                        right_rot,
1341                        RoofFaceSelector::Slope,
1342                        fb_profile,
1343                    ),
1344                    // Lower Hip front/back (which are the gable ends now)
1345                    (
1346                        Vec3::new(sx + o, y_anchor, -o),
1347                        Vec3::new(eave_d, lower_slope_len, 0.0),
1348                        front_rot,
1349                        RoofFaceSelector::Slope,
1350                        lr_profile.clone(),
1351                    ),
1352                    (
1353                        Vec3::new(-o, y_anchor, sz + o),
1354                        Vec3::new(eave_d, lower_slope_len, 0.0),
1355                        back_rot,
1356                        RoofFaceSelector::Slope,
1357                        lr_profile,
1358                    ),
1359                    // Upper Gable left/right
1360                    (
1361                        Vec3::new(-o + break_run, y_break, -o + break_run),
1362                        Vec3::new(top_w, upper_slope_len, 0.0),
1363                        left_rot,
1364                        RoofFaceSelector::Slope,
1365                        FaceProfile::Rectangle,
1366                    ),
1367                    (
1368                        Vec3::new(sx + o - break_run, y_break, sz + o - break_run),
1369                        Vec3::new(top_w, upper_slope_len, 0.0),
1370                        right_rot,
1371                        RoofFaceSelector::Slope,
1372                        FaceProfile::Rectangle,
1373                    ),
1374                    // Small gable ends (Front/Back)
1375                    (
1376                        Vec3::new(sx + o - break_run, y_break, -o + break_run),
1377                        Vec3::new(top_d, upper_h, 0.0),
1378                        Quat::from_axis_angle(Vec3::Y, PI),
1379                        RoofFaceSelector::GableEnd,
1380                        FaceProfile::Triangle { peak_offset: 0.5 },
1381                    ),
1382                    (
1383                        Vec3::new(-o + break_run, y_break, sz + o - break_run),
1384                        Vec3::new(top_d, upper_h, 0.0),
1385                        Quat::IDENTITY,
1386                        RoofFaceSelector::GableEnd,
1387                        FaceProfile::Triangle { peak_offset: 0.5 },
1388                    ),
1389                ]
1390            }
1391        }
1392    };
1393
1394    if queue.len() + panels.len() > MAX_QUEUE {
1395        return Err(ShapeError::CapacityOverflow);
1396    }
1397    for (local_off, face_size, rot_delta, selector, profile) in panels {
1398        let Some(rule) = find_roof_rule(selector, cases) else {
1399            continue;
1400        };
1401        // Skip degenerate panels (zero-area).
1402        if face_size.x < 1e-9 || face_size.y < 1e-9 {
1403            continue;
1404        }
1405        let face_pos = scope.position + scope.rotation * local_off;
1406        let face_rot = (scope.rotation * rot_delta).normalize();
1407        let face_scope = Scope::new(face_pos, face_rot, face_size);
1408        face_scope.validate()?;
1409        if model.len() + queue.len() >= max_terminals {
1410            return Err(ShapeError::CapacityOverflow);
1411        }
1412        queue.push_back(WorkItem {
1413            scope: face_scope,
1414            rule: rule.to_string(),
1415            depth: depth + 1,
1416            taper: 0.0,
1417            face_profile_override: Some(profile),
1418            material: material.clone(),
1419        });
1420    }
1421
1422    Ok(())
1423}
1424
1425// ── Stochastic selection ──────────────────────────────────────────────────────
1426
1427fn select_variant<'a>(variants: &'a [WeightedVariant], rng: &mut Pcg64) -> &'a [ShapeOp] {
1428    if variants.is_empty() {
1429        return &[];
1430    }
1431    if variants.len() == 1 {
1432        return &variants[0].ops;
1433    }
1434    let total: f64 = variants.iter().map(|v| v.weight).sum();
1435    use rand::Rng;
1436    let r: f64 = rng.random::<f64>() * total;
1437    let mut acc = 0.0;
1438    for v in variants {
1439        acc += v.weight;
1440        if r < acc {
1441            return &v.ops;
1442        }
1443    }
1444    &variants.last().unwrap().ops
1445}
1446
1447// ── Interpreter ───────────────────────────────────────────────────────────────
1448
1449/// The CGA Shape Grammar derivation engine.
1450///
1451/// Rules are registered by name, then `derive` is called with a root scope and
1452/// root rule name. The engine expands rules breadth-first until every branch
1453/// terminates with an `I(mesh)` terminal.
1454///
1455/// Stochastic rules with multiple weighted variants use the engine's `seed` for
1456/// reproducible randomness — the same seed always yields the same building.
1457pub struct Interpreter {
1458    rules: HashMap<String, Vec<WeightedVariant>>,
1459    pub max_depth: usize,
1460    pub max_terminals: usize,
1461    /// Seed for stochastic rule selection. Default 0.
1462    pub seed: u64,
1463}
1464
1465impl Default for Interpreter {
1466    fn default() -> Self {
1467        Self::new()
1468    }
1469}
1470
1471impl Interpreter {
1472    pub fn new() -> Self {
1473        Self {
1474            rules: HashMap::new(),
1475            max_depth: MAX_DEPTH,
1476            max_terminals: MAX_TERMINALS,
1477            seed: 0,
1478        }
1479    }
1480
1481    /// Returns a reference to the full rule table (rule name → weighted variants).
1482    pub fn rules(&self) -> &HashMap<String, Vec<WeightedVariant>> {
1483        &self.rules
1484    }
1485
1486    /// Directly inserts a pre-built variant list for `name`, bypassing weight validation.
1487    ///
1488    /// Intended for restoring snapshots produced by [`ShapeGenotype::to_interpreter`].
1489    pub fn set_variants(&mut self, name: impl Into<String>, variants: Vec<WeightedVariant>) {
1490        self.rules.insert(name.into(), variants);
1491    }
1492
1493    /// Registers a deterministic production rule.
1494    pub fn add_rule(&mut self, name: impl Into<String>, ops: Vec<ShapeOp>) {
1495        self.rules
1496            .insert(name.into(), vec![WeightedVariant { weight: 1.0, ops }]);
1497    }
1498
1499    /// Registers a stochastic rule with multiple weighted alternatives.
1500    ///
1501    /// `variants` is a list of `(relative_weight, ops)` pairs. Weights need not
1502    /// sum to 1.0 — they are normalised internally during selection.
1503    ///
1504    /// Returns `Err(InvalidNumericValue)` if any weight is non-finite or negative.
1505    pub fn add_weighted_rules(
1506        &mut self,
1507        name: impl Into<String>,
1508        variants: Vec<(f64, Vec<ShapeOp>)>,
1509    ) -> Result<(), ShapeError> {
1510        for (weight, _) in &variants {
1511            if !weight.is_finite() || *weight < 0.0 {
1512                return Err(ShapeError::InvalidNumericValue);
1513            }
1514        }
1515        let wvs = variants
1516            .into_iter()
1517            .map(|(weight, ops)| WeightedVariant { weight, ops })
1518            .collect();
1519        self.rules.insert(name.into(), wvs);
1520        Ok(())
1521    }
1522
1523    /// Returns true if a rule with `name` is registered.
1524    pub fn has_rule(&self, name: &str) -> bool {
1525        self.rules.contains_key(name)
1526    }
1527
1528    /// Derives the shape model starting from `root_scope` and `root_rule`.
1529    ///
1530    /// Uses a breadth-first work queue to expand rules until all branches
1531    /// terminate via `I(mesh_id)` or an unknown rule name (implicit terminal).
1532    /// A fresh RNG seeded from `self.seed` is created for each call, making
1533    /// derivations reproducible for the same `seed` value.
1534    pub fn derive(
1535        &self,
1536        root_scope: Scope,
1537        root_rule: impl Into<String>,
1538    ) -> Result<ShapeModel, ShapeError> {
1539        root_scope.validate()?;
1540
1541        let mut model = ShapeModel::new();
1542        let mut queue: VecDeque<WorkItem> = VecDeque::new();
1543        let mut rng = Pcg64::seed_from_u64(self.seed);
1544
1545        queue.push_back(WorkItem {
1546            scope: root_scope,
1547            rule: root_rule.into(),
1548            depth: 0,
1549            taper: 0.0,
1550            face_profile_override: None,
1551            material: None,
1552        });
1553
1554        while let Some(item) = queue.pop_front() {
1555            if queue.len() > MAX_QUEUE {
1556                return Err(ShapeError::CapacityOverflow);
1557            }
1558            if item.depth > self.max_depth {
1559                return Err(ShapeError::DepthLimitExceeded(self.max_depth));
1560            }
1561
1562            let ops = match self.rules.get(&item.rule) {
1563                Some(variants) => select_variant(variants, &mut rng),
1564                None => {
1565                    // Unknown rule → implicit I(rule_name) terminal.
1566                    if model.len() >= self.max_terminals {
1567                        return Err(ShapeError::CapacityOverflow);
1568                    }
1569                    let profile = item
1570                        .face_profile_override
1571                        .unwrap_or_else(|| taper_to_profile(item.taper));
1572                    model.push(Terminal::new_profiled(
1573                        item.scope,
1574                        &item.rule,
1575                        profile,
1576                        item.material,
1577                    ));
1578                    continue;
1579                }
1580            };
1581
1582            self.apply_ops(
1583                item.scope,
1584                item.taper,
1585                item.face_profile_override,
1586                item.material,
1587                ops,
1588                item.depth,
1589                &mut queue,
1590                &mut model,
1591            )?;
1592        }
1593
1594        Ok(model)
1595    }
1596
1597    /// Processes the ops sequence for a single rule invocation.
1598    ///
1599    /// Transformation ops (`Extrude`, `Scale`, etc.) mutate `scope` in place.
1600    /// The first branching op (`Split`, `Comp`, `Repeat`) or terminal op
1601    /// (`I`, `Rule`) ends the sequence by pushing new work items.
1602    #[allow(clippy::too_many_arguments)]
1603    fn apply_ops(
1604        &self,
1605        initial_scope: Scope,
1606        initial_taper: f64,
1607        initial_face_profile: Option<FaceProfile>,
1608        initial_material: Option<String>,
1609        ops: &[ShapeOp],
1610        depth: usize,
1611        queue: &mut VecDeque<WorkItem>,
1612        model: &mut ShapeModel,
1613    ) -> Result<(), ShapeError> {
1614        let mut scope = initial_scope;
1615        let mut taper = initial_taper;
1616        let mut face_profile = initial_face_profile;
1617        let mut material = initial_material;
1618
1619        for op in ops {
1620            match op {
1621                // ── Transformations ───────────────────────────────────────
1622                ShapeOp::Extrude(h) => {
1623                    if !h.is_finite() || *h <= 0.0 {
1624                        return Err(ShapeError::InvalidNumericValue);
1625                    }
1626                    // Face scopes from Comp(Faces) have size.z == 0 (the outward-normal
1627                    // direction) and a non-zero size.y (the face height).  Extruding a
1628                    // face scope should push it outward along the normal (local Z), not
1629                    // collapse the height by overwriting size.y.
1630                    // Footprint scopes have size.y == 0; Extrude gives them their height.
1631                    if scope.size.z.abs() < 1e-9 && scope.size.y.abs() > 1e-9 {
1632                        scope.size.z = *h;
1633                    } else {
1634                        scope.size.y = *h;
1635                    }
1636                }
1637
1638                ShapeOp::Taper(amount) => {
1639                    if !amount.is_finite() {
1640                        return Err(ShapeError::InvalidNumericValue);
1641                    }
1642                    taper = amount.clamp(0.0, 1.0);
1643                }
1644
1645                ShapeOp::Rotate(q) => {
1646                    if !q.is_finite() {
1647                        return Err(ShapeError::InvalidNumericValue);
1648                    }
1649                    // Reject degenerate (near-zero) quaternions that cannot represent a
1650                    // rotation. Normalize non-unit inputs so that glam's fast-path
1651                    // `rotation * vec` (which assumes a unit quaternion) is correct.
1652                    let len_sq = q.length_squared();
1653                    if !len_sq.is_finite() || len_sq < 1e-12 {
1654                        return Err(ShapeError::InvalidNumericValue);
1655                    }
1656                    scope.rotation = (scope.rotation * q.normalize()).normalize();
1657                }
1658
1659                ShapeOp::Translate(v) => {
1660                    if !v.is_finite() {
1661                        return Err(ShapeError::InvalidNumericValue);
1662                    }
1663                    scope.position += scope.rotation * *v;
1664                    // Two individually-finite values can add to INFINITY
1665                    // (e.g. f64::MAX/2 + f64::MAX/2). Catch the overflow here.
1666                    if !scope.position.is_finite() {
1667                        return Err(ShapeError::InvalidNumericValue);
1668                    }
1669                }
1670
1671                ShapeOp::Scale(v) => {
1672                    if !v.is_finite() || v.x <= 0.0 || v.y <= 0.0 || v.z <= 0.0 {
1673                        return Err(ShapeError::InvalidNumericValue);
1674                    }
1675                    scope.size *= *v;
1676                    // Two individually-finite scale values can multiply to INFINITY
1677                    // (e.g. 1e200 * 1e200). Catch the overflow here before it
1678                    // propagates into Split/Repeat and causes NaN via ∞ − ∞.
1679                    if !scope.size.is_finite() {
1680                        return Err(ShapeError::InvalidNumericValue);
1681                    }
1682                }
1683
1684                ShapeOp::Mat(mat_id) => {
1685                    material = Some(mat_id.clone());
1686                }
1687
1688                // ── Transform: Align ─────────────────────────────────────
1689                ShapeOp::Align { local_axis, target } => {
1690                    // length_squared() can overflow to INFINITY for large-but-finite
1691                    // vectors (e.g. (1e200, 1e200, 1e200)); INFINITY > 1e-12 so the
1692                    // naive check would pass, then normalize() divides by INFINITY
1693                    // yielding a zero vector and a silent no-op rotation.
1694                    let len_sq = target.length_squared();
1695                    if !target.is_finite() || !len_sq.is_finite() || len_sq < 1e-12 {
1696                        return Err(ShapeError::InvalidAlignTarget);
1697                    }
1698                    let target_norm = target.normalize();
1699                    let current = scope.rotation * axis_vec(*local_axis);
1700                    // from_rotation_arc gives the shortest-arc rotation; it degenerates
1701                    // when vectors are antiparallel — handle that with a fallback 180°.
1702                    let dot = current.dot(target_norm);
1703                    let q = if (dot + 1.0).abs() < 1e-9 {
1704                        // Choose the cardinal axis least parallel to `current` (smallest
1705                        // absolute component) to form the cross product. This avoids the
1706                        // discontinuous snap caused by a hard threshold: the selection
1707                        // only changes when two components are exactly equal, which is
1708                        // rare and well-conditioned.
1709                        let perp = if current.x.abs() <= current.y.abs()
1710                            && current.x.abs() <= current.z.abs()
1711                        {
1712                            current.cross(Vec3::X).normalize()
1713                        } else if current.y.abs() <= current.z.abs() {
1714                            current.cross(Vec3::Y).normalize()
1715                        } else {
1716                            current.cross(Vec3::Z).normalize()
1717                        };
1718                        Quat::from_axis_angle(perp, PI)
1719                    } else {
1720                        Quat::from_rotation_arc(current, target_norm)
1721                    };
1722                    scope.rotation = (q * scope.rotation).normalize();
1723                }
1724
1725                // ── Branching: Offset ─────────────────────────────────────
1726                ShapeOp::Offset { distance, cases } => {
1727                    if !distance.is_finite() {
1728                        return Err(ShapeError::InvalidNumericValue);
1729                    }
1730                    // Negative distance = inset.
1731                    let inset = -*distance;
1732                    if inset <= 0.0 {
1733                        return Err(ShapeError::InvalidNumericValue);
1734                    }
1735                    let sx = scope.size.x;
1736                    let sy = scope.size.y;
1737                    let inside_w = sx - 2.0 * inset;
1738                    let inside_h = sy - 2.0 * inset;
1739                    // Explicit NaN guard: if sx/sy are non-finite (e.g. leaked
1740                    // Infinity from an upstream op), the subtraction produces NaN,
1741                    // which compares false for `< 0.0` and would bypass the check.
1742                    if !inside_w.is_finite()
1743                        || !inside_h.is_finite()
1744                        || inside_w < 0.0
1745                        || inside_h < 0.0
1746                    {
1747                        return Err(ShapeError::OffsetTooLarge);
1748                    }
1749                    if let Some(rule) = find_offset_rule(OffsetSelector::Inside, cases) {
1750                        if queue.len() >= MAX_QUEUE {
1751                            return Err(ShapeError::CapacityOverflow);
1752                        }
1753                        let pos = scope.position + scope.rotation * Vec3::new(inset, inset, 0.0);
1754                        let child_scope =
1755                            Scope::new(pos, scope.rotation, Vec3::new(inside_w, inside_h, 0.0));
1756                        child_scope.validate()?;
1757                        queue.push_back(WorkItem {
1758                            scope: child_scope,
1759                            rule: rule.to_string(),
1760                            depth: depth + 1,
1761                            taper: 0.0,
1762                            face_profile_override: None,
1763                            material: material.clone(),
1764                        });
1765                    }
1766                    if let Some(rule) = find_offset_rule(OffsetSelector::Border, cases) {
1767                        // 4 surrounding strips: bottom, top, left, right.
1768                        let strips = [
1769                            (Vec3::new(0.0, 0.0, 0.0), Vec3::new(sx, inset, 0.0)),
1770                            (Vec3::new(0.0, sy - inset, 0.0), Vec3::new(sx, inset, 0.0)),
1771                            (
1772                                Vec3::new(0.0, inset, 0.0),
1773                                Vec3::new(inset, sy - 2.0 * inset, 0.0),
1774                            ),
1775                            (
1776                                Vec3::new(sx - inset, inset, 0.0),
1777                                Vec3::new(inset, sy - 2.0 * inset, 0.0),
1778                            ),
1779                        ];
1780                        if queue.len() + strips.len() > MAX_QUEUE {
1781                            return Err(ShapeError::CapacityOverflow);
1782                        }
1783                        for (local_off, strip_size) in strips {
1784                            let pos = scope.position + scope.rotation * local_off;
1785                            let child_scope = Scope::new(pos, scope.rotation, strip_size);
1786                            child_scope.validate()?;
1787                            queue.push_back(WorkItem {
1788                                scope: child_scope,
1789                                rule: rule.to_string(),
1790                                depth: depth + 1,
1791                                taper: 0.0,
1792                                face_profile_override: None,
1793                                material: material.clone(),
1794                            });
1795                        }
1796                    }
1797                    return Ok(());
1798                }
1799
1800                // ── Branching: Roof ───────────────────────────────────────
1801                ShapeOp::Roof { config, cases } => {
1802                    apply_roof(
1803                        config,
1804                        cases,
1805                        &scope,
1806                        depth,
1807                        &material,
1808                        queue,
1809                        model,
1810                        self.max_terminals,
1811                    )?;
1812                    return Ok(());
1813                }
1814
1815                // ── Branching: Attach ─────────────────────────────────────
1816                ShapeOp::Attach { world_axis, cases } => {
1817                    let len_sq = world_axis.length_squared();
1818                    if !world_axis.is_finite() || !len_sq.is_finite() || len_sq < 1e-12 {
1819                        return Err(ShapeError::InvalidAlignTarget);
1820                    }
1821                    let axis_norm = world_axis.normalize();
1822                    // Build a new scope whose Y axis = world_axis.
1823                    // The new scope sits at the same corner as the current scope,
1824                    // has X = scope.size.x, Y = scope.size.y, Z = 0 (flat surface).
1825                    let rot = Quat::from_rotation_arc(Vec3::Y, axis_norm);
1826                    let attach_scope = Scope::new(
1827                        scope.position,
1828                        rot.normalize(),
1829                        Vec3::new(scope.size.x, scope.size.y, 0.0),
1830                    );
1831                    attach_scope.validate()?;
1832                    if let Some(rule) = find_attach_rule(crate::ops::AttachSelector::Surface, cases)
1833                    {
1834                        if queue.len() >= MAX_QUEUE {
1835                            return Err(ShapeError::CapacityOverflow);
1836                        }
1837                        queue.push_back(WorkItem {
1838                            scope: attach_scope,
1839                            rule: rule.to_string(),
1840                            depth: depth + 1,
1841                            taper: 0.0,
1842                            face_profile_override: None,
1843                            material: material.clone(),
1844                        });
1845                    }
1846                    return Ok(());
1847                }
1848
1849                // ── Branching: Split ──────────────────────────────────────
1850                ShapeOp::Split { axis, slots } => {
1851                    let total = match axis {
1852                        Axis::X => scope.size.x,
1853                        Axis::Y => scope.size.y,
1854                        Axis::Z => scope.size.z,
1855                    };
1856                    let sizes = resolve_split_sizes(slots, total)?;
1857                    if queue.len() + slots.len() > MAX_QUEUE {
1858                        return Err(ShapeError::CapacityOverflow);
1859                    }
1860                    let mut offset = 0.0;
1861                    for (slot, size) in slots.iter().zip(sizes.iter()) {
1862                        let child = slice_scope(&scope, *axis, offset, *size);
1863                        child.validate()?;
1864                        queue.push_back(WorkItem {
1865                            scope: child,
1866                            rule: slot.rule.clone(),
1867                            depth: depth + 1,
1868                            taper: 0.0,
1869                            face_profile_override: None,
1870                            material: material.clone(),
1871                        });
1872                        offset += size;
1873                    }
1874                    return Ok(());
1875                }
1876
1877                // ── Branching: Repeat ─────────────────────────────────────
1878                //
1879                // Uses `floor()` for tile count (never fewer tiles than fit),
1880                // then stretches actual tile size to fill the scope with no gaps.
1881                // Example: 10.5m scope / 2m target → 5 tiles × 2.1m each.
1882                ShapeOp::Repeat {
1883                    axis,
1884                    tile_size,
1885                    rule,
1886                } => {
1887                    if !tile_size.is_finite() || *tile_size <= 0.0 {
1888                        return Err(ShapeError::InvalidNumericValue);
1889                    }
1890                    let total = match axis {
1891                        Axis::X => scope.size.x,
1892                        Axis::Y => scope.size.y,
1893                        Axis::Z => scope.size.z,
1894                    };
1895                    // Defensive: scope.size should always be finite after earlier
1896                    // checks, but if an Infinity scope size ever sneaks through
1897                    // (e.g. from a Roof child), `0.0 * Infinity = NaN` at i=0.
1898                    if !total.is_finite() || total <= 0.0 {
1899                        return Err(ShapeError::InvalidNumericValue);
1900                    }
1901                    // `total / tile_size` can overflow to INFINITY for very small
1902                    // tile_size values (e.g. f64::MIN_POSITIVE). The subsequent
1903                    // `as usize` cast would saturate to usize::MAX, and
1904                    // `queue.len() + usize::MAX` wraps to 0 in release mode,
1905                    // bypassing the capacity guard and looping usize::MAX times.
1906                    let n_tiles_f = (total / tile_size).floor();
1907                    if !n_tiles_f.is_finite() {
1908                        return Err(ShapeError::CapacityOverflow);
1909                    }
1910                    let n_tiles = n_tiles_f as usize;
1911                    if queue.len().saturating_add(n_tiles) > MAX_QUEUE {
1912                        return Err(ShapeError::CapacityOverflow);
1913                    }
1914                    if n_tiles > 0 {
1915                        let actual_size = total / n_tiles as f64;
1916                        for i in 0..n_tiles {
1917                            let offset = i as f64 * actual_size;
1918                            let child = slice_scope(&scope, *axis, offset, actual_size);
1919                            child.validate()?;
1920                            queue.push_back(WorkItem {
1921                                scope: child,
1922                                rule: rule.clone(),
1923                                depth: depth + 1,
1924                                taper: 0.0,
1925                                face_profile_override: None,
1926                                material: material.clone(),
1927                            });
1928                        }
1929                    }
1930                    return Ok(());
1931                }
1932
1933                // ── Branching: Comp ───────────────────────────────────────
1934                //
1935                // Each face scope is properly oriented so that local Z points
1936                // along the outward face normal. Rules can then use Split(X/Y)
1937                // or Repeat(X) naturally on any face of the parent volume.
1938                ShapeOp::Comp(CompTarget::Faces(cases)) => {
1939                    // face_descs always returns exactly 6 faces; guard before any push.
1940                    if queue.len() + 6 > MAX_QUEUE {
1941                        return Err(ShapeError::CapacityOverflow);
1942                    }
1943                    for (selector, offset_local, face_size, rot_delta) in face_descs(scope.size) {
1944                        let rule = match find_face_rule(selector, cases) {
1945                            Some(r) => r,
1946                            None => continue,
1947                        };
1948                        let face_pos = scope.position + scope.rotation * offset_local;
1949                        let face_rotation = scope.rotation * rot_delta;
1950                        let face_scope = Scope::new(face_pos, face_rotation, face_size);
1951                        face_scope.validate()?;
1952                        queue.push_back(WorkItem {
1953                            scope: face_scope,
1954                            rule: rule.to_string(),
1955                            depth: depth + 1,
1956                            taper: 0.0,
1957                            face_profile_override: None,
1958                            material: material.clone(),
1959                        });
1960                    }
1961                    return Ok(());
1962                }
1963
1964                // ── Terminal: mesh instance ───────────────────────────────
1965                ShapeOp::I(mesh_id) => {
1966                    if model.len() >= self.max_terminals {
1967                        return Err(ShapeError::CapacityOverflow);
1968                    }
1969                    let profile = face_profile
1970                        .take()
1971                        .unwrap_or_else(|| taper_to_profile(taper));
1972                    model.push(Terminal::new_profiled(scope, mesh_id, profile, material));
1973                    return Ok(());
1974                }
1975
1976                // ── Delegate: named sub-rule ──────────────────────────────
1977                ShapeOp::Rule(name) => {
1978                    queue.push_back(WorkItem {
1979                        scope,
1980                        rule: name.clone(),
1981                        depth: depth + 1,
1982                        taper,
1983                        face_profile_override: face_profile,
1984                        material,
1985                    });
1986                    return Ok(());
1987                }
1988            }
1989        }
1990
1991        // Ops exhausted without a terminal — scope is silently discarded
1992        // (matches CGA "delete this shape" semantics for empty successors).
1993        Ok(())
1994    }
1995}
1996
1997#[cfg(test)]
1998mod tests {
1999    use super::*;
2000    use crate::ops::{Axis, SplitSize, SplitSlot};
2001    use crate::scope::{Quat, Vec3};
2002
2003    fn slot(size: SplitSize, rule: &str) -> SplitSlot {
2004        SplitSlot {
2005            size,
2006            rule: rule.to_string(),
2007        }
2008    }
2009
2010    #[test]
2011    fn test_resolve_split_absolute() {
2012        let slots = vec![
2013            slot(SplitSize::Absolute(3.0), "A"),
2014            slot(SplitSize::Absolute(7.0), "B"),
2015        ];
2016        let sizes = resolve_split_sizes(&slots, 10.0).unwrap();
2017        assert!((sizes[0] - 3.0).abs() < 1e-9);
2018        assert!((sizes[1] - 7.0).abs() < 1e-9);
2019    }
2020
2021    #[test]
2022    fn test_resolve_split_floating_equal() {
2023        let slots = vec![
2024            slot(SplitSize::Floating(1.0), "A"),
2025            slot(SplitSize::Floating(1.0), "B"),
2026        ];
2027        let sizes = resolve_split_sizes(&slots, 10.0).unwrap();
2028        assert!((sizes[0] - 5.0).abs() < 1e-9);
2029        assert!((sizes[1] - 5.0).abs() < 1e-9);
2030    }
2031
2032    #[test]
2033    fn test_resolve_split_mixed() {
2034        let slots = vec![
2035            slot(SplitSize::Absolute(2.0), "Base"),
2036            slot(SplitSize::Floating(1.0), "A"),
2037            slot(SplitSize::Floating(1.0), "B"),
2038        ];
2039        let sizes = resolve_split_sizes(&slots, 10.0).unwrap();
2040        assert!((sizes[0] - 2.0).abs() < 1e-9);
2041        assert!((sizes[1] - 4.0).abs() < 1e-9);
2042        assert!((sizes[2] - 4.0).abs() < 1e-9);
2043    }
2044
2045    #[test]
2046    fn test_resolve_split_overflow_rejected() {
2047        let slots = vec![
2048            slot(SplitSize::Absolute(6.0), "A"),
2049            slot(SplitSize::Absolute(6.0), "B"),
2050        ];
2051        assert!(matches!(
2052            resolve_split_sizes(&slots, 10.0),
2053            Err(ShapeError::SplitOverflow(_))
2054        ));
2055    }
2056
2057    #[test]
2058    fn test_derive_extrude_then_terminal() {
2059        let mut interp = Interpreter::new();
2060        interp.add_rule(
2061            "Lot",
2062            vec![ShapeOp::Extrude(10.0), ShapeOp::I("Building".to_string())],
2063        );
2064        let scope = Scope::unit();
2065        let model = interp.derive(scope, "Lot").unwrap();
2066        assert_eq!(model.len(), 1);
2067        assert_eq!(model.terminals[0].mesh_id, "Building");
2068        assert!((model.terminals[0].scope.size.y - 10.0).abs() < 1e-9);
2069    }
2070
2071    #[test]
2072    fn test_derive_split_y_three_floors() {
2073        let mut interp = Interpreter::new();
2074        interp.add_rule(
2075            "Building",
2076            vec![ShapeOp::Split {
2077                axis: Axis::Y,
2078                slots: vec![
2079                    slot(SplitSize::Absolute(2.0), "Ground"),
2080                    slot(SplitSize::Floating(1.0), "Upper"),
2081                    slot(SplitSize::Absolute(1.5), "Roof"),
2082                ],
2083            }],
2084        );
2085        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 10.0, 10.0));
2086        let model = interp.derive(scope, "Building").unwrap();
2087        assert_eq!(model.len(), 3);
2088        assert!((model.terminals[0].scope.size.y - 2.0).abs() < 1e-9);
2089        assert!((model.terminals[1].scope.size.y - 6.5).abs() < 1e-9);
2090        assert!((model.terminals[2].scope.size.y - 1.5).abs() < 1e-9);
2091    }
2092
2093    #[test]
2094    fn test_derive_depth_limit() {
2095        let mut interp = Interpreter::new();
2096        interp.add_rule("A", vec![ShapeOp::Rule("A".to_string())]);
2097        interp.max_depth = 5;
2098        let model = interp.derive(Scope::unit(), "A");
2099        assert!(matches!(model, Err(ShapeError::DepthLimitExceeded(_))));
2100    }
2101
2102    #[test]
2103    fn test_derive_comp_faces() {
2104        let mut interp = Interpreter::new();
2105        interp.add_rule(
2106            "Box",
2107            vec![ShapeOp::Comp(CompTarget::Faces(vec![
2108                crate::ops::CompFaceCase {
2109                    selector: FaceSelector::Top,
2110                    rule: "Roof".to_string(),
2111                },
2112                crate::ops::CompFaceCase {
2113                    selector: FaceSelector::Side,
2114                    rule: "Wall".to_string(),
2115                },
2116                crate::ops::CompFaceCase {
2117                    selector: FaceSelector::Bottom,
2118                    rule: "Base".to_string(),
2119                },
2120            ]))],
2121        );
2122        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(5.0, 3.0, 5.0));
2123        let model = interp.derive(scope, "Box").unwrap();
2124        assert_eq!(model.len(), 6);
2125    }
2126
2127    #[test]
2128    fn test_derive_repeat() {
2129        let mut interp = Interpreter::new();
2130        interp.add_rule(
2131            "Facade",
2132            vec![ShapeOp::Repeat {
2133                axis: Axis::X,
2134                tile_size: 2.0,
2135                rule: "Window".to_string(),
2136            }],
2137        );
2138        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 4.0, 0.0));
2139        let model = interp.derive(scope, "Facade").unwrap();
2140        // 10 / 2 = 5 tiles, each stretched to exactly 2.0m (no remainder here)
2141        assert_eq!(model.len(), 5);
2142    }
2143
2144    #[test]
2145    fn test_derive_mat_propagates() {
2146        let mut interp = Interpreter::new();
2147        interp.add_rule(
2148            "R",
2149            vec![
2150                ShapeOp::Mat("Brick".to_string()),
2151                ShapeOp::I("Wall".to_string()),
2152            ],
2153        );
2154        let model = interp.derive(Scope::unit(), "R").unwrap();
2155        assert_eq!(model.terminals[0].material, Some("Brick".to_string()));
2156    }
2157
2158    // ── Issue 1: empty-variants panic ─────────────────────────────────────────
2159
2160    #[test]
2161    fn test_empty_variants_discards_shape() {
2162        let mut interp = Interpreter::new();
2163        // add_weighted_rules with an empty vec must not panic; the scope is
2164        // silently discarded (consistent with CGA "delete shape" semantics).
2165        interp.add_weighted_rules("Empty", vec![]).unwrap();
2166        let model = interp.derive(Scope::unit(), "Empty").unwrap();
2167        assert_eq!(model.len(), 0);
2168    }
2169
2170    // ── Issue 1 (review #10): n_tiles INFINITY cast ───────────────────────────
2171
2172    #[test]
2173    fn test_repeat_tiny_tile_size_rejected() {
2174        // tile_size = f64::MIN_POSITIVE is finite and > 0, passes validation.
2175        // But total / f64::MIN_POSITIVE overflows to INFINITY, and
2176        // INFINITY as usize saturates to usize::MAX, causing overflow in the
2177        // queue length arithmetic. Must be caught as CapacityOverflow.
2178        let mut interp = Interpreter::new();
2179        interp.add_rule(
2180            "R",
2181            vec![ShapeOp::Repeat {
2182                axis: Axis::X,
2183                tile_size: f64::MIN_POSITIVE,
2184                rule: "Tile".to_string(),
2185            }],
2186        );
2187        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(1.0, 1.0, 1.0));
2188        assert!(matches!(
2189            interp.derive(scope, "R"),
2190            Err(ShapeError::CapacityOverflow)
2191        ));
2192    }
2193
2194    // ── Issue 3 (review #10): Scale multiplication overflow ───────────────────
2195
2196    #[test]
2197    fn test_scale_multiply_overflow_to_infinity_rejected() {
2198        // Each Scale value is individually finite and positive, but scope.size *= v
2199        // can overflow to INFINITY. Must be caught after the multiplication.
2200        let mut interp = Interpreter::new();
2201        interp.add_rule(
2202            "R",
2203            vec![
2204                ShapeOp::Scale(Vec3::new(1e200, 1.0, 1.0)),
2205                ShapeOp::Scale(Vec3::new(1e200, 1.0, 1.0)), // 1e200*1e200=INFINITY
2206                ShapeOp::I("Mesh".to_string()),
2207            ],
2208        );
2209        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(1.0, 1.0, 1.0));
2210        assert!(matches!(
2211            interp.derive(scope, "R"),
2212            Err(ShapeError::InvalidNumericValue)
2213        ));
2214    }
2215
2216    #[test]
2217    fn test_split_absolute_sum_overflow_rejected() {
2218        // Absolute slot sizes whose sum overflows to INFINITY must be rejected.
2219        let slots = vec![
2220            slot(SplitSize::Absolute(f64::MAX), "A"),
2221            slot(SplitSize::Absolute(f64::MAX), "B"),
2222        ];
2223        assert!(matches!(
2224            resolve_split_sizes(&slots, f64::MAX),
2225            Err(ShapeError::InvalidNumericValue)
2226        ));
2227    }
2228
2229    // ── Issue 3: queue capacity accounting ────────────────────────────────────
2230
2231    #[test]
2232    fn test_repeat_respects_combined_queue_limit() {
2233        // A Repeat whose tile count alone is fine (< MAX_QUEUE) but combined with
2234        // the existing queue would exceed MAX_QUEUE should be rejected.
2235        // We can't easily fill the queue to 99_999 in a unit test, so we use
2236        // the public max_depth / max_terminals to drive overflow indirectly.
2237        // Instead, verify the guard fires for a very large n_tiles (> MAX_QUEUE).
2238        let mut interp = Interpreter::new();
2239        // tile_size so small that n_tiles >> MAX_QUEUE (scope is 1e10, tile = 1e-1 → 1e11 tiles)
2240        interp.add_rule(
2241            "Big",
2242            vec![ShapeOp::Repeat {
2243                axis: Axis::X,
2244                tile_size: 1e-1,
2245                rule: "Tile".to_string(),
2246            }],
2247        );
2248        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(1e10, 1.0, 1.0));
2249        assert!(matches!(
2250            interp.derive(scope, "Big"),
2251            Err(ShapeError::CapacityOverflow)
2252        ));
2253    }
2254
2255    // ── Issue 4: negative scale via API ──────────────────────────────────────
2256
2257    #[test]
2258    fn test_api_negative_scale_rejected() {
2259        let mut interp = Interpreter::new();
2260        interp.add_rule(
2261            "R",
2262            vec![
2263                ShapeOp::Scale(Vec3::new(-1.0, 1.0, 1.0)),
2264                ShapeOp::I("Mesh".to_string()),
2265            ],
2266        );
2267        assert!(matches!(
2268            interp.derive(Scope::unit(), "R"),
2269            Err(ShapeError::InvalidNumericValue)
2270        ));
2271    }
2272
2273    #[test]
2274    fn test_api_zero_scale_rejected() {
2275        let mut interp = Interpreter::new();
2276        interp.add_rule(
2277            "R",
2278            vec![
2279                ShapeOp::Scale(Vec3::new(0.0, 1.0, 1.0)),
2280                ShapeOp::I("Mesh".to_string()),
2281            ],
2282        );
2283        assert!(matches!(
2284            interp.derive(Scope::unit(), "R"),
2285            Err(ShapeError::InvalidNumericValue)
2286        ));
2287    }
2288
2289    // ── Issue 1 (review #14): intermediate product overflow in floating split ──
2290
2291    #[test]
2292    fn test_split_floating_large_remaining_no_overflow() {
2293        // remaining ≈ 1e308, w = 2.0, float_weight_total = 3.0.
2294        // Old code: (1e308 * 2.0) / 3.0 = INFINITY / 3.0 = INFINITY.
2295        // Fixed:    1e308 * (2.0 / 3.0) = finite.
2296        let slots = vec![
2297            slot(SplitSize::Floating(2.0), "A"),
2298            slot(SplitSize::Floating(1.0), "B"),
2299        ];
2300        let sizes = resolve_split_sizes(&slots, 1e308).unwrap();
2301        assert!(sizes[0].is_finite(), "size[0] overflowed to {}", sizes[0]);
2302        assert!(sizes[1].is_finite(), "size[1] overflowed to {}", sizes[1]);
2303        // Proportions must be 2/3 and 1/3.
2304        assert!((sizes[0] / sizes[1] - 2.0).abs() < 1e-6);
2305    }
2306
2307    // ── Issue 5: float_weight_total overflow ──────────────────────────────────
2308
2309    #[test]
2310    fn test_split_floating_weight_overflow_rejected() {
2311        // Two floating slots each with weight near f64::MAX; their sum overflows
2312        // to INFINITY in float_weight_total, which should be caught and rejected.
2313        let slots = vec![
2314            slot(SplitSize::Floating(f64::MAX), "A"),
2315            slot(SplitSize::Floating(f64::MAX), "B"),
2316        ];
2317        assert!(matches!(
2318            resolve_split_sizes(&slots, 10.0),
2319            Err(ShapeError::InvalidNumericValue)
2320        ));
2321    }
2322
2323    #[test]
2324    fn test_stochastic_rule_deterministic_with_seed() {
2325        let mut interp = Interpreter::new();
2326        interp
2327            .add_weighted_rules(
2328                "Facade",
2329                vec![
2330                    (70.0, vec![ShapeOp::I("Brick".to_string())]),
2331                    (30.0, vec![ShapeOp::I("Glass".to_string())]),
2332                ],
2333            )
2334            .unwrap();
2335        interp.seed = 42;
2336        // Same seed → same result
2337        let m1 = interp.derive(Scope::unit(), "Facade").unwrap();
2338        let m2 = interp.derive(Scope::unit(), "Facade").unwrap();
2339        assert_eq!(m1.terminals[0].mesh_id, m2.terminals[0].mesh_id);
2340    }
2341
2342    #[test]
2343    fn test_face_comp_orientations() {
2344        // After Comp, each face scope should have local Z pointing along its outward normal.
2345        // We verify by checking the rotation: applying the face rotation to (0,0,1) should
2346        // give the expected world-space normal direction.
2347        let mut interp = Interpreter::new();
2348        interp.add_rule(
2349            "Box",
2350            vec![ShapeOp::Comp(CompTarget::Faces(vec![
2351                crate::ops::CompFaceCase {
2352                    selector: FaceSelector::All,
2353                    rule: "Face".to_string(),
2354                },
2355            ]))],
2356        );
2357        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 2.0));
2358        let model = interp.derive(scope, "Box").unwrap();
2359        assert_eq!(model.len(), 6);
2360
2361        // Collect the outward normals by rotating (0,0,1) with each face's rotation
2362        let normals: Vec<Vec3> = model
2363            .terminals
2364            .iter()
2365            .map(|t| t.scope.rotation * Vec3::Z)
2366            .collect();
2367
2368        // We expect exactly one terminal pointing in each of the 6 cardinal directions
2369        let expected = [
2370            Vec3::NEG_Y, // Bottom
2371            Vec3::Y,     // Top
2372            Vec3::NEG_Z, // Front
2373            Vec3::Z,     // Back
2374            Vec3::NEG_X, // Left
2375            Vec3::X,     // Right
2376        ];
2377        for exp in &expected {
2378            assert!(
2379                normals.iter().any(|n| (*n - *exp).length() < 1e-6),
2380                "missing normal {:?}, got {:?}",
2381                exp,
2382                normals
2383            );
2384        }
2385
2386        // face_descs order is deterministic: Bottom, Top, Front, Back, Left, Right.
2387        // Verify that the face origin positions lie on the correct parent faces.
2388        // scope: position=(0,0,0), size sx=4, sy=3, sz=2.
2389        let pos = |i: usize| model.terminals[i].scope.position;
2390        assert!(
2391            (pos(0) - Vec3::new(0.0, 0.0, 0.0)).length() < 1e-6,
2392            "Bottom pos"
2393        ); // at y=0
2394        assert!(
2395            (pos(1) - Vec3::new(0.0, 3.0, 2.0)).length() < 1e-6,
2396            "Top pos"
2397        ); // at y=sy, origin shifted to (0,sy,sz)
2398        assert!(
2399            (pos(2) - Vec3::new(4.0, 0.0, 0.0)).length() < 1e-6,
2400            "Front pos"
2401        ); // at z=0, origin shifted to (sx,0,0)
2402        assert!(
2403            (pos(3) - Vec3::new(0.0, 0.0, 2.0)).length() < 1e-6,
2404            "Back pos"
2405        ); // at z=sz
2406        assert!(
2407            (pos(4) - Vec3::new(0.0, 0.0, 0.0)).length() < 1e-6,
2408            "Left pos"
2409        ); // at x=0
2410        assert!(
2411            (pos(5) - Vec3::new(4.0, 0.0, 2.0)).length() < 1e-6,
2412            "Right pos"
2413        ); // at x=sx, origin shifted to (sx,0,sz)
2414    }
2415
2416    // ── Issue 4 (review #14): negative scope size rejected by validate() ────────
2417
2418    #[test]
2419    fn test_negative_scope_size_rejected() {
2420        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(-1.0, 1.0, 1.0));
2421        let interp = Interpreter::new();
2422        assert!(matches!(
2423            interp.derive(scope, "Anything"),
2424            Err(ShapeError::InvalidNumericValue)
2425        ));
2426    }
2427
2428    #[test]
2429    fn test_zero_scope_size_accepted() {
2430        // Y=0 is a valid 2D footprint; derive should succeed (rule unknown → implicit terminal).
2431        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 0.0, 10.0));
2432        let interp = Interpreter::new();
2433        let model = interp.derive(scope, "Footprint").unwrap();
2434        assert_eq!(model.len(), 1);
2435    }
2436
2437    // ── Issue 1 (review #11): unnormalized quaternion in root scope ───────────
2438
2439    #[test]
2440    fn test_unnormalized_root_quat_rejected() {
2441        // DQuat::from_xyzw(2,0,0,0) is finite but has length 2 — not a unit quat.
2442        let bad_q = Quat::from_xyzw(0.0, 0.0, 0.0, 2.0);
2443        let scope = Scope::new(Vec3::ZERO, bad_q, Vec3::ONE);
2444        let interp = Interpreter::new();
2445        assert!(matches!(
2446            interp.derive(scope, "Anything"),
2447            Err(ShapeError::InvalidNumericValue)
2448        ));
2449    }
2450
2451    #[test]
2452    fn test_degenerate_rotate_op_rejected() {
2453        // A zero quaternion (len_sq < 1e-12) cannot represent a rotation — must reject.
2454        let zero_q = Quat::from_xyzw(0.0, 0.0, 0.0, 0.0);
2455        let mut interp = Interpreter::new();
2456        interp.add_rule(
2457            "R",
2458            vec![ShapeOp::Rotate(zero_q), ShapeOp::I("M".to_string())],
2459        );
2460        assert!(matches!(
2461            interp.derive(Scope::unit(), "R"),
2462            Err(ShapeError::InvalidNumericValue)
2463        ));
2464    }
2465
2466    #[test]
2467    fn test_scaled_rotate_op_normalized() {
2468        // A quaternion with magnitude 2 (e.g. IDENTITY * 2) is non-unit but valid;
2469        // it must be normalised to IDENTITY rather than rejected.
2470        let scaled_q = Quat::from_xyzw(0.0, 0.0, 0.0, 2.0); // IDENTITY * 2
2471        let mut interp = Interpreter::new();
2472        interp.add_rule(
2473            "R",
2474            vec![ShapeOp::Rotate(scaled_q), ShapeOp::I("M".to_string())],
2475        );
2476        // Should succeed; the terminal scope rotation should be IDENTITY.
2477        let model = interp.derive(Scope::unit(), "R").unwrap();
2478        assert_eq!(model.len(), 1);
2479        let r = model.terminals[0].scope.rotation;
2480        assert!(
2481            (r.length_squared() - 1.0).abs() < 1e-9,
2482            "rotation should be unit"
2483        );
2484    }
2485
2486    // ── Issue 2 (review #12): invalid weights in add_weighted_rules ──────────
2487
2488    #[test]
2489    fn test_nan_weight_rejected() {
2490        let mut interp = Interpreter::new();
2491        assert!(matches!(
2492            interp.add_weighted_rules("R", vec![(f64::NAN, vec![ShapeOp::I("M".to_string())])]),
2493            Err(ShapeError::InvalidNumericValue)
2494        ));
2495    }
2496
2497    #[test]
2498    fn test_infinite_weight_rejected() {
2499        let mut interp = Interpreter::new();
2500        assert!(matches!(
2501            interp.add_weighted_rules(
2502                "R",
2503                vec![(f64::INFINITY, vec![ShapeOp::I("M".to_string())])]
2504            ),
2505            Err(ShapeError::InvalidNumericValue)
2506        ));
2507    }
2508
2509    #[test]
2510    fn test_negative_weight_rejected() {
2511        let mut interp = Interpreter::new();
2512        assert!(matches!(
2513            interp.add_weighted_rules("R", vec![(-1.0, vec![ShapeOp::I("M".to_string())])]),
2514            Err(ShapeError::InvalidNumericValue)
2515        ));
2516    }
2517
2518    // ── Feature: Align ───────────────────────────────────────────────────────
2519
2520    #[test]
2521    fn test_align_y_to_world_up_when_rotated() {
2522        // Rotate 90° around Z (Y → -X), then Align(Y, Up) should restore Y = +Y.
2523        let mut interp = Interpreter::new();
2524        let ninety_z = Quat::from_axis_angle(Vec3::Z, std::f64::consts::FRAC_PI_2);
2525        interp.add_rule(
2526            "R",
2527            vec![
2528                ShapeOp::Rotate(ninety_z),
2529                ShapeOp::Align {
2530                    local_axis: Axis::Y,
2531                    target: Vec3::Y,
2532                },
2533                ShapeOp::I("M".to_string()),
2534            ],
2535        );
2536        let model = interp.derive(Scope::unit(), "R").unwrap();
2537        assert_eq!(model.len(), 1);
2538        let world_y = model.terminals[0].scope.rotation * Vec3::Y;
2539        assert!(
2540            (world_y - Vec3::Y).length() < 1e-6,
2541            "expected Y=(0,1,0), got {:?}",
2542            world_y
2543        );
2544    }
2545
2546    #[test]
2547    fn test_align_already_aligned_is_noop() {
2548        let mut interp = Interpreter::new();
2549        interp.add_rule(
2550            "R",
2551            vec![
2552                ShapeOp::Align {
2553                    local_axis: Axis::Y,
2554                    target: Vec3::Y,
2555                },
2556                ShapeOp::I("M".to_string()),
2557            ],
2558        );
2559        let model = interp.derive(Scope::unit(), "R").unwrap();
2560        let rot = model.terminals[0].scope.rotation;
2561        assert!((rot.length_squared() - 1.0).abs() < 1e-9);
2562        // Rotation should still be unit (identity-like for already-aligned)
2563        let world_y = rot * Vec3::Y;
2564        assert!((world_y - Vec3::Y).length() < 1e-6);
2565    }
2566
2567    #[test]
2568    fn test_align_zero_target_rejected() {
2569        let mut interp = Interpreter::new();
2570        interp.add_rule(
2571            "R",
2572            vec![
2573                ShapeOp::Align {
2574                    local_axis: Axis::Y,
2575                    target: Vec3::ZERO,
2576                },
2577                ShapeOp::I("M".to_string()),
2578            ],
2579        );
2580        assert!(matches!(
2581            interp.derive(Scope::unit(), "R"),
2582            Err(ShapeError::InvalidAlignTarget)
2583        ));
2584    }
2585
2586    // ── Feature: Offset ──────────────────────────────────────────────────────
2587
2588    #[test]
2589    fn test_offset_inset_produces_inside_and_border() {
2590        let mut interp = Interpreter::new();
2591        interp.add_rule(
2592            "R",
2593            vec![ShapeOp::Offset {
2594                distance: -0.5,
2595                cases: vec![
2596                    crate::ops::OffsetCase {
2597                        selector: crate::ops::OffsetSelector::Inside,
2598                        rule: "Glass".to_string(),
2599                    },
2600                    crate::ops::OffsetCase {
2601                        selector: crate::ops::OffsetSelector::Border,
2602                        rule: "Frame".to_string(),
2603                    },
2604                ],
2605            }],
2606        );
2607        // 4×3 face scope (z=0)
2608        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 0.0));
2609        let model = interp.derive(scope, "R").unwrap();
2610        // 1 Inside + 4 Border strips = 5 terminals
2611        assert_eq!(model.len(), 5);
2612        // Inside scope: size = (3.0, 2.0, 0.0), positioned at (0.5, 0.5, 0.0)
2613        let inside = model
2614            .terminals
2615            .iter()
2616            .find(|t| t.mesh_id == "Glass")
2617            .unwrap();
2618        assert!((inside.scope.size.x - 3.0).abs() < 1e-9);
2619        assert!((inside.scope.size.y - 2.0).abs() < 1e-9);
2620        assert!((inside.scope.position - Vec3::new(0.5, 0.5, 0.0)).length() < 1e-9);
2621    }
2622
2623    #[test]
2624    fn test_offset_too_large_rejected() {
2625        let mut interp = Interpreter::new();
2626        interp.add_rule(
2627            "R",
2628            vec![ShapeOp::Offset {
2629                distance: -2.0, // 2*2.0 = 4 > 3 (sy)
2630                cases: vec![crate::ops::OffsetCase {
2631                    selector: crate::ops::OffsetSelector::Inside,
2632                    rule: "A".to_string(),
2633                }],
2634            }],
2635        );
2636        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(4.0, 3.0, 0.0));
2637        assert!(matches!(
2638            interp.derive(scope, "R"),
2639            Err(ShapeError::OffsetTooLarge)
2640        ));
2641    }
2642
2643    #[test]
2644    fn test_offset_positive_distance_rejected() {
2645        let mut interp = Interpreter::new();
2646        interp.add_rule(
2647            "R",
2648            vec![ShapeOp::Offset {
2649                distance: 0.2,
2650                cases: vec![crate::ops::OffsetCase {
2651                    selector: crate::ops::OffsetSelector::Inside,
2652                    rule: "A".to_string(),
2653                }],
2654            }],
2655        );
2656        assert!(matches!(
2657            interp.derive(Scope::unit(), "R"),
2658            Err(ShapeError::InvalidNumericValue)
2659        ));
2660    }
2661
2662    // ── Feature: Roof ────────────────────────────────────────────────────────
2663
2664    #[test]
2665    fn test_roof_shed_produces_one_slope() {
2666        let mut interp = Interpreter::new();
2667        interp.add_rule(
2668            "R",
2669            vec![ShapeOp::Roof {
2670                config: RoofConfig::new(RoofType::Shed, 30.0),
2671                cases: vec![crate::ops::RoofCase {
2672                    selector: RoofFaceSelector::Slope,
2673                    rule: "Tiles".to_string(),
2674                }],
2675            }],
2676        );
2677        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 5.0, 8.0));
2678        let model = interp.derive(scope, "R").unwrap();
2679        assert_eq!(model.len(), 1);
2680        assert_eq!(model.terminals[0].mesh_id, "Tiles");
2681        // Panel positioned at the base of the roof scope (local Y = 0.0)
2682        assert!((model.terminals[0].scope.position.y - 0.0).abs() < 1e-6);
2683    }
2684
2685    #[test]
2686    fn test_roof_gable_produces_four_panels() {
2687        let mut interp = Interpreter::new();
2688        interp.add_rule(
2689            "R",
2690            vec![ShapeOp::Roof {
2691                config: RoofConfig::new(RoofType::Gable, 30.0),
2692                cases: vec![
2693                    crate::ops::RoofCase {
2694                        selector: RoofFaceSelector::Slope,
2695                        rule: "Tiles".to_string(),
2696                    },
2697                    crate::ops::RoofCase {
2698                        selector: RoofFaceSelector::GableEnd,
2699                        rule: "Bricks".to_string(),
2700                    },
2701                ],
2702            }],
2703        );
2704        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 5.0, 8.0));
2705        let model = interp.derive(scope, "R").unwrap();
2706        // 2 slope + 2 gable-end panels
2707        assert_eq!(model.len(), 4);
2708        let tiles: Vec<_> = model
2709            .terminals
2710            .iter()
2711            .filter(|t| t.mesh_id == "Tiles")
2712            .collect();
2713        let bricks: Vec<_> = model
2714            .terminals
2715            .iter()
2716            .filter(|t| t.mesh_id == "Bricks")
2717            .collect();
2718        assert_eq!(tiles.len(), 2);
2719        assert_eq!(bricks.len(), 2);
2720    }
2721
2722    #[test]
2723    fn test_roof_hip_produces_four_slopes() {
2724        let mut interp = Interpreter::new();
2725        interp.add_rule(
2726            "R",
2727            vec![ShapeOp::Roof {
2728                config: {
2729                    let mut c = RoofConfig::new(RoofType::Hip, 45.0);
2730                    c.overhang = 0.3;
2731                    c
2732                },
2733                cases: vec![crate::ops::RoofCase {
2734                    selector: RoofFaceSelector::Slope,
2735                    rule: "Tiles".to_string(),
2736                }],
2737            }],
2738        );
2739        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 4.0, 8.0));
2740        let model = interp.derive(scope, "R").unwrap();
2741        assert_eq!(model.len(), 4);
2742    }
2743
2744    #[test]
2745    fn test_roof_pyramid_produces_four_tapered_slopes() {
2746        let mut interp = Interpreter::new();
2747        interp.add_rule(
2748            "R",
2749            vec![ShapeOp::Roof {
2750                config: RoofConfig::new(RoofType::Pyramid, 40.0),
2751                cases: vec![crate::ops::RoofCase {
2752                    selector: RoofFaceSelector::Slope,
2753                    rule: "Tiles".to_string(),
2754                }],
2755            }],
2756        );
2757        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(6.0, 3.0, 6.0));
2758        let model = interp.derive(scope, "R").unwrap();
2759        assert_eq!(model.len(), 4);
2760        // All pyramid panels carry Triangle face profile
2761        for t in &model.terminals {
2762            assert!(
2763                matches!(t.face_profile, FaceProfile::Triangle { peak_offset } if (peak_offset - 0.5).abs() < 1e-9),
2764                "expected Triangle{{peak_offset=0.5}}, got {:?}",
2765                t.face_profile
2766            );
2767        }
2768    }
2769
2770    #[test]
2771    fn test_roof_slope_normals_outward() {
2772        // All four Hip slopes must have Local Z (= scope.rotation * Z) pointing
2773        // AWAY from the building:
2774        //   front  → (0,  cos α, −sin α)   back  → (0, cos α, +sin α)
2775        //   left   → (−sin α, cos α,  0)   right → (+sin α, cos α,  0)
2776        let alpha: f64 = 30_f64.to_radians();
2777        let cos_a = alpha.cos();
2778        let sin_a = alpha.sin();
2779        let mut interp = Interpreter::new();
2780        interp.add_rule(
2781            "R",
2782            vec![ShapeOp::Roof {
2783                config: RoofConfig::new(RoofType::Hip, 30.0),
2784                cases: vec![crate::ops::RoofCase {
2785                    selector: RoofFaceSelector::Slope,
2786                    rule: "S".to_string(),
2787                }],
2788            }],
2789        );
2790        let scope = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 4.0, 8.0));
2791        let model = interp.derive(scope, "R").unwrap();
2792        assert_eq!(model.len(), 4);
2793        let normals: Vec<Vec3> = model
2794            .terminals
2795            .iter()
2796            .map(|t| t.scope.rotation * Vec3::Z)
2797            .collect();
2798        let expected = [
2799            Vec3::new(0.0, cos_a, -sin_a), // front: up & forward
2800            Vec3::new(0.0, cos_a, sin_a),  // back:  up & backward
2801            Vec3::new(-sin_a, cos_a, 0.0), // left:  up & left
2802            Vec3::new(sin_a, cos_a, 0.0),  // right: up & right
2803        ];
2804        for exp in &expected {
2805            assert!(
2806                normals.iter().any(|n| (*n - *exp).length() < 1e-6),
2807                "missing outward normal {:?}; got {:?}",
2808                exp,
2809                normals
2810            );
2811        }
2812        // All normals must have a positive Y component (point upward).
2813        for n in &normals {
2814            assert!(n.y > 0.0, "normal pointing downward: {:?}", n);
2815        }
2816    }
2817
2818    #[test]
2819    fn test_align_antiparallel_fallback_no_nan() {
2820        // When the local axis is exactly anti-parallel to the target, the fallback
2821        // 180° rotation must produce a unit quaternion, not NaN.
2822        // Rotate scope so local Y = −Y (anti-parallel to world Up), then Align(Y, Up).
2823        let flip_y = Quat::from_axis_angle(Vec3::Z, PI);
2824        let mut interp = Interpreter::new();
2825        interp.add_rule(
2826            "R",
2827            vec![
2828                ShapeOp::Rotate(flip_y),
2829                ShapeOp::Align {
2830                    local_axis: Axis::Y,
2831                    target: Vec3::Y,
2832                },
2833                ShapeOp::I("M".to_string()),
2834            ],
2835        );
2836        let model = interp.derive(Scope::unit(), "R").unwrap();
2837        let world_y = model.terminals[0].scope.rotation * Vec3::Y;
2838        assert!(
2839            (world_y - Vec3::Y).length() < 1e-6,
2840            "anti-parallel Align should point Y to world up, got {:?}",
2841            world_y
2842        );
2843        // Quaternion must remain unit.
2844        let r = model.terminals[0].scope.rotation;
2845        assert!(
2846            (r.length_squared() - 1.0).abs() < 1e-9,
2847            "rotation not unit: length_sq={}",
2848            r.length_squared()
2849        );
2850    }
2851
2852    #[test]
2853    fn test_roof_invalid_angle_rejected() {
2854        let mut interp = Interpreter::new();
2855        interp.add_rule(
2856            "R",
2857            vec![ShapeOp::Roof {
2858                config: RoofConfig::new(RoofType::Shed, 0.0),
2859                cases: vec![],
2860            }],
2861        );
2862        assert!(matches!(
2863            interp.derive(Scope::unit(), "R"),
2864            Err(ShapeError::InvalidRoofAngle(_))
2865        ));
2866    }
2867}