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