Skip to main content

symbios_shape/
grammar.rs

1/// CGA Shape Grammar text parser.
2///
3/// Parses a sequence of operations in a grammar rule body:
4///
5/// ```text
6/// Extrude(10)
7/// Split(Y) { ~1: Floor | ~1: Floor | 2: Roof }
8/// Comp(Faces) { Top: Roof | Side: Facade | Bottom: Foundation }
9/// I("Window")
10/// Mat("Brick")
11/// ```
12///
13/// Stochastic rules use `weight%` syntax:
14/// ```text
15/// Facade --> 70% BrickWall | 30% GlassCurtain
16/// ```
17use nom::{
18    IResult, Parser,
19    branch::alt,
20    bytes::complete::{tag, take_until, take_while, take_while_m_n, take_while1},
21    character::complete::{char as c_char, multispace1},
22    combinator::{cut, map, opt, verify},
23    error::{Error, ErrorKind},
24    multi::many0,
25    number::complete::double,
26    sequence::{delimited, preceded, terminated},
27};
28
29use crate::error::ShapeError;
30use crate::expr::{Expr, MAX_EXPR_NODES, parse_expr};
31use crate::ops::{
32    AttachCase, AttachSelector, Axis, CarveCase, CarveSelector, CompFaceCase, CompTarget,
33    FaceSelector, FitCandidate, OffsetCase, OffsetSelector, RoofCase, RoofFaceSelector, RoofSpec,
34    RoofType, RuleCall, RuleVariant, ShapeOp, SplitEntry, SplitSize, SplitSlot, VariantSelector,
35};
36use crate::scope::Vec3;
37
38// Safety limits (DoS protection)
39const MAX_SPLIT_SLOTS: usize = 256;
40const MAX_COMP_CASES: usize = 32;
41const MAX_OPS: usize = 1024;
42const MAX_IDENTIFIER_LEN: usize = 64;
43/// Maximum number of stochastic variants in a single rule body (`A | B | C…`).
44/// Without this cap an attacker can force unbounded `Vec` allocations in
45/// `split_top_level_pipe` before any derivation-time guards can fire.
46pub const MAX_VARIANTS: usize = 64;
47/// Maximum number of call arguments on a rule reference (`Foo(a, b, …)`).
48pub const MAX_RULE_ARGS: usize = 16;
49
50// ── Whitespace & comments ────────────────────────────────────────────────────
51
52pub(crate) fn space_or_comment<'a, E: nom::error::ParseError<&'a str>>(
53    input: &'a str,
54) -> IResult<&'a str, (), E> {
55    let comment = alt((
56        preceded(tag("/*"), terminated(take_until("*/"), tag("*/"))),
57        // take_while (unlike is_not) accepts empty input, so `// EOF` without a
58        // trailing newline parses correctly instead of returning a fatal error.
59        preceded(tag("//"), take_while(|c: char| c != '\n' && c != '\r')),
60    ));
61    let mut p = many0(alt((map(multispace1, |_| ()), map(comment, |_| ()))));
62    p.parse(input).map(|(i, _)| (i, ()))
63}
64
65fn ws<'a, F, O, E: nom::error::ParseError<&'a str>>(
66    inner: F,
67) -> impl Parser<&'a str, Output = O, Error = E>
68where
69    F: Parser<&'a str, Output = O, Error = E>,
70{
71    delimited(space_or_comment, inner, space_or_comment)
72}
73
74// ── Primitives ───────────────────────────────────────────────────────────────
75
76fn finite_float(input: &str) -> IResult<&str, f64> {
77    verify(double, |x: &f64| x.is_finite()).parse(input)
78}
79
80fn is_ident_start(c: char) -> bool {
81    c.is_alphabetic() || c == '_'
82}
83
84fn is_ident_char(c: char) -> bool {
85    c.is_alphanumeric() || c == '_'
86}
87
88pub(crate) fn identifier(input: &str) -> IResult<&str, &str> {
89    let original = input;
90    let (input, s) = take_while1(is_ident_char).parse(input)?;
91    if !s.chars().next().map(is_ident_start).unwrap_or(false) {
92        // Use `original` so the error span points to the start of the bad token,
93        // not to the position after consuming it.
94        return Err(nom::Err::Error(Error::new(original, ErrorKind::Alpha)));
95    }
96    if s.len() > MAX_IDENTIFIER_LEN {
97        return Err(nom::Err::Failure(Error::new(input, ErrorKind::TooLarge)));
98    }
99    Ok((input, s))
100}
101
102/// Parses an identifier OR a quoted string literal `"name"`.
103///
104/// Quoted strings are limited to `MAX_IDENTIFIER_LEN` characters via
105/// `take_while_m_n`: the scan stops at the limit, causing `c_char('"')` to
106/// fail for oversized inputs before any allocation occurs.
107fn rule_name(input: &str) -> IResult<&str, String> {
108    alt((
109        map(
110            delimited(
111                c_char('"'),
112                take_while_m_n(0, MAX_IDENTIFIER_LEN, |c: char| c != '"'),
113                c_char('"'),
114            ),
115            |s: &str| s.to_string(),
116        ),
117        map(ws(identifier), |s: &str| s.to_string()),
118    ))
119    .parse(input)
120}
121
122/// Consumes `kw` only when NOT followed by an identifier character — `I(`
123/// matches, `Inner` falls through softly so the rule-reference fallback can
124/// claim it. Without this any rule name sharing an op keyword's prefix dies
125/// on the op parser's fatal `cut`.
126fn keyword<'a>(
127    kw: &'static str,
128) -> impl FnMut(&'a str) -> IResult<&'a str, &'a str, Error<&'a str>> {
129    move |input: &'a str| {
130        let (rest, m) = tag::<_, _, Error<&str>>(kw).parse(input)?;
131        if rest.chars().next().is_some_and(is_ident_char) {
132            return Err(nom::Err::Error(Error::new(input, ErrorKind::Tag)));
133        }
134        Ok((rest, m))
135    }
136}
137
138/// Parses an argument expression, enforcing the [`MAX_EXPR_NODES`] cap.
139/// Every numeric argument position in the grammar routes through this.
140fn arg_expr(input: &str) -> IResult<&str, Expr> {
141    let (rest, e) = parse_expr(input)?;
142    if e.node_count() > MAX_EXPR_NODES {
143        return Err(nom::Err::Failure(Error::new(input, ErrorKind::TooLarge)));
144    }
145    Ok((rest, e))
146}
147
148/// Parses a rule reference with optional call arguments:
149/// `Bay`, `"My Rule"`, or `Tier(depth + 1, 3)`.
150///
151/// Quoted names accept arguments too. The argument list is capped at
152/// [`MAX_RULE_ARGS`].
153fn parse_rule_call(input: &str) -> IResult<&str, RuleCall> {
154    let (input, name) = rule_name(input)?;
155    // Optional argument list. A bare reference is the common case.
156    let Ok((mut rem, _)) = ws::<_, _, Error<&str>>(c_char('(')).parse(input) else {
157        return Ok((input, RuleCall::new(name)));
158    };
159    let mut args = Vec::new();
160    if let Ok((after, _)) = ws::<_, _, Error<&str>>(c_char(')')).parse(rem) {
161        return Ok((after, RuleCall::with_args(name, args)));
162    }
163    loop {
164        let (after_arg, arg) = arg_expr(rem)?;
165        args.push(arg);
166        if args.len() > MAX_RULE_ARGS {
167            return Err(nom::Err::Failure(Error::new(
168                after_arg,
169                ErrorKind::TooLarge,
170            )));
171        }
172        if let Ok((after, _)) = ws::<_, _, Error<&str>>(c_char(',')).parse(after_arg) {
173            rem = after;
174            continue;
175        }
176        let (after, _) = cut(ws(c_char(')'))).parse(after_arg)?;
177        rem = after;
178        break;
179    }
180    Ok((rem, RuleCall::with_args(name, args)))
181}
182
183// ── Axis ─────────────────────────────────────────────────────────────────────
184
185fn parse_axis(input: &str) -> IResult<&str, Axis> {
186    alt((
187        map(tag("X"), |_| Axis::X),
188        map(tag("Y"), |_| Axis::Y),
189        map(tag("Z"), |_| Axis::Z),
190    ))
191    .parse(input)
192}
193
194// ── Vec3 / Quat expression tuples ────────────────────────────────────────────
195
196/// Parses `(x, y, z)` where each component is an argument expression.
197fn parse_expr3(input: &str) -> IResult<&str, [Expr; 3]> {
198    let (input, _) = ws(c_char('(')).parse(input)?;
199    let (input, x) = arg_expr(input)?;
200    let (input, _) = ws(c_char(',')).parse(input)?;
201    let (input, y) = arg_expr(input)?;
202    let (input, _) = ws(c_char(',')).parse(input)?;
203    let (input, z) = arg_expr(input)?;
204    let (input, _) = ws(c_char(')')).parse(input)?;
205    Ok((input, [x, y, z]))
206}
207
208/// Parses `(w, x, y, z)` — grammar order is (w,x,y,z); evaluation converts to
209/// glam's (x,y,z,w) and normalizes. When all four components are literals the
210/// degenerate-quaternion check runs at parse time for early feedback.
211fn parse_expr4(input: &str) -> IResult<&str, [Expr; 4]> {
212    let (input, _) = ws(c_char('(')).parse(input)?;
213    let (input, w) = arg_expr(input)?;
214    let (input, _) = ws(c_char(',')).parse(input)?;
215    let (input, x) = arg_expr(input)?;
216    let (input, _) = ws(c_char(',')).parse(input)?;
217    let (input, y) = arg_expr(input)?;
218    let (input, _) = ws(c_char(',')).parse(input)?;
219    let (input, z) = arg_expr(input)?;
220    let (input, _) = ws(c_char(')')).parse(input)?;
221    if let (Some(wv), Some(xv), Some(yv), Some(zv)) =
222        (w.as_lit(), x.as_lit(), y.as_lit(), z.as_lit())
223    {
224        let len_sq = xv * xv + yv * yv + zv * zv + wv * wv;
225        if !len_sq.is_finite() || len_sq < 1e-12 {
226            return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
227        }
228    }
229    Ok((input, [w, x, y, z]))
230}
231
232// ── Split sizes ───────────────────────────────────────────────────────────────
233
234/// `~2.5` → `Floating`, `'0.5` → `Relative`, `2.5` / `scope.x - 4` → `Absolute`.
235/// Sizes are expressions; the interpreter validates the evaluated values.
236fn parse_split_size(input: &str) -> IResult<&str, SplitSize> {
237    if let Ok((rest, _)) = c_char::<_, Error<&str>>('~').parse(input) {
238        let (rest, e) = arg_expr(rest)?;
239        return Ok((rest, SplitSize::Floating(e)));
240    }
241    if let Ok((rest, _)) = c_char::<_, Error<&str>>('\'').parse(input) {
242        let (rest, e) = arg_expr(rest)?;
243        return Ok((rest, SplitSize::Relative(e)));
244    }
245    let (rest, e) = arg_expr(input)?;
246    Ok((rest, SplitSize::Absolute(e)))
247}
248
249/// `~1.0: Floor` or `2.0: Roof(depth + 1)`
250fn parse_split_slot(input: &str) -> IResult<&str, SplitSlot> {
251    let (input, size) = ws(parse_split_size).parse(input)?;
252    let (input, _) = ws(c_char(':')).parse(input)?;
253    let (input, rule) = ws(parse_rule_call).parse(input)?;
254    Ok((input, SplitSlot { size, rule }))
255}
256
257// ── Comp faces ────────────────────────────────────────────────────────────────
258
259fn parse_comp_face_case(input: &str) -> IResult<&str, CompFaceCase> {
260    let (input, sel_str) = ws(identifier).parse(input)?;
261    let selector = FaceSelector::parse(sel_str)
262        .ok_or_else(|| nom::Err::Failure(Error::new(input, ErrorKind::Tag)))?;
263    let (input, _) = ws(c_char(':')).parse(input)?;
264    let (input, rule) = ws(parse_rule_call).parse(input)?;
265    Ok((input, CompFaceCase { selector, rule }))
266}
267
268// ── Individual operations ─────────────────────────────────────────────────────
269
270fn parse_extrude(input: &str) -> IResult<&str, ShapeOp> {
271    let (input, _) = keyword("Extrude")(input)?;
272    let (input, h) = cut(delimited(ws(c_char('(')), arg_expr, ws(c_char(')')))).parse(input)?;
273    // Early feedback for literal arguments; expression results are validated
274    // at derivation time.
275    if let Some(v) = h.as_lit()
276        && v <= 0.0
277    {
278        return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
279    }
280    Ok((input, ShapeOp::Extrude(h)))
281}
282
283fn parse_taper(input: &str) -> IResult<&str, ShapeOp> {
284    let (input, _) = keyword("Taper")(input)?;
285    let (input, amount) =
286        cut(delimited(ws(c_char('(')), arg_expr, ws(c_char(')')))).parse(input)?;
287    if let Some(v) = amount.as_lit()
288        && !(0.0..=1.0).contains(&v)
289    {
290        return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
291    }
292    Ok((input, ShapeOp::Taper(amount)))
293}
294
295fn parse_rotate(input: &str) -> IResult<&str, ShapeOp> {
296    let (input, _) = keyword("Rotate")(input)?;
297    let (input, q) = cut(ws(parse_expr4)).parse(input)?;
298    Ok((input, ShapeOp::Rotate(q)))
299}
300
301fn parse_translate(input: &str) -> IResult<&str, ShapeOp> {
302    let (input, _) = keyword("Translate")(input)?;
303    let (input, v) = cut(ws(parse_expr3)).parse(input)?;
304    Ok((input, ShapeOp::Translate(v)))
305}
306
307fn parse_scale(input: &str) -> IResult<&str, ShapeOp> {
308    let (input, _) = keyword("Scale")(input)?;
309    let (input, v) = cut(ws(parse_expr3)).parse(input)?;
310    for c in &v {
311        if let Some(lit) = c.as_lit()
312            && lit <= 0.0
313        {
314            return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
315        }
316    }
317    Ok((input, ShapeOp::Scale(v)))
318}
319
320/// Parses `snap="label"` or `snap="label", tol=0.5` named arguments inside
321/// `Split(axis, …)`. Returns the optional `SnapBinding` and the `tol` value
322/// stored on it (or `None` for the default).
323fn parse_split_snap(input: &str) -> IResult<&str, Option<crate::ops::SnapBinding>> {
324    // Expect a leading comma. If absent, no snap.
325    let Ok((after_comma, _)) = ws::<_, _, Error<&str>>(c_char(',')).parse(input) else {
326        return Ok((input, None));
327    };
328    let Ok((after_eq, _)) = (
329        ws::<_, _, Error<&str>>(tag("snap")),
330        ws::<_, _, Error<&str>>(c_char('=')),
331    )
332        .parse(after_comma)
333    else {
334        return Ok((input, None));
335    };
336    let (after_label, label) = cut(ws(rule_name)).parse(after_eq)?;
337    // Optional `, tol=N`.
338    let mut remaining = after_label;
339    let mut tolerance: Option<f64> = None;
340    if let Ok((after_c, _)) = ws::<_, _, Error<&str>>(c_char(',')).parse(remaining)
341        && let Ok((after_t_eq, _)) = (
342            ws::<_, _, Error<&str>>(tag("tol")),
343            ws::<_, _, Error<&str>>(c_char('=')),
344        )
345            .parse(after_c)
346    {
347        let (after_v, v) = cut(ws(finite_float)).parse(after_t_eq)?;
348        if v < 0.0 {
349            return Err(nom::Err::Failure(Error::new(after_v, ErrorKind::Verify)));
350        }
351        tolerance = Some(v);
352        remaining = after_v;
353    }
354    Ok((
355        remaining,
356        Some(crate::ops::SnapBinding { label, tolerance }),
357    ))
358}
359
360/// Parses a rhythm group `{ slot | slot }*` inside a `Split` body.
361fn parse_split_group(input: &str) -> IResult<&str, Vec<SplitSlot>> {
362    let (input, _) = ws(c_char('{')).parse(input)?;
363    let (mut remaining, first) = cut(ws(parse_split_slot)).parse(input)?;
364    let mut slots = vec![first];
365    loop {
366        if slots.len() >= MAX_SPLIT_SLOTS {
367            return Err(nom::Err::Failure(Error::new(
368                remaining,
369                ErrorKind::TooLarge,
370            )));
371        }
372        let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(remaining) else {
373            break;
374        };
375        let (after_item, slot) = cut(ws(parse_split_slot)).parse(after_sep)?;
376        slots.push(slot);
377        remaining = after_item;
378    }
379    let (remaining, _) = cut(ws(c_char('}'))).parse(remaining)?;
380    let (remaining, _) = cut(ws(c_char('*'))).parse(remaining)?;
381    Ok((remaining, slots))
382}
383
384/// One `Split` entry: a rhythm group or a single slot.
385fn parse_split_entry(input: &str) -> IResult<&str, SplitEntry> {
386    if input.trim_start().starts_with('{') {
387        let (rest, group) = parse_split_group(input)?;
388        return Ok((rest, SplitEntry::Group(group)));
389    }
390    let (rest, slot) = parse_split_slot(input)?;
391    Ok((rest, SplitEntry::Slot(slot)))
392}
393
394fn parse_split(input: &str) -> IResult<&str, ShapeOp> {
395    let (input, _) = keyword("Split")(input)?;
396    // After the "Split" keyword is consumed, any argument error is fatal.
397    let (input, _) = cut(ws(c_char('('))).parse(input)?;
398    let (input, axis) = cut(ws(parse_axis)).parse(input)?;
399    let (input, snap) = parse_split_snap(input)?;
400    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
401    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
402    // Bounded list: cap allocation before it happens (DoS guard).
403    let (mut remaining, first) = cut(ws(parse_split_entry)).parse(input)?;
404    let mut entries = vec![first];
405    loop {
406        if entries.len() >= MAX_SPLIT_SLOTS {
407            return Err(nom::Err::Failure(Error::new(
408                remaining,
409                ErrorKind::TooLarge,
410            )));
411        }
412        let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(remaining) else {
413            break;
414        };
415        match ws(parse_split_entry).parse(after_sep) {
416            Ok((after_item, entry)) => {
417                entries.push(entry);
418                remaining = after_item;
419            }
420            Err(nom::Err::Failure(e)) => return Err(nom::Err::Failure(e)),
421            Err(_) => {
422                remaining = after_sep; // trailing `|` — consume separator, stop
423                break;
424            }
425        }
426    }
427    let (remaining, _) = ws(c_char('}')).parse(remaining)?;
428    // One rhythm group per split keeps allocation predictable.
429    if entries
430        .iter()
431        .filter(|e| matches!(e, SplitEntry::Group(_)))
432        .count()
433        > 1
434    {
435        return Err(nom::Err::Failure(Error::new(remaining, ErrorKind::Verify)));
436    }
437    Ok((
438        remaining,
439        ShapeOp::Split {
440            axis,
441            entries,
442            snap,
443        },
444    ))
445}
446
447/// `SplitArea(X) { 30: Lot | ~1: Rest }` — split by target areas.
448fn parse_split_area(input: &str) -> IResult<&str, ShapeOp> {
449    let (input, _) = keyword("SplitArea")(input)?;
450    let (input, _) = cut(ws(c_char('('))).parse(input)?;
451    let (input, axis) = cut(ws(parse_axis)).parse(input)?;
452    if axis == Axis::Y {
453        return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
454    }
455    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
456    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
457    let (mut remaining, first) = cut(ws(parse_split_slot)).parse(input)?;
458    let mut slots = vec![first];
459    loop {
460        if slots.len() >= MAX_SPLIT_SLOTS {
461            return Err(nom::Err::Failure(Error::new(
462                remaining,
463                ErrorKind::TooLarge,
464            )));
465        }
466        let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(remaining) else {
467            break;
468        };
469        let (after_item, slot) = cut(ws(parse_split_slot)).parse(after_sep)?;
470        slots.push(slot);
471        remaining = after_item;
472    }
473    let (remaining, _) = ws(c_char('}')).parse(remaining)?;
474    Ok((remaining, ShapeOp::SplitArea { axis, slots }))
475}
476
477/// `Fit(X) { 2.2: DoorBay | 1.2: WinBay | 0: Wall }` — first candidate whose
478/// minimum extent fits the scope wins the whole scope.
479fn parse_fit(input: &str) -> IResult<&str, ShapeOp> {
480    let (input, _) = keyword("Fit")(input)?;
481    let (input, _) = cut(ws(c_char('('))).parse(input)?;
482    let (input, axis) = cut(ws(parse_axis)).parse(input)?;
483    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
484    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
485    let mut candidates = Vec::new();
486    let mut remaining = input;
487    loop {
488        if candidates.len() >= MAX_SPLIT_SLOTS {
489            return Err(nom::Err::Failure(Error::new(
490                remaining,
491                ErrorKind::TooLarge,
492            )));
493        }
494        let (after_min, min_size) = cut(ws(arg_expr)).parse(remaining)?;
495        let (after_colon, _) = cut(ws(c_char(':'))).parse(after_min)?;
496        let (after_rule, rule) = cut(ws(parse_rule_call)).parse(after_colon)?;
497        candidates.push(FitCandidate { min_size, rule });
498        if let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(after_rule) {
499            remaining = after_sep;
500            continue;
501        }
502        remaining = after_rule;
503        break;
504    }
505    let (remaining, _) = cut(ws(c_char('}'))).parse(remaining)?;
506    Ok((remaining, ShapeOp::Fit { axis, candidates }))
507}
508
509/// `RegSnap("label")` — registers all six face planes of the current scope.
510fn parse_reg_snap(input: &str) -> IResult<&str, ShapeOp> {
511    let (input, _) = keyword("RegSnap")(input)?;
512    let (input, _) = cut(ws(c_char('('))).parse(input)?;
513    let (input, label) = cut(ws(rule_name)).parse(input)?;
514    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
515    Ok((input, ShapeOp::RegSnap(label)))
516}
517
518/// Optional `("label")` filter between an occlusion keyword and its brace.
519fn parse_occlusion_label(input: &str) -> IResult<&str, Option<String>> {
520    let Ok((rest, _)) = ws::<_, _, Error<&str>>(c_char('(')).parse(input) else {
521        return Ok((input, None));
522    };
523    let (rest, label) = cut(ws(rule_name)).parse(rest)?;
524    let (rest, _) = cut(ws(c_char(')'))).parse(rest)?;
525    Ok((rest, Some(label)))
526}
527
528/// Shared body for the four occlusion conditionals.
529fn parse_occlusion_body(input: &str) -> IResult<&str, (RuleCall, Option<String>)> {
530    let (input, label) = parse_occlusion_label(input)?;
531    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
532    let (input, rule) = cut(ws(parse_rule_call)).parse(input)?;
533    let (input, _) = cut(ws(c_char('}'))).parse(input)?;
534    Ok((input, (rule, label)))
535}
536
537/// `IfClear { Rule }` / `IfClear("label") { Rule }`.
538fn parse_if_clear(input: &str) -> IResult<&str, ShapeOp> {
539    let (input, _) = keyword("IfClear")(input)?;
540    let (input, (rule, label)) = parse_occlusion_body(input)?;
541    Ok((input, ShapeOp::IfClear { rule, label }))
542}
543
544/// `IfOccluded { Rule }` / `IfOccluded("label") { Rule }`.
545fn parse_if_occluded(input: &str) -> IResult<&str, ShapeOp> {
546    let (input, _) = keyword("IfOccluded")(input)?;
547    let (input, (rule, label)) = parse_occlusion_body(input)?;
548    Ok((input, ShapeOp::IfOccluded { rule, label }))
549}
550
551/// `IfInside { Rule }` / `IfInside("label") { Rule }`.
552fn parse_if_inside(input: &str) -> IResult<&str, ShapeOp> {
553    let (input, _) = keyword("IfInside")(input)?;
554    let (input, (rule, label)) = parse_occlusion_body(input)?;
555    Ok((input, ShapeOp::IfInside { rule, label }))
556}
557
558/// `IfTouches { Rule }` / `IfTouches("label") { Rule }`.
559fn parse_if_touches(input: &str) -> IResult<&str, ShapeOp> {
560    let (input, _) = keyword("IfTouches")(input)?;
561    let (input, (rule, label)) = parse_occlusion_body(input)?;
562    Ok((input, ShapeOp::IfTouches { rule, label }))
563}
564
565/// `Label("chimneys")` — stamps an occlusion label on this branch.
566fn parse_label(input: &str) -> IResult<&str, ShapeOp> {
567    let (input, _) = keyword("Label")(input)?;
568    let (input, _) = cut(ws(c_char('('))).parse(input)?;
569    let (input, label) = cut(ws(rule_name)).parse(input)?;
570    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
571    Ok((input, ShapeOp::Label(label)))
572}
573
574/// `Pick("key") { 60% A | 40% B }` — derivation-coherent weighted choice.
575fn parse_pick(input: &str) -> IResult<&str, ShapeOp> {
576    let (input, _) = keyword("Pick")(input)?;
577    let (input, _) = cut(ws(c_char('('))).parse(input)?;
578    let (input, key) = cut(ws(rule_name)).parse(input)?;
579    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
580    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
581    let mut choices = Vec::new();
582    let mut remaining = input;
583    loop {
584        if choices.len() >= MAX_VARIANTS {
585            return Err(nom::Err::Failure(Error::new(
586                remaining,
587                ErrorKind::TooLarge,
588            )));
589        }
590        let (after_w, w) = cut(ws(parse_weight_prefix)).parse(remaining)?;
591        if !w.is_finite() || w < 0.0 {
592            return Err(nom::Err::Failure(Error::new(after_w, ErrorKind::Verify)));
593        }
594        let (after_rule, rule) = cut(ws(parse_rule_call)).parse(after_w)?;
595        choices.push((w / 100.0, rule));
596        if let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(after_rule) {
597            remaining = after_sep;
598            continue;
599        }
600        remaining = after_rule;
601        break;
602    }
603    let (remaining, _) = cut(ws(c_char('}'))).parse(remaining)?;
604    Ok((remaining, ShapeOp::Pick { key, choices }))
605}
606
607/// `Scatter(Top, 12) { Bush }` / `Scatter(Volume, n * 2) { Mote }`.
608fn parse_scatter(input: &str) -> IResult<&str, ShapeOp> {
609    let (input, _) = keyword("Scatter")(input)?;
610    let (input, _) = cut(ws(c_char('('))).parse(input)?;
611    let (input, kind) = cut(ws(identifier)).parse(input)?;
612    let volume = match kind {
613        "Top" | "top" => false,
614        "Volume" | "volume" => true,
615        _ => return Err(nom::Err::Failure(Error::new(input, ErrorKind::Tag))),
616    };
617    let (input, _) = cut(ws(c_char(','))).parse(input)?;
618    let (input, count) = cut(arg_expr).parse(input)?;
619    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
620    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
621    let (input, rule) = cut(ws(parse_rule_call)).parse(input)?;
622    let (input, _) = cut(ws(c_char('}'))).parse(input)?;
623    Ok((
624        input,
625        ShapeOp::Scatter {
626            volume,
627            count,
628            rule,
629        },
630    ))
631}
632
633/// Cap on the number of entries in a `Repeat(axis, [..])` tile-size list.
634/// Bounds parser allocation; matches `MAX_SPLIT_SLOTS`-style DoS hardening.
635const MAX_REPEAT_TILE_SIZES: usize = 256;
636
637/// Rejects a literal tile size that is not strictly positive; expression
638/// sizes are validated on their evaluated values at derivation time.
639fn check_tile_lit<'a>(e: &Expr, at: &'a str) -> Result<(), nom::Err<Error<&'a str>>> {
640    if let Some(v) = e.as_lit()
641        && v <= 0.0
642    {
643        return Err(nom::Err::Failure(Error::new(at, ErrorKind::Verify)));
644    }
645    Ok(())
646}
647
648/// Parses `Repeat`'s tile-size argument as either a single expression
649/// (`Repeat(X, 2.5)` — uniform) or a bracketed list
650/// (`Repeat(X, [2, 1.5, 3])` — cycled pattern).
651fn parse_repeat_tile_sizes(input: &str) -> IResult<&str, Vec<Expr>> {
652    // Try the bracketed list first.
653    if let Ok((rest, _)) = ws::<_, _, Error<&str>>(c_char('[')).parse(input) {
654        let (rest, first) = cut(arg_expr).parse(rest)?;
655        check_tile_lit(&first, rest)?;
656        let mut sizes = vec![first];
657        let mut remaining = rest;
658        loop {
659            if sizes.len() >= MAX_REPEAT_TILE_SIZES {
660                return Err(nom::Err::Failure(Error::new(
661                    remaining,
662                    ErrorKind::TooLarge,
663                )));
664            }
665            let Ok((after_comma, _)) = ws::<_, _, Error<&str>>(c_char(',')).parse(remaining) else {
666                break;
667            };
668            let (after_num, v) = cut(arg_expr).parse(after_comma)?;
669            check_tile_lit(&v, after_num)?;
670            sizes.push(v);
671            remaining = after_num;
672        }
673        let (remaining, _) = cut(ws(c_char(']'))).parse(remaining)?;
674        return Ok((remaining, sizes));
675    }
676    // Fallback: single expression.
677    let (rest, v) = arg_expr(input)?;
678    check_tile_lit(&v, rest)?;
679    Ok((rest, vec![v]))
680}
681
682fn parse_repeat(input: &str) -> IResult<&str, ShapeOp> {
683    let (input, _) = keyword("Repeat")(input)?;
684    // After the "Repeat" keyword is consumed, any argument error is fatal.
685    let (input, _) = cut(ws(c_char('('))).parse(input)?;
686    let (input, axis) = cut(ws(parse_axis)).parse(input)?;
687    let (input, _) = cut(ws(c_char(','))).parse(input)?;
688    let (input, tile_sizes) = cut(parse_repeat_tile_sizes).parse(input)?;
689    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
690    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
691    let (input, rule) = cut(ws(parse_rule_call)).parse(input)?;
692    let (input, _) = cut(ws(c_char('}'))).parse(input)?;
693    Ok((
694        input,
695        ShapeOp::Repeat {
696            axis,
697            tile_sizes,
698            rule,
699        },
700    ))
701}
702
703fn parse_comp_edge_case(input: &str) -> IResult<&str, crate::ops::CompEdgeCase> {
704    let (input, sel_str) = ws(identifier).parse(input)?;
705    let selector = crate::ops::EdgeSelector::parse(sel_str)
706        .ok_or_else(|| nom::Err::Failure(Error::new(input, ErrorKind::Tag)))?;
707    let (input, _) = ws(c_char(':')).parse(input)?;
708    let (input, rule) = ws(parse_rule_call).parse(input)?;
709    Ok((input, crate::ops::CompEdgeCase { selector, rule }))
710}
711
712fn parse_comp(input: &str) -> IResult<&str, ShapeOp> {
713    let (input, _) = keyword("Comp")(input)?;
714    // After the "Comp" keyword is consumed, any argument error is fatal.
715    let (input, _) = cut(ws(c_char('('))).parse(input)?;
716    let (input, kind) = cut(ws(identifier)).parse(input)?;
717    let edges = match kind {
718        "Faces" | "faces" => false,
719        "Edges" | "edges" => true,
720        _ => return Err(nom::Err::Failure(Error::new(input, ErrorKind::Tag))),
721    };
722    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
723    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
724    if edges {
725        let (mut remaining, first) = cut(ws(parse_comp_edge_case)).parse(input)?;
726        let mut cases = vec![first];
727        loop {
728            if cases.len() >= MAX_COMP_CASES {
729                return Err(nom::Err::Failure(Error::new(
730                    remaining,
731                    ErrorKind::TooLarge,
732                )));
733            }
734            let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(remaining) else {
735                break;
736            };
737            match ws(parse_comp_edge_case).parse(after_sep) {
738                Ok((after_item, case)) => {
739                    cases.push(case);
740                    remaining = after_item;
741                }
742                Err(nom::Err::Failure(e)) => return Err(nom::Err::Failure(e)),
743                Err(_) => {
744                    remaining = after_sep;
745                    break;
746                }
747            }
748        }
749        let (remaining, _) = ws(c_char('}')).parse(remaining)?;
750        return Ok((remaining, ShapeOp::Comp(CompTarget::Edges(cases))));
751    }
752    // Bounded list: cap allocation before it happens (DoS guard).
753    let (mut remaining, first) = cut(ws(parse_comp_face_case)).parse(input)?;
754    let mut cases = vec![first];
755    loop {
756        if cases.len() >= MAX_COMP_CASES {
757            return Err(nom::Err::Failure(Error::new(
758                remaining,
759                ErrorKind::TooLarge,
760            )));
761        }
762        let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(remaining) else {
763            break;
764        };
765        match ws(parse_comp_face_case).parse(after_sep) {
766            Ok((after_item, case)) => {
767                cases.push(case);
768                remaining = after_item;
769            }
770            Err(nom::Err::Failure(e)) => return Err(nom::Err::Failure(e)),
771            Err(_) => {
772                remaining = after_sep; // trailing `|` — consume separator, stop
773                break;
774            }
775        }
776    }
777    let (remaining, _) = ws(c_char('}')).parse(remaining)?;
778    Ok((remaining, ShapeOp::Comp(CompTarget::Faces(cases))))
779}
780
781fn parse_instance(input: &str) -> IResult<&str, ShapeOp> {
782    let (input, _) = tag::<_, _, Error<&str>>("I").parse(input)?;
783    // `I` must be the whole keyword: `Inner` is a rule reference.
784    if input.chars().next().is_some_and(is_ident_char) {
785        return Err(nom::Err::Error(Error::new(input, ErrorKind::Tag)));
786    }
787    let (input, mesh_id) =
788        cut(delimited(ws(c_char('(')), ws(rule_name), ws(c_char(')')))).parse(input)?;
789    Ok((input, ShapeOp::I(mesh_id)))
790}
791
792/// Cap on the vertex count of a `Polygon(...)` op (DoS hardening).
793const MAX_POLYGON_VERTICES: usize = 256;
794
795/// Parses `Polygon((0,0), (4,0), (4,2), (2,2), (2,4), (0,4))` — a variadic
796/// list of at least 3 `(x, y)` 2-D points. Each point is parsed with
797/// `parse_vec2`; non-finite components are rejected by `finite_float`.
798fn parse_vec2(input: &str) -> IResult<&str, glam::DVec2> {
799    let (input, _) = ws(c_char('(')).parse(input)?;
800    let (input, x) = ws(finite_float).parse(input)?;
801    let (input, _) = ws(c_char(',')).parse(input)?;
802    let (input, y) = ws(finite_float).parse(input)?;
803    let (input, _) = ws(c_char(')')).parse(input)?;
804    Ok((input, glam::DVec2::new(x, y)))
805}
806
807fn parse_polygon(input: &str) -> IResult<&str, ShapeOp> {
808    let (input, _) = keyword("Polygon")(input)?;
809    let (input, _) = cut(ws(c_char('('))).parse(input)?;
810    let (input, first) = cut(ws(parse_vec2)).parse(input)?;
811    let mut verts = vec![first];
812    let mut remaining = input;
813    loop {
814        if verts.len() >= MAX_POLYGON_VERTICES {
815            return Err(nom::Err::Failure(Error::new(
816                remaining,
817                ErrorKind::TooLarge,
818            )));
819        }
820        let Ok((after_comma, _)) = ws::<_, _, Error<&str>>(c_char(',')).parse(remaining) else {
821            break;
822        };
823        let (after_pt, pt) = cut(ws(parse_vec2)).parse(after_comma)?;
824        verts.push(pt);
825        remaining = after_pt;
826    }
827    let (remaining, _) = cut(ws(c_char(')'))).parse(remaining)?;
828    if verts.len() < 3 {
829        return Err(nom::Err::Failure(Error::new(remaining, ErrorKind::Verify)));
830    }
831    Ok((remaining, ShapeOp::Polygon(verts)))
832}
833
834/// Parses `Mat("Brick")` / `Mat(Brick)` (id only) or `Mat("Brick", 1800)` /
835/// `Mat(Brick, 1800)` (id + mass density in kg/m³).
836fn parse_mat(input: &str) -> IResult<&str, ShapeOp> {
837    let (input, _) = keyword("Mat")(input)?;
838    let (input, _) = cut(ws(c_char('('))).parse(input)?;
839    let (input, mat_id) = cut(ws(rule_name)).parse(input)?;
840    let (input, density) = opt(preceded(ws(c_char(',')), ws(finite_float))).parse(input)?;
841    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
842    if let Some(d) = density
843        && d <= 0.0
844    {
845        return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
846    }
847    let material = crate::model::Material {
848        id: mat_id,
849        density,
850    };
851    Ok((input, ShapeOp::Mat(material)))
852}
853
854/// Parses a named world-direction shorthand: `Up`, `Down`, `Right`, `Left`,
855/// `Forward`, `Back` (also accepted with a `World.` prefix).
856fn parse_align_target(input: &str) -> IResult<&str, Vec3> {
857    let (input, _) = opt(terminated(tag("World"), c_char('.'))).parse(input)?;
858    let (input, name) = identifier.parse(input)?;
859    let v = match name {
860        "Up" => Vec3::new(0.0, 1.0, 0.0),
861        "Down" => Vec3::new(0.0, -1.0, 0.0),
862        "Right" => Vec3::new(1.0, 0.0, 0.0),
863        "Left" => Vec3::new(-1.0, 0.0, 0.0),
864        "Forward" => Vec3::new(0.0, 0.0, -1.0),
865        "Back" => Vec3::new(0.0, 0.0, 1.0),
866        _ => return Err(nom::Err::Failure(Error::new(input, ErrorKind::Tag))),
867    };
868    Ok((input, v))
869}
870
871/// `Align(Y, Up)` or `Align(Z, World.Forward)`
872fn parse_align(input: &str) -> IResult<&str, ShapeOp> {
873    let (input, _) = keyword("Align")(input)?;
874    let (input, _) = cut(ws(c_char('('))).parse(input)?;
875    let (input, local_axis) = cut(ws(parse_axis)).parse(input)?;
876    let (input, _) = cut(ws(c_char(','))).parse(input)?;
877    let (input, target) = cut(ws(parse_align_target)).parse(input)?;
878    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
879    Ok((input, ShapeOp::Align { local_axis, target }))
880}
881
882fn parse_offset_case(input: &str) -> IResult<&str, OffsetCase> {
883    let (input, sel_str) = ws(identifier).parse(input)?;
884    let selector = OffsetSelector::parse(sel_str)
885        .ok_or_else(|| nom::Err::Failure(Error::new(input, ErrorKind::Tag)))?;
886    let (input, _) = ws(c_char(':')).parse(input)?;
887    let (input, rule) = ws(parse_rule_call).parse(input)?;
888    Ok((input, OffsetCase { selector, rule }))
889}
890
891/// `Offset(-0.2) { Inside: Glass | Border: Frame }`
892fn parse_offset(input: &str) -> IResult<&str, ShapeOp> {
893    let (input, _) = keyword("Offset")(input)?;
894    let (input, distance) =
895        cut(delimited(ws(c_char('(')), arg_expr, ws(c_char(')')))).parse(input)?;
896    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
897    let (mut remaining, first) = cut(ws(parse_offset_case)).parse(input)?;
898    let mut cases = vec![first];
899    loop {
900        if cases.len() >= MAX_COMP_CASES {
901            return Err(nom::Err::Failure(Error::new(
902                remaining,
903                ErrorKind::TooLarge,
904            )));
905        }
906        let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(remaining) else {
907            break;
908        };
909        match ws(parse_offset_case).parse(after_sep) {
910            Ok((after_item, case)) => {
911                cases.push(case);
912                remaining = after_item;
913            }
914            Err(nom::Err::Failure(e)) => return Err(nom::Err::Failure(e)),
915            Err(_) => {
916                remaining = after_sep;
917                break;
918            }
919        }
920    }
921    let (remaining, _) = ws(c_char('}')).parse(remaining)?;
922    Ok((remaining, ShapeOp::Offset { distance, cases }))
923}
924
925fn parse_roof_case(input: &str) -> IResult<&str, RoofCase> {
926    let (input, sel_str) = ws(identifier).parse(input)?;
927    let selector = RoofFaceSelector::parse(sel_str)
928        .ok_or_else(|| nom::Err::Failure(Error::new(input, ErrorKind::Tag)))?;
929    let (input, _) = ws(c_char(':')).parse(input)?;
930    let (input, rule) = ws(parse_rule_call).parse(input)?;
931    Ok((input, RoofCase { selector, rule }))
932}
933
934/// Accumulator for `Roof`'s named parameters.
935#[derive(Default)]
936struct RoofNamed {
937    overhang: Option<Expr>,
938    offset: Option<Expr>,
939    tier: Option<Expr>,
940    fascia: Option<Expr>,
941    secondary: Option<Expr>,
942    height: Option<Expr>,
943    ridge: Option<Axis>,
944}
945
946/// Parses `key=<expr>` named parameters for Roof in any order after the
947/// positional args: `overhang=`, `offset=`, `tier=`, `fascia=`, `secondary=`,
948/// `height=`, plus the axis-valued `ridge=X|Z`. Each is preceded by a comma.
949fn parse_roof_named_params(input: &str) -> IResult<&str, RoofNamed> {
950    let mut remaining = input;
951    let mut named = RoofNamed::default();
952
953    loop {
954        // Attempt to consume a comma.
955        let Ok((after_comma, _)) = ws::<_, _, Error<&str>>(c_char(',')).parse(remaining) else {
956            break;
957        };
958        // `key =` lookahead; unknown keys end the loop without consuming.
959        let Ok((after_eq, (key, _))) = (
960            ws::<_, _, Error<&str>>(identifier),
961            ws::<_, _, Error<&str>>(c_char('=')),
962        )
963            .parse(after_comma)
964        else {
965            break;
966        };
967        if key == "ridge" {
968            let (rest, axis) = cut(ws(parse_axis)).parse(after_eq)?;
969            if axis == Axis::Y {
970                // A ridge must run horizontally across the footprint.
971                return Err(nom::Err::Failure(Error::new(after_eq, ErrorKind::Verify)));
972            }
973            named.ridge = Some(axis);
974            remaining = rest;
975            continue;
976        }
977        let slot = match key {
978            "overhang" => &mut named.overhang,
979            "offset" => &mut named.offset,
980            "tier" => &mut named.tier,
981            "fascia" => &mut named.fascia,
982            "secondary" => &mut named.secondary,
983            "height" => &mut named.height,
984            _ => break,
985        };
986        let (rest, e) = cut(arg_expr).parse(after_eq)?;
987        *slot = Some(e);
988        remaining = rest;
989    }
990    Ok((remaining, named))
991}
992
993/// Whether a `RoofType` uses the third positional argument as `secondary_pitch`
994/// rather than `overhang`.
995fn roof_type_uses_secondary_pitch(rt: RoofType) -> bool {
996    matches!(rt, RoofType::Gambrel | RoofType::Mansard)
997}
998
999/// `Roof(Gable, 30) { Slope: Tiles | GableEnd: Bricks }`
1000/// `Roof(Hip, 30, 0.5) { Slope: Tiles }`
1001/// `Roof(Gambrel, 45, 20) { LowerSlope: Shingles | UpperSlope: Tiles }`
1002/// `Roof(Saltbox, 45, offset=0.3) { Slope: Tiles | GableEnd: Bricks }`
1003/// `Roof(DutchGable, 45, tier=0.7) { Slope: Tiles | GableEnd: Bricks }`
1004/// `Roof(Mansard, 60, 20, tier=0.4) { LowerSlope: Plaster | UpperSlope: Tiles }`
1005fn parse_roof(input: &str) -> IResult<&str, ShapeOp> {
1006    let (input, _) = keyword("Roof")(input)?;
1007    let (input, _) = cut(ws(c_char('('))).parse(input)?;
1008    let (input, type_str) = cut(ws(identifier)).parse(input)?;
1009    let roof_type = RoofType::parse(type_str)
1010        .ok_or_else(|| nom::Err::Failure(Error::new(input, ErrorKind::Tag)))?;
1011
1012    // Positional pitch — omitted when the caller goes straight to named args
1013    // (`Roof(Gable, height=4)`). Lookahead: `ident =` means named-params begin.
1014    let named_ahead = |s: &str| {
1015        (
1016            ws::<_, _, Error<&str>>(identifier),
1017            ws::<_, _, Error<&str>>(c_char('=')),
1018        )
1019            .parse(s)
1020            .is_ok()
1021    };
1022
1023    let mut pitch: Option<Expr> = None;
1024    let mut second_positional: Option<Expr> = None;
1025    let mut remaining = input;
1026
1027    if let Ok((after_comma, _)) = ws::<_, _, Error<&str>>(c_char(',')).parse(remaining)
1028        && !named_ahead(after_comma)
1029    {
1030        let (rest, p) = cut(arg_expr).parse(after_comma)?;
1031        if let Some(v) = p.as_lit()
1032            && (v <= 0.0 || v >= 90.0)
1033        {
1034            return Err(nom::Err::Failure(Error::new(rest, ErrorKind::Verify)));
1035        }
1036        pitch = Some(p);
1037        remaining = rest;
1038        // Optional second positional: secondary pitch for Gambrel/Mansard,
1039        // overhang for every other type.
1040        if let Ok((after_c2, _)) = ws::<_, _, Error<&str>>(c_char(',')).parse(remaining)
1041            && !named_ahead(after_c2)
1042        {
1043            let (rest2, v) = cut(arg_expr).parse(after_c2)?;
1044            second_positional = Some(v);
1045            remaining = rest2;
1046        }
1047    }
1048
1049    let uses_secondary = roof_type_uses_secondary_pitch(roof_type);
1050    let mut secondary_pitch: Option<Expr> = None;
1051    let mut overhang: Option<Expr> = None;
1052    if let Some(v) = second_positional {
1053        if uses_secondary {
1054            secondary_pitch = Some(v);
1055        } else {
1056            if let Some(lit) = v.as_lit()
1057                && lit < 0.0
1058            {
1059                return Err(nom::Err::Failure(Error::new(remaining, ErrorKind::Verify)));
1060            }
1061            overhang = Some(v);
1062        }
1063    }
1064
1065    // Parse named params (any order); named overrides positional.
1066    let (input, named) = parse_roof_named_params(remaining)?;
1067    if let Some(v) = named.overhang {
1068        if let Some(lit) = v.as_lit()
1069            && lit < 0.0
1070        {
1071            return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
1072        }
1073        overhang = Some(v);
1074    }
1075    if let Some(v) = named.secondary {
1076        secondary_pitch = Some(v);
1077    }
1078
1079    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
1080
1081    // Steepness must come from somewhere: a positional pitch or `height=`.
1082    let Some(pitch) = pitch.or_else(|| named.height.is_some().then(|| Expr::lit(45.0))) else {
1083        return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
1084    };
1085
1086    let spec = RoofSpec {
1087        roof_type,
1088        pitch,
1089        height: named.height,
1090        secondary_pitch,
1091        overhang: overhang.unwrap_or_else(|| Expr::lit(0.0)),
1092        ridge_offset: named.offset.unwrap_or_else(|| Expr::lit(0.5)),
1093        fascia_depth: named.fascia.unwrap_or_else(|| Expr::lit(0.0)),
1094        tier_height: named.tier,
1095        ridge_axis: named.ridge,
1096    };
1097
1098    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
1099    let (mut remaining, first) = cut(ws(parse_roof_case)).parse(input)?;
1100    let mut cases = vec![first];
1101    loop {
1102        if cases.len() >= MAX_COMP_CASES {
1103            return Err(nom::Err::Failure(Error::new(
1104                remaining,
1105                ErrorKind::TooLarge,
1106            )));
1107        }
1108        let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(remaining) else {
1109            break;
1110        };
1111        match ws(parse_roof_case).parse(after_sep) {
1112            Ok((after_item, case)) => {
1113                cases.push(case);
1114                remaining = after_item;
1115            }
1116            Err(nom::Err::Failure(e)) => return Err(nom::Err::Failure(e)),
1117            Err(_) => {
1118                remaining = after_sep;
1119                break;
1120            }
1121        }
1122    }
1123    let (remaining, _) = ws(c_char('}')).parse(remaining)?;
1124    Ok((remaining, ShapeOp::Roof { spec, cases }))
1125}
1126
1127/// Parses a single `Attach` case: `Surface: Rule` or `All: Rule`.
1128fn parse_attach_case(input: &str) -> IResult<&str, AttachCase> {
1129    let (input, sel_str) = ws(identifier).parse(input)?;
1130    let selector = AttachSelector::parse(sel_str)
1131        .ok_or_else(|| nom::Err::Failure(Error::new(input, ErrorKind::Tag)))?;
1132    let (input, _) = ws(c_char(':')).parse(input)?;
1133    let (input, rule) = ws(parse_rule_call).parse(input)?;
1134    Ok((input, AttachCase { selector, rule }))
1135}
1136
1137/// Named world-direction targets shared with `Align`.
1138fn parse_world_axis(input: &str) -> IResult<&str, Vec3> {
1139    let (input, name) = ws(identifier).parse(input)?;
1140    let v = match name {
1141        "Up" | "up" => Vec3::Y,
1142        "Down" | "down" => Vec3::NEG_Y,
1143        "Right" | "right" => Vec3::X,
1144        "Left" | "left" => Vec3::NEG_X,
1145        "Forward" | "forward" => Vec3::NEG_Z,
1146        "Back" | "back" => Vec3::Z,
1147        _ => return Err(nom::Err::Failure(Error::new(input, ErrorKind::Tag))),
1148    };
1149    Ok((input, v))
1150}
1151
1152/// `Attach(Up) { Surface: DormerMass }`
1153fn parse_attach(input: &str) -> IResult<&str, ShapeOp> {
1154    let (input, _) = keyword("Attach")(input)?;
1155    let (input, _) = cut(ws(c_char('('))).parse(input)?;
1156    let (input, world_axis) = cut(ws(parse_world_axis)).parse(input)?;
1157    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
1158    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
1159    let (mut remaining, first) = cut(ws(parse_attach_case)).parse(input)?;
1160    let mut cases = vec![first];
1161    loop {
1162        if cases.len() >= MAX_COMP_CASES {
1163            return Err(nom::Err::Failure(Error::new(
1164                remaining,
1165                ErrorKind::TooLarge,
1166            )));
1167        }
1168        let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(remaining) else {
1169            break;
1170        };
1171        match ws(parse_attach_case).parse(after_sep) {
1172            Ok((after_item, case)) => {
1173                cases.push(case);
1174                remaining = after_item;
1175            }
1176            Err(nom::Err::Failure(e)) => return Err(nom::Err::Failure(e)),
1177            Err(_) => {
1178                remaining = after_sep;
1179                break;
1180            }
1181        }
1182    }
1183    let (remaining, _) = ws(c_char('}')).parse(remaining)?;
1184    Ok((remaining, ShapeOp::Attach { world_axis, cases }))
1185}
1186
1187/// `Size(x, y, z)` — absolute scope size (CGA `s()` parity).
1188fn parse_size(input: &str) -> IResult<&str, ShapeOp> {
1189    let (input, _) = keyword("Size")(input)?;
1190    let (input, v) = cut(ws(parse_expr3)).parse(input)?;
1191    for c in &v {
1192        if let Some(lit) = c.as_lit()
1193            && lit < 0.0
1194        {
1195            return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
1196        }
1197    }
1198    Ok((input, ShapeOp::Size(v)))
1199}
1200
1201/// `Center(X)` / `Center(XZ)` / `Center(XYZ)` — recentre masked axes within
1202/// the rule-entry bounds.
1203fn parse_center(input: &str) -> IResult<&str, ShapeOp> {
1204    let (input, _) = keyword("Center")(input)?;
1205    let (input, _) = cut(ws(c_char('('))).parse(input)?;
1206    let (input, mask) = cut(ws(identifier)).parse(input)?;
1207    let (mut x, mut y, mut z) = (false, false, false);
1208    for ch in mask.chars() {
1209        match ch {
1210            'X' | 'x' => x = true,
1211            'Y' | 'y' => y = true,
1212            'Z' | 'z' => z = true,
1213            _ => return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify))),
1214        }
1215    }
1216    if !(x || y || z) {
1217        return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
1218    }
1219    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
1220    Ok((input, ShapeOp::Center { x, y, z }))
1221}
1222
1223/// `Mirror(X)` — mirrors the pending face profile across its vertical
1224/// centre line. Only the X form exists (profiles are 2-D).
1225fn parse_mirror(input: &str) -> IResult<&str, ShapeOp> {
1226    let (input, _) = keyword("Mirror")(input)?;
1227    let (input, _) = cut(ws(c_char('('))).parse(input)?;
1228    let (input, axis) = cut(ws(parse_axis)).parse(input)?;
1229    if axis != Axis::X {
1230        return Err(nom::Err::Failure(Error::new(input, ErrorKind::Verify)));
1231    }
1232    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
1233    Ok((input, ShapeOp::Mirror))
1234}
1235
1236fn parse_carve_case(input: &str) -> IResult<&str, CarveCase> {
1237    let (input, sel_str) = ws(identifier).parse(input)?;
1238    let selector = CarveSelector::parse(sel_str)
1239        .ok_or_else(|| nom::Err::Failure(Error::new(input, ErrorKind::Tag)))?;
1240    let (input, _) = ws(c_char(':')).parse(input)?;
1241    let (input, rule) = ws(parse_rule_call).parse(input)?;
1242    Ok((input, CarveCase { selector, rule }))
1243}
1244
1245fn parse_carve_cases(input: &str) -> IResult<&str, Vec<CarveCase>> {
1246    let (input, _) = cut(ws(c_char('{'))).parse(input)?;
1247    let (mut remaining, first) = cut(ws(parse_carve_case)).parse(input)?;
1248    let mut cases = vec![first];
1249    loop {
1250        if cases.len() >= MAX_COMP_CASES {
1251            return Err(nom::Err::Failure(Error::new(
1252                remaining,
1253                ErrorKind::TooLarge,
1254            )));
1255        }
1256        let Ok((after_sep, _)) = ws::<_, _, Error<&str>>(c_char('|')).parse(remaining) else {
1257            break;
1258        };
1259        let (after_item, case) = cut(ws(parse_carve_case)).parse(after_sep)?;
1260        cases.push(case);
1261        remaining = after_item;
1262    }
1263    let (remaining, _) = cut(ws(c_char('}'))).parse(remaining)?;
1264    Ok((remaining, cases))
1265}
1266
1267/// `ShapeL(front, side) { Shape: Wing | Remainder: Court }`
1268fn parse_shape_l(input: &str) -> IResult<&str, ShapeOp> {
1269    let (input, _) = keyword("ShapeL")(input)?;
1270    let (input, _) = cut(ws(c_char('('))).parse(input)?;
1271    let (input, front) = cut(arg_expr).parse(input)?;
1272    let (input, _) = cut(ws(c_char(','))).parse(input)?;
1273    let (input, side) = cut(arg_expr).parse(input)?;
1274    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
1275    let (input, cases) = parse_carve_cases(input)?;
1276    Ok((input, ShapeOp::ShapeL { front, side, cases }))
1277}
1278
1279/// `ShapeU(front, left, right) { Shape: Range | Remainder: Court }`
1280fn parse_shape_u(input: &str) -> IResult<&str, ShapeOp> {
1281    let (input, _) = keyword("ShapeU")(input)?;
1282    let (input, _) = cut(ws(c_char('('))).parse(input)?;
1283    let (input, front) = cut(arg_expr).parse(input)?;
1284    let (input, _) = cut(ws(c_char(','))).parse(input)?;
1285    let (input, left) = cut(arg_expr).parse(input)?;
1286    let (input, _) = cut(ws(c_char(','))).parse(input)?;
1287    let (input, right) = cut(arg_expr).parse(input)?;
1288    let (input, _) = cut(ws(c_char(')'))).parse(input)?;
1289    let (input, cases) = parse_carve_cases(input)?;
1290    Ok((
1291        input,
1292        ShapeOp::ShapeU {
1293            front,
1294            left,
1295            right,
1296            cases,
1297        },
1298    ))
1299}
1300
1301fn parse_rule_ref(input: &str) -> IResult<&str, ShapeOp> {
1302    map(ws(parse_rule_call), ShapeOp::Rule).parse(input)
1303}
1304
1305// ── Top-level op parser ───────────────────────────────────────────────────────
1306
1307fn parse_op(input: &str) -> IResult<&str, ShapeOp> {
1308    // Order matters: longer/specific tags must come before the generic
1309    // rule_name fallback. Nested `alt`s sidestep nom's tuple-arity cap.
1310    alt((
1311        alt((
1312            parse_extrude,
1313            parse_taper,
1314            parse_rotate,
1315            parse_translate,
1316            parse_scale,
1317            // `SplitArea` before `Split`: `Split` is a prefix of `SplitArea`
1318            // and consumes the keyword with a fatal cut on its arguments.
1319            parse_split_area,
1320            parse_split,
1321            parse_fit,
1322            parse_shape_l,
1323            parse_shape_u,
1324            parse_size,
1325            parse_center,
1326            parse_mirror,
1327            parse_repeat,
1328            parse_comp,
1329        )),
1330        alt((
1331            // The `If*` family MUST come before `parse_instance` —
1332            // `parse_instance` matches `I(` with `cut` (fatal-on-failure),
1333            // which greedily consumes the `I` prefix of `IfClear` etc. and
1334            // then errors instead of falling through.
1335            parse_if_clear,
1336            parse_if_occluded,
1337            parse_if_inside,
1338            parse_if_touches,
1339            parse_label,
1340            parse_scatter,
1341            parse_pick,
1342            parse_instance,
1343            parse_mat,
1344            parse_align,
1345            parse_offset,
1346            parse_roof,
1347            parse_attach,
1348            parse_polygon,
1349            parse_reg_snap,
1350            parse_rule_ref,
1351        )),
1352    ))
1353    .parse(input)
1354}
1355
1356// ── Public API ────────────────────────────────────────────────────────────────
1357
1358/// Returns true if `op` ends execution of a rule body — either by branching
1359/// (Split / Comp / Repeat / Offset / Roof) or by producing a terminal (I / Rule).
1360/// Any operations listed after such an op in the source are unreachable.
1361fn is_terminating_op(op: &ShapeOp) -> bool {
1362    matches!(
1363        op,
1364        ShapeOp::I(_)
1365            | ShapeOp::Rule(_)
1366            | ShapeOp::Split { .. }
1367            | ShapeOp::SplitArea { .. }
1368            | ShapeOp::Fit { .. }
1369            | ShapeOp::ShapeL { .. }
1370            | ShapeOp::ShapeU { .. }
1371            | ShapeOp::Comp(_)
1372            | ShapeOp::Repeat { .. }
1373            | ShapeOp::Offset { .. }
1374            | ShapeOp::Roof { .. }
1375            | ShapeOp::Attach { .. }
1376            | ShapeOp::Scatter { .. }
1377            | ShapeOp::Pick { .. }
1378    )
1379}
1380
1381/// Parses a sequence of CGA operations separated by optional whitespace/newlines.
1382///
1383/// # Example
1384/// ```
1385/// use symbios_shape::grammar::parse_ops;
1386///
1387/// let ops = parse_ops("Extrude(10) Split(Y) { ~1: Floor | 2: Roof }").unwrap();
1388/// assert_eq!(ops.len(), 2);
1389/// ```
1390pub fn parse_ops(input: &str) -> Result<Vec<ShapeOp>, ShapeError> {
1391    let mut remaining = input;
1392    let mut ops = Vec::new();
1393
1394    loop {
1395        let (next, _) = space_or_comment::<Error<&str>>(remaining)
1396            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1397        remaining = next;
1398
1399        if remaining.is_empty() {
1400            break;
1401        }
1402
1403        if ops.len() >= MAX_OPS {
1404            return Err(ShapeError::CapacityOverflow);
1405        }
1406
1407        let (next, op) = parse_op(remaining).map_err(|e| ShapeError::ParseError(e.to_string()))?;
1408        let terminates = is_terminating_op(&op);
1409        ops.push(op);
1410        remaining = next;
1411
1412        if terminates {
1413            // Any content after a terminal/branching op is unreachable. Catch it
1414            // here so the parser rejects such rules rather than silently dropping ops.
1415            let (after_ws, _) = space_or_comment::<Error<&str>>(remaining)
1416                .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1417            if !after_ws.is_empty() {
1418                return Err(ShapeError::ParseError(
1419                    "unreachable operations after terminal or branching op".to_string(),
1420                ));
1421            }
1422            break;
1423        }
1424    }
1425
1426    Ok(ops)
1427}
1428
1429// ── Stochastic rule helpers ───────────────────────────────────────────────────
1430
1431/// Splits `s` on `|` characters at brace depth 0, respecting quoted strings
1432/// and comments so that `|` inside `"Wall|Door"` or `/* { */` is ignored.
1433///
1434/// Returns at most [`MAX_VARIANTS`] parts; exceeding that limit returns
1435/// `Err(CapacityOverflow)` before any further allocation occurs.
1436///
1437/// Contexts handled:
1438/// - `"..."` string literals — `|` and `{`/`}` inside are invisible.
1439/// - `/* ... */` block comments — same.
1440/// - `// ...` line comments — same (skipped until `\n` or `\r`).
1441fn split_top_level_pipe(s: &str) -> Result<Vec<&str>, ShapeError> {
1442    let mut depth: i32 = 0;
1443    let mut in_string = false;
1444    let mut in_block_comment = false;
1445    let mut in_line_comment = false;
1446    let mut start = 0;
1447    let mut parts = Vec::new();
1448    // Use a Peekable iterator so we never allocate the full char list — a
1449    // Vec<(usize,char)> of the whole input would be O(n) memory (16 bytes per
1450    // char on 64-bit) even before any safety limits can fire.
1451    let mut iter = s.char_indices().peekable();
1452
1453    while let Some((byte_pos, c)) = iter.next() {
1454        if in_line_comment {
1455            if c == '\n' || c == '\r' {
1456                in_line_comment = false;
1457            }
1458            continue;
1459        }
1460
1461        if in_block_comment {
1462            if c == '*' && matches!(iter.peek(), Some((_, '/'))) {
1463                iter.next(); // consume '/'
1464                in_block_comment = false;
1465            }
1466            continue;
1467        }
1468
1469        if in_string {
1470            if c == '"' {
1471                in_string = false;
1472            }
1473            continue;
1474        }
1475
1476        // Normal (unquoted, uncommented) context — check for comment openers.
1477        if c == '/' {
1478            match iter.peek() {
1479                Some(&(_, '/')) => {
1480                    iter.next();
1481                    in_line_comment = true;
1482                    continue;
1483                }
1484                Some(&(_, '*')) => {
1485                    iter.next();
1486                    in_block_comment = true;
1487                    continue;
1488                }
1489                _ => {}
1490            }
1491        }
1492
1493        match c {
1494            '"' => in_string = true,
1495            '{' => depth += 1,
1496            '}' => depth -= 1,
1497            '|' if depth == 0 => {
1498                // After this push, `parts.len() + 1` parts exist; the loop's
1499                // final push adds one more, giving `parts.len() + 2` total.
1500                // Reject before the push when that would exceed MAX_VARIANTS.
1501                if parts.len() + 2 > MAX_VARIANTS {
1502                    return Err(ShapeError::CapacityOverflow);
1503                }
1504                parts.push(s[start..byte_pos].trim());
1505                start = byte_pos + c.len_utf8();
1506            }
1507            _ => {}
1508        }
1509    }
1510
1511    parts.push(s[start..].trim());
1512    Ok(parts)
1513}
1514
1515fn parse_weight_prefix(input: &str) -> IResult<&str, f64> {
1516    let (input, w) = ws(double).parse(input)?;
1517    let (input, _) = ws(c_char('%')).parse(input)?;
1518    Ok((input, w))
1519}
1520
1521/// Tries to parse `float%` from the start of `input`, returning `(weight_0_to_1, rest)`.
1522fn try_parse_weight(input: &str) -> Option<(f64, &str)> {
1523    let trimmed = input.trim_start();
1524    match parse_weight_prefix(trimmed) {
1525        Ok((rest, w)) if w.is_finite() && w >= 0.0 => Some((w / 100.0, rest)),
1526        _ => None,
1527    }
1528}
1529
1530// ── Named grammar rules ───────────────────────────────────────────────────────
1531
1532/// A named production rule in a shape grammar.
1533///
1534/// A rule may declare parameters, and its variants are either weighted
1535/// (stochastic) or guarded (`when(cond): … | else: …`):
1536///
1537/// ```text
1538/// Lot --> Extrude(10) Split(Y) { ~1: Floor | 2: Roof }
1539/// Facade --> 70% BrickWall | 30% GlassCurtain
1540/// Bay --> when(scope.x < 1.2): Wall | else: Window
1541/// Spire(n) --> when(n == 0): I("finial") | else: Extrude(scope.y * 0.7) Spire(n - 1)
1542/// ```
1543#[derive(Debug, Clone)]
1544pub struct GrammarRule {
1545    pub name: String,
1546    /// Declared parameter names (`Spire(n)`); empty for plain rules.
1547    pub params: Vec<String>,
1548    pub variants: Vec<RuleVariant>,
1549}
1550
1551impl GrammarRule {
1552    /// Convenience accessor for deterministic (single-variant) rules.
1553    /// Returns an empty slice if the rule has no variants.
1554    pub fn ops(&self) -> &[ShapeOp] {
1555        self.variants
1556            .first()
1557            .map(|v| v.ops.as_slice())
1558            .unwrap_or(&[])
1559    }
1560}
1561
1562/// Tries to parse a `when ( expr ) :` guard prefix.
1563fn try_parse_when(part: &str) -> Option<Result<(Expr, &str), ShapeError>> {
1564    let trimmed = part.trim_start();
1565    let rest = trimmed.strip_prefix("when")?;
1566    // Must be a keyword use, not an identifier prefix (`whenever`).
1567    if rest.chars().next().is_some_and(is_ident_char) {
1568        return None;
1569    }
1570    let parse = || -> Result<(Expr, &str), ShapeError> {
1571        let (rest2, _) = ws::<_, _, Error<&str>>(c_char('('))
1572            .parse(rest)
1573            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1574        let (rest3, cond) = arg_expr(rest2).map_err(|e| ShapeError::ParseError(e.to_string()))?;
1575        let (rest4, _) = ws::<_, _, Error<&str>>(c_char(')'))
1576            .parse(rest3)
1577            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1578        let (rest5, _) = ws::<_, _, Error<&str>>(c_char(':'))
1579            .parse(rest4)
1580            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1581        Ok((cond, rest5))
1582    };
1583    Some(parse())
1584}
1585
1586/// Tries to parse an `else :` prefix.
1587fn try_parse_else(part: &str) -> Option<&str> {
1588    let trimmed = part.trim_start();
1589    let rest = trimmed.strip_prefix("else")?;
1590    if rest.chars().next().is_some_and(is_ident_char) {
1591        return None;
1592    }
1593    let (rest2, _) = ws::<_, _, Error<&str>>(c_char(':')).parse(rest).ok()?;
1594    Some(rest2)
1595}
1596
1597/// Parses the optional `(p1, p2, …)` parameter declaration on a rule head.
1598fn parse_param_decl(input: &str) -> Result<(Vec<String>, &str), ShapeError> {
1599    let Ok((rest, _)) = ws::<_, _, Error<&str>>(c_char('(')).parse(input) else {
1600        return Ok((Vec::new(), input));
1601    };
1602    let mut params = Vec::new();
1603    let mut remaining = rest;
1604    if let Ok((after, _)) = ws::<_, _, Error<&str>>(c_char(')')).parse(remaining) {
1605        return Ok((params, after));
1606    }
1607    loop {
1608        let (after, p) = ws(identifier)
1609            .parse(remaining)
1610            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1611        if params.contains(&p.to_string()) {
1612            return Err(ShapeError::ParseError(format!(
1613                "duplicate rule parameter name: {p}"
1614            )));
1615        }
1616        params.push(p.to_string());
1617        if params.len() > MAX_RULE_ARGS {
1618            return Err(ShapeError::CapacityOverflow);
1619        }
1620        if let Ok((after2, _)) = ws::<_, _, Error<&str>>(c_char(',')).parse(after) {
1621            remaining = after2;
1622            continue;
1623        }
1624        let (after2, _) = ws::<_, _, Error<&str>>(c_char(')'))
1625            .parse(after)
1626            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1627        return Ok((params, after2));
1628    }
1629}
1630
1631/// One top-level grammar statement.
1632#[derive(Debug, Clone)]
1633pub enum Statement {
1634    Rule(GrammarRule),
1635    /// `attr Name = expr` — host-overridable knob (constant expression).
1636    Attr {
1637        name: String,
1638        value: f64,
1639    },
1640    /// `const Name = expr` — fixed named value (constant expression).
1641    Const {
1642        name: String,
1643        value: f64,
1644    },
1645    /// `style Name [extends Base] { A = 1, B = 2 }` — named attr override set.
1646    Style {
1647        name: String,
1648        extends: Option<String>,
1649        overrides: Vec<(String, f64)>,
1650    },
1651}
1652
1653/// Evaluates a declaration expression, which must be constant: no scope /
1654/// split / depth variables, no named references, no `rand`.
1655fn const_eval(e: &Expr, at: &str) -> Result<f64, ShapeError> {
1656    fn check(e: &Expr) -> bool {
1657        match e {
1658            Expr::Lit(_) => true,
1659            Expr::Var(_) => false,
1660            Expr::Unary(_, a) => check(a),
1661            Expr::Binary(_, a, b) => check(a) && check(b),
1662            Expr::Call(f, args) => *f != crate::expr::Func::Rand && args.iter().all(check),
1663        }
1664    }
1665    if !check(e) {
1666        return Err(ShapeError::ParseError(format!(
1667            "declaration value must be a constant expression (no variables, no rand): {at:?}"
1668        )));
1669    }
1670    let mut rng = rand_pcg::Pcg64::new(0, 0);
1671    let globals = std::collections::HashMap::new();
1672    let mut ctx = crate::expr::EvalCtx {
1673        scope_size: crate::scope::Vec3::ZERO,
1674        split_i: 0.0,
1675        split_n: 1.0,
1676        depth: 0.0,
1677        params: &[],
1678        globals: &globals,
1679        rng: &mut rng,
1680    };
1681    e.eval(&mut ctx)
1682}
1683
1684/// Parses `Name = expr` (comma list) inside a style block.
1685fn parse_style_overrides(input: &str) -> Result<Vec<(String, f64)>, ShapeError> {
1686    let mut out = Vec::new();
1687    let mut remaining = input;
1688    loop {
1689        let (rest, name) = ws::<_, _, Error<&str>>(identifier)
1690            .parse(remaining)
1691            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1692        let (rest, _) = ws::<_, _, Error<&str>>(c_char('='))
1693            .parse(rest)
1694            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1695        let (rest, value_expr) =
1696            arg_expr(rest).map_err(|e| ShapeError::ParseError(e.to_string()))?;
1697        out.push((name.to_string(), const_eval(&value_expr, name)?));
1698        if out.len() > MAX_SPLIT_SLOTS {
1699            return Err(ShapeError::CapacityOverflow);
1700        }
1701        if let Ok((rest2, _)) = ws::<_, _, Error<&str>>(c_char(',')).parse(rest) {
1702            remaining = rest2;
1703            continue;
1704        }
1705        let (rest2, _) = ws::<_, _, Error<&str>>(c_char('}'))
1706            .parse(rest)
1707            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1708        let (rest3, _) = space_or_comment::<Error<&str>>(rest2)
1709            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1710        if !rest3.is_empty() {
1711            return Err(ShapeError::ParseError(format!(
1712                "trailing input after style block: {rest3:?}"
1713            )));
1714        }
1715        return Ok(out);
1716    }
1717}
1718
1719/// Parses one top-level statement: a rule, or an `attr` / `const` / `style`
1720/// declaration.
1721///
1722/// ```text
1723/// attr Floors = 4
1724/// const FloorH = 3.2
1725/// style Poor { Floors = 2, Wear = 0.8 }
1726/// style Rich extends Poor { Floors = 6 }
1727/// Lot --> Extrude(Floors * FloorH) I("Mass")
1728/// ```
1729pub fn parse_statement(input: &str) -> Result<Statement, ShapeError> {
1730    let (rest, _) = space_or_comment::<Error<&str>>(input)
1731        .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1732    // Keyword lookahead: `attr` / `const` / `style` followed by non-ident.
1733    for (kw, is_attr) in [("attr", true), ("const", false)] {
1734        if let Some(after) = rest.strip_prefix(kw)
1735            && !after.chars().next().is_some_and(is_ident_char)
1736        {
1737            let (after2, name) = ws::<_, _, Error<&str>>(identifier)
1738                .parse(after)
1739                .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1740            let (after3, _) = ws::<_, _, Error<&str>>(c_char('='))
1741                .parse(after2)
1742                .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1743            let (after4, value_expr) =
1744                arg_expr(after3).map_err(|e| ShapeError::ParseError(e.to_string()))?;
1745            let (after5, _) = space_or_comment::<Error<&str>>(after4)
1746                .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1747            if !after5.is_empty() {
1748                return Err(ShapeError::ParseError(format!(
1749                    "trailing input after declaration: {after5:?}"
1750                )));
1751            }
1752            let value = const_eval(&value_expr, name)?;
1753            return Ok(if is_attr {
1754                Statement::Attr {
1755                    name: name.to_string(),
1756                    value,
1757                }
1758            } else {
1759                Statement::Const {
1760                    name: name.to_string(),
1761                    value,
1762                }
1763            });
1764        }
1765    }
1766    if let Some(after) = rest.strip_prefix("style")
1767        && !after.chars().next().is_some_and(is_ident_char)
1768    {
1769        let (after2, name) = ws::<_, _, Error<&str>>(identifier)
1770            .parse(after)
1771            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1772        let (after3, extends) =
1773            if let Ok((a, _)) = ws::<_, _, Error<&str>>(tag("extends")).parse(after2) {
1774                let (a2, base) = ws::<_, _, Error<&str>>(identifier)
1775                    .parse(a)
1776                    .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1777                (a2, Some(base.to_string()))
1778            } else {
1779                (after2, None)
1780            };
1781        let (after4, _) = ws::<_, _, Error<&str>>(c_char('{'))
1782            .parse(after3)
1783            .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1784        let overrides = parse_style_overrides(after4)?;
1785        return Ok(Statement::Style {
1786            name: name.to_string(),
1787            extends,
1788            overrides,
1789        });
1790    }
1791    Ok(Statement::Rule(parse_rule(input)?))
1792}
1793
1794/// Parses a complete named grammar rule.
1795///
1796/// Forms:
1797/// - deterministic: `Name --> op op ...`
1798/// - stochastic: `Name --> 70% ops | 30% ops` (optionally `| else: ops` for
1799///   the remaining probability mass, treating weights as percentages)
1800/// - guarded: `Name --> when(cond): ops | when(cond): ops | else: ops`
1801/// - parameterized head: `Name(a, b) --> ...`
1802///
1803/// Weighted and guarded variants cannot be mixed in one rule.
1804pub fn parse_rule(input: &str) -> Result<GrammarRule, ShapeError> {
1805    let (remaining, _) = space_or_comment::<Error<&str>>(input)
1806        .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1807    let (remaining, name) = ws(identifier)
1808        .parse(remaining)
1809        .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1810    let (params, remaining) = parse_param_decl(remaining)?;
1811    let (remaining, _) = ws(tag::<_, _, Error<&str>>("-->"))
1812        .parse(remaining)
1813        .map_err(|e| ShapeError::ParseError(e.to_string()))?;
1814
1815    // Split the body on top-level `|` to detect alternatives.
1816    let parts = split_top_level_pipe(remaining)?;
1817
1818    let mut variants: Vec<RuleVariant> = Vec::with_capacity(parts.len());
1819    let multi = parts.len() > 1;
1820    for (i, part) in parts.iter().enumerate() {
1821        let is_last = i + 1 == parts.len();
1822        if let Some(res) = try_parse_when(part) {
1823            let (cond, rest) = res?;
1824            variants.push(RuleVariant {
1825                selector: VariantSelector::When(cond),
1826                ops: parse_ops(rest)?,
1827            });
1828            continue;
1829        }
1830        if let Some(rest) = try_parse_else(part) {
1831            if !is_last {
1832                return Err(ShapeError::ParseError(
1833                    "`else:` must be the last variant".to_string(),
1834                ));
1835            }
1836            variants.push(RuleVariant {
1837                selector: VariantSelector::Else,
1838                ops: parse_ops(rest)?,
1839            });
1840            continue;
1841        }
1842        match try_parse_weight(part) {
1843            Some((weight, rest)) => variants.push(RuleVariant {
1844                selector: VariantSelector::Weight(weight),
1845                ops: parse_ops(rest)?,
1846            }),
1847            None if multi => {
1848                return Err(ShapeError::ParseError(format!(
1849                    "rule alternative needs a 'weight%', 'when(..):', or 'else:' prefix: {part:?}"
1850                )));
1851            }
1852            None => variants.push(RuleVariant {
1853                selector: VariantSelector::Weight(1.0),
1854                ops: parse_ops(part)?,
1855            }),
1856        }
1857    }
1858
1859    // Resolve selector shape: weighted rules may end with `else:` sugar (the
1860    // remaining probability mass, weights read as percentages); guarded rules
1861    // must not contain weights.
1862    let n_weight = variants
1863        .iter()
1864        .filter(|v| matches!(v.selector, VariantSelector::Weight(_)))
1865        .count();
1866    let n_when = variants
1867        .iter()
1868        .filter(|v| matches!(v.selector, VariantSelector::When(_)))
1869        .count();
1870    let has_else = variants
1871        .iter()
1872        .any(|v| matches!(v.selector, VariantSelector::Else));
1873
1874    if n_weight > 0 && n_when > 0 {
1875        return Err(ShapeError::ParseError(
1876            "rule mixes weighted and guarded variants".to_string(),
1877        ));
1878    }
1879    if n_weight > 0 && has_else {
1880        let used: f64 = variants
1881            .iter()
1882            .filter_map(|v| match v.selector {
1883                VariantSelector::Weight(w) => Some(w),
1884                _ => None,
1885            })
1886            .sum();
1887        let remainder = 1.0 - used;
1888        if remainder <= 1e-9 {
1889            return Err(ShapeError::ParseError(format!(
1890                "`else:` has no probability mass left (weights already sum to {:.0}%)",
1891                used * 100.0
1892            )));
1893        }
1894        if let Some(last) = variants.last_mut() {
1895            last.selector = VariantSelector::Weight(remainder);
1896        }
1897    }
1898
1899    Ok(GrammarRule {
1900        name: name.to_string(),
1901        params,
1902        variants,
1903    })
1904}
1905
1906#[cfg(test)]
1907mod tests {
1908    use super::*;
1909    use crate::ops::{Axis, ShapeOp, SplitSize};
1910
1911    #[test]
1912    fn test_parse_extrude() {
1913        let ops = parse_ops("Extrude(10)").unwrap();
1914        assert_eq!(ops.len(), 1);
1915        assert_eq!(ops[0], ShapeOp::Extrude(Expr::lit(10.0)));
1916    }
1917
1918    #[test]
1919    fn test_parse_taper() {
1920        let ops = parse_ops("Taper(0.5)").unwrap();
1921        assert_eq!(ops[0], ShapeOp::Taper(Expr::lit(0.5)));
1922    }
1923
1924    #[test]
1925    fn test_parse_split_y() {
1926        let ops = parse_ops("Split(Y) { ~1.0: Floor | ~1.0: Floor | 2.0: Roof }").unwrap();
1927        assert_eq!(ops.len(), 1);
1928        let ShapeOp::Split { axis, entries, .. } = &ops[0] else {
1929            panic!("expected Split");
1930        };
1931        let slots: Vec<_> = entries.iter().filter_map(|e| e.as_slot()).collect();
1932        assert_eq!(*axis, Axis::Y);
1933        assert_eq!(slots.len(), 3);
1934        assert_eq!(slots[0].size, SplitSize::float(1.0));
1935        assert_eq!(slots[0].rule, "Floor");
1936        assert_eq!(slots[2].size, SplitSize::abs(2.0));
1937        assert_eq!(slots[2].rule, "Roof");
1938    }
1939
1940    #[test]
1941    fn test_parse_split_relative() {
1942        let ops = parse_ops("Split(X) { '0.3: Left | '0.7: Right }").unwrap();
1943        let ShapeOp::Split { entries, .. } = &ops[0] else {
1944            panic!("expected Split");
1945        };
1946        let slots: Vec<_> = entries.iter().filter_map(|e| e.as_slot()).collect();
1947        assert_eq!(slots[0].size, SplitSize::rel(0.3));
1948        assert_eq!(slots[1].size, SplitSize::rel(0.7));
1949    }
1950
1951    #[test]
1952    fn test_parse_comp_faces() {
1953        let ops =
1954            parse_ops("Comp(Faces) { Top: Roof | Side: Facade | Bottom: Foundation }").unwrap();
1955        assert_eq!(ops.len(), 1);
1956        let ShapeOp::Comp(CompTarget::Faces(cases)) = &ops[0] else {
1957            panic!("expected Comp(Faces)");
1958        };
1959        assert_eq!(cases.len(), 3);
1960        assert_eq!(cases[0].selector, FaceSelector::Top);
1961        assert_eq!(cases[0].rule, "Roof");
1962    }
1963
1964    #[test]
1965    fn test_parse_instance() {
1966        let ops = parse_ops(r#"I("Window")"#).unwrap();
1967        assert_eq!(ops[0], ShapeOp::I("Window".to_string()));
1968    }
1969
1970    #[test]
1971    fn test_parse_mat() {
1972        let ops = parse_ops(r#"Mat("Brick")"#).unwrap();
1973        assert_eq!(ops[0], ShapeOp::Mat(crate::model::Material::new("Brick")));
1974        let ops2 = parse_ops("Mat(Stone)").unwrap();
1975        assert_eq!(ops2[0], ShapeOp::Mat(crate::model::Material::new("Stone")));
1976    }
1977
1978    #[test]
1979    fn test_parse_mat_with_density() {
1980        let ops = parse_ops(r#"Mat("Brick", 1800)"#).unwrap();
1981        assert_eq!(
1982            ops[0],
1983            ShapeOp::Mat(crate::model::Material::with_density("Brick", 1800.0))
1984        );
1985    }
1986
1987    #[test]
1988    fn test_parse_mat_zero_density_rejected() {
1989        assert!(parse_ops("Mat(Stone, 0)").is_err());
1990    }
1991
1992    #[test]
1993    fn test_parse_mat_negative_density_rejected() {
1994        assert!(parse_ops("Mat(Stone, -5)").is_err());
1995    }
1996
1997    #[test]
1998    fn test_parse_rule_ref() {
1999        let ops = parse_ops("Floor").unwrap();
2000        assert_eq!(ops[0], ShapeOp::Rule("Floor".into()));
2001    }
2002
2003    #[test]
2004    fn test_parse_multiple_ops() {
2005        let ops =
2006            parse_ops(r#"Extrude(10) Split(Y) { 2.0: Ground | ~1.0: Upper | 3.0: Roof }"#).unwrap();
2007        assert_eq!(ops.len(), 2);
2008    }
2009
2010    #[test]
2011    fn test_parse_grammar_rule_deterministic() {
2012        let rule = parse_rule("Lot --> Extrude(10) Split(Y) { ~1: Floor | 2: Roof }").unwrap();
2013        assert_eq!(rule.name, "Lot");
2014        assert_eq!(rule.variants.len(), 1);
2015        assert!((rule.variants[0].weight().unwrap() - 1.0).abs() < 1e-9);
2016        assert_eq!(rule.variants[0].ops.len(), 2);
2017    }
2018
2019    #[test]
2020    fn test_parse_grammar_rule_stochastic() {
2021        let rule = parse_rule("Facade --> 70% BrickWall | 30% GlassCurtain").unwrap();
2022        assert_eq!(rule.name, "Facade");
2023        assert_eq!(rule.variants.len(), 2);
2024        assert!((rule.variants[0].weight().unwrap() - 0.70).abs() < 1e-9);
2025        assert_eq!(
2026            rule.variants[0].ops,
2027            vec![ShapeOp::Rule("BrickWall".into())]
2028        );
2029        assert!((rule.variants[1].weight().unwrap() - 0.30).abs() < 1e-9);
2030        assert_eq!(
2031            rule.variants[1].ops,
2032            vec![ShapeOp::Rule("GlassCurtain".into())]
2033        );
2034    }
2035
2036    #[test]
2037    fn test_parse_stochastic_with_complex_ops() {
2038        // `|` inside braces must not split the alternative
2039        let rule = parse_rule("R --> 50% Split(X) { ~1: A | ~1: B } | 50% I(Solid)").unwrap();
2040        assert_eq!(rule.variants.len(), 2);
2041        assert_eq!(rule.variants[0].ops.len(), 1); // the Split op
2042        assert_eq!(rule.variants[1].ops.len(), 1); // I(Solid)
2043    }
2044
2045    #[test]
2046    fn test_extrude_zero_rejected() {
2047        assert!(parse_ops("Extrude(0)").is_err());
2048    }
2049
2050    #[test]
2051    fn test_taper_out_of_range_rejected() {
2052        assert!(parse_ops("Taper(1.5)").is_err());
2053    }
2054
2055    #[test]
2056    fn test_comments_ignored() {
2057        let ops = parse_ops("// comment\nExtrude(5) /* block */ Taper(0.2)").unwrap();
2058        assert_eq!(ops.len(), 2);
2059    }
2060
2061    // ── Issue 5 (review #10): GrammarRule::ops() safe on empty variants ──────
2062
2063    #[test]
2064    fn test_grammar_rule_ops_empty_variants_returns_empty() {
2065        let rule = GrammarRule {
2066            name: "Empty".to_string(),
2067            params: vec![],
2068            variants: vec![],
2069        };
2070        assert_eq!(rule.ops(), &[] as &[ShapeOp]);
2071    }
2072
2073    // ── Issue 2: quaternion overflow bypass ───────────────────────────────────
2074
2075    #[test]
2076    fn test_rotate_overflow_components_rejected() {
2077        // Each component is finite, but squaring overflows to INFINITY.
2078        // The len_sq check must catch this and reject the quaternion.
2079        assert!(parse_ops("Rotate(1e160, 0, 0, 0)").is_err());
2080        assert!(parse_ops("Rotate(1, 1e200, 0, 0)").is_err());
2081    }
2082
2083    // ── Issue 1: split_top_level_pipe must ignore `|` inside quoted strings ────
2084
2085    #[test]
2086    fn test_pipe_in_quoted_mesh_id_not_split() {
2087        // `|` inside `"Wall|Door"` must NOT be treated as a stochastic separator.
2088        let rule = parse_rule(r#"Lot --> I("Wall|Door")"#).unwrap();
2089        assert_eq!(rule.variants.len(), 1);
2090        assert_eq!(
2091            rule.variants[0].ops,
2092            vec![ShapeOp::I("Wall|Door".to_string())]
2093        );
2094    }
2095
2096    #[test]
2097    fn test_block_comment_with_brace_does_not_confuse_depth() {
2098        // `/* { */` must not increment brace depth, so the top-level `|` is still found.
2099        let rule = parse_rule("Facade --> 50% Extrude(10) /* { */ | 50% I(Solid)").unwrap();
2100        assert_eq!(rule.variants.len(), 2);
2101        assert!((rule.variants[0].weight().unwrap() - 0.50).abs() < 1e-9);
2102        assert!((rule.variants[1].weight().unwrap() - 0.50).abs() < 1e-9);
2103    }
2104
2105    // ── Issue 2: unreachable ops after terminal/branching op are rejected ──────
2106
2107    #[test]
2108    fn test_ops_after_instance_rejected() {
2109        assert!(parse_ops(r#"I("Wall") Scale(2, 2, 2)"#).is_err());
2110    }
2111
2112    #[test]
2113    fn test_ops_after_rule_ref_rejected() {
2114        assert!(parse_ops("Floor Scale(2, 2, 2)").is_err());
2115    }
2116
2117    #[test]
2118    fn test_ops_after_split_rejected() {
2119        assert!(parse_ops("Split(Y) { ~1: A | ~1: B } Scale(1, 2, 1)").is_err());
2120    }
2121
2122    // ── Issue 3 (review #14): MAX_VARIANTS cap in stochastic rule parsing ───────
2123
2124    #[test]
2125    fn test_too_many_variants_rejected() {
2126        // Build a rule body with MAX_VARIANTS + 1 alternatives.
2127        let variants: Vec<String> = (0..=MAX_VARIANTS).map(|i| format!("1% I(M{i})")).collect();
2128        let rule_str = format!("R --> {}", variants.join(" | "));
2129        assert!(matches!(
2130            parse_rule(&rule_str),
2131            Err(ShapeError::CapacityOverflow)
2132        ));
2133    }
2134
2135    #[test]
2136    fn test_max_variants_boundary_accepted() {
2137        // Exactly MAX_VARIANTS alternatives must succeed.
2138        let variants: Vec<String> = (0..MAX_VARIANTS).map(|i| format!("1% I(M{i})")).collect();
2139        let rule_str = format!("R --> {}", variants.join(" | "));
2140        let rule = parse_rule(&rule_str).unwrap();
2141        assert_eq!(rule.variants.len(), MAX_VARIANTS);
2142    }
2143
2144    // ── Issue 2 (review #14): cut prevents backtracking on malformed op args ───
2145
2146    #[test]
2147    fn test_translate_wrong_arg_count_rejected() {
2148        // Before cut: "Translate" would backtrack to parse_rule_ref, producing a
2149        // misleading "unreachable operations after terminal" error.
2150        // After cut: immediately fatal once "Translate" tag is consumed.
2151        assert!(parse_ops("Translate(1.0, 2.0)").is_err());
2152    }
2153
2154    #[test]
2155    fn test_extrude_missing_arg_rejected() {
2156        assert!(parse_ops("Extrude()").is_err());
2157    }
2158
2159    #[test]
2160    fn test_split_missing_brace_rejected() {
2161        // "Split(Y)" without a body should be a fatal parse error, not a rule ref.
2162        assert!(parse_ops("Split(Y)").is_err());
2163    }
2164
2165    // ── Issue 3: single-variant stochastic rule (100% prefix) ─────────────────
2166
2167    #[test]
2168    fn test_single_variant_with_weight_prefix() {
2169        let rule = parse_rule("Lot --> 100% Extrude(10)").unwrap();
2170        assert_eq!(rule.variants.len(), 1);
2171        assert!((rule.variants[0].weight().unwrap() - 1.0).abs() < 1e-9);
2172        assert_eq!(
2173            rule.variants[0].ops,
2174            vec![ShapeOp::Extrude(Expr::lit(10.0))]
2175        );
2176    }
2177
2178    // ── Issue 5: non-positive scale values are rejected ───────────────────────
2179
2180    #[test]
2181    fn test_scale_negative_rejected() {
2182        assert!(parse_ops("Scale(-1, 1, 1)").is_err());
2183    }
2184
2185    #[test]
2186    fn test_scale_zero_rejected() {
2187        assert!(parse_ops("Scale(0, 1, 1)").is_err());
2188    }
2189
2190    #[test]
2191    fn test_scale_positive_accepted() {
2192        let ops = parse_ops("Scale(0.5, 2, 1)").unwrap();
2193        assert_eq!(ops.len(), 1);
2194    }
2195
2196    // ── Feature: Align ───────────────────────────────────────────────────────
2197
2198    #[test]
2199    fn test_parse_align_basic() {
2200        let ops = parse_ops("Align(Y, Up)").unwrap();
2201        assert_eq!(ops.len(), 1);
2202        let ShapeOp::Align { local_axis, target } = &ops[0] else {
2203            panic!("expected Align");
2204        };
2205        assert_eq!(*local_axis, Axis::Y);
2206        assert!((*target - crate::scope::Vec3::new(0.0, 1.0, 0.0)).length() < 1e-9);
2207    }
2208
2209    #[test]
2210    fn test_parse_align_world_prefix() {
2211        let ops = parse_ops("Align(Z, World.Forward)").unwrap();
2212        let ShapeOp::Align { local_axis, target } = &ops[0] else {
2213            panic!("expected Align");
2214        };
2215        assert_eq!(*local_axis, Axis::Z);
2216        assert!((*target - crate::scope::Vec3::new(0.0, 0.0, -1.0)).length() < 1e-9);
2217    }
2218
2219    #[test]
2220    fn test_parse_align_unknown_target_rejected() {
2221        assert!(parse_ops("Align(Y, Sideways)").is_err());
2222    }
2223
2224    // ── Feature: Offset ──────────────────────────────────────────────────────
2225
2226    #[test]
2227    fn test_parse_offset_basic() {
2228        let ops = parse_ops("Offset(-0.2) { Inside: Glass | Border: Frame }").unwrap();
2229        assert_eq!(ops.len(), 1);
2230        let ShapeOp::Offset { distance, cases } = &ops[0] else {
2231            panic!("expected Offset");
2232        };
2233        assert!((distance.as_lit().unwrap() - (-0.2)).abs() < 1e-9);
2234        assert_eq!(cases.len(), 2);
2235        assert_eq!(cases[0].selector, OffsetSelector::Inside);
2236        assert_eq!(cases[0].rule, "Glass");
2237        assert_eq!(cases[1].selector, OffsetSelector::Border);
2238        assert_eq!(cases[1].rule, "Frame");
2239    }
2240
2241    #[test]
2242    fn test_parse_offset_is_terminating() {
2243        assert!(parse_ops("Offset(-0.1) { Inside: A } Scale(1, 2, 1)").is_err());
2244    }
2245
2246    // ── Feature: Roof ────────────────────────────────────────────────────────
2247
2248    #[test]
2249    fn test_parse_roof_gable_no_overhang() {
2250        let ops = parse_ops("Roof(Gable, 30) { Slope: Tiles | GableEnd: Bricks }").unwrap();
2251        assert_eq!(ops.len(), 1);
2252        let ShapeOp::Roof { spec, cases } = &ops[0] else {
2253            panic!("expected Roof");
2254        };
2255        assert_eq!(spec.roof_type, RoofType::Gable);
2256        assert!((spec.pitch.as_lit().unwrap() - 30.0).abs() < 1e-9);
2257        assert!(spec.overhang.as_lit().unwrap().abs() < 1e-9);
2258        assert_eq!(cases.len(), 2);
2259        assert_eq!(cases[0].selector, RoofFaceSelector::Slope);
2260        assert_eq!(cases[1].selector, RoofFaceSelector::GableEnd);
2261    }
2262
2263    #[test]
2264    fn test_parse_roof_hip_with_overhang() {
2265        let ops = parse_ops("Roof(Hip, 45, 0.5) { Slope: Tiles }").unwrap();
2266        let ShapeOp::Roof { spec, .. } = &ops[0] else {
2267            panic!("expected Roof");
2268        };
2269        assert_eq!(spec.roof_type, RoofType::Hip);
2270        assert!((spec.pitch.as_lit().unwrap() - 45.0).abs() < 1e-9);
2271        assert!((spec.overhang.as_lit().unwrap() - 0.5).abs() < 1e-9);
2272    }
2273
2274    #[test]
2275    fn test_parse_roof_angle_out_of_range_rejected() {
2276        assert!(parse_ops("Roof(Gable, 0) { Slope: Tiles }").is_err());
2277        assert!(parse_ops("Roof(Gable, 90) { Slope: Tiles }").is_err());
2278    }
2279
2280    #[test]
2281    fn test_parse_roof_is_terminating() {
2282        assert!(parse_ops("Roof(Shed, 30) { Slope: Tiles } Scale(1, 2, 1)").is_err());
2283    }
2284}