Skip to main content

rpic_core/
eval.rs

1//! Evaluator: walks the [`crate::ast`] and produces a placed-primitive
2//! [`Drawing`] using pic's positioning semantics.
3//!
4//! Model (Kernighan §3): a pen with a *current position* and *current
5//! direction* walks the plane dropping primitives. Closed objects (box/circle/
6//! ellipse/block) attach at the current point and advance by their extent;
7//! open objects (line/arrow/move/spline/arc) trace from the current point in
8//! the current direction. Labels, compass corners and ordinals
9//! (`last`/`nth`) resolve against previously placed objects.
10//!
11//! Approximations (documented; refined later): `arc` renders a default quarter
12//! turn.
13
14use std::collections::{HashMap, HashSet};
15use std::f64::consts::{FRAC_1_SQRT_2, PI};
16
17use crate::ast::*;
18use crate::diagnostic::{Diagnostic, Span};
19use crate::geom::{Bbox, Point};
20use crate::ir::*;
21use crate::token::{self, Corner, Dir, EnvVar, Func1, Func2, LineType, Prim};
22
23/// An evaluation error. `info` carries the structured diagnostic when the
24/// failure site had one at hand (a deferred-parse error's full [`Diagnostic`],
25/// or a place reference's span); bindings surface it without re-deriving
26/// positions from the message.
27#[derive(Debug, Clone, PartialEq)]
28pub struct EvalError {
29    pub msg: String,
30    pub info: Option<Box<Diagnostic>>,
31}
32
33impl std::fmt::Display for EvalError {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        write!(f, "{}", self.msg)
36    }
37}
38
39type ER<T> = Result<T, EvalError>;
40
41const DP_TEXT_RATIO: f64 = 0.66;
42const TEXT_EM: f64 = 11.0 / 72.0;
43/// Label font size in points, matching the SVG backend's `FONT_PT`.
44const FONT_PT_MATH: f64 = 11.0;
45const TEXT_CHAR_W: f64 = 0.6 * TEXT_EM;
46const TEXT_LINE_H: f64 = 1.2 * TEXT_EM;
47const TEXT_XHEIGHT: f64 = DP_TEXT_RATIO * TEXT_EM;
48const DEFAULT_BRACE_DEPTH: f64 = 0.18;
49const DEFAULT_BRACE_POS: f64 = 0.5;
50const DEFAULT_HATCH_ANGLE: f64 = 45.0;
51const DEFAULT_HATCH_SEP: f64 = 0.08;
52const DEFAULT_HATCH_WIDTH: f64 = 0.8;
53
54fn err<T>(msg: impl Into<String>) -> ER<T> {
55    Err(EvalError {
56        msg: msg.into(),
57        info: None,
58    })
59}
60
61/// An error that keeps its structured diagnostic (position, kind, hints).
62fn err_diag<T>(d: Diagnostic) -> ER<T> {
63    Err(diag_error(d))
64}
65
66fn diag_error(d: Diagnostic) -> EvalError {
67    EvalError {
68        msg: d.message.clone(),
69        info: Some(Box::new(d)),
70    }
71}
72
73/// A deferred-parse failure (an `if`/`for` body, `exec`, `sprintf` re-parse)
74/// keeps the original ParseError's full diagnostic instead of flattening it
75/// to a string.
76fn parse_eval_error(e: crate::parser::ParseError) -> EvalError {
77    EvalError {
78        msg: e.to_string(),
79        info: Some(Box::new(e.diagnostic())),
80    }
81}
82
83fn unknown_label_error(name: &str, span: Option<&Span>) -> EvalError {
84    let mut d = Diagnostic::new("unknown_label", format!("unknown label `{name}`")).found(name);
85    if let Some(s) = span {
86        d = d.at(s.clone());
87    }
88    diag_error(d)
89}
90
91fn ordinal_diagnostic(n: i64, available: usize, span: Option<&Span>) -> Diagnostic {
92    let mut d = Diagnostic::new(
93        "ordinal_out_of_range",
94        format!("ordinal {n} out of range (available {available})"),
95    )
96    .found(n.to_string())
97    .expected(format!("1..{available}"));
98    if let Some(s) = span {
99        d = d.at(s.clone());
100    }
101    d
102}
103
104fn finite(v: f64, context: &str) -> ER<f64> {
105    if v.is_finite() {
106        Ok(v)
107    } else {
108        err(format!("{context} produced non-finite numeric value"))
109    }
110}
111
112/// Evaluate a parsed picture into a [`Drawing`].
113pub fn eval(pic: &Picture) -> ER<Drawing> {
114    let mut st = State::new();
115    st.macros = pic.macros.clone();
116    st.includes = pic.includes.clone();
117    st.eval_stmts(&pic.stmts)?;
118    let want_w = match &pic.width {
119        Some(e) => Some(st.eval_expr(e)?),
120        None => None,
121    };
122    let want_h = match &pic.height {
123        Some(e) => Some(st.eval_expr(e)?),
124        None => None,
125    };
126    let (maxw, maxh) = (st.env.get(EnvVar::Maxpswid), st.env.get(EnvVar::Maxpsht));
127    let canvas_margin = st.canvas_margin()?;
128    let mut d = Drawing {
129        shapes: st.shapes,
130        shape_layers: st.shape_layers,
131        shape_classes: st.shape_classes,
132        bbox: st.bbox,
133        prelude_thick: st.env.get(EnvVar::Linethick),
134        canvas_margin,
135        anims: st.anims,
136        diagnostics: st.diagnostics,
137        warnings: st.warnings,
138    };
139    apply_ps_size(&mut d, want_w, want_h);
140    clamp_to_maxps(&mut d, maxw, maxh);
141    Ok(d)
142}
143
144/// Clamp the drawing to the `maxpswid`/`maxpsht` page bounds: if it exceeds
145/// either, scale the whole picture down uniformly to fit (never up), matching
146/// pic's PostScript page-fit behaviour.
147fn clamp_to_maxps(d: &mut Drawing, maxw: f64, maxh: f64) {
148    for _ in 0..4 {
149        if d.bbox.is_empty() {
150            return;
151        }
152        let (w, h) = (canvas_width(d), canvas_height(d));
153        let mut factor = 1.0_f64;
154        if maxw > 0.0 && w > maxw {
155            factor = factor.min(maxw / w);
156        }
157        if maxh > 0.0 && h > maxh {
158            factor = factor.min(maxh / h);
159        }
160        if factor >= 1.0 - 1e-9 {
161            return;
162        }
163        for sh in &mut d.shapes {
164            scale_shape(sh, factor);
165        }
166        d.prelude_thick *= factor;
167        d.canvas_margin.scale_by(factor);
168        d.bbox = drawing_painted_bbox(&d.shapes);
169    }
170}
171
172/// Apply `.PS <width> [<height>]` sizing: uniformly scale the whole drawing so
173/// it matches the requested width (or height if only height is given). Font size
174/// is left unchanged.
175fn apply_ps_size(d: &mut Drawing, want_w: Option<f64>, want_h: Option<f64>) {
176    if d.bbox.is_empty() {
177        return;
178    }
179    let factor = match (want_w, want_h) {
180        (Some(w), _) if w > 0.0 && d.bbox.width() > 0.0 => w / d.bbox.width(),
181        (None, Some(h)) if h > 0.0 && d.bbox.height() > 0.0 => h / d.bbox.height(),
182        _ => return,
183    };
184    if (factor - 1.0).abs() < 1e-9 {
185        return;
186    }
187    for sh in &mut d.shapes {
188        scale_shape(sh, factor);
189    }
190    d.prelude_thick *= factor;
191    d.canvas_margin.scale_by(factor);
192    d.bbox = drawing_painted_bbox(&d.shapes);
193}
194
195fn canvas_width(d: &Drawing) -> f64 {
196    d.bbox.width() + d.canvas_margin.horizontal()
197}
198
199fn canvas_height(d: &Drawing) -> f64 {
200    d.bbox.height() + d.canvas_margin.vertical()
201}
202
203/// The dimension variables that track `scale`.
204const SCALED_VARS: [EnvVar; 23] = [
205    EnvVar::Arcrad,
206    EnvVar::Arrowht,
207    EnvVar::Arrowwid,
208    EnvVar::Boxht,
209    EnvVar::Boxrad,
210    EnvVar::Boxwid,
211    EnvVar::Circlerad,
212    EnvVar::Dashwid,
213    EnvVar::Ellipseht,
214    EnvVar::Ellipsewid,
215    EnvVar::Lineht,
216    EnvVar::Linewid,
217    EnvVar::Moveht,
218    EnvVar::Movewid,
219    EnvVar::Textht,
220    EnvVar::Textwid,
221    EnvVar::Textoffset,
222    EnvVar::Margin,
223    EnvVar::Topmargin,
224    EnvVar::Rightmargin,
225    EnvVar::Bottommargin,
226    EnvVar::Leftmargin,
227    EnvVar::Dotrad,
228];
229
230// ---- environment variables -------------------------------------------------
231
232#[derive(Clone)]
233struct EnvVars {
234    v: HashMap<u8, f64>,
235}
236
237fn ev_key(e: EnvVar) -> u8 {
238    e as u8
239}
240
241impl EnvVars {
242    fn new() -> Self {
243        use EnvVar::*;
244        let defaults = [
245            (Arcrad, 0.25),
246            (Arrowht, 0.1),
247            (Arrowwid, 0.05),
248            (Boxht, 0.5),
249            (Boxrad, 0.0),
250            (Boxwid, 0.75),
251            (Circlerad, 0.25),
252            (Dashwid, 0.05),
253            (Ellipseht, 0.5),
254            (Ellipsewid, 0.75),
255            (Lineht, 0.5),
256            (Linewid, 0.5),
257            (Moveht, 0.5),
258            (Movewid, 0.5),
259            (Textht, (11.0 / 72.0) * DP_TEXT_RATIO),
260            (Textoffset, 2.0 / 72.0),
261            (Textwid, 0.0),
262            (Arrowhead, 1.0),
263            (Fillval, 0.5),
264            (Linethick, 0.8),
265            (Maxpsht, 11.0),
266            (Maxpswid, 8.5),
267            (Scale, 1.0),
268            (Margin, 0.0),
269            (Topmargin, 0.0),
270            (Rightmargin, 0.0),
271            (Bottommargin, 0.0),
272            (Leftmargin, 0.0),
273            (Texlabels, 0.0),
274            (Dotrad, 0.035),
275        ];
276        let mut v = HashMap::new();
277        for (e, d) in defaults {
278            v.insert(ev_key(e), d);
279        }
280        EnvVars { v }
281    }
282    fn get(&self, e: EnvVar) -> f64 {
283        *self.v.get(&ev_key(e)).unwrap_or(&0.0)
284    }
285    fn set(&mut self, e: EnvVar, val: f64) {
286        self.v.insert(ev_key(e), val);
287    }
288}
289
290// ---- placed-object bookkeeping ---------------------------------------------
291
292#[derive(Clone, Copy, PartialEq, Eq)]
293enum PKind {
294    Box,
295    Circle,
296    Ellipse,
297    Line,
298    Move,
299    Spline,
300    Arc,
301    Brace,
302    Block,
303    Text,
304}
305
306#[derive(Clone)]
307struct Placed {
308    kind: PKind,
309    center: Point,
310    bbox: Bbox,
311    start: Point,
312    end: Point,
313    thick: f64,
314    points: Vec<Point>,
315    radius: f64,
316    box_rad: f64,
317    line_wid: f64,
318    line_ht: f64,
319    closed_path: bool,
320    layer: i32,
321    /// Index of the primary shape in `shapes` (None for point-only labels).
322    shape: Option<usize>,
323    /// For blocks: inner labels (sub-objects), translated into parent
324    /// coordinates, so `B.A` / `last [].Outer` resolve. Empty otherwise.
325    members: HashMap<String, Placed>,
326}
327
328impl Placed {
329    fn corner(&self, c: Corner) -> Point {
330        match self.kind {
331            PKind::Circle | PKind::Ellipse => self.ellipse_corner(c),
332            PKind::Line | PKind::Move | PKind::Spline => self.linear_corner(c),
333            PKind::Brace => self.brace_corner(c),
334            PKind::Arc => self.arc_corner(c),
335            PKind::Box => self.box_corner(c),
336            PKind::Block | PKind::Text => self.bbox_corner(c),
337        }
338    }
339
340    fn bbox_corner(&self, c: Corner) -> Point {
341        let (lo, hi) = (self.bbox.min, self.bbox.max);
342        let mid = self.center;
343        match c {
344            Corner::N => Point::new(mid.x, hi.y),
345            Corner::S => Point::new(mid.x, lo.y),
346            Corner::E => Point::new(hi.x, mid.y),
347            Corner::W => Point::new(lo.x, mid.y),
348            Corner::Ne => Point::new(hi.x, hi.y),
349            Corner::Se => Point::new(hi.x, lo.y),
350            Corner::Nw => Point::new(lo.x, hi.y),
351            Corner::Sw => Point::new(lo.x, lo.y),
352            Corner::Center => mid,
353            Corner::Start => self.start,
354            Corner::End => self.end,
355        }
356    }
357
358    fn ellipse_corner(&self, c: Corner) -> Point {
359        let (rx, ry) = (self.bbox.width() / 2.0, self.bbox.height() / 2.0);
360        let diag = |sx: f64, sy: f64| Point::new(sx * rx * FRAC_1_SQRT_2, sy * ry * FRAC_1_SQRT_2);
361        let off = match c {
362            Corner::N => Point::new(0.0, ry),
363            Corner::S => Point::new(0.0, -ry),
364            Corner::E => Point::new(rx, 0.0),
365            Corner::W => Point::new(-rx, 0.0),
366            Corner::Ne => diag(1.0, 1.0),
367            Corner::Se => diag(1.0, -1.0),
368            Corner::Nw => diag(-1.0, 1.0),
369            Corner::Sw => diag(-1.0, -1.0),
370            Corner::Center => Point::ZERO,
371            Corner::Start => return self.start,
372            Corner::End => return self.end,
373        };
374        self.center + off
375    }
376
377    fn linear_corner(&self, c: Corner) -> Point {
378        if self.points.is_empty() {
379            return self.bbox_corner(c);
380        }
381        if self.closed_path {
382            return match c {
383                Corner::Start => self.points[0],
384                Corner::End => *self.points.last().unwrap(),
385                _ => self.bbox_corner(c),
386            };
387        }
388        match c {
389            Corner::Center => (self.points[0] + *self.points.last().unwrap()) * 0.5,
390            Corner::Start => self.points[0],
391            Corner::End => *self.points.last().unwrap(),
392            _ => {
393                let mut best = self.points[0];
394                for p in self.points.iter().skip(1) {
395                    let better = match c {
396                        Corner::N => p.y > best.y,
397                        Corner::S => p.y < best.y,
398                        Corner::E => p.x > best.x,
399                        Corner::W => p.x < best.x,
400                        Corner::Ne => {
401                            (p.y > best.y && p.x >= best.x) || (p.y >= best.y && p.x > best.x)
402                        }
403                        Corner::Se => {
404                            (p.y < best.y && p.x >= best.x) || (p.y <= best.y && p.x > best.x)
405                        }
406                        Corner::Sw => {
407                            (p.y < best.y && p.x <= best.x) || (p.y <= best.y && p.x < best.x)
408                        }
409                        Corner::Nw => {
410                            (p.y > best.y && p.x <= best.x) || (p.y >= best.y && p.x < best.x)
411                        }
412                        Corner::Center | Corner::Start | Corner::End => false,
413                    };
414                    if better {
415                        best = *p;
416                    }
417                }
418                best
419            }
420        }
421    }
422
423    fn brace_corner(&self, c: Corner) -> Point {
424        match c {
425            Corner::Center => self.center,
426            Corner::Start => self.start,
427            Corner::End => self.end,
428            _ => {
429                let mut bb = Bbox::new();
430                for p in &self.points {
431                    bb.add(*p);
432                }
433                if bb.is_empty() {
434                    return self.bbox_corner(c);
435                }
436                bbox_point(&bb, c)
437            }
438        }
439    }
440
441    fn arc_corner(&self, c: Corner) -> Point {
442        let diag = self.radius * FRAC_1_SQRT_2;
443        let off = match c {
444            Corner::N => Point::new(0.0, self.radius),
445            Corner::S => Point::new(0.0, -self.radius),
446            Corner::E => Point::new(self.radius, 0.0),
447            Corner::W => Point::new(-self.radius, 0.0),
448            Corner::Ne => Point::new(diag, diag),
449            Corner::Se => Point::new(diag, -diag),
450            Corner::Nw => Point::new(-diag, diag),
451            Corner::Sw => Point::new(-diag, -diag),
452            Corner::Center => Point::ZERO,
453            Corner::Start => return self.start,
454            Corner::End => return self.end,
455        };
456        self.center + off
457    }
458
459    fn box_corner(&self, c: Corner) -> Point {
460        match c {
461            Corner::Ne | Corner::Se | Corner::Nw | Corner::Sw if self.box_rad > 0.0 => {
462                let inset = self
463                    .box_rad
464                    .min(self.bbox.width().abs().min(self.bbox.height().abs()) / 2.0)
465                    * (1.0 - FRAC_1_SQRT_2);
466                let x = self.bbox.width() / 2.0 - inset;
467                let y = self.bbox.height() / 2.0 - inset;
468                let off = match c {
469                    Corner::Ne => Point::new(x, y),
470                    Corner::Se => Point::new(x, -y),
471                    Corner::Nw => Point::new(-x, y),
472                    Corner::Sw => Point::new(-x, -y),
473                    _ => Point::ZERO,
474                };
475                self.center + off
476            }
477            _ => self.bbox_corner(c),
478        }
479    }
480
481    fn attr_width(&self) -> f64 {
482        match self.kind {
483            PKind::Brace => self.bbox.width(),
484            PKind::Line | PKind::Move | PKind::Spline => self.line_wid,
485            _ => self.bbox.width(),
486        }
487    }
488
489    fn attr_height(&self) -> f64 {
490        match self.kind {
491            PKind::Brace => self.bbox.height(),
492            PKind::Line | PKind::Move | PKind::Spline => self.line_ht,
493            _ => self.bbox.height(),
494        }
495    }
496
497    fn attr_radius(&self) -> f64 {
498        match self.kind {
499            PKind::Box => self.box_rad,
500            PKind::Circle => self.bbox.width() / 2.0,
501            PKind::Arc => self.radius,
502            _ => 0.0,
503        }
504    }
505
506    fn attr_diameter(&self) -> f64 {
507        match self.kind {
508            PKind::Circle => self.bbox.width(),
509            PKind::Arc => self.radius * 2.0,
510            _ => 0.0,
511        }
512    }
513
514    fn attr_length(&self) -> f64 {
515        match self.kind {
516            PKind::Line | PKind::Move | PKind::Spline | PKind::Brace => self.start.dist(self.end),
517            _ => 0.0,
518        }
519    }
520}
521
522fn bbox_point(bb: &Bbox, c: Corner) -> Point {
523    let lo = bb.min;
524    let hi = bb.max;
525    let mid = (lo + hi) * 0.5;
526    match c {
527        Corner::N => Point::new(mid.x, hi.y),
528        Corner::S => Point::new(mid.x, lo.y),
529        Corner::E => Point::new(hi.x, mid.y),
530        Corner::W => Point::new(lo.x, mid.y),
531        Corner::Ne => Point::new(hi.x, hi.y),
532        Corner::Se => Point::new(hi.x, lo.y),
533        Corner::Nw => Point::new(lo.x, hi.y),
534        Corner::Sw => Point::new(lo.x, lo.y),
535        Corner::Center => mid,
536        Corner::Start => lo,
537        Corner::End => hi,
538    }
539}
540
541// ---- evaluator state -------------------------------------------------------
542
543struct State {
544    pos: Point,
545    dir: Dir,
546    vars: HashMap<String, f64>,
547    inherited_vars: HashSet<String>,
548    export_vars: HashSet<String>,
549    env: EnvVars,
550    macros: Macros,
551    includes: IncludeCtx,
552    /// Labels visible from an enclosing scope (read-only, in absolute parent
553    /// coordinates). A block may reference outer labels but must not let them
554    /// affect its own `last`/nth/bbox, so they live here, not in `placed`.
555    outer_labels: HashMap<String, Placed>,
556    shapes: Vec<Shape>,
557    shape_layers: Vec<i32>,
558    shape_classes: Vec<Option<String>>,
559    placed: Vec<Placed>,
560    labels: HashMap<String, usize>,
561    /// Visible geometry and text only; this becomes the final drawing/viewBox.
562    bbox: Bbox,
563    /// All evaluated geometry, including invisible helpers, for block sizing
564    /// and anchor placement.
565    layout_bbox: Bbox,
566    // animation state
567    anims: Vec<Anim>,
568    diagnostics: Vec<String>,
569    warnings: Vec<Diagnostic>,
570    anim_cursor: f64,
571    anim_end: HashMap<usize, f64>,
572    rng: GlibcRand,
573}
574
575const DEFAULT_ANIM_DUR: f64 = 0.6;
576
577fn dir_unit(d: Dir) -> Point {
578    match d {
579        Dir::Right => Point::new(1.0, 0.0),
580        Dir::Left => Point::new(-1.0, 0.0),
581        Dir::Up => Point::new(0.0, 1.0),
582        Dir::Down => Point::new(0.0, -1.0),
583    }
584}
585fn horizontal(d: Dir) -> bool {
586    matches!(d, Dir::Right | Dir::Left)
587}
588
589impl State {
590    fn new() -> Self {
591        let mut vars = HashMap::new();
592        install_dpic_compat_vars(&mut vars);
593        State {
594            pos: Point::ZERO,
595            dir: Dir::Right,
596            vars,
597            inherited_vars: HashSet::new(),
598            export_vars: HashSet::new(),
599            env: EnvVars::new(),
600            macros: HashMap::new(),
601            includes: IncludeCtx::default(),
602            outer_labels: HashMap::new(),
603            shapes: Vec::new(),
604            shape_layers: Vec::new(),
605            shape_classes: Vec::new(),
606            placed: Vec::new(),
607            labels: HashMap::new(),
608            bbox: Bbox::new(),
609            layout_bbox: Bbox::new(),
610            anims: Vec::new(),
611            diagnostics: Vec::new(),
612            warnings: Vec::new(),
613            anim_cursor: 0.0,
614            anim_end: HashMap::new(),
615            rng: GlibcRand::new(1),
616        }
617    }
618
619    fn eval_stmts(&mut self, stmts: &[Stmt]) -> ER<()> {
620        for s in stmts {
621            self.eval_stmt(s)?;
622        }
623        Ok(())
624    }
625
626    /// Parse a deferred `if`/`for` body now, expanding macros along this path.
627    fn parse_body(&mut self, body: &Body) -> ER<Vec<Stmt>> {
628        crate::parser::parse_body_tokens(body, &mut self.macros, &self.includes)
629            .map_err(parse_eval_error)
630    }
631
632    fn eval_stmt(&mut self, s: &Stmt) -> ER<()> {
633        match s {
634            Stmt::Direction(d) => {
635                self.dir = *d;
636            }
637            Stmt::Assign(list) => {
638                for a in list {
639                    self.eval_assignment(a)?;
640                }
641            }
642            Stmt::Place { label, pos } => {
643                let p = self.eval_pos(pos)?;
644                let key = self.label_key(label)?;
645                let mut bb = Bbox::new();
646                bb.add(p);
647                let idx = self.placed.len();
648                self.placed.push(Placed {
649                    kind: PKind::Text,
650                    center: p,
651                    bbox: bb,
652                    start: p,
653                    end: p,
654                    thick: 0.0,
655                    points: Vec::new(),
656                    radius: 0.0,
657                    box_rad: 0.0,
658                    line_wid: 0.0,
659                    line_ht: 0.0,
660                    closed_path: false,
661                    layer: 0,
662                    shape: None,
663                    members: HashMap::new(),
664                });
665                self.labels.insert(key, idx);
666            }
667            Stmt::Group(stmts) => {
668                let (pos, dir) = (self.pos, self.dir);
669                self.eval_stmts(stmts)?;
670                self.pos = pos;
671                self.dir = dir;
672            }
673            Stmt::Object { label, object } => {
674                let idx = self.eval_object(object)?;
675                if let Some(l) = label {
676                    let key = self.label_key(l)?;
677                    self.labels.insert(key, idx);
678                }
679            }
680            Stmt::Animate(a) => self.eval_animate(a)?,
681            Stmt::Class { target, class } => {
682                let idx = self.place_index(target)?;
683                let name = self.eval_stringexpr(class)?;
684                self.append_class_at(idx, &name)?;
685            }
686            Stmt::If {
687                cond,
688                then_body,
689                else_body,
690            } => {
691                // only the taken branch is parsed (dead branches may be
692                // syntactically invalid, e.g. an empty default-argument body)
693                if self.eval_expr(cond)? != 0.0 {
694                    let stmts = self.parse_body(then_body)?;
695                    self.eval_stmts(&stmts)?;
696                } else if let Some(e) = else_body {
697                    let stmts = self.parse_body(e)?;
698                    self.eval_stmts(&stmts)?;
699                }
700            }
701            Stmt::For {
702                var,
703                subscript,
704                from,
705                to,
706                by,
707                mult,
708                body,
709            } => {
710                let from = self.eval_expr(from)?;
711                let to = self.eval_expr(to)?;
712                let by = self.eval_expr(by)?;
713                let mut v = from;
714                let mut iters = 0u64;
715                // Parsed lazily on the first iteration that actually runs, so
716                // a zero-iteration loop never parses (or macro-expands) its
717                // body — same deferred rule as dead `if` branches (#196).
718                let mut parsed: Option<Vec<Stmt>> = None;
719                const EPS: f64 = 1e-9;
720                loop {
721                    let cont = if *mult {
722                        if by >= 1.0 {
723                            v <= to + EPS
724                        } else {
725                            v >= to - EPS
726                        }
727                    } else if by >= 0.0 {
728                        v <= to + EPS
729                    } else {
730                        v >= to - EPS
731                    };
732                    if !cont {
733                        break;
734                    }
735                    let key = self.indexed_name(var, subscript.as_ref())?;
736                    self.vars.insert(key, v);
737                    if parsed.is_none() {
738                        parsed = Some(self.parse_body(body)?);
739                    }
740                    self.eval_stmts(parsed.as_deref().unwrap())?;
741                    let prev = v;
742                    v = if *mult { v * by } else { v + by };
743                    if (v - prev).abs() < f64::EPSILON {
744                        break; // no progress (by 0, or *1) — avoid infinite loop
745                    }
746                    iters += 1;
747                    if iters > 1_000_000 {
748                        return err("for loop exceeded 1,000,000 iterations");
749                    }
750                }
751            }
752            Stmt::Print(item) => match item {
753                PrintItem::Expr(e) => {
754                    let v = self.eval_expr(e)?;
755                    self.diagnostics.push(fmt_num(v));
756                }
757                PrintItem::Str(se) => {
758                    let s = self.eval_stringexpr(se)?;
759                    self.diagnostics.push(s);
760                }
761            },
762            Stmt::Exec { command, arg_frame } => {
763                let src = unescape_exec_source(&self.eval_stringexpr(command)?);
764                let stmts = crate::parser::parse_exec_source(
765                    &src,
766                    &self.macros,
767                    &self.includes,
768                    arg_frame.as_deref(),
769                )
770                .map_err(parse_eval_error)?;
771                self.eval_stmts(&stmts)?;
772            }
773            Stmt::Reset(list) => {
774                if list.is_empty() {
775                    self.env = EnvVars::new();
776                } else {
777                    let d = EnvVars::new();
778                    for e in list {
779                        self.env.set(*e, d.get(*e));
780                    }
781                }
782            }
783        }
784        Ok(())
785    }
786
787    fn eval_animate(&mut self, a: &Animate) -> ER<()> {
788        let idx = self.place_index(&a.target)?;
789        let shape = self.placed[idx].shape.ok_or_else(|| EvalError {
790            msg: "cannot animate a point (no drawn shape)".into(),
791            info: None,
792        })?;
793        let dur = match &a.duration {
794            Some(e) => self.eval_expr(e)?,
795            None => DEFAULT_ANIM_DUR,
796        };
797        let mut start = match &a.timing {
798            Timing::Sequential => self.anim_cursor,
799            Timing::At(e) => self.eval_expr(e)?,
800            Timing::After(p) => {
801                let i = self.place_index(p)?;
802                let sh = self.placed[i].shape.ok_or_else(|| EvalError {
803                    msg: "`after` target has no animation".into(),
804                    info: None,
805                })?;
806                *self.anim_end.get(&sh).unwrap_or(&0.0)
807            }
808        };
809        if let Some(d) = &a.delay {
810            start += self.eval_expr(d)?;
811        }
812        let end = start + dur;
813        self.anim_cursor = end;
814        self.anim_end.insert(shape, end);
815        let effect = stringexpr_lit(&a.effect);
816        if !matches!(effect.as_str(), "draw" | "fade" | "pop") {
817            let mut warning = Diagnostic::new(
818                "unknown_animation_effect",
819                format!(
820                    "unknown animation effect `{effect}`; supported effects are `draw`, `fade`, and `pop`"
821                ),
822            )
823            .found(effect.clone())
824            .expected("draw, fade, or pop");
825            if let Some(span) = &a.effect_span {
826                warning = warning.at(span.clone());
827            }
828            self.warnings.push(warning);
829        }
830        self.anims.push(Anim {
831            shape,
832            effect,
833            start,
834            duration: dur,
835        });
836        Ok(())
837    }
838
839    /// Append a validated CSS class to the shape behind `placed_idx` (rpic
840    /// `class` extension). Multiple applications compose: `class="a b"`.
841    fn append_class_at(&mut self, placed_idx: usize, name: &str) -> ER<()> {
842        let name = name.trim();
843        validate_class(name)?;
844        let pl = &self.placed[placed_idx];
845        if matches!(pl.kind, PKind::Block) {
846            return err("`class` on a block is not supported yet; class its inner objects");
847        }
848        let Some(sh) = pl.shape else {
849            return err("cannot attach a class to a point (no drawn shape)");
850        };
851        match &mut self.shape_classes[sh] {
852            Some(existing) => {
853                existing.push(' ');
854                existing.push_str(name);
855            }
856            slot @ None => *slot = Some(name.to_string()),
857        }
858        Ok(())
859    }
860
861    /// rpic `texlabels` extension: typeset a fully `$…$`-delimited label as
862    /// math via the registered renderer. Any failure — extension off, no
863    /// renderer in this build, not fully delimited, or a TeX parse error —
864    /// falls back to the literal text (a parse error also leaves a
865    /// diagnostic). The picture itself never fails because of a math label.
866    fn math_span_for(&mut self, s: &str) -> Option<crate::math::MathSpan> {
867        if self.env.get(EnvVar::Texlabels) == 0.0 {
868            return None;
869        }
870        let t = s.trim();
871        let inner = t.strip_prefix('$')?.strip_suffix('$')?;
872        if inner.is_empty() || inner.contains('$') {
873            return None;
874        }
875        let Some(render) = crate::math::math_renderer() else {
876            self.diagnostics.push(format!(
877                "texlabels: no math renderer in this build; `{t}` kept literal"
878            ));
879            return None;
880        };
881        match render(inner, FONT_PT_MATH) {
882            Ok(span) => Some(span),
883            Err(e) => {
884                self.diagnostics.push(format!(
885                    "texlabels: `{t}` is not valid TeX math ({e}); label kept literal"
886                ));
887                None
888            }
889        }
890    }
891
892    fn scale_value(&self) -> ER<f64> {
893        let scale = self.env.get(EnvVar::Scale);
894        if scale.abs() < 1e-12 {
895            return err("scale must be non-zero");
896        }
897        Ok(scale)
898    }
899
900    /// Convert a pic dimension from the current user units to internal inches.
901    fn to_internal_dim(&self, v: f64) -> ER<f64> {
902        Ok(v / self.scale_value()?)
903    }
904
905    fn env_dim(&self, e: EnvVar) -> ER<f64> {
906        self.to_internal_dim(self.env.get(e))
907    }
908
909    fn canvas_margin(&self) -> ER<CanvasMargin> {
910        let all = self.env_dim(EnvVar::Margin)?;
911        Ok(CanvasMargin {
912            top: all + self.env_dim(EnvVar::Topmargin)?,
913            right: all + self.env_dim(EnvVar::Rightmargin)?,
914            bottom: all + self.env_dim(EnvVar::Bottommargin)?,
915            left: all + self.env_dim(EnvVar::Leftmargin)?,
916        })
917    }
918
919    fn expr_dim(&mut self, e: &Expr) -> ER<f64> {
920        let v = self.eval_expr(e)?;
921        self.to_internal_dim(v)
922    }
923
924    /// Convert an internal geometric length back to pic's current user units.
925    fn to_user_dim(&self, v: f64) -> f64 {
926        v * self.env.get(EnvVar::Scale)
927    }
928
929    fn eval_assignment(&mut self, a: &Assignment) -> ER<()> {
930        let rhs = self.eval_expr(&a.value)?;
931        match &a.target {
932            AssignTarget::Var(name, subscript) => {
933                let key = self.indexed_name(name, subscript.as_ref())?;
934                let cur = match self.vars.get(&key).copied() {
935                    Some(v) => v,
936                    None if matches!(a.op, AssignOp::Set) => 0.0,
937                    None => return err(format!("variable not found `{key}`")),
938                };
939                let val = apply_op(a.op, cur, rhs)?;
940                if !matches!(a.op, AssignOp::Set) && self.inherited_vars.contains(&key) {
941                    self.export_vars.insert(key.clone());
942                }
943                self.vars.insert(key, val);
944            }
945            AssignTarget::Env(e) => {
946                let cur = self.env.get(*e);
947                let val = apply_op(a.op, cur, rhs)?;
948                if matches!(e, EnvVar::Scale) {
949                    if val.abs() < 1e-12 {
950                        return err("scale must be non-zero");
951                    }
952                    if cur.abs() >= 1e-12 {
953                        self.scale_existing_geometry(cur / val);
954                    }
955                    // Changing `scale` rescales all scaled dimension variables by
956                    // the ratio (dpic semantics). They remain in user units;
957                    // geometry converts them to internal inches by dividing by
958                    // the current scale at use sites.
959                    let ratio = if cur != 0.0 { val / cur } else { val };
960                    for sv in SCALED_VARS {
961                        let v = self.env.get(sv);
962                        self.env.set(sv, v * ratio);
963                    }
964                }
965                self.env.set(*e, val);
966            }
967        }
968        Ok(())
969    }
970
971    fn scale_existing_geometry(&mut self, factor: f64) {
972        if (factor - 1.0).abs() < 1e-12 {
973            return;
974        }
975        self.pos = self.pos * factor;
976        for sh in &mut self.shapes {
977            scale_shape(sh, factor);
978        }
979        for pl in &mut self.placed {
980            scale_placed(pl, factor);
981        }
982        scale_bbox_in_place(&mut self.bbox, factor);
983        scale_bbox_in_place(&mut self.layout_bbox, factor);
984    }
985
986    // ---- objects -----------------------------------------------------------
987
988    fn eval_object(&mut self, obj: &Object) -> ER<usize> {
989        if !object_uses_bare_distance(&obj.kind) {
990            self.warn_ignored_dist_attrs(obj);
991        }
992        let idx = self.eval_object_inner(obj)?;
993        for a in &obj.attrs {
994            if let Attr::Class(se) = a {
995                let name = self.eval_stringexpr(se)?;
996                self.append_class_at(idx, &name)?;
997            }
998        }
999        Ok(idx)
1000    }
1001
1002    fn warn_ignored_dist_attrs(&mut self, obj: &Object) {
1003        for attr in &obj.attrs {
1004            let Attr::Dist(expr, span) = attr else {
1005                continue;
1006            };
1007            let found = expr_bare_name(expr).unwrap_or("bare distance");
1008            let mut warning = Diagnostic::new(
1009                "ignored_attribute",
1010                format!("ignored `{found}` because this object does not accept a bare distance"),
1011            )
1012            .found(found)
1013            .expected("an attribute");
1014            if let Some(hint) = suggest_attribute(found) {
1015                warning = warning.hint(format!("did you mean `{hint}`?"));
1016            }
1017            if let Some(span) = span {
1018                warning = warning.at(span.clone());
1019            }
1020            self.warnings.push(warning);
1021        }
1022    }
1023
1024    fn eval_object_inner(&mut self, obj: &Object) -> ER<usize> {
1025        match &obj.kind {
1026            ObjectKind::Primitive(p) => match p {
1027                Prim::Box | Prim::Circle | Prim::Ellipse => self.closed(*p, obj),
1028                Prim::Line | Prim::Arrow | Prim::Move | Prim::Spline => self.open(*p, obj),
1029                Prim::Arc => self.arc(obj),
1030            },
1031            ObjectKind::Text => self.text_obj(obj),
1032            ObjectKind::Brace => self.brace(obj),
1033            ObjectKind::Dot => self.closed_dot(obj),
1034            ObjectKind::Block(stmts) => self.block(stmts, obj),
1035            ObjectKind::Empty => self.block(&[], obj),
1036            ObjectKind::Continue => self.continue_obj(obj),
1037        }
1038    }
1039
1040    /// `continue`: append another segment to the most recent line/spline,
1041    /// extending it from its current end in the current (or given) direction.
1042    fn continue_obj(&mut self, obj: &Object) -> ER<usize> {
1043        let idx = self
1044            .shapes
1045            .iter()
1046            .rposition(|s| matches!(s, Shape::Path { .. } | Shape::Spline { .. }))
1047            .ok_or_else(|| EvalError {
1048                msg: "`continue` has no previous line to extend".into(),
1049                info: None,
1050            })?;
1051        let pidx = self.placed.iter().position(|pl| pl.shape == Some(idx));
1052        if let Some(pi) = pidx
1053            && self.placed[pi].closed_path
1054        {
1055            return err("polygon is closed");
1056        }
1057        let start = match &self.shapes[idx] {
1058            Shape::Path { pts, .. } | Shape::Spline { pts, .. } => *pts.last().unwrap(),
1059            _ => unreachable!(),
1060        };
1061        let deflen_h = self.env_dim(EnvVar::Linewid)?;
1062        let deflen_v = self.env_dim(EnvVar::Lineht)?;
1063
1064        let mut pts = vec![start];
1065        let mut pend = Point::ZERO;
1066        let mut any = false;
1067        let mut last_dir = self.dir;
1068        for a in &obj.attrs {
1069            match a {
1070                Attr::Direction(d, opt) => {
1071                    let dist = match opt {
1072                        Some(e) => self.expr_dim(e)?,
1073                        None => {
1074                            if horizontal(*d) {
1075                                deflen_h
1076                            } else {
1077                                deflen_v
1078                            }
1079                        }
1080                    };
1081                    pend = pend + dir_unit(*d) * dist;
1082                    last_dir = *d;
1083                    any = true;
1084                }
1085                Attr::Then => {
1086                    let np = *pts.last().unwrap() + pend;
1087                    pts.push(np);
1088                    pend = Point::ZERO;
1089                }
1090                Attr::To(pos) => {
1091                    if pend != Point::ZERO {
1092                        let np = *pts.last().unwrap() + pend;
1093                        pts.push(np);
1094                        pend = Point::ZERO;
1095                    }
1096                    pts.push(self.eval_pos(pos)?);
1097                    any = true;
1098                }
1099                Attr::By(pos) => {
1100                    let dp = self.eval_pos(pos)?;
1101                    pend = pend + (dp - Point::ZERO);
1102                    any = true;
1103                }
1104                Attr::Dist(e, _) => {
1105                    let dist = self.expr_dim(e)?;
1106                    pend = pend + dir_unit(last_dir) * dist;
1107                    any = true;
1108                }
1109                _ => {}
1110            }
1111        }
1112        if pend != Point::ZERO {
1113            let np = *pts.last().unwrap() + pend;
1114            pts.push(np);
1115        }
1116        if pts.len() == 1 && !any {
1117            let dist = if horizontal(self.dir) {
1118                deflen_h
1119            } else {
1120                deflen_v
1121            };
1122            pts.push(start + dir_unit(self.dir) * dist);
1123            last_dir = self.dir;
1124        }
1125
1126        let new: Vec<Point> = pts[1..].to_vec();
1127        let visible = match &mut self.shapes[idx] {
1128            Shape::Path { pts: p, style, .. } | Shape::Spline { pts: p, style, .. } => {
1129                p.extend_from_slice(&new);
1130                (!style.invis, stroke_half_width(style))
1131            }
1132            _ => unreachable!(),
1133        };
1134        let end = *pts.last().unwrap();
1135        let mut bb = Bbox::new();
1136        for q in &new {
1137            self.layout_bbox.add(*q);
1138            bb.add(*q);
1139        }
1140        if visible.0 {
1141            self.bbox.union(&painted_bbox(&bb, visible.1));
1142        }
1143        self.pos = end;
1144        self.dir = last_dir;
1145
1146        if let Some(pi) = pidx {
1147            self.placed[pi].end = end;
1148            self.placed[pi].bbox.add(end);
1149            self.placed[pi].center = (self.placed[pi].start + end) * 0.5;
1150            self.placed[pi].points.extend_from_slice(&new);
1151        }
1152        Ok(pidx.unwrap_or(idx))
1153    }
1154
1155    /// rpic extension: a junction dot — circle machinery with `dotrad` as
1156    /// the default radius and a solid (gray-0) fill unless overridden, the
1157    /// exact geometry the classic `dot(P)` circuit macro produced.
1158    fn closed_dot(&mut self, obj: &Object) -> ER<usize> {
1159        self.closed_impl(Prim::Circle, obj, true)
1160    }
1161
1162    fn closed(&mut self, p: Prim, obj: &Object) -> ER<usize> {
1163        self.closed_impl(p, obj, false)
1164    }
1165
1166    fn closed_impl(&mut self, p: Prim, obj: &Object, dot: bool) -> ER<usize> {
1167        let mut style = self.style_of(obj)?;
1168        if dot && style.fill.is_none() {
1169            style.fill = Some(Fill::Gray(0.0));
1170        }
1171        let (text, fit_text) = self.text_and_fit_text_of(obj)?;
1172        let dir = self.dir_of(obj);
1173        let scale = self.scale_of(obj)?;
1174
1175        // dimensions — `same` reuses the previous like-object's size as the
1176        // default; explicit ht/wid/rad still override.
1177        let prev = if obj.attrs.iter().any(|a| matches!(a, Attr::Same)) {
1178            self.last_dims_of(p)
1179        } else {
1180            None
1181        };
1182        let (mut w, mut h, mut rad);
1183        match p {
1184            Prim::Circle => {
1185                let def_r = prev.map(|(pw, _)| pw / 2.0).unwrap_or(self.env_dim(if dot {
1186                    EnvVar::Dotrad
1187                } else {
1188                    EnvVar::Circlerad
1189                })?);
1190                let r = self.dim(obj, DimKind::Rad)?.unwrap_or(def_r);
1191                let r = self.dim(obj, DimKind::Diam)?.map(|d| d / 2.0).unwrap_or(r);
1192                w = 2.0 * r;
1193                h = 2.0 * r;
1194                rad = r;
1195            }
1196            Prim::Ellipse => {
1197                let (dw, dh) = prev.unwrap_or((
1198                    self.env_dim(EnvVar::Ellipsewid)?,
1199                    self.env_dim(EnvVar::Ellipseht)?,
1200                ));
1201                w = self.dim(obj, DimKind::Wid)?.unwrap_or(dw);
1202                h = self.dim(obj, DimKind::Ht)?.unwrap_or(dh);
1203                rad = 0.0;
1204            }
1205            _ => {
1206                let (dw, dh) =
1207                    prev.unwrap_or((self.env_dim(EnvVar::Boxwid)?, self.env_dim(EnvVar::Boxht)?));
1208                w = self.dim(obj, DimKind::Wid)?.unwrap_or(dw);
1209                h = self.dim(obj, DimKind::Ht)?.unwrap_or(dh);
1210                rad = self
1211                    .dim(obj, DimKind::Rad)?
1212                    .unwrap_or(self.env_dim(EnvVar::Boxrad)?);
1213            }
1214        }
1215        if let Some(fit_text) = fit_text {
1216            let (fit_w, fit_h) = fitted_text_size(&fit_text).ok_or_else(|| EvalError {
1217                msg: "`fit` requires visible text before the attribute".into(),
1218                info: None,
1219            })?;
1220            match p {
1221                Prim::Circle => {
1222                    if !self.has_dim(obj, DimKind::Rad) && !self.has_dim(obj, DimKind::Diam) {
1223                        let diam = fit_w.hypot(fit_h);
1224                        w = diam;
1225                        h = diam;
1226                        rad = diam / 2.0;
1227                    }
1228                }
1229                Prim::Ellipse => {
1230                    if !self.has_dim(obj, DimKind::Wid) {
1231                        w = fit_w;
1232                    }
1233                    if !self.has_dim(obj, DimKind::Ht) {
1234                        h = fit_h;
1235                    }
1236                }
1237                _ => {
1238                    if !self.has_dim(obj, DimKind::Wid) {
1239                        w = fit_w;
1240                    }
1241                    if !self.has_dim(obj, DimKind::Ht) {
1242                        h = fit_h;
1243                    }
1244                }
1245            }
1246        }
1247        w *= scale;
1248        h *= scale;
1249        rad *= scale;
1250
1251        let extent = if horizontal(dir) { w } else { h };
1252        let center = self.place_closed_center(p, obj, dir, extent, (w, h), rad)?;
1253
1254        let mut bb = Bbox::new();
1255        bb.add(center - Point::new(w / 2.0, h / 2.0));
1256        bb.add(center + Point::new(w / 2.0, h / 2.0));
1257        let layout_bb = if matches!(p, Prim::Box) {
1258            dpic_box_layout_bbox(center, w, h)
1259        } else {
1260            bb
1261        };
1262        self.layout_bbox.union(&layout_bb);
1263        if closed_shape_is_visible(&style) {
1264            self.bbox
1265                .union(&painted_bbox(&bb, stroke_half_width(&style)));
1266        }
1267        self.union_text(center, &text);
1268
1269        let shape = match p {
1270            Prim::Circle => Shape::Circle {
1271                c: center,
1272                r: rad,
1273                style,
1274                text,
1275            },
1276            Prim::Ellipse => Shape::Ellipse {
1277                c: center,
1278                w,
1279                h,
1280                style,
1281                text,
1282            },
1283            _ => Shape::Box {
1284                c: center,
1285                w,
1286                h,
1287                rad,
1288                style,
1289                text,
1290            },
1291        };
1292        let layer = self.layer_of(obj, 0)?;
1293        self.push_shape(shape, layer);
1294
1295        let half = dir_unit(dir) * (extent / 2.0);
1296        let start = center - half;
1297        let end = center + half;
1298        self.pos = end;
1299        self.dir = dir;
1300        let kind = match p {
1301            Prim::Circle => PKind::Circle,
1302            Prim::Ellipse => PKind::Ellipse,
1303            _ => PKind::Box,
1304        };
1305        let sh = self.shapes.len() - 1;
1306        let idx = self.record(kind, center, bb, start, end, 0.0, Some(sh));
1307        if matches!(kind, PKind::Box) {
1308            self.placed[idx].box_rad = rad;
1309        }
1310        Ok(idx)
1311    }
1312
1313    fn brace(&mut self, obj: &Object) -> ER<usize> {
1314        let style = self.style_of(obj)?;
1315        let text = self.text_of(obj)?;
1316        let start = self.find_from(obj)?.unwrap_or(self.pos);
1317        let has_to = obj.attrs.iter().any(|a| matches!(a, Attr::To(_)));
1318        let (end, last_dir) = self.brace_end(obj, start, has_to)?;
1319        let v = end - start;
1320        let len = v.len();
1321        if len <= 1e-12 {
1322            return err("brace endpoints must be distinct");
1323        }
1324
1325        let depth = self
1326            .dim(obj, DimKind::Wid)?
1327            .unwrap_or(DEFAULT_BRACE_DEPTH)
1328            .abs();
1329        let pos = self.brace_pos_of(obj)?;
1330        let label_offset = self.brace_label_offset_of(obj)?;
1331        let side = brace_side(v / len, self.brace_side_dir(obj, has_to));
1332        let cubics = brace_cubics(start, end, side * depth, pos);
1333        let cusp = brace_cusp(&cubics).unwrap_or(start + v * pos);
1334        let label_at =
1335            cusp + side * (self.env_dim(EnvVar::Textoffset)? + TEXT_LINE_H + label_offset);
1336        let mut bb = cubics_bbox(&cubics);
1337        self.layout_bbox.union(&bb);
1338        if !style.invis {
1339            self.bbox
1340                .union(&painted_bbox(&bb, stroke_half_width(&style)));
1341        } else if style.invis_bounds {
1342            self.bbox.union(&bb);
1343        }
1344        let text_bb = text_bbox(label_at, &text);
1345        self.bbox.union(&text_bb);
1346        bb.union(&text_bb);
1347
1348        let shape = Shape::Brace {
1349            a: start,
1350            b: end,
1351            cubics: cubics.clone(),
1352            label_at,
1353            style,
1354            text,
1355        };
1356        let layer = self.layer_of(obj, 0)?;
1357        self.push_shape(shape, layer);
1358
1359        self.pos = end;
1360        self.dir = last_dir;
1361        let sh = self.shapes.len() - 1;
1362        let idx = self.record(PKind::Brace, cusp, bb, start, end, 0.0, Some(sh));
1363        self.placed[idx].points = sample_cubics(&cubics, 6);
1364        Ok(idx)
1365    }
1366
1367    fn brace_end(&mut self, obj: &Object, start: Point, has_to: bool) -> ER<(Point, Dir)> {
1368        if has_to {
1369            let mut end = None;
1370            for a in &obj.attrs {
1371                if let Attr::To(pos) = a {
1372                    end = Some(self.eval_pos(pos)?);
1373                }
1374            }
1375            let end = end.unwrap();
1376            return Ok((end, nearest_dir(end - start)));
1377        }
1378
1379        let mut pend = Point::ZERO;
1380        let mut any = false;
1381        let mut last_dir = self.dir;
1382        for a in &obj.attrs {
1383            match a {
1384                Attr::Direction(d, opt) => {
1385                    let dist = match opt {
1386                        Some(e) => self.expr_dim(e)?,
1387                        None => {
1388                            if horizontal(*d) {
1389                                self.env_dim(EnvVar::Linewid)?
1390                            } else {
1391                                self.env_dim(EnvVar::Lineht)?
1392                            }
1393                        }
1394                    };
1395                    pend = pend + dir_unit(*d) * dist;
1396                    last_dir = *d;
1397                    any = true;
1398                }
1399                Attr::By(pos) => {
1400                    pend = pend + self.eval_pos(pos)?;
1401                    any = true;
1402                }
1403                Attr::Dist(e, _) => {
1404                    let dist = self.expr_dim(e)?;
1405                    pend = pend + dir_unit(last_dir) * dist;
1406                    any = true;
1407                }
1408                _ => {}
1409            }
1410        }
1411        if any {
1412            Ok((start + pend, last_dir))
1413        } else {
1414            let dist = if horizontal(self.dir) {
1415                self.env_dim(EnvVar::Linewid)?
1416            } else {
1417                self.env_dim(EnvVar::Lineht)?
1418            };
1419            Ok((start + dir_unit(self.dir) * dist, self.dir))
1420        }
1421    }
1422
1423    fn brace_side_dir(&self, obj: &Object, has_to: bool) -> Option<Dir> {
1424        if !has_to {
1425            return None;
1426        }
1427        obj.attrs.iter().rev().find_map(|a| match a {
1428            Attr::Direction(d, None) => Some(*d),
1429            _ => None,
1430        })
1431    }
1432
1433    fn brace_pos_of(&mut self, obj: &Object) -> ER<f64> {
1434        let mut pos = DEFAULT_BRACE_POS;
1435        for a in &obj.attrs {
1436            if let Attr::BracePos(e) = a {
1437                pos = self.eval_expr(e)?;
1438            }
1439        }
1440        if !pos.is_finite() || pos <= 0.0 || pos >= 1.0 {
1441            return err("bracepos must be between 0 and 1");
1442        }
1443        Ok(pos)
1444    }
1445
1446    fn brace_label_offset_of(&mut self, obj: &Object) -> ER<f64> {
1447        let mut offset = 0.0;
1448        for a in &obj.attrs {
1449            if let Attr::BraceLabelOffset(e) = a {
1450                offset = self.expr_dim(e)?;
1451            }
1452        }
1453        Ok(offset)
1454    }
1455
1456    fn open(&mut self, p: Prim, obj: &Object) -> ER<usize> {
1457        let mut style = self.style_of(obj)?;
1458        let text = self.text_of(obj)?;
1459        let line_wid = style.arrow_wid;
1460        let line_ht = style.arrow_ht;
1461        let is_move = matches!(p, Prim::Move);
1462        if is_move {
1463            style.invis = true;
1464            style.invis_bounds = true;
1465        }
1466        let (deflen_h, deflen_v) = if is_move {
1467            (
1468                self.env_dim(EnvVar::Movewid)?,
1469                self.env_dim(EnvVar::Moveht)?,
1470            )
1471        } else {
1472            (
1473                self.env_dim(EnvVar::Linewid)?,
1474                self.env_dim(EnvVar::Lineht)?,
1475            )
1476        };
1477        let same_vec = if obj.attrs.iter().any(|a| matches!(a, Attr::Same)) {
1478            self.last_open_vector(p)
1479        } else {
1480            None
1481        };
1482
1483        // starting point
1484        let start = self.find_from(obj)?.unwrap_or(self.pos);
1485        let mut pts = vec![start];
1486        let mut pend = Point::ZERO;
1487        let mut any = false;
1488        let mut last_dir = self.dir;
1489        let mut closed = false;
1490
1491        for a in &obj.attrs {
1492            match a {
1493                Attr::Direction(d, opt) => {
1494                    if closed {
1495                        return err("polygon is closed");
1496                    }
1497                    let dist = match opt {
1498                        Some(e) => self.expr_dim(e)?,
1499                        None => {
1500                            if horizontal(*d) {
1501                                deflen_h
1502                            } else {
1503                                deflen_v
1504                            }
1505                        }
1506                    };
1507                    pend = pend + dir_unit(*d) * dist;
1508                    last_dir = *d;
1509                    any = true;
1510                }
1511                Attr::Then => {
1512                    if closed {
1513                        return err("polygon is closed");
1514                    }
1515                    let np = *pts.last().unwrap() + pend;
1516                    pts.push(np);
1517                    pend = Point::ZERO;
1518                }
1519                Attr::To(pos) => {
1520                    if closed {
1521                        return err("polygon is closed");
1522                    }
1523                    if pend != Point::ZERO {
1524                        let np = *pts.last().unwrap() + pend;
1525                        pts.push(np);
1526                        pend = Point::ZERO;
1527                    }
1528                    pts.push(self.eval_pos(pos)?);
1529                    any = true;
1530                }
1531                Attr::By(pos) => {
1532                    if closed {
1533                        return err("polygon is closed");
1534                    }
1535                    let d = self.eval_pos(pos)?;
1536                    pend = pend + (d - Point::ZERO);
1537                    any = true;
1538                }
1539                Attr::Dist(e, _) => {
1540                    if closed {
1541                        return err("polygon is closed");
1542                    }
1543                    let dist = self.expr_dim(e)?;
1544                    pend = pend + dir_unit(last_dir) * dist;
1545                    any = true;
1546                }
1547                Attr::Close => {
1548                    if pend != Point::ZERO {
1549                        let np = *pts.last().unwrap() + pend;
1550                        pts.push(np);
1551                        pend = Point::ZERO;
1552                    }
1553                    if pts.len() < 3 {
1554                        return err("need at least 3 vertices in order to close the polygon");
1555                    }
1556                    if closed {
1557                        return err("polygon already closed");
1558                    }
1559                    let first = pts[0];
1560                    let last = *pts.last().unwrap();
1561                    if first.dist(last) > 1e-12 {
1562                        let closing = first - last;
1563                        pts.push(first);
1564                        last_dir = nearest_dir(closing);
1565                    }
1566                    closed = true;
1567                    any = true;
1568                }
1569                _ => {}
1570            }
1571        }
1572        if pend != Point::ZERO {
1573            let np = *pts.last().unwrap() + pend;
1574            pts.push(np);
1575        }
1576        if pts.len() == 1 && !any {
1577            if let Some(v) = same_vec.filter(|v| v.len() > 1e-12) {
1578                pts.push(start + v);
1579                last_dir = nearest_dir(v);
1580            } else {
1581                // bare line/arrow/move in the current direction
1582                let dist = if horizontal(self.dir) {
1583                    deflen_h
1584                } else {
1585                    deflen_v
1586                };
1587                pts.push(start + dir_unit(self.dir) * dist);
1588                last_dir = self.dir;
1589            }
1590        }
1591
1592        // `chop`: positive values trim each end; negative values extend it,
1593        // as in dpic examples such as `chop 0 chop -0.1`.
1594        if let Some((start_chop, end_chop)) = self.chop_of(obj)?
1595            && pts.len() >= 2
1596        {
1597            let n = pts.len();
1598            if start_chop != 0.0 {
1599                let d0 = pts[1] - pts[0];
1600                let l0 = d0.len();
1601                if l0 > f64::EPSILON && (start_chop < 0.0 || l0 > start_chop) {
1602                    pts[0] = pts[0] + d0 / l0 * start_chop;
1603                }
1604            }
1605            if end_chop != 0.0 {
1606                let d1 = pts[n - 2] - pts[n - 1];
1607                let l1 = d1.len();
1608                if l1 > f64::EPSILON && (end_chop < 0.0 || l1 > end_chop) {
1609                    pts[n - 1] = pts[n - 1] + d1 / l1 * end_chop;
1610                }
1611            }
1612        }
1613
1614        // arrowheads
1615        let arrows = self.arrows_of(obj, matches!(p, Prim::Arrow));
1616
1617        let mut bb = Bbox::new();
1618        for pt in &pts {
1619            bb.add(*pt);
1620        }
1621        self.layout_bbox.union(&bb);
1622        if !style.invis {
1623            self.bbox
1624                .union(&painted_bbox(&bb, stroke_half_width(&style)));
1625        } else if style.invis_bounds {
1626            self.bbox.union(&bb);
1627        }
1628
1629        let end = *pts.last().unwrap();
1630        let center = if closed {
1631            (bb.min + bb.max) * 0.5
1632        } else {
1633            (pts[0] + end) * 0.5
1634        };
1635        self.union_text(center, &text);
1636        let kind = match p {
1637            Prim::Spline => PKind::Spline,
1638            Prim::Move => PKind::Move,
1639            _ => PKind::Line,
1640        };
1641        let tension = if matches!(p, Prim::Spline) {
1642            let mut t = None;
1643            for a in &obj.attrs {
1644                if let Attr::SplineTension(e) = a {
1645                    t = Some(self.eval_expr(e)?);
1646                }
1647            }
1648            t
1649        } else {
1650            None
1651        };
1652        let shape = if matches!(p, Prim::Spline) {
1653            Shape::Spline {
1654                pts: pts.clone(),
1655                tension,
1656                arrows,
1657                style,
1658                text,
1659            }
1660        } else {
1661            Shape::Path {
1662                pts: pts.clone(),
1663                closed,
1664                arrows,
1665                style,
1666                text,
1667            }
1668        };
1669        let layer = self.layer_of(obj, 0)?;
1670        self.push_shape(shape, layer);
1671
1672        self.pos = end;
1673        self.dir = last_dir;
1674        let sh = self.shapes.len() - 1;
1675        let idx = self.record(kind, center, bb, pts[0], end, 0.0, Some(sh));
1676        self.placed[idx].points = pts;
1677        self.placed[idx].line_wid = line_wid;
1678        self.placed[idx].line_ht = line_ht;
1679        self.placed[idx].closed_path = closed;
1680        Ok(idx)
1681    }
1682
1683    fn arc(&mut self, obj: &Object) -> ER<usize> {
1684        let mut style = self.style_of(obj)?;
1685        if let Some(wid) = self.dim(obj, DimKind::Wid)? {
1686            style.arrow_wid = wid;
1687        }
1688        if let Some(ht) = self.dim(obj, DimKind::Ht)? {
1689            style.arrow_ht = ht;
1690        }
1691        let text = self.text_of(obj)?;
1692        let start = self.find_from(obj)?.unwrap_or(self.pos);
1693        let cw = self.arc_cw_of(obj);
1694        let arc_dir = self.dir_of(obj);
1695        let rad_attr = self.dim(obj, DimKind::Rad)?;
1696        let to = self.dest_of(obj)?;
1697        let explicit_center = if to.is_some() {
1698            self.arc_explicit_center(obj)?
1699        } else {
1700            None
1701        };
1702
1703        // (center, radius, start angle, end angle)
1704        let (center, r, a0, a1) = if let Some(end) = to {
1705            // arc from `start` to `to`, optional radius
1706            let chord = end - start;
1707            let clen = chord.len();
1708            if clen < 1e-9 {
1709                return err("degenerate arc: `from` and `to` coincide");
1710            }
1711            let r = rad_attr
1712                .unwrap_or(self.env_dim(EnvVar::Arcrad)?)
1713                .max(clen / 2.0);
1714            let dx = chord.x;
1715            let dy = chord.y;
1716            let ts = dx * dx + dy * dy;
1717            let mut t = ((4.0 * r * r - ts).max(0.0) / ts).sqrt();
1718            let arc_sign = if cw { -1.0 } else { 1.0 };
1719            // Dpic uses the prevailing direction to choose between the two
1720            // possible circle centers for a chord/radius pair.
1721            match arc_dir {
1722                Dir::Up => {
1723                    if arc_sign * ((-dx) - (t * dy)) < 0.0 {
1724                        t = -t;
1725                    }
1726                }
1727                Dir::Down => {
1728                    if arc_sign * ((-dx) - (t * dy)) > 0.0 {
1729                        t = -t;
1730                    }
1731                }
1732                Dir::Right => {
1733                    if arc_sign * (dy - (t * dx)) < 0.0 {
1734                        t = -t;
1735                    }
1736                }
1737                Dir::Left => {
1738                    if arc_sign * (dy - (t * dx)) > 0.0 {
1739                        t = -t;
1740                    }
1741                }
1742            }
1743            let center = start + Point::new(0.5 * (dx + t * dy), 0.5 * (dy - t * dx));
1744            let (center, r) = if let Some(center) = explicit_center {
1745                (center, end.dist(center))
1746            } else {
1747                (center, r)
1748            };
1749            let (a0, a1) = arc_angles(center, start, end, cw);
1750            (center, r, a0, a1)
1751        } else {
1752            // default: a quarter turn from the current heading
1753            let r = rad_attr.unwrap_or(self.env_dim(EnvVar::Arcrad)?);
1754            let din = dir_unit(self.dir);
1755            let normal = if cw {
1756                Point::new(din.y, -din.x)
1757            } else {
1758                Point::new(-din.y, din.x)
1759            };
1760            let center = start + normal * r;
1761            let a0 = (start - center).y.atan2((start - center).x);
1762            let sweep = if cw { -PI / 2.0 } else { PI / 2.0 };
1763            (center, r, a0, a0 + sweep)
1764        };
1765
1766        let at = |t: f64| center + Point::new(t.cos(), t.sin()) * r;
1767        let end = at(a1);
1768        let arrows = self.arrows_of(obj, false);
1769
1770        let mut bb = Bbox::new();
1771        bb.add(start);
1772        bb.add(end);
1773        for k in 0..=12 {
1774            bb.add(at(a0 + (a1 - a0) * (k as f64 / 12.0)));
1775        }
1776        self.layout_bbox.union(&bb);
1777        if !style.invis {
1778            self.bbox
1779                .union(&painted_bbox(&bb, stroke_half_width(&style)));
1780        }
1781        self.union_text(center, &text);
1782
1783        let layer = self.layer_of(obj, 0)?;
1784        self.push_shape(
1785            Shape::Arc {
1786                c: center,
1787                r,
1788                a0,
1789                a1,
1790                cw,
1791                arrows,
1792                style,
1793                text,
1794            },
1795            layer,
1796        );
1797
1798        // new heading is the tangent at the end point
1799        let tang = if a1 >= a0 {
1800            Point::new(-a1.sin(), a1.cos())
1801        } else {
1802            Point::new(a1.sin(), -a1.cos())
1803        };
1804        self.dir = nearest_dir(tang);
1805        self.pos = end;
1806        let sh = self.shapes.len() - 1;
1807        let idx = self.record(PKind::Arc, center, bb, start, end, 0.0, Some(sh));
1808        self.placed[idx].radius = r;
1809        Ok(idx)
1810    }
1811
1812    fn arc_cw_of(&self, obj: &Object) -> bool {
1813        obj.attrs
1814            .iter()
1815            .rev()
1816            .find_map(|a| match a {
1817                Attr::Cw => Some(true),
1818                Attr::Ccw => Some(false),
1819                _ => None,
1820            })
1821            .unwrap_or(false)
1822    }
1823
1824    fn arc_explicit_center(&mut self, obj: &Object) -> ER<Option<Point>> {
1825        for a in &obj.attrs {
1826            match a {
1827                Attr::At(pos) => return Ok(Some(self.eval_pos(pos)?)),
1828                Attr::With {
1829                    anchor: WithAnchor::Plain | WithAnchor::Corner(Corner::Center),
1830                    at,
1831                } => {
1832                    return Ok(Some(self.eval_pos(at)?));
1833                }
1834                _ => {}
1835            }
1836        }
1837        Ok(None)
1838    }
1839
1840    fn text_obj(&mut self, obj: &Object) -> ER<usize> {
1841        let text = self.text_of(obj)?;
1842        if obj.attrs.iter().any(|a| matches!(a, Attr::Opacity(_))) {
1843            return err("opacity applies only to filled regions");
1844        }
1845        let dir = self.dir_of(obj);
1846        let w = self
1847            .dim(obj, DimKind::Wid)?
1848            .unwrap_or(self.env_dim(EnvVar::Textwid)?);
1849        let h = match self.dim(obj, DimKind::Ht)? {
1850            Some(h) => h,
1851            None => self.env_dim(EnvVar::Textht)? * text.len().max(1) as f64,
1852        };
1853        let extent = if horizontal(dir) { w } else { h };
1854        let at = self.place_center(obj, dir, extent, w, h)?;
1855        let mut bb = Bbox::new();
1856        bb.add(at - Point::new(w / 2.0, h / 2.0));
1857        bb.add(at + Point::new(w / 2.0, h / 2.0));
1858        self.layout_bbox.union(&bb);
1859        let text_bb = text_object_bbox(at, &text, w, h);
1860        self.bbox.union(&text_bb);
1861        let layer = self.layer_of(obj, 0)?;
1862        self.push_shape(
1863            Shape::Text {
1864                at,
1865                text,
1866                bbox: text_bb,
1867                w,
1868                h,
1869                standalone: true,
1870            },
1871            layer,
1872        );
1873        let half = dir_unit(dir) * (extent / 2.0);
1874        let start = at - half;
1875        let end = at + half;
1876        self.pos = end;
1877        self.dir = dir;
1878        let sh = self.shapes.len() - 1;
1879        Ok(self.record(PKind::Text, at, bb, start, end, 0.0, Some(sh)))
1880    }
1881
1882    /// Union an estimated text extent into the drawing bbox, so wide labels and
1883    /// bare text objects aren't clipped by the SVG viewBox.
1884    fn union_text(&mut self, center: Point, lines: &[TextLine]) {
1885        let bb = text_bbox(center, lines);
1886        self.bbox.union(&bb);
1887    }
1888
1889    fn block(&mut self, stmts: &[Stmt], obj: &Object) -> ER<usize> {
1890        let block_text = self.text_of(obj)?;
1891        let block_fill_opacity = self.style_of(obj)?.fill_opacity;
1892        // Evaluate the block in a local scope at its own origin. Labels from
1893        // the containing scope are visible for references such as `$1.start`
1894        // inside macro-generated blocks, but are not captured as new members.
1895        let mut sub = State::new();
1896        sub.env = self.env.clone();
1897        sub.vars = self.vars.clone();
1898        sub.inherited_vars = self.vars.keys().cloned().collect();
1899        sub.export_vars.clear();
1900        sub.macros = self.macros.clone();
1901        sub.includes = self.includes.clone();
1902        sub.rng = self.rng.clone();
1903        // expose this scope's labels (read-only, absolute coords) to the block
1904        sub.outer_labels = self.outer_labels.clone();
1905        for (name, &i) in &self.labels {
1906            sub.outer_labels
1907                .insert(name.clone(), self.placed[i].clone());
1908        }
1909        sub.eval_stmts(stmts)?;
1910        // Variables, parameters and direction changes inside `[ ... ]` are
1911        // local to the block. Random draws still consume the shared sequence.
1912        self.rng = sub.rng.clone();
1913        self.diagnostics.append(&mut sub.diagnostics);
1914        self.warnings.append(&mut sub.warnings);
1915        for key in &sub.export_vars {
1916            if let Some(val) = sub.vars.get(key).copied() {
1917                self.vars.insert(key.clone(), val);
1918                if self.inherited_vars.contains(key) {
1919                    self.export_vars.insert(key.clone());
1920                }
1921            }
1922        }
1923
1924        let layout_sub_bb = if sub.layout_bbox.is_empty() {
1925            let mut b = Bbox::new();
1926            b.add(Point::ZERO);
1927            b
1928        } else {
1929            sub.layout_bbox
1930        };
1931        let local_center = (layout_sub_bb.min + layout_sub_bb.max) * 0.5;
1932        let w = layout_sub_bb.width();
1933        let h = layout_sub_bb.height();
1934
1935        let dir = self.dir_of(obj);
1936        let extent = if horizontal(dir) { w } else { h };
1937        let target = self.block_center(obj, dir, extent, w, h, local_center, &mut sub)?;
1938        let shift = target - local_center;
1939
1940        let first_shape = self.shapes.len();
1941        // capture inner labels (translated into parent space) before the block's
1942        // shapes are moved out, so `B.A` / `last [].Outer` can resolve.
1943        let mut members: HashMap<String, Placed> = HashMap::new();
1944        for (name, &i) in &sub.labels {
1945            let mut pl = sub.placed[i].clone();
1946            rebase_placed(&mut pl, shift, first_shape);
1947            members.insert(name.clone(), pl);
1948        }
1949        let layer_shift =
1950            self.layer_shift_for(obj, sub.shape_layers.iter().copied().max().unwrap_or(0))?;
1951        for ((mut sh, layer), class) in sub
1952            .shapes
1953            .into_iter()
1954            .zip(sub.shape_layers)
1955            .zip(sub.shape_classes)
1956        {
1957            translate_shape(&mut sh, shift);
1958            if let Some(opacity) = block_fill_opacity {
1959                multiply_shape_fill_opacity(&mut sh, opacity);
1960            }
1961            self.push_shape(sh, layer + layer_shift);
1962            *self.shape_classes.last_mut().unwrap() = class;
1963        }
1964        let shape = if self.shapes.len() > first_shape {
1965            Some(first_shape)
1966        } else {
1967            None
1968        };
1969        let mut bb = Bbox::new();
1970        bb.add(layout_sub_bb.min + shift);
1971        bb.add(layout_sub_bb.max + shift);
1972        self.layout_bbox.union(&bb);
1973        if !sub.bbox.is_empty() {
1974            let mut visible_bb = Bbox::new();
1975            visible_bb.add(sub.bbox.min + shift);
1976            visible_bb.add(sub.bbox.max + shift);
1977            self.bbox.union(&visible_bb);
1978        }
1979        let block_text_bb = text_bbox(target, &block_text);
1980        self.bbox.union(&block_text_bb);
1981        if has_visible_text(&block_text) {
1982            let layer = self.layer_of(obj, 0)?;
1983            self.push_shape(
1984                Shape::Text {
1985                    at: target,
1986                    text: block_text,
1987                    bbox: block_text_bb,
1988                    w: 0.0,
1989                    h: 0.0,
1990                    standalone: false,
1991                },
1992                layer,
1993            );
1994        }
1995
1996        let half = dir_unit(dir) * (extent / 2.0);
1997        let start = target - half;
1998        let end = target + half;
1999        self.pos = end;
2000        self.dir = dir;
2001        let idx = self.record(PKind::Block, target, bb, start, end, 0.0, shape);
2002        self.placed[idx].members = members;
2003        Ok(idx)
2004    }
2005
2006    #[allow(clippy::too_many_arguments)]
2007    fn record(
2008        &mut self,
2009        kind: PKind,
2010        center: Point,
2011        bbox: Bbox,
2012        start: Point,
2013        end: Point,
2014        thick: f64,
2015        shape: Option<usize>,
2016    ) -> usize {
2017        let idx = self.placed.len();
2018        self.placed.push(Placed {
2019            kind,
2020            center,
2021            bbox,
2022            start,
2023            end,
2024            thick,
2025            points: Vec::new(),
2026            radius: 0.0,
2027            box_rad: 0.0,
2028            line_wid: 0.0,
2029            line_ht: 0.0,
2030            closed_path: false,
2031            layer: shape
2032                .and_then(|s| self.shape_layers.get(s).copied())
2033                .unwrap_or(0),
2034            shape,
2035            members: HashMap::new(),
2036        });
2037        idx
2038    }
2039
2040    fn push_shape(&mut self, shape: Shape, layer: i32) {
2041        self.shapes.push(shape);
2042        self.shape_layers.push(layer);
2043        self.shape_classes.push(None);
2044    }
2045
2046    fn layer_of(&mut self, obj: &Object, current: i32) -> ER<i32> {
2047        let mut layer = current;
2048        for a in &obj.attrs {
2049            if let Attr::Behind(place) = a {
2050                let target = self.resolve_obj(place)?;
2051                if layer >= target.layer {
2052                    layer = target.layer - 1;
2053                }
2054            }
2055        }
2056        Ok(layer)
2057    }
2058
2059    fn layer_shift_for(&mut self, obj: &Object, current_max: i32) -> ER<i32> {
2060        Ok(self.layer_of(obj, current_max)? - current_max)
2061    }
2062
2063    // ---- attribute helpers -------------------------------------------------
2064
2065    fn dim(&mut self, obj: &Object, kind: DimKind) -> ER<Option<f64>> {
2066        for a in &obj.attrs {
2067            if let Attr::Dim(k, e) = a
2068                && *k == kind
2069            {
2070                return Ok(Some(match kind {
2071                    DimKind::Thick | DimKind::Scaled => self.eval_expr(e)?,
2072                    DimKind::Ht | DimKind::Wid | DimKind::Rad | DimKind::Diam => {
2073                        self.expr_dim(e)?
2074                    }
2075                }));
2076            }
2077        }
2078        Ok(None)
2079    }
2080
2081    fn has_dim(&self, obj: &Object, kind: DimKind) -> bool {
2082        obj.attrs
2083            .iter()
2084            .any(|a| matches!(a, Attr::Dim(k, _) if *k == kind))
2085    }
2086
2087    fn scale_of(&mut self, obj: &Object) -> ER<f64> {
2088        Ok(self.dim(obj, DimKind::Scaled)?.unwrap_or(1.0))
2089    }
2090
2091    fn dir_of(&self, obj: &Object) -> Dir {
2092        obj.attrs
2093            .iter()
2094            .rev()
2095            .find_map(|a| match a {
2096                Attr::Direction(d, _) => Some(*d),
2097                _ => None,
2098            })
2099            .unwrap_or(self.dir)
2100    }
2101
2102    fn find_from(&mut self, obj: &Object) -> ER<Option<Point>> {
2103        for a in &obj.attrs {
2104            if let Attr::From(pos) = a {
2105                return Ok(Some(self.eval_pos(pos)?));
2106            }
2107        }
2108        Ok(None)
2109    }
2110
2111    fn at_of(&mut self, obj: &Object) -> ER<Option<Point>> {
2112        for a in &obj.attrs {
2113            if let Attr::At(pos) = a {
2114                return Ok(Some(self.eval_pos(pos)?));
2115            }
2116        }
2117        Ok(None)
2118    }
2119
2120    fn dest_of(&mut self, obj: &Object) -> ER<Option<Point>> {
2121        for a in &obj.attrs {
2122            if let Attr::To(pos) = a {
2123                return Ok(Some(self.eval_pos(pos)?));
2124            }
2125        }
2126        Ok(None)
2127    }
2128
2129    /// The start/end `chop` amounts. `chop r1 chop r2` trims each end
2130    /// independently; a single `chop` applies to both ends.
2131    fn chop_of(&mut self, obj: &Object) -> ER<Option<(f64, f64)>> {
2132        let mut vals = Vec::new();
2133        for a in &obj.attrs {
2134            if let Attr::Chop(opt) = a {
2135                let amt = match opt {
2136                    Some(e) => self.expr_dim(e)?,
2137                    None => self.env_dim(EnvVar::Circlerad)?,
2138                };
2139                vals.push(amt);
2140            }
2141        }
2142        Ok(match vals.as_slice() {
2143            [] => None,
2144            [one] => Some((*one, *one)),
2145            [start, end, ..] => Some((*start, *end)),
2146        })
2147    }
2148
2149    /// Width/height of the last placed object of the same closed kind (for `same`).
2150    fn last_dims_of(&self, p: Prim) -> Option<(f64, f64)> {
2151        let want = match p {
2152            Prim::Box => PKind::Box,
2153            Prim::Circle => PKind::Circle,
2154            Prim::Ellipse => PKind::Ellipse,
2155            _ => return None,
2156        };
2157        self.placed
2158            .iter()
2159            .rev()
2160            .find(|pl| pl.kind == want)
2161            .map(|pl| (pl.bbox.width(), pl.bbox.height()))
2162    }
2163
2164    /// End-to-end vector of the last placed open object of the same kind.
2165    fn last_open_vector(&self, p: Prim) -> Option<Point> {
2166        let want = match p {
2167            Prim::Move => PKind::Move,
2168            Prim::Spline => PKind::Spline,
2169            Prim::Line | Prim::Arrow => PKind::Line,
2170            _ => return None,
2171        };
2172        self.placed
2173            .iter()
2174            .rev()
2175            .find(|pl| pl.kind == want)
2176            .map(|pl| pl.end - pl.start)
2177    }
2178
2179    /// Compute the center of a closed object given direction and extents.
2180    fn place_center(&mut self, obj: &Object, dir: Dir, extent: f64, w: f64, h: f64) -> ER<Point> {
2181        self.place_with_corner_offset(obj, dir, extent, w, h, corner_offset)
2182    }
2183
2184    fn place_closed_center(
2185        &mut self,
2186        p: Prim,
2187        obj: &Object,
2188        dir: Dir,
2189        extent: f64,
2190        dims: (f64, f64),
2191        rad: f64,
2192    ) -> ER<Point> {
2193        let (w, h) = dims;
2194        self.place_with_corner_offset(obj, dir, extent, w, h, |c, w, h| {
2195            closed_corner_offset(p, c, w, h, rad)
2196        })
2197    }
2198
2199    fn place_with_corner_offset(
2200        &mut self,
2201        obj: &Object,
2202        dir: Dir,
2203        extent: f64,
2204        w: f64,
2205        h: f64,
2206        corner: impl Fn(Corner, f64, f64) -> Point,
2207    ) -> ER<Point> {
2208        if let Some(at) = self.at_of(obj)? {
2209            return Ok(at);
2210        }
2211        for a in &obj.attrs {
2212            if let Attr::With { anchor, at } = a {
2213                let ap = self.eval_pos(at)?;
2214                let off = match anchor {
2215                    WithAnchor::Corner(c) => corner(*c, w, h),
2216                    WithAnchor::Pair(x, y) => Point::new(self.expr_dim(x)?, self.expr_dim(y)?),
2217                    WithAnchor::Place(_) => {
2218                        return err("`with .label` anchors are only valid on blocks");
2219                    }
2220                    WithAnchor::Plain => Point::ZERO,
2221                };
2222                return Ok(ap - off);
2223            }
2224        }
2225        Ok(self.pos + dir_unit(dir) * (extent / 2.0))
2226    }
2227
2228    #[allow(clippy::too_many_arguments)]
2229    fn block_center(
2230        &mut self,
2231        obj: &Object,
2232        dir: Dir,
2233        extent: f64,
2234        w: f64,
2235        h: f64,
2236        local_center: Point,
2237        sub: &mut State,
2238    ) -> ER<Point> {
2239        if let Some(at) = self.at_of(obj)? {
2240            return Ok(at);
2241        }
2242        for a in &obj.attrs {
2243            if let Attr::With { anchor, at } = a {
2244                let ap = self.eval_pos(at)?;
2245                let off = match anchor {
2246                    WithAnchor::Corner(c) => corner_offset(*c, w, h),
2247                    WithAnchor::Pair(x, y) => {
2248                        Point::new(self.expr_dim(x)?, self.expr_dim(y)?) - local_center
2249                    }
2250                    WithAnchor::Place(place) => sub.place_point(place)? - local_center,
2251                    WithAnchor::Plain => Point::ZERO,
2252                };
2253                return Ok(ap - off);
2254            }
2255        }
2256        Ok(self.pos + dir_unit(dir) * (extent / 2.0))
2257    }
2258
2259    fn arrows_of(&self, obj: &Object, default_end: bool) -> Arrowheads {
2260        let mut found = None;
2261        for a in &obj.attrs {
2262            if let Attr::Arrowhead(h, _) = a {
2263                found = Some(match h {
2264                    token::Arrow::Left => Arrowheads::Start,
2265                    token::Arrow::Right => Arrowheads::End,
2266                    token::Arrow::Double => Arrowheads::Both,
2267                });
2268            }
2269        }
2270        found.unwrap_or(if default_end {
2271            Arrowheads::End
2272        } else {
2273            Arrowheads::None
2274        })
2275    }
2276
2277    fn style_of(&mut self, obj: &Object) -> ER<Style> {
2278        // arrowhead dimensions follow the current `arrowht`/`arrowwid` globals
2279        let mut s = Style {
2280            arrow_ht: self.env_dim(EnvVar::Arrowht)?,
2281            arrow_wid: self.env_dim(EnvVar::Arrowwid)?,
2282            // `arrowhead = 0` draws an open (two-stroke) head; anything else
2283            // (default 2) is a filled triangle.
2284            arrow_filled: self.env.get(EnvVar::Arrowhead).round() as i64 != 0,
2285            ..Default::default()
2286        };
2287        let lt = self.env.get(EnvVar::Linethick);
2288        if lt > 0.0 {
2289            s.thick = Some(lt);
2290        }
2291        for a in &obj.attrs {
2292            match a {
2293                Attr::LineStyle(lt, opt) => match lt {
2294                    LineType::Solid => s.dash = Dash::Solid,
2295                    LineType::Dashed => {
2296                        let w = match opt {
2297                            Some(e) => self.expr_dim(e)?,
2298                            None => self.env_dim(EnvVar::Dashwid)?,
2299                        };
2300                        s.dash = Dash::Dashed(w);
2301                    }
2302                    LineType::Dotted => {
2303                        s.dash = Dash::Dotted(match opt {
2304                            Some(e) => Some(self.expr_dim(e)?),
2305                            None => None,
2306                        });
2307                    }
2308                    LineType::Invis => s.invis = true,
2309                },
2310                Attr::Fill(opt) => {
2311                    let g = match opt {
2312                        Some(e) => self.eval_expr(e)?,
2313                        None => self.env.get(EnvVar::Fillval),
2314                    };
2315                    s.fill = Some(Fill::Gray(g));
2316                    s.fill_open = true;
2317                }
2318                Attr::Color(kind, se) => {
2319                    let name = self.eval_color_expr(se)?;
2320                    match kind {
2321                        token::Color::Outlined => s.stroke = Some(name),
2322                        token::Color::Colored => {
2323                            s.stroke = Some(name.clone());
2324                            s.fill = Some(Fill::Color(name));
2325                        }
2326                        token::Color::Shaded => {
2327                            s.fill = Some(Fill::Color(name));
2328                            s.fill_open = true;
2329                        }
2330                    }
2331                }
2332                Attr::Hatch(kind) => {
2333                    let h = ensure_hatch(&mut s);
2334                    h.cross = matches!(kind, HatchKind::Cross);
2335                    s.fill_open = true;
2336                }
2337                Attr::HatchAngle(e) => ensure_hatch(&mut s).angle = self.eval_expr(e)?,
2338                Attr::HatchSep(e) => {
2339                    let sep = self.expr_dim(e)?;
2340                    if sep <= 0.0 {
2341                        return err("hatchsep must be positive");
2342                    }
2343                    ensure_hatch(&mut s).sep = sep;
2344                    s.fill_open = true;
2345                }
2346                Attr::HatchWidth(e) => {
2347                    let width = self.eval_expr(e)?;
2348                    if width < 0.0 {
2349                        return err("hatchwidth must be non-negative");
2350                    }
2351                    ensure_hatch(&mut s).width = width;
2352                    s.fill_open = true;
2353                }
2354                Attr::HatchColor(se) => {
2355                    let name = self.eval_color_expr(se)?;
2356                    ensure_hatch(&mut s).color = name;
2357                    s.fill_open = true;
2358                }
2359                Attr::Gradient(a, b) => {
2360                    let from = self.eval_color_expr(a)?;
2361                    let to = self.eval_color_expr(b)?;
2362                    let g = ensure_gradient(&mut s);
2363                    g.from = from;
2364                    g.to = to;
2365                    s.fill_open = true;
2366                }
2367                Attr::GradientAngle(e) => {
2368                    ensure_gradient(&mut s).angle = self.eval_expr(e)?;
2369                    s.fill_open = true;
2370                }
2371                Attr::Opacity(e) => {
2372                    let opacity = self.eval_expr(e)?;
2373                    if !(0.0..=1.0).contains(&opacity) {
2374                        return err("opacity must be between 0 and 1");
2375                    }
2376                    s.fill_opacity = Some(opacity);
2377                }
2378                Attr::Dim(DimKind::Thick, e) => s.thick = Some(self.eval_expr(e)?),
2379                Attr::Arrowhead(_, Some(e)) => {
2380                    s.arrow_filled = self.eval_expr(e)?.round() as i64 != 0;
2381                }
2382                _ => {}
2383            }
2384        }
2385        Ok(s)
2386    }
2387
2388    fn eval_color_expr(&mut self, se: &StringExpr) -> ER<String> {
2389        if let StringExpr::Lit(name) = se
2390            && let Some(body) = self.macros.get(name).cloned()
2391        {
2392            if let Some(lit) = single_token_macro_string(&body) {
2393                return Ok(lit);
2394            }
2395            let parsed =
2396                crate::parser::parse_stringexpr_tokens(&body, &mut self.macros, &self.includes)
2397                    .map_err(parse_eval_error)?;
2398            return self.eval_stringexpr(&parsed);
2399        }
2400        self.eval_stringexpr(se)
2401    }
2402
2403    fn text_of(&mut self, obj: &Object) -> ER<Vec<TextLine>> {
2404        Ok(self.text_and_fit_text_of(obj)?.0)
2405    }
2406
2407    fn text_and_fit_text_of(&mut self, obj: &Object) -> ER<(Vec<TextLine>, Option<Vec<TextLine>>)> {
2408        let mut lines: Vec<TextLine> = Vec::new();
2409        let mut fit_lines = None;
2410        let mut pending_halign = 0i8;
2411        let mut pending_valign = 0i8;
2412        for a in &obj.attrs {
2413            match a {
2414                Attr::TextPos(tp) => {
2415                    if let Some(line) = lines.last_mut() {
2416                        apply_text_pos(&mut line.halign, &mut line.valign, *tp);
2417                    } else {
2418                        apply_text_pos(&mut pending_halign, &mut pending_valign, *tp);
2419                    }
2420                }
2421                Attr::Text(se) => {
2422                    let s = self.eval_stringexpr(se)?;
2423                    let math = self.math_span_for(&s);
2424                    lines.push(TextLine {
2425                        s,
2426                        math,
2427                        halign: pending_halign,
2428                        valign: pending_valign,
2429                        text_offset: self.env_dim(EnvVar::Textoffset)?,
2430                    });
2431                    pending_halign = 0;
2432                    pending_valign = 0;
2433                }
2434                Attr::Fit if fit_lines.is_none() => {
2435                    fit_lines = Some(lines.clone());
2436                }
2437                _ => {}
2438            }
2439        }
2440        Ok((lines, fit_lines))
2441    }
2442
2443    // ---- positions & places ------------------------------------------------
2444
2445    fn eval_pos(&mut self, pos: &Position) -> ER<Point> {
2446        match pos {
2447            Position::Pair(x, y) => Ok(Point::new(self.expr_dim(x)?, self.expr_dim(y)?)),
2448            Position::Place(loc) => self.eval_loc(loc),
2449            Position::Between { frac, a, b, .. } => {
2450                let f = self.eval_expr(frac)?;
2451                let pa = self.eval_pos(a)?;
2452                let pb = self.eval_pos(b)?;
2453                Ok(pa.lerp(pb, f))
2454            }
2455            Position::Sum(sign, a, b) => {
2456                let pa = self.eval_pos(a)?;
2457                let pb = self.eval_pos(b)?;
2458                Ok(match sign {
2459                    Sign::Plus => pa + pb,
2460                    Sign::Minus => pa - pb,
2461                })
2462            }
2463            Position::Scale(p, s, div) => {
2464                let pp = self.eval_pos(p)?;
2465                let sv = self.eval_expr(s)?;
2466                if *div {
2467                    if sv == 0.0 {
2468                        return err("division by zero in position");
2469                    }
2470                    Ok(pp / sv)
2471                } else {
2472                    Ok(pp * sv)
2473                }
2474            }
2475        }
2476    }
2477
2478    fn eval_loc(&mut self, loc: &Location) -> ER<Point> {
2479        match loc {
2480            Location::Place(p) => self.place_point(p),
2481            Location::Paren(pos) => self.eval_pos(pos),
2482            Location::ParenPair(p1, p2) => {
2483                Ok(Point::new(self.eval_pos(p1)?.x, self.eval_pos(p2)?.y))
2484            }
2485        }
2486    }
2487
2488    fn place_point(&mut self, p: &Place) -> ER<Point> {
2489        match p {
2490            Place::Here => Ok(self.pos),
2491            Place::Name {
2492                name,
2493                subscript,
2494                span,
2495            } => {
2496                let key = self.indexed_name(name, subscript.as_deref())?;
2497                Ok(self.resolve_label(&key, span.as_ref())?.center)
2498            }
2499            Place::Nth { count, obj, span } => {
2500                let idx = self.nth_index(count, obj, span.as_ref())?;
2501                Ok(self.placed[idx].center)
2502            }
2503            Place::Corner(inner, c) => {
2504                let pl = self.resolve_obj(inner)?;
2505                Ok(pl.corner(*c))
2506            }
2507            Place::CornerOf(c, inner) => {
2508                let pl = self.resolve_obj(inner)?;
2509                Ok(pl.corner(*c))
2510            }
2511            Place::Member(..) => Ok(self.resolve_obj(p)?.center),
2512        }
2513    }
2514
2515    /// Resolve an object-valued place to its [`Placed`] record, descending into
2516    /// block members for `B.A` / `last [].Outer`.
2517    fn resolve_obj(&mut self, p: &Place) -> ER<Placed> {
2518        match p {
2519            Place::Name {
2520                name,
2521                subscript,
2522                span,
2523            } => {
2524                let key = self.indexed_name(name, subscript.as_deref())?;
2525                self.resolve_label(&key, span.as_ref())
2526            }
2527            Place::Nth { count, obj, span } => {
2528                let idx = self.nth_index(count, obj, span.as_ref())?;
2529                Ok(self.placed[idx].clone())
2530            }
2531            Place::Corner(inner, _) | Place::CornerOf(_, inner) => self.resolve_obj(inner),
2532            Place::Member(base, sub) => {
2533                let b = self.resolve_obj(base)?;
2534                let key = match sub.as_ref() {
2535                    Place::Name {
2536                        name, subscript, ..
2537                    } => self.indexed_name(name, subscript.as_deref())?,
2538                    _ => return err("a block sub-label must be a name"),
2539                };
2540                b.members.get(&key).cloned().ok_or_else(|| EvalError {
2541                    msg: format!("no sub-label `{key}` in that block"),
2542                    info: None,
2543                })
2544            }
2545            Place::Here => err("`Here` is a point, not an object"),
2546        }
2547    }
2548
2549    fn place_index(&mut self, p: &Place) -> ER<usize> {
2550        match p {
2551            Place::Name {
2552                name,
2553                subscript,
2554                span,
2555            } => {
2556                let key = self.indexed_name(name, subscript.as_deref())?;
2557                self.label_index(&key, span.as_ref())
2558            }
2559            Place::Nth { count, obj, span } => self.nth_index(count, obj, span.as_ref()),
2560            Place::Corner(inner, _) | Place::CornerOf(_, inner) => self.place_index(inner),
2561            Place::Here => err("`Here` is a point, not an object"),
2562            Place::Member(_, _) => err("block sub-labels (B.A) are not supported yet"),
2563        }
2564    }
2565
2566    fn label_index(&self, name: &str, span: Option<&Span>) -> ER<usize> {
2567        self.labels
2568            .get(name)
2569            .copied()
2570            .ok_or_else(|| unknown_label_error(name, span))
2571    }
2572
2573    /// Resolve a label to its [`Placed`], falling back to enclosing-scope labels
2574    /// (absolute coordinates) so a block can reference outer labels.
2575    fn resolve_label(&self, key: &str, span: Option<&Span>) -> ER<Placed> {
2576        if let Some(&idx) = self.labels.get(key) {
2577            Ok(self.placed[idx].clone())
2578        } else if let Some(pl) = self.outer_labels.get(key) {
2579            Ok(pl.clone())
2580        } else {
2581            Err(unknown_label_error(key, span))
2582        }
2583    }
2584
2585    fn label_key(&mut self, label: &Label) -> ER<String> {
2586        self.indexed_name(&label.name, label.subscript.as_ref())
2587    }
2588
2589    fn indexed_name(&mut self, name: &str, subscript: Option<&Expr>) -> ER<String> {
2590        match subscript {
2591            Some(Expr::Index(items)) => {
2592                let mut parts = Vec::with_capacity(items.len());
2593                for e in items {
2594                    parts.push(fmt_num(self.eval_expr(e)?));
2595                }
2596                Ok(format!("{name}[{}]", parts.join(",")))
2597            }
2598            Some(e) => Ok(format!("{name}[{}]", fmt_num(self.eval_expr(e)?))),
2599            None => Ok(name.to_string()),
2600        }
2601    }
2602
2603    fn nth_index(&mut self, count: &Nth, obj: &PrimObj, span: Option<&Span>) -> ER<usize> {
2604        // Untyped `last` matches the most recent object of any kind; a typed
2605        // reference (`last box`) filters to that kind.
2606        let want = primobj_kind(obj);
2607        let matches: Vec<usize> = self
2608            .placed
2609            .iter()
2610            .enumerate()
2611            .filter(|(_, pl)| want.is_none_or(|w| pl.kind == w))
2612            .map(|(i, _)| i)
2613            .collect();
2614        if matches.is_empty() {
2615            return match want {
2616                Some(w) => err(format!("no {:?} object to reference", want_name(w))),
2617                None => err("no object to reference".to_string()),
2618            };
2619        }
2620        let idx = match count {
2621            Nth::Last => *matches.last().unwrap(),
2622            Nth::Count(e, from_last) => {
2623                let n = self.eval_expr(e)?.round() as i64;
2624                if n < 1 {
2625                    return err("ordinal must be >= 1");
2626                }
2627                let k = (n - 1) as usize;
2628                if *from_last {
2629                    if k >= matches.len() {
2630                        return err_diag(ordinal_diagnostic(n, matches.len(), span));
2631                    }
2632                    matches[matches.len() - 1 - k]
2633                } else {
2634                    if k >= matches.len() {
2635                        return err_diag(ordinal_diagnostic(n, matches.len(), span));
2636                    }
2637                    matches[k]
2638                }
2639            }
2640        };
2641        Ok(idx)
2642    }
2643
2644    // ---- string expressions ------------------------------------------------
2645
2646    fn eval_stringexpr(&mut self, se: &StringExpr) -> ER<String> {
2647        Ok(match se {
2648            StringExpr::Lit(s) => s.clone(),
2649            StringExpr::Concat(a, b) => {
2650                format!("{}{}", self.eval_stringexpr(a)?, self.eval_stringexpr(b)?)
2651            }
2652            StringExpr::Arg(n) => format!("${n}"), // should have been expanded
2653            StringExpr::Sprintf(fmt, args) => {
2654                let f = self.eval_stringexpr(fmt)?;
2655                let mut vals = Vec::with_capacity(args.len());
2656                for e in args {
2657                    vals.push(self.eval_printf_arg(e)?);
2658                }
2659                sprintf_fmt(&f, &vals)
2660            }
2661            StringExpr::SvgFont(_) => String::new(),
2662        })
2663    }
2664
2665    fn eval_printf_arg(&mut self, e: &Expr) -> ER<PrintfArg> {
2666        match e {
2667            Expr::Str(se) => Ok(PrintfArg::Str(self.eval_stringexpr(se)?)),
2668            _ => Ok(PrintfArg::Num(self.eval_expr(e)?)),
2669        }
2670    }
2671
2672    // ---- expressions -------------------------------------------------------
2673
2674    /// Evaluate an operand to its string form (for `==`/`!=`); a numeric operand
2675    /// is rendered textually so `"$1" == "2"` style comparisons work uniformly.
2676    fn expr_str(&mut self, e: &Expr) -> ER<String> {
2677        match e {
2678            Expr::Str(se) => self.eval_stringexpr(se),
2679            _ => Ok(fmt_num(self.eval_expr(e)?)),
2680        }
2681    }
2682
2683    fn eval_expr(&mut self, e: &Expr) -> ER<f64> {
2684        let value = match e {
2685            Expr::Num(v) => *v,
2686            Expr::Str(_) => return err("a string is only valid as an `==`/`!=` operand"),
2687            Expr::Index(_) => return err("a comma subscript is only valid inside `name[...]`"),
2688            Expr::Var(name, subscript) => {
2689                let key = self.indexed_name(name, subscript.as_deref())?;
2690                self.vars.get(&key).copied().ok_or_else(|| EvalError {
2691                    msg: format!("variable not found `{key}`"),
2692                    info: None,
2693                })?
2694            }
2695            Expr::Env(v) => self.env.get(*v),
2696            Expr::Unary(op, a) => {
2697                let x = self.eval_expr(a)?;
2698                match op {
2699                    UnOp::Neg => -x,
2700                    UnOp::Pos => x,
2701                    UnOp::Not => bool_f(x == 0.0),
2702                }
2703            }
2704            Expr::Bin(op, a, b) => {
2705                // string equality: pic compares string operands with `==`/`!=`
2706                if matches!(op, BinOp::Eq | BinOp::Ne)
2707                    && (matches!(a.as_ref(), Expr::Str(_)) || matches!(b.as_ref(), Expr::Str(_)))
2708                {
2709                    let sa = self.expr_str(a)?;
2710                    let sb = self.expr_str(b)?;
2711                    return Ok(bool_f(if matches!(op, BinOp::Eq) {
2712                        sa == sb
2713                    } else {
2714                        sa != sb
2715                    }));
2716                }
2717                let x = self.eval_expr(a)?;
2718                let y = self.eval_expr(b)?;
2719                match op {
2720                    BinOp::Add => x + y,
2721                    BinOp::Sub => x - y,
2722                    BinOp::Mul => x * y,
2723                    BinOp::Div => {
2724                        if y == 0.0 {
2725                            return err("division by zero");
2726                        }
2727                        x / y
2728                    }
2729                    BinOp::Mod => dpic_mod(x, y, "modulo by zero")?,
2730                    BinOp::Pow => dpic_pow(x, y)?,
2731                    BinOp::Eq => bool_f(x == y),
2732                    BinOp::Ne => bool_f(x != y),
2733                    BinOp::Lt => bool_f(x < y),
2734                    BinOp::Le => bool_f(x <= y),
2735                    BinOp::Gt => bool_f(x > y),
2736                    BinOp::Ge => bool_f(x >= y),
2737                    BinOp::And => bool_f(x != 0.0 && y != 0.0),
2738                    BinOp::Or => bool_f(x != 0.0 || y != 0.0),
2739                }
2740            }
2741            Expr::Func1(f, a) => {
2742                let x = self.eval_expr(a)?;
2743                match f {
2744                    Func1::Abs => x.abs(),
2745                    Func1::Acos => x.acos(),
2746                    Func1::Asin => x.asin(),
2747                    Func1::Cos => x.cos(),
2748                    Func1::Exp => 10f64.powf(x),
2749                    Func1::Expe => x.exp(),
2750                    Func1::Int => x.trunc(),
2751                    Func1::Log => x.log10(),
2752                    Func1::Loge => x.ln(),
2753                    Func1::Sign => {
2754                        if x >= 0.0 {
2755                            1.0
2756                        } else {
2757                            -1.0
2758                        }
2759                    }
2760                    Func1::Sin => x.sin(),
2761                    Func1::Sqrt => x.sqrt(),
2762                    Func1::Tan => x.tan(),
2763                    Func1::Floor => x.floor(),
2764                }
2765            }
2766            Expr::Func2(f, a, b) => {
2767                let x = self.eval_expr(a)?;
2768                let y = self.eval_expr(b)?;
2769                match f {
2770                    Func2::Atan2 => x.atan2(y),
2771                    Func2::Max => x.max(y),
2772                    Func2::Min => x.min(y),
2773                    Func2::Pmod => x.rem_euclid(y),
2774                }
2775            }
2776            Expr::Rand(seed) => {
2777                let seed = match seed {
2778                    Some(e) => Some(self.eval_expr(e)?),
2779                    None => None,
2780                };
2781                self.rand(seed)
2782            }
2783            Expr::Assign(name, subscript, v) => {
2784                let val = self.eval_expr(v)?;
2785                let key = self.indexed_name(name, subscript.as_deref())?;
2786                self.vars.insert(key, val);
2787                val
2788            }
2789            Expr::DotX(loc) => {
2790                let x = self.eval_loc(loc)?.x;
2791                self.to_user_dim(x)
2792            }
2793            Expr::DotY(loc) => {
2794                let y = self.eval_loc(loc)?.y;
2795                self.to_user_dim(y)
2796            }
2797            Expr::PlaceAttr(place, param) => {
2798                let pl = self.resolve_obj(place)?;
2799                match param {
2800                    token::Param::Width => self.to_user_dim(pl.attr_width()),
2801                    token::Param::Height => self.to_user_dim(pl.attr_height()),
2802                    token::Param::Radius => self.to_user_dim(pl.attr_radius()),
2803                    token::Param::Diameter => self.to_user_dim(pl.attr_diameter()),
2804                    token::Param::Length => self.to_user_dim(pl.attr_length()),
2805                    token::Param::Thickness => pl.thick,
2806                }
2807            }
2808        };
2809        finite(value, "numeric expression")
2810    }
2811
2812    fn rand(&mut self, seed: Option<f64>) -> f64 {
2813        if let Some(seed) = seed {
2814            self.rng.seed(seed.trunc() as i64);
2815        }
2816        self.rng.next_f64()
2817    }
2818}
2819
2820// ---- free helpers ----------------------------------------------------------
2821
2822fn apply_op(op: AssignOp, cur: f64, rhs: f64) -> ER<f64> {
2823    let value = match op {
2824        AssignOp::Set | AssignOp::ColonSet => rhs,
2825        AssignOp::Add => cur + rhs,
2826        AssignOp::Sub => cur - rhs,
2827        AssignOp::Mul => cur * rhs,
2828        AssignOp::Div => {
2829            if rhs == 0.0 {
2830                return err("division by zero in assignment");
2831            }
2832            cur / rhs
2833        }
2834        AssignOp::Rem => dpic_mod(cur, rhs, "modulo by zero in assignment")?,
2835    };
2836    finite(value, "assignment")
2837}
2838
2839#[derive(Clone)]
2840struct GlibcRand {
2841    state: [u32; 31],
2842    f: usize,
2843    r: usize,
2844}
2845
2846impl GlibcRand {
2847    fn new(seed: i64) -> Self {
2848        let mut rng = GlibcRand {
2849            state: [0; 31],
2850            f: 3,
2851            r: 0,
2852        };
2853        rng.seed(seed);
2854        rng
2855    }
2856
2857    fn seed(&mut self, seed: i64) {
2858        let seed = if seed == 0 { 1 } else { seed };
2859        let mut x = seed.rem_euclid(2_147_483_647) as u32;
2860        if x == 0 {
2861            x = 1;
2862        }
2863        self.state[0] = x;
2864        for i in 1..31 {
2865            let prev = self.state[i - 1] as i64;
2866            let hi = prev / 127_773;
2867            let lo = prev % 127_773;
2868            let mut next = 16_807 * lo - 2_836 * hi;
2869            if next < 0 {
2870                next += 2_147_483_647;
2871            }
2872            self.state[i] = next as u32;
2873        }
2874        self.f = 3;
2875        self.r = 0;
2876        for _ in 0..310 {
2877            self.next_i31();
2878        }
2879    }
2880
2881    fn next_i31(&mut self) -> u32 {
2882        let x = self.state[self.f].wrapping_add(self.state[self.r]);
2883        self.state[self.f] = x;
2884        let out = (x >> 1) & 0x7fff_ffff;
2885        self.f = (self.f + 1) % 31;
2886        self.r = (self.r + 1) % 31;
2887        out
2888    }
2889
2890    fn next_f64(&mut self) -> f64 {
2891        self.next_i31() as f64 / 2_147_483_647.0
2892    }
2893}
2894
2895fn bool_f(b: bool) -> f64 {
2896    if b { 1.0 } else { 0.0 }
2897}
2898
2899fn dpic_round(x: f64) -> i64 {
2900    if x < 0.0 {
2901        -((-x + 0.5).floor() as i64)
2902    } else {
2903        (x + 0.5).floor() as i64
2904    }
2905}
2906
2907fn dpic_mod(x: f64, y: f64, zero_msg: &'static str) -> ER<f64> {
2908    let i = dpic_round(x);
2909    let j = dpic_round(y);
2910    if j == 0 {
2911        return err(zero_msg);
2912    }
2913    Ok((i - (i / j) * j) as f64)
2914}
2915
2916fn dpic_pow(x: f64, y: f64) -> ER<f64> {
2917    if x == 0.0 && y < 0.0 {
2918        return err("zero cannot be raised to a negative power");
2919    }
2920    let iy = dpic_round(y);
2921    if iy as f64 == y {
2922        return Ok(dpic_int_pow(x, iy));
2923    }
2924    if x < 0.0 {
2925        return err("negative base with non-integer exponent");
2926    }
2927    Ok(x.powf(y))
2928}
2929
2930fn dpic_int_pow(x: f64, y: i64) -> f64 {
2931    if y == 0 {
2932        return 1.0;
2933    }
2934    if x == 0.0 || y == 1 {
2935        return x;
2936    }
2937    if y < 0 {
2938        return dpic_int_pow(1.0 / x, -y);
2939    }
2940    if y == 2 {
2941        return x * x;
2942    }
2943    if y & 1 == 1 {
2944        x * dpic_int_pow(x, y - 1)
2945    } else {
2946        let half = dpic_int_pow(x, y >> 1);
2947        half * half
2948    }
2949}
2950
2951fn apply_text_pos(halign: &mut i8, valign: &mut i8, pos: token::TextPos) {
2952    match pos {
2953        token::TextPos::Ljust => *halign = -1,
2954        token::TextPos::Rjust => *halign = 1,
2955        token::TextPos::Center => {
2956            *halign = 0;
2957            *valign = 0;
2958        }
2959        token::TextPos::Above => *valign = 1,
2960        token::TextPos::Below => *valign = -1,
2961    }
2962}
2963
2964fn ensure_hatch(style: &mut Style) -> &mut Hatch {
2965    style.hatch.get_or_insert_with(|| Hatch {
2966        cross: false,
2967        angle: DEFAULT_HATCH_ANGLE,
2968        sep: DEFAULT_HATCH_SEP,
2969        width: DEFAULT_HATCH_WIDTH,
2970        color: "black".into(),
2971    })
2972}
2973
2974/// Validate a `class` extension name list: whitespace-separated tokens, each
2975/// matching `[A-Za-z_][A-Za-z0-9_-]*`. Rejecting everything else keeps the
2976/// hook free of attribute-injection surface.
2977fn validate_class(name: &str) -> ER<()> {
2978    if name.is_empty() {
2979        return err("class name must not be empty");
2980    }
2981    for tok in name.split_whitespace() {
2982        let mut chars = tok.chars();
2983        let first = chars.next().unwrap();
2984        if !(first.is_ascii_alphabetic() || first == '_')
2985            || !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2986        {
2987            return err(format!(
2988                "invalid class name `{tok}`: use `[A-Za-z_][A-Za-z0-9_-]*`"
2989            ));
2990        }
2991    }
2992    Ok(())
2993}
2994
2995fn ensure_gradient(style: &mut Style) -> &mut Gradient {
2996    style.gradient.get_or_insert_with(|| Gradient {
2997        from: "black".into(),
2998        to: "white".into(),
2999        angle: 0.0,
3000    })
3001}
3002
3003fn has_visible_text(lines: &[TextLine]) -> bool {
3004    lines.iter().any(|line| !line.s.is_empty())
3005}
3006
3007fn closed_shape_is_visible(style: &Style) -> bool {
3008    !style.invis || style.fill.is_some() || style.hatch.is_some() || style.gradient.is_some()
3009}
3010
3011fn open_fill_is_visible(style: &Style) -> bool {
3012    style.fill_open && (style.fill.is_some() || style.hatch.is_some() || style.gradient.is_some())
3013}
3014
3015fn stroke_half_width(style: &Style) -> f64 {
3016    if style.invis {
3017        0.0
3018    } else {
3019        style.thick.unwrap_or(0.8) / 144.0
3020    }
3021}
3022
3023fn painted_bbox(bb: &Bbox, pad: f64) -> Bbox {
3024    if bb.is_empty() || pad <= 0.0 {
3025        return *bb;
3026    }
3027    let mut out = Bbox::new();
3028    out.add(bb.min - Point::new(pad, pad));
3029    out.add(bb.max + Point::new(pad, pad));
3030    out
3031}
3032
3033fn dpic_box_layout_bbox(center: Point, w: f64, h: f64) -> Bbox {
3034    fn axis(center: f64, extent: f64) -> (f64, f64) {
3035        let lo = center - extent / 2.0;
3036        let hi = center + extent / 2.0;
3037        if lo <= hi { (lo, hi) } else { (0.0, 0.0) }
3038    }
3039
3040    let (x0, x1) = axis(center.x, w);
3041    let (y0, y1) = axis(center.y, h);
3042    let mut bb = Bbox::new();
3043    bb.add(Point::new(x0, y0));
3044    bb.add(Point::new(x1, y1));
3045    bb
3046}
3047
3048fn drawing_painted_bbox(shapes: &[Shape]) -> Bbox {
3049    let mut out = Bbox::new();
3050    for sh in shapes {
3051        out.union(&shape_painted_bbox(sh));
3052    }
3053    out
3054}
3055
3056fn shape_painted_bbox(sh: &Shape) -> Bbox {
3057    let mut out = Bbox::new();
3058    match sh {
3059        Shape::Box {
3060            c,
3061            w,
3062            h,
3063            style,
3064            text,
3065            ..
3066        } => {
3067            let mut bb = Bbox::new();
3068            bb.add(*c - Point::new(*w / 2.0, *h / 2.0));
3069            bb.add(*c + Point::new(*w / 2.0, *h / 2.0));
3070            if closed_shape_is_visible(style) {
3071                out.union(&painted_bbox(&bb, stroke_half_width(style)));
3072            }
3073            out.union(&text_bbox(*c, text));
3074        }
3075        Shape::Circle { c, r, style, text } => {
3076            let mut bb = Bbox::new();
3077            bb.add(*c - Point::new(*r, *r));
3078            bb.add(*c + Point::new(*r, *r));
3079            if closed_shape_is_visible(style) {
3080                out.union(&painted_bbox(&bb, stroke_half_width(style)));
3081            }
3082            out.union(&text_bbox(*c, text));
3083        }
3084        Shape::Ellipse {
3085            c,
3086            w,
3087            h,
3088            style,
3089            text,
3090        } => {
3091            let mut bb = Bbox::new();
3092            bb.add(*c - Point::new(*w / 2.0, *h / 2.0));
3093            bb.add(*c + Point::new(*w / 2.0, *h / 2.0));
3094            if closed_shape_is_visible(style) {
3095                out.union(&painted_bbox(&bb, stroke_half_width(style)));
3096            }
3097            out.union(&text_bbox(*c, text));
3098        }
3099        Shape::Path {
3100            pts,
3101            closed,
3102            style,
3103            text,
3104            ..
3105        } => {
3106            let mut bb = Bbox::new();
3107            for p in pts {
3108                bb.add(*p);
3109            }
3110            if open_fill_is_visible(style) {
3111                out.union(&bb);
3112            }
3113            if !style.invis {
3114                out.union(&painted_bbox(&bb, stroke_half_width(style)));
3115            } else if style.invis_bounds {
3116                out.union(&bb);
3117            }
3118            if !bb.is_empty() {
3119                let text_at = if *closed {
3120                    (bb.min + bb.max) * 0.5
3121                } else if let (Some(first), Some(last)) = (pts.first(), pts.last()) {
3122                    (*first + *last) * 0.5
3123                } else {
3124                    Point::ZERO
3125                };
3126                out.union(&text_bbox(text_at, text));
3127            }
3128        }
3129        Shape::Spline {
3130            pts, style, text, ..
3131        } => {
3132            let mut bb = Bbox::new();
3133            for p in pts {
3134                bb.add(*p);
3135            }
3136            if open_fill_is_visible(style) {
3137                out.union(&bb);
3138            }
3139            if !style.invis {
3140                out.union(&painted_bbox(&bb, stroke_half_width(style)));
3141            } else if style.invis_bounds {
3142                out.union(&bb);
3143            }
3144            if let (Some(first), Some(last)) = (pts.first(), pts.last()) {
3145                out.union(&text_bbox((*first + *last) * 0.5, text));
3146            }
3147        }
3148        Shape::Arc {
3149            c,
3150            r,
3151            a0,
3152            a1,
3153            style,
3154            text,
3155            ..
3156        } => {
3157            let at = |t: f64| *c + Point::new(t.cos(), t.sin()) * *r;
3158            let mut bb = Bbox::new();
3159            for k in 0..=12 {
3160                bb.add(at(*a0 + (*a1 - *a0) * (k as f64 / 12.0)));
3161            }
3162            if open_fill_is_visible(style) {
3163                out.union(&bb);
3164            }
3165            if !style.invis {
3166                out.union(&painted_bbox(&bb, stroke_half_width(style)));
3167            }
3168            out.union(&text_bbox(*c, text));
3169        }
3170        Shape::Brace {
3171            cubics,
3172            label_at,
3173            style,
3174            text,
3175            ..
3176        } => {
3177            let bb = cubics_bbox(cubics);
3178            if !style.invis {
3179                out.union(&painted_bbox(&bb, stroke_half_width(style)));
3180            } else if style.invis_bounds {
3181                out.union(&bb);
3182            }
3183            out.union(&text_bbox(*label_at, text));
3184        }
3185        Shape::Text { bbox, .. } => out.union(bbox),
3186    }
3187    out
3188}
3189
3190fn brace_side(unit: Point, side_dir: Option<Dir>) -> Point {
3191    let right = Point::new(unit.y, -unit.x);
3192    if let Some(d) = side_dir {
3193        let target = dir_unit(d);
3194        if right.x * target.x + right.y * target.y < 0.0 {
3195            right * -1.0
3196        } else {
3197            right
3198        }
3199    } else {
3200        right
3201    }
3202}
3203
3204fn brace_cubics(a: Point, b: Point, depth: Point, pos: f64) -> Vec<[Point; 4]> {
3205    let v = b - a;
3206    let len = v.len();
3207    let depth_len = depth.len();
3208    if len <= 1e-12 || depth_len <= 1e-12 {
3209        return vec![[a, a, b, b]];
3210    }
3211    let u = v / len;
3212    let side = depth / depth_len;
3213    let left_len = len * pos;
3214    let right_len = len - left_len;
3215    let depth_len = depth_len.min(left_len * 0.45).min(right_len * 0.45);
3216    let shoulder = depth_len * 0.45;
3217    let curl = depth_len - shoulder;
3218    let k = 0.552_284_749_830_793_6;
3219    let at = |x: f64, y: f64| a + u * x + side * y;
3220    let line = |p0: Point, p1: Point| [p0, p0, p1, p1];
3221
3222    let p0 = at(0.0, 0.0);
3223    let p1 = at(shoulder, shoulder);
3224    let p2_x = (left_len - curl).max(shoulder);
3225    let p2 = at(p2_x, shoulder);
3226    let cusp = at(left_len, depth_len);
3227    let p3_x = (left_len + curl).min(len - shoulder);
3228    let p3 = at(p3_x, shoulder);
3229    let p4_x = len - shoulder;
3230    let p4 = at(p4_x, shoulder);
3231    let p5 = at(len, 0.0);
3232
3233    vec![
3234        [
3235            p0,
3236            at(0.0, k * shoulder),
3237            at(shoulder - k * shoulder, shoulder),
3238            p1,
3239        ],
3240        line(p1, p2),
3241        [
3242            p2,
3243            at(p2_x + k * curl, shoulder),
3244            at(left_len, depth_len - k * curl),
3245            cusp,
3246        ],
3247        [
3248            cusp,
3249            at(left_len, depth_len - k * curl),
3250            at(p3_x - k * curl, shoulder),
3251            p3,
3252        ],
3253        line(p3, p4),
3254        [
3255            p4,
3256            at(p4_x + k * shoulder, shoulder),
3257            at(len, k * shoulder),
3258            p5,
3259        ],
3260    ]
3261}
3262
3263fn brace_cusp(cubics: &[[Point; 4]]) -> Option<Point> {
3264    cubics.get(2).map(|c| c[3])
3265}
3266
3267fn cubic_at(c: &[Point; 4], t: f64) -> Point {
3268    let mt = 1.0 - t;
3269    c[0] * (mt * mt * mt)
3270        + c[1] * (3.0 * mt * mt * t)
3271        + c[2] * (3.0 * mt * t * t)
3272        + c[3] * (t * t * t)
3273}
3274
3275fn sample_cubics(cubics: &[[Point; 4]], steps: usize) -> Vec<Point> {
3276    let steps = steps.max(1);
3277    let mut pts = Vec::new();
3278    for (i, c) in cubics.iter().enumerate() {
3279        if i == 0 {
3280            pts.push(c[0]);
3281        }
3282        for step in 1..=steps {
3283            pts.push(cubic_at(c, step as f64 / steps as f64));
3284        }
3285    }
3286    pts
3287}
3288
3289fn cubics_bbox(cubics: &[[Point; 4]]) -> Bbox {
3290    let mut bb = Bbox::new();
3291    for c in cubics {
3292        for p in c {
3293            bb.add(*p);
3294        }
3295    }
3296    for p in sample_cubics(cubics, 12) {
3297        bb.add(p);
3298    }
3299    bb
3300}
3301
3302/// Line advance width: exact metrics for typeset math, the classic
3303/// 0.6 em/char estimate otherwise.
3304fn text_line_width(line: &TextLine) -> f64 {
3305    match &line.math {
3306        Some(m) => m.width,
3307        None => line.s.chars().count() as f64 * TEXT_CHAR_W,
3308    }
3309}
3310
3311fn text_bbox(center: Point, lines: &[TextLine]) -> Bbox {
3312    let mut bb = Bbox::new();
3313    if !has_visible_text(lines) {
3314        return bb;
3315    }
3316    let n = lines.len() as f64;
3317    for (i, line) in lines.iter().enumerate() {
3318        if line.s.is_empty() {
3319            continue;
3320        }
3321        let w = text_line_width(line);
3322        let base_y = center.y - (i as f64 - (n - 1.0) / 2.0) * TEXT_LINE_H;
3323        let y = base_y + line.valign as f64 * (TEXT_XHEIGHT / 2.0 + line.text_offset);
3324        let x = center.x
3325            + match line.halign {
3326                -1 => line.text_offset,
3327                1 => -line.text_offset,
3328                _ => 0.0,
3329            };
3330        let (min_x, max_x) = match line.halign {
3331            -1 => (x, x + w),
3332            1 => (x - w, x),
3333            _ => (x - w / 2.0, x + w / 2.0),
3334        };
3335        match &line.math {
3336            Some(m) => {
3337                // mirror the SVG backend's box placement: centered formulas
3338                // center their ink box on base_y + xheight/2; above/below
3339                // formulas keep the whole box clear of the reference
3340                let half = (m.height + m.depth) / 2.0;
3341                let center = match line.valign {
3342                    1 => base_y + TEXT_XHEIGHT / 2.0 + line.text_offset + half,
3343                    -1 => base_y - TEXT_XHEIGHT / 2.0 - line.text_offset - half,
3344                    _ => base_y + TEXT_XHEIGHT / 2.0,
3345                };
3346                bb.add(Point::new(min_x, center - half));
3347                bb.add(Point::new(max_x, center + half));
3348            }
3349            None => {
3350                bb.add(Point::new(min_x, y - TEXT_LINE_H / 2.0));
3351                bb.add(Point::new(max_x, y + TEXT_LINE_H / 2.0));
3352            }
3353        }
3354    }
3355    bb
3356}
3357
3358fn fitted_text_size(lines: &[TextLine]) -> Option<(f64, f64)> {
3359    if !has_visible_text(lines) {
3360        return None;
3361    }
3362    let bb = text_bbox(Point::ZERO, lines);
3363    if bb.is_empty() {
3364        return None;
3365    }
3366    let half_w = bb.min.x.abs().max(bb.max.x.abs());
3367    let half_h = bb.min.y.abs().max(bb.max.y.abs());
3368    Some((
3369        2.0 * half_w + TEXT_CHAR_W,
3370        2.0 * half_h + TEXT_XHEIGHT / 2.0,
3371    ))
3372}
3373
3374fn text_object_bbox(center: Point, lines: &[TextLine], w: f64, h: f64) -> Bbox {
3375    let text_bb = text_bbox(center, lines);
3376    if text_bb.is_empty() {
3377        return text_bb;
3378    }
3379    let min_x = if w > 0.0 {
3380        center.x - w / 2.0
3381    } else {
3382        text_bb.min.x
3383    };
3384    let max_x = if w > 0.0 {
3385        center.x + w / 2.0
3386    } else {
3387        text_bb.max.x
3388    };
3389    let y_bb = if h > 0.0 {
3390        text_object_vertical_bbox(center, lines, h)
3391    } else {
3392        text_bb
3393    };
3394    let min_y = y_bb.min.y;
3395    let max_y = y_bb.max.y;
3396    let mut bb = Bbox::new();
3397    bb.add(Point::new(min_x, min_y));
3398    bb.add(Point::new(max_x, max_y));
3399    bb
3400}
3401
3402fn text_object_vertical_bbox(center: Point, lines: &[TextLine], h: f64) -> Bbox {
3403    let mut bb = Bbox::new();
3404    if !has_visible_text(lines) {
3405        return bb;
3406    }
3407    let n = lines.len() as f64;
3408    let v = n - 1.0 + DP_TEXT_RATIO;
3409    let lineskip = if v.abs() > 1e-12 { h / v } else { 11.0 / 72.0 };
3410    let xheight = lineskip * DP_TEXT_RATIO;
3411    let mut baseline_y = center.y + (v * lineskip / 2.0) - xheight;
3412    for line in lines {
3413        if line.s.is_empty() {
3414            baseline_y -= lineskip;
3415            continue;
3416        }
3417        let just_offset = xheight / 2.0 + line.text_offset;
3418        let y = baseline_y + (line.valign as f64) * just_offset;
3419        bb.add(Point::new(center.x, y));
3420        bb.add(Point::new(center.x, y + xheight));
3421        baseline_y -= lineskip;
3422    }
3423    bb
3424}
3425
3426/// Render a number the way pic's `%g`/string contexts do (no trailing zeros).
3427fn fmt_num(v: f64) -> String {
3428    format!("{v}")
3429}
3430
3431fn install_dpic_compat_vars(vars: &mut HashMap<String, f64>) {
3432    // dpic backend option constants are zero-based in the order used by dpic's
3433    // own `case(dpicopt, ...)` examples. rpic renders SVG.
3434    let opts = [
3435        ("optMFpic", 0.0),
3436        ("optMpost", 1.0),
3437        ("optPDF", 2.0),
3438        ("optPGF", 3.0),
3439        ("optPict2e", 4.0),
3440        ("optPS", 5.0),
3441        ("optPSfrag", 6.0),
3442        ("optPSTricks", 7.0),
3443        ("optSVG", 8.0),
3444        ("optTeX", 9.0),
3445        ("opttTeX", 10.0),
3446        ("optxfig", 11.0),
3447    ];
3448    for (name, val) in opts {
3449        vars.insert(name.to_string(), val);
3450    }
3451    vars.insert("dpicopt".to_string(), 8.0);
3452}
3453
3454fn corner_offset(c: Corner, w: f64, h: f64) -> Point {
3455    let (hw, hh) = (w / 2.0, h / 2.0);
3456    match c {
3457        Corner::N => Point::new(0.0, hh),
3458        Corner::S => Point::new(0.0, -hh),
3459        Corner::E => Point::new(hw, 0.0),
3460        Corner::W => Point::new(-hw, 0.0),
3461        Corner::Ne => Point::new(hw, hh),
3462        Corner::Se => Point::new(hw, -hh),
3463        Corner::Nw => Point::new(-hw, hh),
3464        Corner::Sw => Point::new(-hw, -hh),
3465        Corner::Center | Corner::Start | Corner::End => Point::ZERO,
3466    }
3467}
3468
3469fn closed_corner_offset(p: Prim, c: Corner, w: f64, h: f64, rad: f64) -> Point {
3470    match p {
3471        Prim::Circle | Prim::Ellipse => ellipse_corner_offset(c, w, h),
3472        Prim::Box => box_corner_offset(c, w, h, rad),
3473        _ => corner_offset(c, w, h),
3474    }
3475}
3476
3477fn arc_angles(center: Point, start: Point, end: Point, cw: bool) -> (f64, f64) {
3478    let a0 = (start - center).y.atan2((start - center).x);
3479    let mut a1 = (end - center).y.atan2((end - center).x);
3480    if cw {
3481        while a1 > a0 {
3482            a1 -= 2.0 * PI;
3483        }
3484    } else {
3485        while a1 < a0 {
3486            a1 += 2.0 * PI;
3487        }
3488    }
3489    (a0, a1)
3490}
3491
3492fn ellipse_corner_offset(c: Corner, w: f64, h: f64) -> Point {
3493    let (rx, ry) = (w / 2.0, h / 2.0);
3494    let diag = |sx: f64, sy: f64| Point::new(sx * rx * FRAC_1_SQRT_2, sy * ry * FRAC_1_SQRT_2);
3495    match c {
3496        Corner::N => Point::new(0.0, ry),
3497        Corner::S => Point::new(0.0, -ry),
3498        Corner::E => Point::new(rx, 0.0),
3499        Corner::W => Point::new(-rx, 0.0),
3500        Corner::Ne => diag(1.0, 1.0),
3501        Corner::Se => diag(1.0, -1.0),
3502        Corner::Nw => diag(-1.0, 1.0),
3503        Corner::Sw => diag(-1.0, -1.0),
3504        Corner::Center | Corner::Start | Corner::End => Point::ZERO,
3505    }
3506}
3507
3508fn box_corner_offset(c: Corner, w: f64, h: f64, rad: f64) -> Point {
3509    if rad > 0.0 && matches!(c, Corner::Ne | Corner::Se | Corner::Nw | Corner::Sw) {
3510        let inset = rad.min(w.abs().min(h.abs()) / 2.0) * (1.0 - FRAC_1_SQRT_2);
3511        let x = w / 2.0 - inset;
3512        let y = h / 2.0 - inset;
3513        return match c {
3514            Corner::Ne => Point::new(x, y),
3515            Corner::Se => Point::new(x, -y),
3516            Corner::Nw => Point::new(-x, y),
3517            Corner::Sw => Point::new(-x, -y),
3518            _ => Point::ZERO,
3519        };
3520    }
3521    corner_offset(c, w, h)
3522}
3523
3524/// The placed-object kind a `PrimObj` selects, or `None` for untyped `Any`
3525/// (matches every kind).
3526fn primobj_kind(o: &PrimObj) -> Option<PKind> {
3527    Some(match o {
3528        PrimObj::Prim(p) => match p {
3529            Prim::Box => PKind::Box,
3530            Prim::Circle => PKind::Circle,
3531            Prim::Ellipse => PKind::Ellipse,
3532            Prim::Line | Prim::Arrow => PKind::Line,
3533            Prim::Move => PKind::Move,
3534            Prim::Spline => PKind::Spline,
3535            Prim::Arc => PKind::Arc,
3536        },
3537        PrimObj::Brace => PKind::Brace,
3538        PrimObj::Block | PrimObj::EmptyBrack => PKind::Block,
3539        PrimObj::Str(_) => PKind::Text,
3540        PrimObj::Any => return None,
3541    })
3542}
3543
3544fn want_name(k: PKind) -> &'static str {
3545    match k {
3546        PKind::Box => "box",
3547        PKind::Circle => "circle",
3548        PKind::Ellipse => "ellipse",
3549        PKind::Line => "line",
3550        PKind::Move => "move",
3551        PKind::Spline => "spline",
3552        PKind::Arc => "arc",
3553        PKind::Brace => "brace",
3554        PKind::Block => "block",
3555        PKind::Text => "text",
3556    }
3557}
3558
3559fn nearest_dir(v: Point) -> Dir {
3560    if v.x.abs() >= v.y.abs() {
3561        if v.x >= 0.0 { Dir::Right } else { Dir::Left }
3562    } else if v.y >= 0.0 {
3563        Dir::Up
3564    } else {
3565        Dir::Down
3566    }
3567}
3568
3569enum PrintfArg {
3570    Num(f64),
3571    Str(String),
3572}
3573
3574impl PrintfArg {
3575    fn num(&self) -> f64 {
3576        match self {
3577            PrintfArg::Num(v) => *v,
3578            PrintfArg::Str(s) => s.parse::<f64>().unwrap_or(0.0),
3579        }
3580    }
3581
3582    fn string(&self) -> String {
3583        match self {
3584            PrintfArg::Num(v) => fmt_num(*v),
3585            PrintfArg::Str(s) => s.clone(),
3586        }
3587    }
3588}
3589
3590/// Minimal printf-style formatter supporting `%d %i %f %e %g %s %%` with
3591/// optional `.precision`. Width/flags are accepted but ignored.
3592fn sprintf_fmt(fmt: &str, vals: &[PrintfArg]) -> String {
3593    let mut out = String::new();
3594    let mut chars = fmt.chars().peekable();
3595    let mut ai = 0usize;
3596    while let Some(c) = chars.next() {
3597        if c != '%' {
3598            out.push(c);
3599            continue;
3600        }
3601        let mut spec = String::new();
3602        while let Some(&n) = chars.peek() {
3603            if "+-0123456789. #".contains(n) {
3604                spec.push(n);
3605                chars.next();
3606            } else {
3607                break;
3608            }
3609        }
3610        let conv = match chars.next() {
3611            Some(c) => c,
3612            None => break,
3613        };
3614        if conv == '%' {
3615            out.push('%');
3616            continue;
3617        }
3618        let arg = vals.get(ai);
3619        ai += 1;
3620        let prec = spec.split('.').nth(1).and_then(|p| {
3621            p.chars()
3622                .take_while(|c| c.is_ascii_digit())
3623                .collect::<String>()
3624                .parse::<usize>()
3625                .ok()
3626        });
3627        match conv {
3628            'd' | 'i' => out.push_str(&format!(
3629                "{}",
3630                arg.map(PrintfArg::num).unwrap_or(0.0).round() as i64
3631            )),
3632            'f' | 'F' => out.push_str(&format!(
3633                "{:.*}",
3634                prec.unwrap_or(6),
3635                arg.map(PrintfArg::num).unwrap_or(0.0)
3636            )),
3637            'e' | 'E' => out.push_str(&format!(
3638                "{:.*e}",
3639                prec.unwrap_or(6),
3640                arg.map(PrintfArg::num).unwrap_or(0.0)
3641            )),
3642            'g' | 'G' => out.push_str(&format!("{}", arg.map(PrintfArg::num).unwrap_or(0.0))),
3643            's' => out.push_str(&arg.map(PrintfArg::string).unwrap_or_default()),
3644            other => {
3645                out.push('%');
3646                out.push(other);
3647            }
3648        }
3649    }
3650    out
3651}
3652
3653fn stringexpr_lit(se: &StringExpr) -> String {
3654    match se {
3655        StringExpr::Lit(s) => s.clone(),
3656        StringExpr::Concat(a, b) => format!("{}{}", stringexpr_lit(a), stringexpr_lit(b)),
3657        StringExpr::Arg(n) => format!("${n}"),
3658        StringExpr::Sprintf(fmt, _) => stringexpr_lit(fmt),
3659        StringExpr::SvgFont(_) => String::new(),
3660    }
3661}
3662
3663fn single_token_macro_string(toks: &[crate::lexer::Spanned]) -> Option<String> {
3664    let mut toks = toks
3665        .iter()
3666        .filter(|s| !matches!(s.tok, token::Token::Newline | token::Token::Eof));
3667    let tok = &toks.next()?.tok;
3668    if toks.next().is_some() {
3669        return None;
3670    }
3671    match tok {
3672        token::Token::Str(s) | token::Token::Name(s) | token::Token::Label(s) => Some(s.clone()),
3673        _ => None,
3674    }
3675}
3676
3677fn unescape_exec_source(src: &str) -> String {
3678    let mut out = String::with_capacity(src.len());
3679    let mut chars = src.chars();
3680    while let Some(c) = chars.next() {
3681        if c == '\\'
3682            && let Some('"') = chars.clone().next()
3683        {
3684            chars.next();
3685            out.push('"');
3686        } else {
3687            out.push(c);
3688        }
3689    }
3690    out
3691}
3692
3693/// Shift a [`Placed`] (and its block members) by `d` and re-index its shape
3694/// references by `shape_off`, mapping a block's local records into the parent.
3695fn rebase_placed(pl: &mut Placed, d: Point, shape_off: usize) {
3696    pl.center = pl.center + d;
3697    pl.start = pl.start + d;
3698    pl.end = pl.end + d;
3699    for p in &mut pl.points {
3700        *p = *p + d;
3701    }
3702    let mut bb = Bbox::new();
3703    bb.add(pl.bbox.min + d);
3704    bb.add(pl.bbox.max + d);
3705    pl.bbox = bb;
3706    if let Some(s) = pl.shape {
3707        pl.shape = Some(s + shape_off);
3708    }
3709    for m in pl.members.values_mut() {
3710        rebase_placed(m, d, shape_off);
3711    }
3712}
3713
3714fn scale_placed(pl: &mut Placed, f: f64) {
3715    pl.center = pl.center * f;
3716    pl.start = pl.start * f;
3717    pl.end = pl.end * f;
3718    for p in &mut pl.points {
3719        *p = *p * f;
3720    }
3721    pl.radius *= f;
3722    pl.box_rad *= f;
3723    pl.line_wid *= f;
3724    pl.line_ht *= f;
3725    scale_bbox_in_place(&mut pl.bbox, f);
3726    for m in pl.members.values_mut() {
3727        scale_placed(m, f);
3728    }
3729}
3730
3731fn translate_shape(sh: &mut Shape, d: Point) {
3732    let mv = |p: &mut Point| *p = *p + d;
3733    match sh {
3734        Shape::Box { c, .. } | Shape::Circle { c, .. } | Shape::Ellipse { c, .. } => mv(c),
3735        Shape::Path { pts, .. } | Shape::Spline { pts, .. } => {
3736            for p in pts {
3737                mv(p);
3738            }
3739        }
3740        Shape::Arc { c, .. } => mv(c),
3741        Shape::Brace {
3742            a,
3743            b,
3744            cubics,
3745            label_at,
3746            ..
3747        } => {
3748            mv(a);
3749            mv(b);
3750            mv(label_at);
3751            for cubic in cubics {
3752                for p in cubic {
3753                    mv(p);
3754                }
3755            }
3756        }
3757        Shape::Text { at, bbox, .. } => {
3758            mv(at);
3759            bbox.min = bbox.min + d;
3760            bbox.max = bbox.max + d;
3761        }
3762    }
3763}
3764
3765fn scale_bbox_in_place(bb: &mut Bbox, f: f64) {
3766    if bb.is_empty() {
3767        return;
3768    }
3769    let mut b = Bbox::new();
3770    b.add(bb.min * f);
3771    b.add(bb.max * f);
3772    *bb = b;
3773}
3774
3775fn scale_style(style: &mut Style, f: f64) {
3776    let f = f.abs();
3777    style.arrow_ht *= f;
3778    style.arrow_wid *= f;
3779    if let Some(hatch) = &mut style.hatch {
3780        hatch.sep *= f;
3781    }
3782    match &mut style.dash {
3783        Dash::Dashed(w) => *w *= f,
3784        Dash::Dotted(Some(w)) => *w *= f,
3785        Dash::Solid | Dash::Dotted(None) => {}
3786    }
3787}
3788
3789fn multiply_shape_fill_opacity(sh: &mut Shape, opacity: f64) {
3790    match sh {
3791        Shape::Box { style, .. }
3792        | Shape::Circle { style, .. }
3793        | Shape::Ellipse { style, .. }
3794        | Shape::Path { style, .. }
3795        | Shape::Spline { style, .. }
3796        | Shape::Arc { style, .. }
3797        | Shape::Brace { style, .. } => {
3798            style.fill_opacity = Some(style.fill_opacity.unwrap_or(1.0) * opacity);
3799        }
3800        Shape::Text { .. } => {}
3801    }
3802}
3803
3804/// Uniformly scale a shape's geometry about the origin (font size unchanged).
3805fn scale_shape(sh: &mut Shape, f: f64) {
3806    match sh {
3807        Shape::Box {
3808            c,
3809            w,
3810            h,
3811            rad,
3812            style,
3813            ..
3814        } => {
3815            *c = *c * f;
3816            *w *= f;
3817            *h *= f;
3818            *rad *= f;
3819            scale_style(style, f);
3820        }
3821        Shape::Circle { c, r, style, .. } => {
3822            *c = *c * f;
3823            *r *= f;
3824            scale_style(style, f);
3825        }
3826        Shape::Ellipse { c, w, h, style, .. } => {
3827            *c = *c * f;
3828            *w *= f;
3829            *h *= f;
3830            scale_style(style, f);
3831        }
3832        Shape::Path { pts, style, .. } | Shape::Spline { pts, style, .. } => {
3833            for p in pts {
3834                *p = *p * f;
3835            }
3836            scale_style(style, f);
3837        }
3838        Shape::Arc { c, r, style, .. } => {
3839            *c = *c * f;
3840            *r *= f;
3841            scale_style(style, f);
3842        }
3843        Shape::Brace {
3844            a,
3845            b,
3846            cubics,
3847            label_at,
3848            style,
3849            ..
3850        } => {
3851            *a = *a * f;
3852            *b = *b * f;
3853            *label_at = *label_at * f;
3854            for cubic in cubics {
3855                for p in cubic {
3856                    *p = *p * f;
3857                }
3858            }
3859            scale_style(style, f);
3860        }
3861        Shape::Text { at, bbox, w, h, .. } => {
3862            *at = *at * f;
3863            scale_bbox_in_place(bbox, f);
3864            *w *= f;
3865            *h *= f;
3866        }
3867    }
3868}
3869
3870fn object_uses_bare_distance(kind: &ObjectKind) -> bool {
3871    matches!(
3872        kind,
3873        ObjectKind::Primitive(Prim::Line | Prim::Arrow | Prim::Move | Prim::Spline)
3874            | ObjectKind::Brace
3875            | ObjectKind::Continue
3876    )
3877}
3878
3879fn expr_bare_name(expr: &Expr) -> Option<&str> {
3880    match expr {
3881        Expr::Var(name, None) => Some(name.as_str()),
3882        _ => None,
3883    }
3884}
3885
3886fn suggest_attribute(word: &str) -> Option<&'static str> {
3887    const WORDS: &[&str] = &[
3888        "above",
3889        "at",
3890        "below",
3891        "bracepos",
3892        "by",
3893        "ccw",
3894        "center",
3895        "chop",
3896        "class",
3897        "close",
3898        "color",
3899        "colored",
3900        "cw",
3901        "dashed",
3902        "diam",
3903        "dotrad",
3904        "dotted",
3905        "down",
3906        "fill",
3907        "fit",
3908        "from",
3909        "gradient",
3910        "hatch",
3911        "ht",
3912        "invis",
3913        "labeloffset",
3914        "left",
3915        "ljust",
3916        "opacity",
3917        "outlined",
3918        "rad",
3919        "right",
3920        "rjust",
3921        "same",
3922        "scaled",
3923        "shaded",
3924        "thick",
3925        "to",
3926        "up",
3927        "wid",
3928        "with",
3929    ];
3930    crate::diagnostic::closest(word, WORDS)
3931}
3932
3933#[cfg(test)]
3934mod tests {
3935    use super::*;
3936    use crate::parser::{parse, parse_in_dir};
3937
3938    fn draw(src: &str) -> Drawing {
3939        eval(&parse(src).unwrap()).unwrap()
3940    }
3941
3942    fn scalar(src: &str) -> ER<f64> {
3943        let prog = parse(&format!("x = {src}")).unwrap();
3944        let mut st = State::new();
3945        st.eval_stmts(&prog.stmts)?;
3946        Ok(st.vars["x"])
3947    }
3948
3949    fn assert_box_size(shape: &Shape, want_w: f64, want_h: f64) {
3950        let Shape::Box { w, h, .. } = shape else {
3951            panic!()
3952        };
3953        assert!((*w - want_w).abs() < 1e-9, "w = {w}, want {want_w}");
3954        assert!((*h - want_h).abs() < 1e-9, "h = {h}, want {want_h}");
3955    }
3956
3957    const DEFAULT_STROKE_IN: f64 = 0.8 / 72.0;
3958
3959    #[test]
3960    fn zero_iteration_for_body_is_never_parsed() {
3961        // #196: a dead loop body must not be parsed or macro-expanded —
3962        // the same deferred rule as dead `if` branches (dpic accepts both).
3963        let d = draw("for i = 1 to 0 do { bxo }\nbox");
3964        assert_eq!(d.shapes.len(), 1);
3965
3966        // a recursive macro in a dead body must not hit the expansion guard
3967        let d = draw("define f { f() }\nfor i = 1 to 0 do { f() }\nbox");
3968        assert_eq!(d.shapes.len(), 1);
3969
3970        // a backwards `by` range that yields no iterations counts too
3971        let d = draw("for i = 5 to 1 do { bxo }\nbox");
3972        assert_eq!(d.shapes.len(), 1);
3973    }
3974
3975    #[test]
3976    fn executed_for_body_still_reports_errors_with_structure() {
3977        let e = eval(&parse("for i = 1 to 2 do { bxo }").unwrap()).unwrap_err();
3978        assert!(e.msg.contains("expected an object"), "{e}");
3979        let info = e.info.expect("structured info");
3980        assert_eq!(info.kind, "expected_token");
3981        assert_eq!(info.hint.as_deref(), Some("did you mean `box`?"));
3982    }
3983
3984    #[test]
3985    fn pipeline_chains_left_to_right() {
3986        let d = draw(".PS\nellipse \"document\"\narrow\nbox \"PIC\"\narrow\nbox \"TROFF\"\n.PE");
3987        // 5 shapes
3988        assert_eq!(d.shapes.len(), 5);
3989        // first ellipse centered at (0.375, 0): ellipsewid/2
3990        if let Shape::Ellipse { c, .. } = &d.shapes[0] {
3991            assert!((c.x - 0.375).abs() < 1e-9, "ellipse x = {}", c.x);
3992            assert!(c.y.abs() < 1e-9);
3993        } else {
3994            panic!("expected ellipse");
3995        }
3996        // bbox grows to the right
3997        assert!(d.bbox.width() > 2.0);
3998        assert!((d.bbox.height() - (0.5 + DEFAULT_STROKE_IN)).abs() < 1e-9);
3999    }
4000
4001    #[test]
4002    fn box_at_absolute() {
4003        let d = draw("box ht 0.3 wid 0.5 at 1,2");
4004        let Shape::Box { c, w, h, .. } = &d.shapes[0] else {
4005            panic!()
4006        };
4007        assert_eq!(*c, Point::new(1.0, 2.0));
4008        assert!((*w - 0.5).abs() < 1e-9 && (*h - 0.3).abs() < 1e-9);
4009    }
4010
4011    #[test]
4012    fn diamond_is_closed() {
4013        let d = draw("line up right then down right then down left then up left");
4014        let Shape::Path { pts, .. } = &d.shapes[0] else {
4015            panic!()
4016        };
4017        assert_eq!(pts.len(), 5);
4018        // returns to start
4019        assert!(pts[0].dist(*pts.last().unwrap()) < 1e-9);
4020    }
4021
4022    #[test]
4023    fn close_line_marks_polygon_and_uses_bbox_center_anchor() {
4024        let d = draw(
4025            "L: line right 1 then up 1 close\nbox wid .1 ht .1 at L.c\nbox wid .1 ht .1 at L.end",
4026        );
4027        let Shape::Path { pts, closed, .. } = &d.shapes[0] else {
4028            panic!()
4029        };
4030        assert!(*closed);
4031        assert_eq!(pts.len(), 4);
4032        assert_eq!(pts[0], Point::ZERO);
4033        assert_eq!(pts[1], Point::new(1.0, 0.0));
4034        assert_eq!(pts[2], Point::new(1.0, 1.0));
4035        assert_eq!(pts[3], Point::ZERO);
4036
4037        let Shape::Box { c, .. } = &d.shapes[1] else {
4038            panic!()
4039        };
4040        assert_eq!(*c, Point::new(0.5, 0.5));
4041
4042        let Shape::Box { c, .. } = &d.shapes[2] else {
4043            panic!()
4044        };
4045        assert_eq!(*c, Point::ZERO);
4046    }
4047
4048    #[test]
4049    fn close_line_requires_three_vertices_and_ends_path() {
4050        let err = eval(&parse("line right close").unwrap()).unwrap_err();
4051        assert!(err.msg.contains("need at least 3 vertices"), "{err}");
4052
4053        let err = eval(&parse("line right then up close then left").unwrap()).unwrap_err();
4054        assert!(err.msg.contains("polygon is closed"), "{err}");
4055    }
4056
4057    #[test]
4058    fn corners_and_labels() {
4059        let d = draw("A: box wid 1 ht 1 at 0,0\nbox wid 0.5 ht 0.5 with .sw at A.ne");
4060        // second box sw corner at A.ne (0.5,0.5) => its center at (0.75,0.75)
4061        let Shape::Box { c, .. } = &d.shapes[1] else {
4062            panic!()
4063        };
4064        assert!(
4065            (c.x - 0.75).abs() < 1e-9 && (c.y - 0.75).abs() < 1e-9,
4066            "c = {c:?}"
4067        );
4068    }
4069
4070    #[test]
4071    fn circle_corner_anchors_are_on_the_circumference() {
4072        let d = draw("C: circle rad 1 at 0,0\nline from C.sw to C.ne");
4073        let Shape::Path { pts, .. } = &d.shapes[1] else {
4074            panic!()
4075        };
4076        let a = 1.0 / 2.0_f64.sqrt();
4077        assert_eq!(pts.len(), 2);
4078        assert!((pts[0].x + a).abs() < 1e-9 && (pts[0].y + a).abs() < 1e-9);
4079        assert!((pts[1].x - a).abs() < 1e-9 && (pts[1].y - a).abs() < 1e-9);
4080    }
4081
4082    #[test]
4083    fn type_specific_corner_anchors_match_dpic() {
4084        let d = draw("L: line from (0,0) to (1,0) then to (1,1)\ncircle rad .01 at L.nw");
4085        let Shape::Circle { c, .. } = &d.shapes[1] else {
4086            panic!()
4087        };
4088        assert!(c.dist(Point::new(0.0, 0.0)) < 1e-9, "line nw = {c:?}");
4089
4090        let d = draw("A: arc cw rad 0.5 from (0,0) to (0.5,0.5)\ncircle rad .01 at A.s");
4091        let Shape::Circle { c, .. } = &d.shapes[1] else {
4092            panic!()
4093        };
4094        assert!(c.dist(Point::new(0.5, -0.5)) < 1e-9, "arc s = {c:?}");
4095
4096        let d = draw("B: box wid 1 ht 1 rad 0.3 at (0,0)\ncircle rad .01 at B.ne");
4097        let Shape::Circle { c, .. } = &d.shapes[1] else {
4098            panic!()
4099        };
4100        let inset = 0.3 * (1.0 - FRAC_1_SQRT_2);
4101        let expected = Point::new(0.5 - inset, 0.5 - inset);
4102        assert!(c.dist(expected) < 1e-9, "rounded box ne = {c:?}");
4103    }
4104
4105    #[test]
4106    fn with_corner_uses_ellipse_geometry_for_placed_object() {
4107        let d = draw("ellipse; ellipse with .nw at last ellipse.se");
4108        let Shape::Ellipse { c: first, .. } = &d.shapes[0] else {
4109            panic!()
4110        };
4111        let Shape::Ellipse { c: second, .. } = &d.shapes[1] else {
4112            panic!()
4113        };
4114        let expected = *first + Point::new(0.75 * FRAC_1_SQRT_2, -0.5 * FRAC_1_SQRT_2);
4115        assert!(
4116            second.dist(expected) < 1e-9,
4117            "second center = {second:?}, expected = {expected:?}"
4118        );
4119    }
4120
4121    #[test]
4122    fn for_loop_repeats() {
4123        let d = draw("for i = 1 to 3 do { box }");
4124        assert_eq!(d.shapes.len(), 3);
4125    }
4126
4127    #[test]
4128    fn for_loop_can_assign_subscripted_counter() {
4129        let d = draw("i = 1\nfor A[i] = 1 to 3 do { i += 1 }\nbox wid A[1] + A[2] + A[3] ht 0.3");
4130        let Shape::Box { w, .. } = &d.shapes[0] else {
4131            panic!()
4132        };
4133        assert!((*w - 6.0).abs() < 1e-9, "w = {w}");
4134    }
4135
4136    #[test]
4137    fn if_else_branches() {
4138        let d1 = draw("x = 1\nif x > 0 then { box } else { circle }");
4139        assert!(matches!(d1.shapes[0], Shape::Box { .. }));
4140        let d2 = draw("x = 0\nif x > 0 then { box } else { circle }");
4141        assert!(matches!(d2.shapes[0], Shape::Circle { .. }));
4142    }
4143
4144    #[test]
4145    fn define_macro_with_args() {
4146        let d = draw("define elem { box wid $1 }\nelem(0.5)\nelem(1.25)");
4147        assert_eq!(d.shapes.len(), 2);
4148        let Shape::Box { w: w0, .. } = &d.shapes[0] else {
4149            panic!()
4150        };
4151        let Shape::Box { w: w1, .. } = &d.shapes[1] else {
4152            panic!()
4153        };
4154        assert!((*w0 - 0.5).abs() < 1e-9 && (*w1 - 1.25).abs() < 1e-9);
4155    }
4156
4157    #[test]
4158    fn define_accepts_arbitrary_delimiter() {
4159        let d = draw("define elem / box /\nelem");
4160        assert_eq!(d.shapes.len(), 1);
4161        assert!(matches!(d.shapes[0], Shape::Box { .. }));
4162    }
4163
4164    #[test]
4165    fn labelled_call_to_multiline_body_macro() {
4166        // A multi-line `{ … }` body picks up newlines after `{` / before `}`.
4167        // They must not leak into a labelled call (`Q: m()` -> `Q: ⏎ <obj>`),
4168        // which would be a parse error. The block's terminals must still resolve.
4169        let d = draw(
4170            "define elem {\n  [\n    box wid 0.4 ht 0.2\n    L: last box.w\n    R: last box.e\n  ]\n}\nQ: elem() with .L at (1,1)\n\"x\" at Q.R",
4171        );
4172        // the block drew its box, and Q.R (a block sub-label) resolved for the text
4173        assert!(d.shapes.iter().any(|s| matches!(s, Shape::Box { .. })));
4174        let Shape::Text { at, .. } = d.shapes.last().unwrap() else {
4175            panic!()
4176        };
4177        // Q placed with .L (west) at (1,1); .R (east) is one box-width to the right
4178        assert!(
4179            (at.x - 1.4).abs() < 1e-6 && (at.y - 1.0).abs() < 1e-6,
4180            "at = {at:?}"
4181        );
4182    }
4183
4184    #[test]
4185    fn copied_forward_macro_expands_in_deferred_multiline_call() {
4186        let dir = std::env::temp_dir().join(format!("rpic_forward_macro_{}", std::process::id()));
4187        std::fs::create_dir_all(&dir).unwrap();
4188        std::fs::write(
4189            dir.join("lib.pic"),
4190            "define outer { if gate then { inner(0.2,\n  0.3) } }\ndefine inner { box wid $1 ht $2 }\n",
4191        )
4192        .unwrap();
4193
4194        let pic = parse_in_dir(
4195            "if 1 then { copy \"lib.pic\" }\ngate = 1\nouter()",
4196            Some(dir.as_path()),
4197        )
4198        .unwrap();
4199        let d = eval(&pic).unwrap();
4200        let _ = std::fs::remove_dir_all(&dir);
4201
4202        let Shape::Box { w, h, .. } = &d.shapes[0] else {
4203            panic!()
4204        };
4205        assert!((*w - 0.2).abs() < 1e-9 && (*h - 0.3).abs() < 1e-9);
4206    }
4207
4208    #[test]
4209    fn deferred_body_uses_macro_frame_from_own_expansion() {
4210        let d = draw(
4211            "define draw_one { [\n  scalev = 2\n  define project { $1/scalev }\n  if use_it then { box wid project(4) ht 0.1 }\n] }\ndefine draw_two { define project { $1/future_scale } }\nuse_it = 1\ndraw_one()\ndraw_two()",
4212        );
4213        let Shape::Box { w, .. } = &d.shapes[0] else {
4214            panic!()
4215        };
4216        assert!((*w - 2.0).abs() < 1e-9, "w = {w}");
4217    }
4218
4219    #[test]
4220    fn subscripted_variables_store_by_index() {
4221        let d = draw("P[1] = 0.4\nP[2] = 0.9\nP[2] += 0.1\nbox wid P[2] ht P[1]");
4222        let Shape::Box { w, h, .. } = &d.shapes[0] else {
4223            panic!()
4224        };
4225        assert!((*w - 1.0).abs() < 1e-9 && (*h - 0.4).abs() < 1e-9);
4226    }
4227
4228    #[test]
4229    fn multidimensional_variables_store_by_index_tuple() {
4230        let d = draw("M[1,2] = 0.7\nj = 2\nM[1,j] += 0.2\nbox wid M[1,2] ht 0.3");
4231        let Shape::Box { w, .. } = &d.shapes[0] else {
4232            panic!()
4233        };
4234        assert!((*w - 0.9).abs() < 1e-9, "w = {w}");
4235    }
4236
4237    #[test]
4238    fn subscripted_label_places_resolve_by_index() {
4239        let d =
4240            draw("for i = 1 to 2 do { A[i]: circle rad 0.01 at (i,0) }\nline from A[1] to A[2]");
4241        let Shape::Path { pts, .. } = &d.shapes[2] else {
4242            panic!()
4243        };
4244        assert!(
4245            pts[0].dist(Point::new(1.0, 0.0)) < 1e-9,
4246            "start {:?}",
4247            pts[0]
4248        );
4249        assert!(pts[1].dist(Point::new(2.0, 0.0)) < 1e-9, "end {:?}", pts[1]);
4250    }
4251
4252    #[test]
4253    fn for_loop_places_in_a_row() {
4254        // boxes step right by default; bbox should be ~3*boxwid wide
4255        let d = draw("for i = 1 to 3 do { box; move }");
4256        assert!(d.bbox.width() > 2.0);
4257    }
4258
4259    #[test]
4260    fn animate_timing() {
4261        let d = draw(
4262            "A: box\narrow\nbox\nanimate A with \"fade\" for 0.5\nanimate last arrow with \"draw\"\nanimate 2nd box with \"pop\" after A",
4263        );
4264        assert_eq!(d.anims.len(), 3);
4265        // A: fade, start 0, dur 0.5, targets shape 0
4266        assert_eq!(d.anims[0].effect, "fade");
4267        assert_eq!(d.anims[0].shape, 0);
4268        assert!((d.anims[0].start).abs() < 1e-9 && (d.anims[0].duration - 0.5).abs() < 1e-9);
4269        // arrow: draw, sequential after A -> start 0.5, default dur 0.6, shape 1
4270        assert_eq!(d.anims[1].shape, 1);
4271        assert!((d.anims[1].start - 0.5).abs() < 1e-9);
4272        // 2nd box: pop, after A (ends at 0.5) -> start 0.5, shape 2
4273        assert_eq!(d.anims[2].shape, 2);
4274        assert!((d.anims[2].start - 0.5).abs() < 1e-9);
4275    }
4276
4277    #[test]
4278    fn behind_sets_render_layer_without_changing_shape_indices() {
4279        let d = draw("A: box\nB: box behind A\nanimate B with \"fade\"");
4280        assert_eq!(d.shapes.len(), 2);
4281        assert_eq!(d.shape_layers, vec![0, -1]);
4282        assert_eq!(d.anims.len(), 1);
4283        assert_eq!(d.anims[0].shape, 1);
4284    }
4285
4286    #[test]
4287    fn behind_keeps_last_and_ordinals_semantic() {
4288        let d =
4289            draw("A: box at (0,0)\nB: box behind A at (2,0)\nline from last box.c to 1st box.c");
4290        let Shape::Path { pts, .. } = &d.shapes[2] else {
4291            panic!()
4292        };
4293        assert_eq!(pts.len(), 2);
4294        assert!(
4295            pts[0].dist(Point::new(2.0, 0.0)) < 1e-9,
4296            "start = {:?}",
4297            pts[0]
4298        );
4299        assert!(
4300            pts[1].dist(Point::new(0.0, 0.0)) < 1e-9,
4301            "end = {:?}",
4302            pts[1]
4303        );
4304    }
4305
4306    #[test]
4307    fn continue_extends_previous_line() {
4308        // issue #7: `continue` appends a segment to the last line (no new shape)
4309        let d = draw("line right 1\ncontinue down 0.5");
4310        assert_eq!(d.shapes.len(), 1, "should extend, not add a shape");
4311        let Shape::Path { pts, .. } = &d.shapes[0] else {
4312            panic!()
4313        };
4314        assert_eq!(pts.len(), 3);
4315        assert!(pts[2].dist(Point::new(1.0, -0.5)) < 1e-9, "{:?}", pts[2]);
4316        // bare continue extends in the current direction by linewid
4317        let d2 = draw("line right 1\ncontinue");
4318        let Shape::Path { pts, .. } = &d2.shapes[0] else {
4319            panic!()
4320        };
4321        assert!((pts.last().unwrap().x - 1.5).abs() < 1e-9);
4322    }
4323
4324    #[test]
4325    fn continue_rejects_closed_path() {
4326        let err = eval(&parse("line right then up then left close\ncontinue right").unwrap())
4327            .unwrap_err();
4328        assert!(err.msg.contains("polygon is closed"), "{err}");
4329    }
4330
4331    #[test]
4332    fn arc_from_to_endpoints_and_radius() {
4333        // issue #6: `arc from A to B` passes through both endpoints
4334        let d = draw("A:(0,0)\nB:(1,1)\narc from A to B");
4335        let Shape::Arc { c, r, a0, a1, .. } = &d.shapes[0] else {
4336            panic!()
4337        };
4338        assert!((*r - 2.0_f64.sqrt() / 2.0).abs() < 1e-9, "r = {r}");
4339        let s = *c + Point::new(a0.cos(), a0.sin()) * *r;
4340        let e = *c + Point::new(a1.cos(), a1.sin()) * *r;
4341        assert!(s.dist(Point::new(0.0, 0.0)) < 1e-9, "start {s:?}");
4342        assert!(e.dist(Point::new(1.0, 1.0)) < 1e-9, "end {e:?}");
4343
4344        let d_short = draw("arc from (0,0) to (0.3,0)");
4345        let Shape::Arc { r, .. } = &d_short.shapes[0] else {
4346            panic!()
4347        };
4348        assert!((*r - 0.25).abs() < 1e-9, "r = {r}");
4349
4350        let d_custom = draw("arcrad = 0.5\narc from (0,0) to (0.3,0)");
4351        let Shape::Arc { r, .. } = &d_custom.shapes[0] else {
4352            panic!()
4353        };
4354        assert!((*r - 0.5).abs() < 1e-9, "r = {r}");
4355
4356        // explicit radius is honored
4357        let d2 = draw("A:(0,0)\nB:(1,0)\narc from A to B rad 2");
4358        let Shape::Arc { r, .. } = &d2.shapes[0] else {
4359            panic!()
4360        };
4361        assert!((*r - 2.0).abs() < 1e-9, "r = {r}");
4362    }
4363
4364    #[test]
4365    fn arc_direction_disambiguates_from_to_radius() {
4366        let d = draw(
4367            "arc left from (0.5,0) to (0,0.5) rad 0.5\n\
4368             arc right from (0.5,0) to (0,0.5) rad 0.5 dashed",
4369        );
4370        let Shape::Arc {
4371            c: left,
4372            a0: left_a0,
4373            a1: left_a1,
4374            ..
4375        } = &d.shapes[0]
4376        else {
4377            panic!()
4378        };
4379        let Shape::Arc {
4380            c: right,
4381            a0: right_a0,
4382            a1: right_a1,
4383            ..
4384        } = &d.shapes[1]
4385        else {
4386            panic!()
4387        };
4388
4389        assert!(left.dist(Point::new(0.0, 0.0)) < 1e-9, "left = {left:?}");
4390        assert!(right.dist(Point::new(0.5, 0.5)) < 1e-9, "right = {right:?}");
4391        assert!((left_a1 - left_a0).abs() < PI);
4392        assert!((right_a1 - right_a0).abs() > PI);
4393    }
4394
4395    #[test]
4396    fn arc_with_center_at_disambiguates_large_clockwise_sweep() {
4397        let d = draw("arc cw rad 1 from (0,-1) to (1,0) with .c at (0,0)");
4398        let Shape::Arc { c, r, a0, a1, .. } = &d.shapes[0] else {
4399            panic!()
4400        };
4401        let start = *c + Point::new(a0.cos(), a0.sin()) * *r;
4402        let end = *c + Point::new(a1.cos(), a1.sin()) * *r;
4403
4404        assert!(c.dist(Point::ZERO) < 1e-9, "center = {c:?}");
4405        assert!((*r - 1.0).abs() < 1e-9, "r = {r}");
4406        assert!(
4407            start.dist(Point::new(0.0, -1.0)) < 1e-9,
4408            "start = {start:?}"
4409        );
4410        assert!(end.dist(Point::new(1.0, 0.0)) < 1e-9, "end = {end:?}");
4411        assert!(*a1 - *a0 < -PI, "sweep = {}", *a1 - *a0);
4412    }
4413
4414    #[test]
4415    fn arc_width_height_attrs_size_arrowheads() {
4416        let d = draw("arc <-> wid .5 ht .75");
4417        let Shape::Arc { style, .. } = &d.shapes[0] else {
4418            panic!()
4419        };
4420
4421        assert!(
4422            (style.arrow_wid - 0.5).abs() < 1e-9,
4423            "wid = {}",
4424            style.arrow_wid
4425        );
4426        assert!(
4427            (style.arrow_ht - 0.75).abs() < 1e-9,
4428            "ht = {}",
4429            style.arrow_ht
4430        );
4431    }
4432
4433    #[test]
4434    fn scale_converts_user_units_to_inches() {
4435        // `scale = 2` means two user units per inch: defaults stay the same
4436        // physical size, while explicit dimensions and coordinates are halved.
4437        let d = draw("scale = 2\nbox");
4438        let Shape::Box { w, h, .. } = &d.shapes[0] else {
4439            panic!()
4440        };
4441        assert!(
4442            (*w - 0.75).abs() < 1e-9 && (*h - 0.5).abs() < 1e-9,
4443            "{w} x {h}"
4444        );
4445
4446        let d = draw("scale = 2\nbox wid 2 ht 1");
4447        let Shape::Box { w, h, .. } = &d.shapes[0] else {
4448            panic!()
4449        };
4450        assert!((*w - 1.0).abs() < 1e-9 && (*h - 0.5).abs() < 1e-9);
4451
4452        let d = draw("scale = 2\nline from (0,0) to (2,0)");
4453        let Shape::Path { pts, .. } = &d.shapes[0] else {
4454            panic!()
4455        };
4456        assert!((pts.last().unwrap().x - 1.0).abs() < 1e-9, "{pts:?}");
4457
4458        let d = draw("scale = 2\nA: (2,0)\nbox wid A.x ht .2");
4459        let Shape::Box { w, .. } = &d.shapes[0] else {
4460            panic!()
4461        };
4462        assert!((*w - 1.0).abs() < 1e-9, "w = {w}");
4463
4464        let d = draw("scale = 2\nbox wid 2 ht 1\nscale = 1\nbox wid 1 ht .5");
4465        let Shape::Box { w, .. } = &d.shapes[0] else {
4466            panic!()
4467        };
4468        assert!((*w - 2.0).abs() < 1e-9, "w = {w}");
4469        let Shape::Box { c, .. } = &d.shapes[1] else {
4470            panic!()
4471        };
4472        assert!((c.x - 2.5).abs() < 1e-9, "center = {c:?}");
4473    }
4474
4475    #[test]
4476    fn same_reuses_previous_dims() {
4477        // issue #4: `box same` reuses the previous box's dimensions
4478        let d = draw("box wid 1 ht 0.4 at 0,0\nbox same at 2,0");
4479        let Shape::Box { w, h, .. } = &d.shapes[1] else {
4480            panic!()
4481        };
4482        assert!(
4483            (*w - 1.0).abs() < 1e-9 && (*h - 0.4).abs() < 1e-9,
4484            "{w} x {h}"
4485        );
4486    }
4487
4488    #[test]
4489    fn same_reuses_previous_open_vector() {
4490        let d = draw("line up 1\nright\nline same");
4491        let Shape::Path { pts, .. } = &d.shapes[1] else {
4492            panic!()
4493        };
4494        assert!(pts[0].dist(Point::new(0.0, 1.0)) < 1e-9, "{pts:?}");
4495        assert!(pts[1].dist(Point::new(0.0, 2.0)) < 1e-9, "{pts:?}");
4496    }
4497
4498    #[test]
4499    fn spline_expr_is_tension_not_distance() {
4500        // issue #63: `spline <expr>` is a dpic tension parameter, not a bare
4501        // distance. The control polygon (and thus start/end) must be unchanged,
4502        // and the tension recorded.
4503        let d = draw("spline 0.5 from 0,0 to 1,1 to 2,0");
4504        let Shape::Spline { pts, tension, .. } = &d.shapes[0] else {
4505            panic!("expected a spline")
4506        };
4507        assert_eq!(*tension, Some(0.5));
4508        assert_eq!(pts.len(), 3, "tension must not add a segment: {pts:?}");
4509        assert!(pts[0].dist(Point::new(0.0, 0.0)) < 1e-9, "{pts:?}");
4510        assert!(pts[2].dist(Point::new(2.0, 0.0)) < 1e-9, "{pts:?}");
4511    }
4512
4513    #[test]
4514    fn spline_variable_tension_does_not_drift() {
4515        // The doc/spline.pic idiom: `for x … { spline x from 0,0 … }`. Each
4516        // tensioned spline must keep the same start/end as the untensioned one
4517        // (only the curvature changes), instead of `x` shifting the geometry.
4518        let plain = draw("spline from 0,0 up 1.5 then right 2 then down 1.5");
4519        let Shape::Spline {
4520            pts: p0,
4521            tension: t0,
4522            ..
4523        } = &plain.shapes[0]
4524        else {
4525            panic!()
4526        };
4527        assert_eq!(*t0, None);
4528
4529        let tensioned = draw("x = 0.6\nspline x from 0,0 up 1.5 then right 2 then down 1.5");
4530        let Shape::Spline {
4531            pts: p1,
4532            tension: t1,
4533            ..
4534        } = &tensioned.shapes[0]
4535        else {
4536            panic!()
4537        };
4538        assert_eq!(*t1, Some(0.6));
4539        assert_eq!(p0.len(), p1.len());
4540        assert!(p0.first().unwrap().dist(*p1.first().unwrap()) < 1e-9);
4541        assert!(p0.last().unwrap().dist(*p1.last().unwrap()) < 1e-9);
4542    }
4543
4544    #[test]
4545    fn chop_trims_line_endpoints() {
4546        // issue #4: `chop` trims circlerad (0.25) off each end
4547        let d = draw("circle at 0,0\ncircle at 2,0\nline from 1st circle to 2nd circle chop");
4548        let Shape::Path { pts, .. } = &d.shapes[2] else {
4549            panic!()
4550        };
4551        assert!((pts[0].x - 0.25).abs() < 1e-9, "start {:?}", pts[0]);
4552        assert!(
4553            (pts.last().unwrap().x - 1.75).abs() < 1e-9,
4554            "end {:?}",
4555            pts.last()
4556        );
4557
4558        let d = draw("line from (0,0) to (2,0) chop 0 chop .5");
4559        let Shape::Path { pts, .. } = &d.shapes[0] else {
4560            panic!()
4561        };
4562        assert!((pts[0].x - 0.0).abs() < 1e-9, "{pts:?}");
4563        assert!((pts[1].x - 1.5).abs() < 1e-9, "{pts:?}");
4564
4565        let d = draw("line from (0,0) to (2,0) chop .5 chop 0");
4566        let Shape::Path { pts, .. } = &d.shapes[0] else {
4567            panic!()
4568        };
4569        assert!((pts[0].x - 0.5).abs() < 1e-9, "{pts:?}");
4570        assert!((pts[1].x - 2.0).abs() < 1e-9, "{pts:?}");
4571
4572        let d = draw("line from (0,0) to (2,0) chop -.25 chop -.5");
4573        let Shape::Path { pts, .. } = &d.shapes[0] else {
4574            panic!()
4575        };
4576        assert!((pts[0].x + 0.25).abs() < 1e-9, "{pts:?}");
4577        assert!((pts[1].x - 2.5).abs() < 1e-9, "{pts:?}");
4578    }
4579
4580    #[test]
4581    fn chop_on_zero_length_line_is_ignored_like_dpic() {
4582        let plain = draw("line from (0,0) to (0,0)");
4583        let chopped = draw("line from (0,0) to (0,0) chop -0.1");
4584        assert_eq!(crate::to_svg(&chopped), crate::to_svg(&plain));
4585
4586        let Shape::Path { pts, .. } = &chopped.shapes[0] else {
4587            panic!()
4588        };
4589        assert_eq!(pts, &[Point::ZERO, Point::ZERO]);
4590        assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
4591
4592        let extended = draw("line from (0,0) to (1,0) chop -0.1");
4593        let Shape::Path { pts, .. } = &extended.shapes[0] else {
4594            panic!()
4595        };
4596        assert!((pts[0].x + 0.1).abs() < 1e-9, "{pts:?}");
4597        assert!((pts[1].x - 1.1).abs() < 1e-9, "{pts:?}");
4598    }
4599
4600    #[test]
4601    fn unknown_variables_are_errors() {
4602        assert!(eval(&parse("box wid typo ht 0.2").unwrap()).is_err());
4603        assert!(eval(&parse("typo += 1").unwrap()).is_err());
4604    }
4605
4606    #[test]
4607    fn rand_advances_and_seed_repeats() {
4608        let mut st = State::new();
4609        let a = st.eval_expr(&Expr::Rand(None)).unwrap();
4610        let b = st.eval_expr(&Expr::Rand(None)).unwrap();
4611        assert!((0.0..1.0).contains(&a));
4612        assert!((0.0..1.0).contains(&b));
4613        assert_ne!(a, b);
4614
4615        let seeded_a = st
4616            .eval_expr(&Expr::Rand(Some(Box::new(Expr::Num(1.0)))))
4617            .unwrap();
4618        let seeded_b = st
4619            .eval_expr(&Expr::Rand(Some(Box::new(Expr::Num(1.0)))))
4620            .unwrap();
4621        assert_eq!(seeded_a, seeded_b);
4622        assert!((seeded_a - 0.840_187_717).abs() < 1e-9, "{seeded_a}");
4623        let next = st.eval_expr(&Expr::Rand(None)).unwrap();
4624        assert!((next - 0.394_382_927).abs() < 1e-9, "{next}");
4625    }
4626
4627    #[test]
4628    fn arithmetic_matches_dpic_edge_cases() {
4629        assert_eq!(scalar("5.5 % 2").unwrap(), 0.0);
4630        assert_eq!(scalar("2.5 % 2").unwrap(), 1.0);
4631        assert_eq!(scalar("-2.5 % 2").unwrap(), -1.0);
4632        assert!(scalar("5 % 0.4").is_err());
4633
4634        let mut st = State::new();
4635        st.eval_stmts(&parse("x = 5.5; x %= 2").unwrap().stmts)
4636            .unwrap();
4637        assert_eq!(st.vars["x"], 0.0);
4638
4639        assert_eq!(scalar("sign(0)").unwrap(), 1.0);
4640        assert_eq!(scalar("sign(-0.1)").unwrap(), -1.0);
4641
4642        assert_eq!(scalar("(-2)^3").unwrap(), -8.0);
4643        assert_eq!(scalar("(-2)^2").unwrap(), 4.0);
4644        assert_eq!(scalar("0^0").unwrap(), 1.0);
4645        assert!(scalar("(-2)^0.5").is_err());
4646        assert!(scalar("0^-1").is_err());
4647    }
4648
4649    #[test]
4650    fn standalone_text_occupies_invisible_box() {
4651        let d = draw("textwid = 1; textht = .2\n\"x\"\nbox wid .2 ht .2");
4652        let Shape::Text { at, .. } = &d.shapes[0] else {
4653            panic!()
4654        };
4655        assert!((at.x - 0.5).abs() < 1e-9, "text at {at:?}");
4656        let Shape::Box { c, .. } = &d.shapes[1] else {
4657            panic!()
4658        };
4659        assert!((c.x - 1.1).abs() < 1e-9, "box center {c:?}");
4660    }
4661
4662    #[test]
4663    fn text_position_modifies_the_preceding_string_only() {
4664        let d = draw("\"LLLL\" ljust");
4665        let Shape::Text { text, .. } = &d.shapes[0] else {
4666            panic!()
4667        };
4668        assert_eq!(text[0].halign, -1);
4669
4670        let d = draw("\"RRRR\" rjust");
4671        let Shape::Text { text, .. } = &d.shapes[0] else {
4672            panic!()
4673        };
4674        assert_eq!(text[0].halign, 1);
4675
4676        let d = draw("box wid 1 ht .6 \"AAAA\" above \"BBBB\" below");
4677        let Shape::Box { text, .. } = &d.shapes[0] else {
4678            panic!()
4679        };
4680        assert_eq!(text[0].valign, 1);
4681        assert_eq!(text[1].valign, -1);
4682
4683        let d = draw("box \"AAAA\" above \"BBBB\"");
4684        let Shape::Box { text, .. } = &d.shapes[0] else {
4685            panic!()
4686        };
4687        assert_eq!(text[0].valign, 1);
4688        assert_eq!(text[1].valign, 0);
4689    }
4690
4691    #[test]
4692    fn fit_sizes_closed_objects_to_preceding_text() {
4693        let d = draw(
4694            "box \"wide label\" fit\n\
4695             ellipse \"one\" \"two\" \"three\" fit\n\
4696             circle \"wide label\" fit",
4697        );
4698
4699        let Shape::Box { w, h, text, .. } = &d.shapes[0] else {
4700            panic!()
4701        };
4702        let (want_w, want_h) = fitted_text_size(text).unwrap();
4703        assert!((*w - want_w).abs() < 1e-9, "box w = {w}, want {want_w}");
4704        assert!((*h - want_h).abs() < 1e-9, "box h = {h}, want {want_h}");
4705
4706        let Shape::Ellipse { w, h, text, .. } = &d.shapes[1] else {
4707            panic!()
4708        };
4709        let (want_w, want_h) = fitted_text_size(text).unwrap();
4710        assert!((*w - want_w).abs() < 1e-9, "ellipse w = {w}, want {want_w}");
4711        assert!((*h - want_h).abs() < 1e-9, "ellipse h = {h}, want {want_h}");
4712
4713        let Shape::Circle { r, text, .. } = &d.shapes[2] else {
4714            panic!()
4715        };
4716        let (want_w, want_h) = fitted_text_size(text).unwrap();
4717        let want_r = want_w.hypot(want_h) / 2.0;
4718        assert!((*r - want_r).abs() < 1e-9, "circle r = {r}, want {want_r}");
4719    }
4720
4721    #[test]
4722    fn fit_respects_explicit_dimensions_and_text_order() {
4723        let d = draw("box wid 1 ht .2 \"very long label\" fit");
4724        assert_box_size(&d.shapes[0], 1.0, 0.2);
4725
4726        let before = draw("box \"short\" fit");
4727        let after = draw("box \"short\" fit \"this later text does not affect fit\"");
4728        let Shape::Box {
4729            w: before_w,
4730            h: before_h,
4731            ..
4732        } = &before.shapes[0]
4733        else {
4734            panic!()
4735        };
4736        let Shape::Box {
4737            w: after_w,
4738            h: after_h,
4739            ..
4740        } = &after.shapes[0]
4741        else {
4742            panic!()
4743        };
4744        assert!((*before_w - *after_w).abs() < 1e-9);
4745        assert!((*before_h - *after_h).abs() < 1e-9);
4746    }
4747
4748    #[test]
4749    fn fit_without_preceding_visible_text_errors() {
4750        let err = eval(&parse("box fit").unwrap()).unwrap_err();
4751        assert!(err.msg.contains("visible text"), "{}", err.msg);
4752
4753        let err = eval(&parse("box \"\" fit").unwrap()).unwrap_err();
4754        assert!(err.msg.contains("visible text"), "{}", err.msg);
4755    }
4756
4757    #[test]
4758    fn brace_draws_curly_annotation_between_points() {
4759        let d = draw("brace from (0,0) to (2,0) down \"n\" wid .25 bracepos .25");
4760        let Shape::Brace {
4761            a,
4762            b,
4763            cubics,
4764            label_at,
4765            text,
4766            ..
4767        } = &d.shapes[0]
4768        else {
4769            panic!()
4770        };
4771        assert!(a.dist(Point::new(0.0, 0.0)) < 1e-9, "a = {a:?}");
4772        assert!(b.dist(Point::new(2.0, 0.0)) < 1e-9, "b = {b:?}");
4773        assert_eq!(text[0].s, "n");
4774        assert!(label_at.y < -0.25, "label_at = {label_at:?}");
4775        assert!(
4776            (cubics[2][3].x - 0.5).abs() < 1e-9,
4777            "cusp = {:?}",
4778            cubics[2][3]
4779        );
4780        assert!(cubics[2][3].y < -0.2, "cusp = {:?}", cubics[2][3]);
4781        assert!(d.bbox.min.y < -0.25, "bbox = {:?}", d.bbox);
4782    }
4783
4784    #[test]
4785    fn brace_side_words_choose_absolute_side() {
4786        let up = draw("brace from (0,0) to (2,0) up wid .2");
4787        let down = draw("brace from (0,0) to (2,0) down wid .2");
4788        let Shape::Brace {
4789            label_at: up_label, ..
4790        } = &up.shapes[0]
4791        else {
4792            panic!()
4793        };
4794        let Shape::Brace {
4795            label_at: down_label,
4796            ..
4797        } = &down.shapes[0]
4798        else {
4799            panic!()
4800        };
4801        assert!(up_label.y > 0.0, "up label = {up_label:?}");
4802        assert!(down_label.y < 0.0, "down label = {down_label:?}");
4803    }
4804
4805    #[test]
4806    fn brace_labeloffset_moves_label_outward_from_cusp() {
4807        let base = draw("brace from (0,0) to (2,0) up \"n\" wid .25");
4808        let far = draw("brace from (0,0) to (2,0) up \"n\" wid .25 labeloffset .2");
4809        let Shape::Brace {
4810            label_at: base_label,
4811            ..
4812        } = &base.shapes[0]
4813        else {
4814            panic!()
4815        };
4816        let Shape::Brace {
4817            label_at: far_label,
4818            ..
4819        } = &far.shapes[0]
4820        else {
4821            panic!()
4822        };
4823        assert!(
4824            (far_label.y - base_label.y - 0.2).abs() < 1e-9,
4825            "base = {base_label:?}, far = {far_label:?}"
4826        );
4827    }
4828
4829    #[test]
4830    fn brace_compass_anchors_use_curve_bbox() {
4831        let d = draw(
4832            "B: brace from (0,0) to (2,0) up wid .25 bracepos .25\n\
4833             circle rad .01 at B.nw\n\
4834             circle rad .01 at B.ne\n\
4835             circle rad .01 at B.n\n\
4836             circle rad .01 at B.c",
4837        );
4838        let circles: Vec<Point> = d
4839            .shapes
4840            .iter()
4841            .skip(1)
4842            .map(|shape| {
4843                let Shape::Circle { c, .. } = shape else {
4844                    panic!()
4845                };
4846                *c
4847            })
4848            .collect();
4849
4850        assert!(circles[0].x.abs() < 1e-9, "nw = {:?}", circles[0]);
4851        assert!((circles[1].x - 2.0).abs() < 1e-9, "ne = {:?}", circles[1]);
4852        assert!(
4853            (circles[0].y - circles[1].y).abs() < 1e-9,
4854            "nw = {:?}, ne = {:?}",
4855            circles[0],
4856            circles[1]
4857        );
4858        assert!(
4859            (circles[2].x - 1.0).abs() < 1e-9 && circles[2].y > 0.2,
4860            "n = {:?}",
4861            circles[2]
4862        );
4863        assert!(
4864            (circles[3].x - 0.5).abs() < 1e-9 && circles[3].y > 0.2,
4865            "c = {:?}",
4866            circles[3]
4867        );
4868    }
4869
4870    #[test]
4871    fn brace_has_open_object_anchors_and_length() {
4872        let d = draw(
4873            "B: brace from (0,0) to (2,0) down wid .25\n\
4874             box wid (B.len) ht .1 at B.c\n\
4875             line from B.start to B.end",
4876        );
4877        assert_box_size(&d.shapes[1], 2.0, 0.1);
4878        let Shape::Path { pts, .. } = &d.shapes[2] else {
4879            panic!()
4880        };
4881        assert!(pts[0].dist(Point::new(0.0, 0.0)) < 1e-9);
4882        assert!(pts[1].dist(Point::new(2.0, 0.0)) < 1e-9);
4883    }
4884
4885    #[test]
4886    fn bracepos_must_be_inside_segment() {
4887        let err = eval(&parse("brace from (0,0) to (1,0) bracepos 1").unwrap()).unwrap_err();
4888        assert!(err.msg.contains("bracepos"), "{}", err.msg);
4889    }
4890
4891    #[test]
4892    fn style_globals_and_dash_lengths_apply() {
4893        let d = draw("linethick = 3\nline dashed .2\nline dotted .05");
4894        let Shape::Path { style, .. } = &d.shapes[0] else {
4895            panic!()
4896        };
4897        assert_eq!(style.thick, Some(3.0));
4898        assert_eq!(style.dash, Dash::Dashed(0.2));
4899        let Shape::Path { style, .. } = &d.shapes[1] else {
4900            panic!()
4901        };
4902        assert_eq!(style.dash, Dash::Dotted(Some(0.05)));
4903    }
4904
4905    #[test]
4906    fn hatch_style_records_pattern_attributes() {
4907        let d = draw("box crosshatch hatchangle 30 hatchsep .05 hatchwidth 1.5 hatchcolor red");
4908        let Shape::Box { style, .. } = &d.shapes[0] else {
4909            panic!()
4910        };
4911        let hatch = style.hatch.as_ref().expect("expected hatch style");
4912        assert!(hatch.cross);
4913        assert!((hatch.angle - 30.0).abs() < 1e-9);
4914        assert!((hatch.sep - 0.05).abs() < 1e-9);
4915        assert!((hatch.width - 1.5).abs() < 1e-9);
4916        assert_eq!(hatch.color, "red");
4917        assert!(style.fill_open);
4918    }
4919
4920    #[test]
4921    fn opacity_style_records_fill_opacity() {
4922        let d = draw("box fill .8 opacity .4");
4923        let Shape::Box { style, .. } = &d.shapes[0] else {
4924            panic!()
4925        };
4926        assert_eq!(style.fill_opacity, Some(0.4));
4927    }
4928
4929    #[test]
4930    fn block_opacity_multiplies_child_fill_opacity() {
4931        let d = draw("[ box opacity .5; circle ] opacity .5");
4932        let Shape::Box { style, .. } = &d.shapes[0] else {
4933            panic!()
4934        };
4935        assert_eq!(style.fill_opacity, Some(0.25));
4936        let Shape::Circle { style, .. } = &d.shapes[1] else {
4937            panic!()
4938        };
4939        assert_eq!(style.fill_opacity, Some(0.5));
4940    }
4941
4942    #[test]
4943    fn opacity_must_be_between_zero_and_one() {
4944        let err = eval(&parse("box opacity 1.1").unwrap()).unwrap_err();
4945        assert!(err.msg.contains("opacity"), "{}", err.msg);
4946        let err = eval(&parse("box opacity -0.1").unwrap()).unwrap_err();
4947        assert!(err.msg.contains("opacity"), "{}", err.msg);
4948    }
4949
4950    #[test]
4951    fn standalone_text_rejects_opacity() {
4952        let err = eval(&parse("\"note\" opacity .5").unwrap()).unwrap_err();
4953        assert!(err.msg.contains("filled regions"), "{}", err.msg);
4954    }
4955
4956    #[test]
4957    fn color_attribute_expands_runtime_macro_string() {
4958        let d = draw(
4959            "r = 0; g = 0; b = 0.6\n\
4960             if dpicopt == optSVG then {\n\
4961               define customcolor { sprintf(\"rgb(%g,%g,%g)\", int(r*255), int(g*255), int(b*255)) }\n\
4962             }\n\
4963             arc color customcolor",
4964        );
4965        let Shape::Arc { style, .. } = &d.shapes[0] else {
4966            panic!()
4967        };
4968        assert_eq!(style.stroke.as_deref(), Some("rgb(0,0,153)"));
4969        assert_eq!(style.fill, Some(Fill::Color("rgb(0,0,153)".into())));
4970    }
4971
4972    #[test]
4973    fn color_attribute_accepts_dpictools_rgbstring_macro_call() {
4974        let d = draw(
4975            "if dpicopt == optSVG then {\n\
4976               define rgbstring { sprintf(\"rgb(%g,%g,%g)\", int(($1)*255+0.5), int(($2)*255+0.5), int(($3)*255+0.5)) }\n\
4977             }\n\
4978             circle shaded rgbstring(1,0.84,0) outlined \"black\"",
4979        );
4980        let Shape::Circle { style, .. } = &d.shapes[0] else {
4981            panic!()
4982        };
4983        assert_eq!(style.fill, Some(Fill::Color("rgb(255,214,0)".into())));
4984        assert_eq!(style.stroke.as_deref(), Some("black"));
4985    }
4986
4987    #[test]
4988    fn ps_width_scales_drawing() {
4989        // issue #4, dpic oracle: `.PS 6` scales the painted picture, so the
4990        // box geometry is slightly under 6in once default stroke is reserved.
4991        let d = draw(".PS 6\nbox\n.PE");
4992        let Shape::Box { w, .. } = &d.shapes[0] else {
4993            panic!()
4994        };
4995        assert!((*w - 5.912_408_759).abs() < 1e-9, "w = {w}");
4996        assert!(
4997            (d.bbox.width() - 5.923_519_870).abs() < 1e-9,
4998            "w = {}",
4999            d.bbox.width()
5000        );
5001    }
5002
5003    #[test]
5004    fn text_extent_in_bbox() {
5005        // issue #5: a bare label must yield a non-degenerate bbox (no clipping)
5006        let d = draw("\"a long label here\"");
5007        assert!(d.bbox.width() > 0.5, "w = {}", d.bbox.width());
5008        assert!(d.bbox.height() > 0.1, "h = {}", d.bbox.height());
5009        // text wider than its box widens the bbox beyond the box
5010        let d2 = draw("box wid 0.2 ht 0.2 \"a very wide label\"");
5011        assert!(d2.bbox.width() > 0.3, "w = {}", d2.bbox.width());
5012    }
5013
5014    #[test]
5015    fn text_object_width_bounds_rendered_bbox() {
5016        // dpic oracle: a standalone text object's `wid` controls its bbox;
5017        // the literal text width is not used when an explicit width is given.
5018        let d = draw("\"abcdefghij\" wid 0.1");
5019        assert!(
5020            (d.bbox.width() - 0.1).abs() < 1e-9,
5021            "w = {}",
5022            d.bbox.width()
5023        );
5024
5025        let d = draw(".PS 1\n\"abcdefghij\" wid 0.1\n.PE");
5026        assert!(
5027            (d.bbox.width() - 1.0).abs() < 1e-9,
5028            "w = {}",
5029            d.bbox.width()
5030        );
5031    }
5032
5033    #[test]
5034    fn text_position_and_offset_expand_bbox_in_the_rendered_direction() {
5035        let d = draw("textoffset = 0.1\n\"abc\" ljust at (0,0)");
5036        assert!(d.bbox.min.x >= 0.1 - 1e-9, "{:?}", d.bbox);
5037
5038        let d = draw("textoffset = 0.1\n\"abc\" rjust at (0,0)");
5039        assert!(d.bbox.max.x <= -0.1 + 1e-9, "{:?}", d.bbox);
5040
5041        let d = draw("textoffset = 0.1\n\"abc\" above at (0,0)");
5042        assert!(d.bbox.min.y > 0.0, "{:?}", d.bbox);
5043
5044        let d = draw("textoffset = 0.1\n\"abc\" below at (0,0)");
5045        assert!(d.bbox.max.y < 0.0, "{:?}", d.bbox);
5046    }
5047
5048    #[test]
5049    fn invisible_geometry_does_not_expand_drawing_bbox() {
5050        let d = draw("box invis wid 1000 ht 1000 at (0,0)\nbox wid 1 ht 1 at (0,0)");
5051        assert!(
5052            (d.bbox.width() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
5053            "w = {}",
5054            d.bbox.width()
5055        );
5056        assert!(
5057            (d.bbox.height() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
5058            "h = {}",
5059            d.bbox.height()
5060        );
5061
5062        let d2 = draw("line invis from (0,0) to (1000,1000)\nbox wid 1 ht 1 at (0,0)");
5063        assert!(
5064            (d2.bbox.width() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
5065            "w = {}",
5066            d2.bbox.width()
5067        );
5068        assert!(
5069            (d2.bbox.height() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
5070            "h = {}",
5071            d2.bbox.height()
5072        );
5073
5074        let d3 = draw("I: box invis wid 1000 ht 1000 at (0,0)\nbox wid 1 ht 1 with .sw at I.ne");
5075        let Shape::Box { c, .. } = &d3.shapes[1] else {
5076            panic!()
5077        };
5078        assert!(c.dist(Point::new(500.5, 500.5)) < 1e-9, "c = {c:?}");
5079        assert!(
5080            (d3.bbox.width() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
5081            "w = {}",
5082            d3.bbox.width()
5083        );
5084        assert!(
5085            (d3.bbox.height() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
5086            "h = {}",
5087            d3.bbox.height()
5088        );
5089    }
5090
5091    #[test]
5092    fn move_expands_drawing_bbox_like_dpic() {
5093        let d = draw("line from (0,0) to (1,0)\nmove left 0.4 from (0,0)");
5094        assert!(
5095            (d.bbox.width() - (1.4 + DEFAULT_STROKE_IN / 2.0)).abs() < 1e-9,
5096            "w = {}",
5097            d.bbox.width()
5098        );
5099    }
5100
5101    #[test]
5102    fn division_by_zero_errors() {
5103        // a zero divisor must error rather than silently produce NaN coordinates
5104        assert!(eval(&parse("box wid 1/0").unwrap()).is_err());
5105        assert!(eval(&parse("A:(0,0)\nB:(0,0)\nx = (B.x-A.x)/(B.y-A.y)").unwrap()).is_err());
5106    }
5107
5108    #[test]
5109    fn non_finite_numeric_values_error() {
5110        let literal = parse("box wid 1e999 ht 1").unwrap_err();
5111        assert!(literal.msg.contains("not finite"), "{literal}");
5112
5113        for src in [
5114            "box wid exp(1000) ht 1",
5115            "box wid sqrt(-1) ht 1",
5116            "scale = exp(1000)\nbox",
5117            "x = 1e308\nx *= 1e308\nbox wid x",
5118        ] {
5119            let err = eval(&parse(src).unwrap()).unwrap_err();
5120            assert!(err.msg.contains("non-finite"), "{src}: {err}");
5121        }
5122    }
5123
5124    #[test]
5125    fn place_dot_in_coordinate_pair() {
5126        // (A.x, A.y - 1) — place scalar accessors inside a coordinate pair (issue #3)
5127        let d = draw("A: box wid 1 ht 1 at 2,3\nbox wid 0.2 ht 0.2 at (A.x, A.y - 1)");
5128        let Shape::Box { c, .. } = &d.shapes[1] else {
5129            panic!()
5130        };
5131        assert!(
5132            (c.x - 2.0).abs() < 1e-9 && (c.y - 2.0).abs() < 1e-9,
5133            "c = {c:?}"
5134        );
5135    }
5136
5137    #[test]
5138    fn bare_coordinate_pair_places_label() {
5139        let d = draw("P: 1,2\nbox wid 0.2 ht 0.2 at P");
5140        let Shape::Box { c, .. } = &d.shapes[0] else {
5141            panic!()
5142        };
5143        assert!(c.dist(Point::new(1.0, 2.0)) < 1e-9, "c = {c:?}");
5144    }
5145
5146    #[test]
5147    fn block_sub_labels_resolve() {
5148        // `B.A` and `B.A.corner` reach a labelled object inside a block
5149        let d = draw("B: [ A: box wid 1 ht 1 at 0,0 ]\nbox wid 0.2 ht 0.2 with .sw at B.A.ne");
5150        let Shape::Box { c, .. } = &d.shapes.last().unwrap() else {
5151            panic!()
5152        };
5153        // the block is placed with its center at (0.5,0); inner A.ne is then
5154        // (1.0,0.5), and the small box centers 0.1 beyond that corner.
5155        assert!(
5156            (c.x - 1.1).abs() < 1e-9 && (c.y - 0.6).abs() < 1e-9,
5157            "c = {c:?}"
5158        );
5159    }
5160
5161    #[test]
5162    fn block_can_anchor_on_own_member() {
5163        // The block bbox center is not A.c, so this catches the two-pass anchor
5164        // resolution rather than accidentally aligning the block center.
5165        let d = draw("P:(2,3)\n[ A: box wid 1 ht 1 at 0,0; circle rad 0.1 at 2,0 ] with .A.c at P");
5166        let Shape::Box { c, .. } = &d.shapes[0] else {
5167            panic!()
5168        };
5169        assert!(c.dist(Point::new(2.0, 3.0)) < 1e-9, "A center = {c:?}");
5170        let Shape::Circle { c, .. } = &d.shapes[1] else {
5171            panic!()
5172        };
5173        assert!(c.dist(Point::new(4.0, 3.0)) < 1e-9, "circle center = {c:?}");
5174    }
5175
5176    #[test]
5177    fn block_pair_anchor_uses_local_coordinates() {
5178        // dpic oracle: for `[ ... ] with (x,y) at P`, `(x,y)` is a point in
5179        // the block's local coordinate system, not an offset from its center.
5180        let d = draw("[ box wid 2 ht 1 at (1,0) ] with (0,0) at (10,20)");
5181        let Shape::Box { c, .. } = &d.shapes[0] else {
5182            panic!()
5183        };
5184        assert!(c.dist(Point::new(11.0, 20.0)) < 1e-9, "c = {c:?}");
5185    }
5186
5187    #[test]
5188    fn block_layout_matches_dpic_for_negative_box_width() {
5189        let d = draw("move 1\n[ box wid -0.5 ht 0.5 ]; box wid 0.75 ht 0.75");
5190        let boxes: Vec<Point> = d
5191            .shapes
5192            .iter()
5193            .filter_map(|shape| match shape {
5194                Shape::Box { c, .. } => Some(*c),
5195                _ => None,
5196            })
5197            .collect();
5198        assert_eq!(boxes.len(), 2);
5199        assert!(
5200            boxes[0].dist(Point::new(0.75, 0.0)) < 1e-9,
5201            "negative box center = {:?}",
5202            boxes[0]
5203        );
5204        assert!(
5205            boxes[1].dist(Point::new(1.375, 0.0)) < 1e-9,
5206            "following box center = {:?}",
5207            boxes[1]
5208        );
5209    }
5210
5211    #[test]
5212    fn block_object_renders_attached_text() {
5213        let d = draw("[ box ] \"block label\"");
5214        assert!(d.shapes.iter().any(|s| {
5215            matches!(
5216                s,
5217                Shape::Text { text, .. } if text.iter().any(|line| line.s == "block label")
5218            )
5219        }));
5220    }
5221
5222    #[test]
5223    fn block_anchors_ignore_attached_text_extents() {
5224        // dpic oracle: text contributes to the painted bbox, but not to block
5225        // anchors such as `last [].s`; those come from the geometric objects.
5226        let d = draw(
5227            r#"B: [ right; box "{\bf veryveryverywide}"; move; box ]
5228box wid 0.1 ht 0.1 at B.s"#,
5229        );
5230        let Shape::Box { c, .. } = d.shapes.last().unwrap() else {
5231            panic!()
5232        };
5233        assert!(c.dist(Point::new(1.0, -0.25)) < 1e-9, "c = {c:?}");
5234    }
5235
5236    #[test]
5237    fn nested_macro_block_can_reference_parent_label() {
5238        let d = draw(
5239            "define marker { [ P: circle rad 0.01 at $1.start ] with .P at $1.start }\n[ A: arrow from (0,0) to (1,0); marker(A) ]",
5240        );
5241        let Shape::Path { pts, .. } = &d.shapes[0] else {
5242            panic!()
5243        };
5244        let Shape::Circle { c, .. } = &d.shapes[1] else {
5245            panic!()
5246        };
5247        assert!(
5248            c.dist(pts[0]) < 1e-9,
5249            "circle = {c:?}, arrow start = {:?}",
5250            pts[0]
5251        );
5252    }
5253
5254    #[test]
5255    fn position_vector_arithmetic() {
5256        // (w,h)/2 and p + q with correct precedence
5257        let d = draw("box wid 0.2 ht 0.2 at (2,4)/2 + (1,0)");
5258        let Shape::Box { c, .. } = &d.shapes[0] else {
5259            panic!()
5260        };
5261        assert!(
5262            (c.x - 2.0).abs() < 1e-9 && (c.y - 2.0).abs() < 1e-9,
5263            "c = {c:?}"
5264        );
5265    }
5266
5267    #[test]
5268    fn interpolation_angle_brackets() {
5269        let d = draw("A:(0,0)\nB:(2,0)\nbox wid 0.1 ht 0.1 at 0.5 <A,B>");
5270        let Shape::Box { c, .. } = &d.shapes.last().unwrap() else {
5271            panic!()
5272        };
5273        assert!((c.x - 1.0).abs() < 1e-9, "c = {c:?}");
5274    }
5275
5276    #[test]
5277    fn string_equality_in_if() {
5278        // the `"$1"==""` default-argument idiom (here without a macro)
5279        let d1 = draw("if \"a\" == \"\" then { box } else { circle }");
5280        assert!(matches!(d1.shapes[0], Shape::Circle { .. }));
5281        let d2 = draw("if \"\" == \"\" then { box } else { circle }");
5282        assert!(matches!(d2.shapes[0], Shape::Box { .. }));
5283    }
5284
5285    #[test]
5286    fn dpicopt_defaults_to_svg_backend() {
5287        let d = draw("if dpicopt == optSVG then { box } else { circle }");
5288        assert!(matches!(d.shapes[0], Shape::Box { .. }));
5289    }
5290
5291    #[test]
5292    fn svg_font_stub_and_string_sprintf_are_harmless() {
5293        let d = draw("box sprintf(\"x%s\", svg_font(\"Times\", 12))");
5294        let Shape::Box { text, .. } = &d.shapes[0] else {
5295            panic!()
5296        };
5297        assert_eq!(text[0].s, "x");
5298    }
5299
5300    #[test]
5301    fn inch_suffix_and_bare_distance() {
5302        // `.5i` is half an inch; `move 1` / `move -0.1` advance the pen
5303        let d = draw("box wid .5i ht .5i");
5304        let Shape::Box { w, .. } = &d.shapes[0] else {
5305            panic!()
5306        };
5307        assert!((*w - 0.5).abs() < 1e-9, "w = {w}");
5308        let d2 = draw("right\nmove 1\nbox at Here");
5309        let Shape::Box { c, .. } = &d2.shapes.last().unwrap() else {
5310            panic!()
5311        };
5312        assert!(c.x > 0.9, "moved to {c:?}");
5313    }
5314
5315    #[test]
5316    fn embedded_assignment_returns_value() {
5317        let d = draw("if (s = 3) > 1 then { box wid s ht 0.1 }");
5318        let Shape::Box { w, .. } = &d.shapes[0] else {
5319            panic!()
5320        };
5321        assert!((*w - 3.0).abs() < 1e-9, "w = {w}");
5322    }
5323
5324    #[test]
5325    fn copy_includes_a_file() {
5326        // `copy "file"` splices another pic file relative to the base directory
5327        let dir = std::env::temp_dir().join(format!("rpic_copy_{}", std::process::id()));
5328        std::fs::create_dir_all(&dir).unwrap();
5329        std::fs::write(dir.join("inc.pic"), "box wid 0.5 ht 0.5\n").unwrap();
5330        let pic = parse_in_dir("copy \"inc.pic\"\ncircle", Some(dir.as_path())).unwrap();
5331        let d = eval(&pic).unwrap();
5332        assert!(d.shapes.iter().any(|s| matches!(s, Shape::Box { .. })));
5333        assert!(d.shapes.iter().any(|s| matches!(s, Shape::Circle { .. })));
5334        let _ = std::fs::remove_dir_all(&dir);
5335    }
5336
5337    #[test]
5338    fn paren_position_coordinate() {
5339        // `.x`/`.y` on a parenthesised position expression
5340        let d = draw("A:(0,0)\nB:(3,1)\nbox wid (B-A).x ht (B-A).y at 0,-2");
5341        let Shape::Box { w, h, .. } = &d.shapes[0] else {
5342            panic!()
5343        };
5344        assert!(
5345            (*w - 3.0).abs() < 1e-9 && (*h - 1.0).abs() < 1e-9,
5346            "{w} x {h}"
5347        );
5348    }
5349
5350    #[test]
5351    fn arrowhead_size_follows_globals() {
5352        // arrowht/arrowwid control the rendered arrowhead, not a hardcoded size
5353        let d = draw("arrowht = 0.3; arrowwid = 0.2\narrow right 1");
5354        let Shape::Path { style, .. } = &d.shapes[0] else {
5355            panic!()
5356        };
5357        assert!(
5358            (style.arrow_ht - 0.3).abs() < 1e-9,
5359            "ht = {}",
5360            style.arrow_ht
5361        );
5362        assert!(
5363            (style.arrow_wid - 0.2).abs() < 1e-9,
5364            "wid = {}",
5365            style.arrow_wid
5366        );
5367    }
5368
5369    #[test]
5370    fn scaling_existing_geometry_scales_arrowhead_metadata() {
5371        // manual/man35 draws at a temporary scale, then restores `scale`.
5372        // The points are scaled at restore time, and the arrowhead dimensions
5373        // attached to the already-emitted path must scale with them.
5374        let factor = 6.6 / 8.2;
5375        let d = draw("scale = 6.6/8.2\nline <-\nscale = 1");
5376        let Shape::Path { style, .. } = &d.shapes[0] else {
5377            panic!()
5378        };
5379        assert!(
5380            (style.arrow_ht - 0.1 * factor).abs() < 1e-9,
5381            "ht = {}",
5382            style.arrow_ht
5383        );
5384        assert!(
5385            (style.arrow_wid - 0.05 * factor).abs() < 1e-9,
5386            "wid = {}",
5387            style.arrow_wid
5388        );
5389    }
5390
5391    #[test]
5392    fn dpic_default_env_values_are_readable() {
5393        assert!((scalar("textoffset").unwrap() - 2.0 / 72.0).abs() < 1e-9);
5394        assert!((scalar("textht").unwrap() - (11.0 / 72.0) * 0.66).abs() < 1e-9);
5395        assert!((scalar("arrowhead").unwrap() - 1.0).abs() < 1e-9);
5396        assert!((scalar("linethick").unwrap() - 0.8).abs() < 1e-9);
5397        assert_eq!(scalar("margin").unwrap(), 0.0);
5398        assert_eq!(scalar("topmargin").unwrap(), 0.0);
5399        assert_eq!(scalar("rightmargin").unwrap(), 0.0);
5400        assert_eq!(scalar("bottommargin").unwrap(), 0.0);
5401        assert_eq!(scalar("leftmargin").unwrap(), 0.0);
5402    }
5403
5404    #[test]
5405    fn canvas_margin_vars_are_scaled_dimensions() {
5406        let d = draw("margin = 1; topmargin = 0.5; rightmargin = 0.25; line right");
5407        assert_eq!(
5408            d.canvas_margin,
5409            CanvasMargin {
5410                top: 1.5,
5411                right: 1.25,
5412                bottom: 1.0,
5413                left: 1.0,
5414            }
5415        );
5416
5417        let d = draw("scale = 2; margin = 1; topmargin = 1; line right");
5418        assert_eq!(
5419            d.canvas_margin,
5420            CanvasMargin {
5421                top: 1.0,
5422                right: 0.5,
5423                bottom: 0.5,
5424                left: 0.5,
5425            }
5426        );
5427
5428        let d = draw("margin = 1; scale = 2; line right");
5429        assert_eq!(
5430            d.canvas_margin,
5431            CanvasMargin {
5432                top: 1.0,
5433                right: 1.0,
5434                bottom: 1.0,
5435                left: 1.0,
5436            }
5437        );
5438    }
5439
5440    #[test]
5441    fn print_statements_collect_diagnostics() {
5442        let d = draw("print 5.5\nprint 5.5%2\nprint \"hello\"\nprint sprintf(\"x=%g\", 1.25)");
5443        assert_eq!(d.diagnostics, ["5.5", "0", "hello", "x=1.25"]);
5444
5445        let d = draw("[ print \"inside\"; box ]\nprint 7");
5446        assert_eq!(d.diagnostics, ["inside", "7"]);
5447    }
5448
5449    #[test]
5450    fn command_and_sh_are_silent_noops() {
5451        // Policy (#129): `command` raw backend text is never injected and `sh`
5452        // is never executed. Both are tolerated so dpic sources keep
5453        // compiling, and they emit no diagnostic lines and no shapes.
5454        let d = draw("box wid 1 ht 1\nsh \"echo hi\"\ncommand \"</g>\"\nbox wid 1 ht 1");
5455        assert!(d.diagnostics.is_empty(), "{:?}", d.diagnostics);
5456        assert_eq!(d.shapes.len(), 2);
5457
5458        // Geometry flows across the skipped directives unchanged: the second
5459        // box lands exactly where it would without them.
5460        let plain = draw("box wid 1 ht 1\nbox wid 1 ht 1");
5461        assert_eq!(d.bbox, plain.bbox);
5462    }
5463
5464    #[test]
5465    fn gradient_style_records_stops_and_angle() {
5466        let d = draw("box gradient \"steelblue\" \"white\" gradientangle 45");
5467        let Shape::Box { style, .. } = &d.shapes[0] else {
5468            panic!()
5469        };
5470        let g = style.gradient.as_ref().expect("expected gradient");
5471        assert_eq!(g.from, "steelblue");
5472        assert_eq!(g.to, "white");
5473        assert!((g.angle - 45.0).abs() < 1e-9);
5474        assert!(style.fill_open);
5475
5476        // gradientangle alone creates the default black-to-white gradient,
5477        // mirroring how `hatchangle` alone creates a default hatch
5478        let d = draw("box gradientangle 90");
5479        let Shape::Box { style, .. } = &d.shapes[0] else {
5480            panic!()
5481        };
5482        let g = style.gradient.as_ref().unwrap();
5483        assert_eq!((g.from.as_str(), g.to.as_str()), ("black", "white"));
5484    }
5485
5486    fn fake_math(tex: &str, font_pt: f64) -> Result<crate::math::MathSpan, String> {
5487        if tex.contains("boom") {
5488            return Err("fake parse error".into());
5489        }
5490        let em = font_pt / 72.0;
5491        Ok(crate::math::MathSpan {
5492            svg: format!("<svg width=\"9.6\" height=\"14.08\"><!--{tex}--></svg>"),
5493            width: 2.0 * em,
5494            height: 0.8 * em,
5495            depth: 0.2 * em,
5496        })
5497    }
5498
5499    #[test]
5500    fn texlabels_routes_dollar_labels_through_the_math_hook() {
5501        crate::math::set_math_renderer(fake_math);
5502
5503        // off by default: no math span even with a renderer registered
5504        let d = draw("box \"$x$\"");
5505        let Shape::Box { text, .. } = &d.shapes[0] else {
5506            panic!()
5507        };
5508        assert!(text[0].math.is_none());
5509
5510        // on: fully $-delimited labels are typeset; others stay literal
5511        let d = draw("texlabels = 1\nbox \"$x$\" \"plain\" \"$a$b$\"");
5512        let Shape::Box { text, .. } = &d.shapes[0] else {
5513            panic!()
5514        };
5515        let m = text[0].math.as_ref().expect("math span");
5516        assert!((m.width - 2.0 * 11.0 / 72.0).abs() < 1e-9);
5517        assert!(text[0].s.contains("$x$")); // literal kept for fallback
5518        assert!(text[1].math.is_none());
5519        assert!(text[2].math.is_none()); // inner `$` disqualifies
5520
5521        // exact metrics drive the text bbox (2 em wide, not 3 chars * 0.6 em)
5522        let d = draw("texlabels = 1\n\"$x$\" at (0,0)");
5523        assert!(
5524            (d.bbox.width() - 2.0 * 11.0 / 72.0).abs() < 0.02,
5525            "{}",
5526            d.bbox.width()
5527        );
5528
5529        // renderer failure: literal fallback plus a diagnostic, never an error
5530        let d = draw("texlabels = 1\nbox \"$boom$\"");
5531        let Shape::Box { text, .. } = &d.shapes[0] else {
5532            panic!()
5533        };
5534        assert!(text[0].math.is_none());
5535        assert!(
5536            d.diagnostics.iter().any(|l| l.contains("fake parse error")),
5537            "{:?}",
5538            d.diagnostics
5539        );
5540    }
5541
5542    #[test]
5543    fn dot_is_a_solid_circle_with_dotrad_default() {
5544        let d = draw("dot at (0.5, 0.5)");
5545        let Shape::Circle { r, style, .. } = &d.shapes[0] else {
5546            panic!()
5547        };
5548        assert!((r - 0.035).abs() < 1e-9);
5549        assert_eq!(style.fill, Some(Fill::Gray(0.0)));
5550
5551        // dotrad env var + attribute overrides
5552        let d = draw("dotrad = 0.06\ndot\ndot rad 0.1 shaded \"red\"");
5553        let Shape::Circle { r, .. } = &d.shapes[0] else {
5554            panic!()
5555        };
5556        assert!((r - 0.06).abs() < 1e-9);
5557        let Shape::Circle { r, style, .. } = &d.shapes[1] else {
5558            panic!()
5559        };
5560        assert!((r - 0.1).abs() < 1e-9);
5561        assert_eq!(style.fill, Some(Fill::Color("red".into())));
5562
5563        // contextual: dot stays usable as a variable
5564        let d = draw("dot = 2\nbox wid dot ht 0.3");
5565        let Shape::Box { w, .. } = &d.shapes[0] else {
5566            panic!()
5567        };
5568        assert!((w - 2.0).abs() < 1e-9);
5569
5570        // dots are circles for ordinals
5571        let d = draw("dot at (0,0)\nbox at last circle + (0.5, 0)");
5572        assert_eq!(d.shapes.len(), 2);
5573    }
5574
5575    #[test]
5576    fn class_attribute_and_statement_set_shape_classes() {
5577        // inline attribute, and append composition
5578        let d = draw("box class \"critical\" class \"hot\"");
5579        assert_eq!(d.shape_classes[0].as_deref(), Some("critical hot"));
5580
5581        // statement form by label, by ordinal, and reaching macro-drawn shapes
5582        let d = draw(
5583            "define wire { line right 0.5 }\nA: box\nwire()\nclass A \"node\"\nclass last line \"bus\"",
5584        );
5585        assert_eq!(d.shape_classes[0].as_deref(), Some("node"));
5586        assert_eq!(d.shape_classes[1].as_deref(), Some("bus"));
5587
5588        // `class` stays usable as a plain variable
5589        let d = draw("class = 2\nbox wid class ht 1");
5590        let Shape::Box { w, .. } = &d.shapes[0] else {
5591            panic!()
5592        };
5593        assert!((w - 2.0).abs() < 1e-9);
5594    }
5595
5596    #[test]
5597    fn class_validates_names_and_targets() {
5598        let e = eval(&parse("box class \"a<b\"").unwrap()).unwrap_err();
5599        assert!(e.msg.contains("invalid class name"), "{e}");
5600
5601        let e = eval(&parse("box class \"2fast\"").unwrap()).unwrap_err();
5602        assert!(e.msg.contains("invalid class name"), "{e}");
5603
5604        let e = eval(&parse("A: (0,0)\nclass A \"x\"").unwrap()).unwrap_err();
5605        assert!(e.msg.contains("no drawn shape"), "{e}");
5606    }
5607
5608    #[test]
5609    fn class_composes_with_animate_on_the_same_shape() {
5610        // The class hook and the animation layer share the `s<N>` contract:
5611        // both must resolve to the same shape index, and adding a class must
5612        // not disturb the animation target.
5613        let d = draw(
5614            "A: box\ncircle\nanimate A with \"pop\"\nclass A \"critical\"\nanimate last circle with \"fade\"\nclass last circle \"soft\"",
5615        );
5616        assert_eq!(d.anims.len(), 2);
5617        assert_eq!(d.anims[0].shape, 0);
5618        assert_eq!(d.anims[1].shape, 1);
5619        assert_eq!(d.shape_classes[0].as_deref(), Some("critical"));
5620        assert_eq!(d.shape_classes[1].as_deref(), Some("soft"));
5621    }
5622
5623    #[test]
5624    fn class_inside_block_survives_flattening() {
5625        let d = draw("[ box class \"in\"; circle ]");
5626        assert_eq!(d.shape_classes[0].as_deref(), Some("in"));
5627        assert_eq!(d.shape_classes[1], None);
5628
5629        let e = eval(&parse("[ box ] class \"x\"").unwrap()).unwrap_err();
5630        assert!(e.msg.contains("block"), "{e}");
5631    }
5632
5633    #[test]
5634    fn open_object_width_height_attrs_are_arrowhead_dimensions() {
5635        let d = draw("arrowwid = 0.2; arrowht = 0.3\nA: line right 2\nbox wid (A.wid) ht (A.ht)");
5636        assert_box_size(&d.shapes[1], 0.2, 0.3);
5637
5638        let d = draw("arrowwid = 0.12; arrowht = 0.34\nA: move right 2\nbox wid (A.wid) ht (A.ht)");
5639        assert_box_size(&d.shapes[1], 0.12, 0.34);
5640
5641        let d = draw(
5642            "arrowwid = 0.23; arrowht = 0.31\nA: spline from (0,0) to (2,1)\nbox wid (A.wid) ht (A.ht)",
5643        );
5644        assert_box_size(&d.shapes[1], 0.23, 0.31);
5645    }
5646
5647    #[test]
5648    fn radius_and_diameter_attrs_are_type_specific() {
5649        let d = draw("B: box rad 0.1 wid 1 ht 1\nbox wid (B.rad) ht 0.3");
5650        assert_box_size(&d.shapes[1], 0.1, 0.3);
5651
5652        let d = draw("C: arc rad 0.7 from (0,0) to (0,1.4)\nbox wid (C.rad) ht (C.diam)");
5653        assert_box_size(&d.shapes[1], 0.7, 1.4);
5654    }
5655
5656    #[test]
5657    fn invalid_type_scalar_attrs_match_dpic_zero() {
5658        let prog = parse(
5659            "E: ellipse wid 2 ht 1\nB: box wid 1 ht 1\nA: arc rad .5\n\
5660             e_rad = E.rad\ne_diam = E.diam\nb_diam = B.diam\na_len = A.len",
5661        )
5662        .unwrap();
5663        let mut st = State::new();
5664        st.eval_stmts(&prog.stmts).unwrap();
5665        assert_eq!(st.vars["e_rad"], 0.0);
5666        assert_eq!(st.vars["e_diam"], 0.0);
5667        assert_eq!(st.vars["b_diam"], 0.0);
5668        assert_eq!(st.vars["a_len"], 0.0);
5669    }
5670
5671    #[test]
5672    fn arrowhead_type_open_vs_filled() {
5673        // default is a filled head; `arrowhead = 0` is an open (two-stroke) head
5674        let d = draw("arrow right 1");
5675        let Shape::Path { style, .. } = &d.shapes[0] else {
5676            panic!()
5677        };
5678        assert!(style.arrow_filled, "default should be filled");
5679        let d2 = draw("arrowhead = 0\narrow right 1");
5680        let Shape::Path { style, .. } = &d2.shapes[0] else {
5681            panic!()
5682        };
5683        assert!(!style.arrow_filled, "arrowhead=0 should be open");
5684
5685        let d3 = draw("arrowhead = 0\nline <- 1 up");
5686        let Shape::Path { style, .. } = &d3.shapes[0] else {
5687            panic!()
5688        };
5689        assert!(style.arrow_filled, "`<- 1` should override the global");
5690
5691        let d4 = draw("line <- 0 up");
5692        let Shape::Path { style, .. } = &d4.shapes[0] else {
5693            panic!()
5694        };
5695        assert!(!style.arrow_filled, "`<- 0` should override the global");
5696    }
5697
5698    #[test]
5699    fn maxps_clamps_oversized_drawing() {
5700        // larger than the default 8.5x11in page → scaled down to fit
5701        let d = draw("box wid 20 ht 30");
5702        assert!(
5703            d.bbox.width() <= 8.5 + 1e-6 && d.bbox.height() <= 11.0 + 1e-6,
5704            "{}x{}",
5705            d.bbox.width(),
5706            d.bbox.height()
5707        );
5708        // raising the limits disables the clamp
5709        let d2 = draw("maxpsht = 200; maxpswid = 50\nbox wid 20 ht 30");
5710        assert!(
5711            (d2.bbox.height() - (30.0 + DEFAULT_STROKE_IN)).abs() < 1e-6,
5712            "h = {}",
5713            d2.bbox.height()
5714        );
5715        // a small drawing is untouched
5716        let d3 = draw("box wid 2 ht 1");
5717        assert!((d3.bbox.width() - (2.0 + DEFAULT_STROKE_IN)).abs() < 1e-6);
5718
5719        let d4 = draw("maxpswid = 2; maxpsht = 100\nmargin = 1\nbox wid 1 ht 0.5");
5720        assert!(
5721            d4.bbox.width() + d4.canvas_margin.horizontal() <= 2.0 + 1e-6,
5722            "canvas width = {}",
5723            d4.bbox.width() + d4.canvas_margin.horizontal()
5724        );
5725        assert!(
5726            d4.canvas_margin.left < 1.0 && d4.canvas_margin.right < 1.0,
5727            "{:?}",
5728            d4.canvas_margin
5729        );
5730    }
5731
5732    #[test]
5733    fn block_variable_assignments_are_local() {
5734        let d = draw("x = 1\n[ x = 5 ]\nbox wid x ht 0.3");
5735        let Shape::Box { w, .. } = d.shapes.last().unwrap() else {
5736            panic!()
5737        };
5738        assert!((*w - 1.0).abs() < 1e-9, "w = {w}");
5739
5740        assert!(eval(&parse("[ x = 5 ]\nbox wid x ht 0.3").unwrap()).is_err());
5741    }
5742
5743    #[test]
5744    fn block_env_assignments_are_local() {
5745        let d = draw("[ boxwid = 2; box ]\nbox");
5746        let Shape::Box { w, .. } = &d.shapes[0] else {
5747            panic!()
5748        };
5749        assert!((*w - 2.0).abs() < 1e-9, "inner w = {w}");
5750        let Shape::Box { w, .. } = &d.shapes[1] else {
5751            panic!()
5752        };
5753        assert!((*w - 0.75).abs() < 1e-9, "outer w = {w}");
5754    }
5755
5756    #[test]
5757    fn block_mutating_var_assignments_update_inherited_vars() {
5758        let d = draw("x = 1\n[ x := 5 ]\nbox wid x ht 0.3");
5759        let Shape::Box { w, .. } = d.shapes.last().unwrap() else {
5760            panic!()
5761        };
5762        assert!((*w - 5.0).abs() < 1e-9, "w = {w}");
5763
5764        let d = draw("x = 1\n[ x += 2 ]\nbox wid x ht 0.3");
5765        let Shape::Box { w, .. } = d.shapes.last().unwrap() else {
5766            panic!()
5767        };
5768        assert!((*w - 3.0).abs() < 1e-9, "w = {w}");
5769
5770        assert!(eval(&parse("[ x = 1; x += 2 ]\nbox wid x ht 0.3").unwrap()).is_err());
5771
5772        let d = draw("boxwid = 0.75\n[ boxwid := 2; box ]\nbox");
5773        let Shape::Box { w, .. } = &d.shapes[1] else {
5774            panic!()
5775        };
5776        assert!((*w - 0.75).abs() < 1e-9, "outer w = {w}");
5777    }
5778
5779    #[test]
5780    fn figuras_examples_compile() {
5781        // a few of André Leite's circuit_macros figures (examples/figuras/),
5782        // adapted with the compatibility shim — they must keep compiling/drawing.
5783        for src in [
5784            include_str!("../../../examples/figuras/fig01.pic"),
5785            include_str!("../../../examples/figuras/fig36.pic"),
5786            include_str!("../../../examples/figuras/fig40.pic"),
5787        ] {
5788            let d = eval(&parse(src).unwrap()).unwrap();
5789            assert!(!d.shapes.is_empty());
5790        }
5791    }
5792
5793    #[test]
5794    fn figuras_element_examples_compile() {
5795        // André Leite's circuit_macros figures that use the *element API*
5796        // (resistor(dir len), bi_tr, opamp, …). These render with the circuit
5797        // library (-c) plus the compatibility shim, which reuses the native
5798        // element geometry. The shim is `copy`-d in by each file; here we splice
5799        // it in directly and prepend the circuit library.
5800        let shim = include_str!("../../../examples/figuras/circuit_macros.pic");
5801        for body in [
5802            include_str!("../../../examples/figuras/fig21.pic"),
5803            include_str!("../../../examples/figuras/fig23.pic"),
5804            include_str!("../../../examples/figuras/fig26.pic"),
5805            include_str!("../../../examples/figuras/fig27.pic"),
5806            include_str!("../../../examples/figuras/fig28.pic"),
5807            include_str!("../../../examples/figuras/fig30.pic"),
5808            include_str!("../../../examples/figuras/fig33.pic"),
5809            include_str!("../../../examples/figuras/fig45.pic"),
5810            include_str!("../../../examples/figuras/fig46.pic"),
5811            include_str!("../../../examples/figuras/fig09.pic"),
5812            include_str!("../../../examples/figuras/fig11.pic"),
5813        ] {
5814            let body = body.replace("copy \"circuit_macros.pic\"", shim);
5815            let src = format!("{}\n{}", crate::CIRCUITS, body);
5816            let d = eval(&parse(&src).unwrap()).unwrap();
5817            assert!(!d.shapes.is_empty());
5818        }
5819    }
5820
5821    #[test]
5822    fn lib3d_examples_compile() {
5823        // The lib3D shim (3D -> 2D axonometric projection) and its demos must
5824        // keep compiling and drawing. The demos `copy` the shim; splice it in.
5825        let shim = include_str!("../../../examples/lib3d/lib3d.pic");
5826        for body in [
5827            include_str!("../../../examples/lib3d/frame.pic"),
5828            include_str!("../../../examples/lib3d/views.pic"),
5829        ] {
5830            let src = body.replace("copy \"lib3d.pic\"", shim);
5831            let d = eval(&parse(&src).unwrap()).unwrap();
5832            assert!(!d.shapes.is_empty());
5833        }
5834    }
5835
5836    #[test]
5837    fn brace_ncount_as_place() {
5838        // `{expr}th last box` — a brace-counted ordinal used as a place
5839        let d = draw(
5840            "box at 0,0\nbox at 2,0\nbox at 4,0\narrow from {2}th last box.e to {1}th last box.w",
5841        );
5842        let Shape::Path { pts, .. } = d.shapes.last().unwrap() else {
5843            panic!()
5844        };
5845        assert!(pts[0].x > 2.0 && pts.last().unwrap().x < 4.0, "{pts:?}");
5846    }
5847
5848    #[test]
5849    fn dpic_unit_suffix() {
5850        // `72bp__` == 72 * scale/72 == 1 inch
5851        let d = draw("box wid 72bp__ ht 0.3");
5852        let Shape::Box { w, .. } = &d.shapes[0] else {
5853            panic!()
5854        };
5855        assert!((*w - 1.0).abs() < 1e-9, "w = {w}");
5856    }
5857
5858    #[test]
5859    fn block_sees_outer_labels() {
5860        // a label defined before a block is visible (read-only) inside it
5861        let d = draw("A: (0,0)\n[ line from A to (2,0) ]");
5862        assert!(
5863            d.shapes.iter().any(|s| matches!(s, Shape::Path { .. })),
5864            "block should draw a line referencing the outer label A"
5865        );
5866        // outer labels must not pollute the block's `last`/nth: a box drawn
5867        // before the block isn't the block's `last box`.
5868        assert!(eval(&parse("box\n[ circle; \"x\" at last box ]").unwrap()).is_err());
5869    }
5870
5871    #[test]
5872    fn arg_count_macro() {
5873        // `$+` is the number of arguments passed to the current macro
5874        let d = draw("define cnt { $+ }\nx = cnt(a, b, c)\nbox wid x ht 0.3");
5875        let Shape::Box { w, .. } = &d.shapes[0] else {
5876            panic!()
5877        };
5878        assert!((*w - 3.0).abs() < 1e-9, "w = {w}");
5879    }
5880
5881    #[test]
5882    fn exec_evaluates_generated_pic_in_macro_arg_scope() {
5883        let d = draw(
5884            "define array { for i_array=2 to $+ do { exec sprintf(\"$1[%g] = $%g\", i_array-1, i_array) } }\narray(a, 0, 1, 3)\nbox wid a[2] ht a[3]",
5885        );
5886        let Shape::Box { w, h, .. } = &d.shapes[0] else {
5887            panic!()
5888        };
5889        assert!(
5890            (*w - 1.0).abs() < 1e-9 && (*h - 3.0).abs() < 1e-9,
5891            "{w} x {h}"
5892        );
5893    }
5894
5895    #[test]
5896    fn exec_unescapes_generated_quoted_text() {
5897        let d = draw("exec sprintf(\"\\\"x\\\" at Here\")");
5898        let Shape::Text { text, .. } = &d.shapes[0] else {
5899            panic!()
5900        };
5901        assert_eq!(text[0].s, "x");
5902    }
5903
5904    #[test]
5905    fn macro_token_pasting_concatenates_adjacent_args() {
5906        let d = draw("define mark { $1$2: (1,0) }\nmark(A,B)\nbox wid 0.2 ht 0.2 at AB");
5907        let Shape::Box { c, .. } = &d.shapes[0] else {
5908            panic!()
5909        };
5910        assert!(c.dist(Point::new(1.0, 0.0)) < 1e-9, "c = {c:?}");
5911    }
5912
5913    #[test]
5914    fn macro_string_substitution_preserves_dot_prefixed_arguments() {
5915        let d = draw("define label { \"$1\"; \"$2\" }\nlabel(.ne,above)");
5916        let labels: Vec<&str> = d
5917            .shapes
5918            .iter()
5919            .filter_map(|shape| match shape {
5920                Shape::Text { text, .. } => Some(text[0].s.as_str()),
5921                _ => None,
5922            })
5923            .collect();
5924        assert_eq!(labels, [".ne", "above"]);
5925    }
5926
5927    #[test]
5928    fn recursive_macro_terminates() {
5929        // a self-calling macro bounded by `if`: textual pre-expansion would
5930        // diverge, but lazy (eval-time) expansion of the taken branch stops it.
5931        let d = draw("define rec { if $1 <= 0 then { circle } else { box; rec($1-1) } }\nrec(3)");
5932        let boxes = d
5933            .shapes
5934            .iter()
5935            .filter(|s| matches!(s, Shape::Box { .. }))
5936            .count();
5937        let circles = d
5938            .shapes
5939            .iter()
5940            .filter(|s| matches!(s, Shape::Circle { .. }))
5941            .count();
5942        assert_eq!((boxes, circles), (3, 1), "shapes = {:?}", d.shapes.len());
5943    }
5944
5945    #[test]
5946    fn default_argument_idiom() {
5947        // empty argument: the dead `else { w = $1 }` becomes `w =`, which must
5948        // not be parsed because the then-branch is taken.
5949        let d = draw(
5950            "define b { if \"$1\"==\"\" then { w = 1 } else { w = $1 }\n box wid w ht 0.2 }\nb()",
5951        );
5952        let Shape::Box { w, .. } = &d.shapes[0] else {
5953            panic!()
5954        };
5955        assert!((*w - 1.0).abs() < 1e-9, "w = {w}");
5956        // and with an argument supplied, the else-branch value is used
5957        let d2 = draw(
5958            "define b { if \"$1\"==\"\" then { w = 1 } else { w = $1 }\n box wid w ht 0.2 }\nb(2.5)",
5959        );
5960        let Shape::Box { w, .. } = &d2.shapes[0] else {
5961            panic!()
5962        };
5963        assert!((*w - 2.5).abs() < 1e-9, "w = {w}");
5964    }
5965
5966    #[test]
5967    fn last_ordinal() {
5968        let d = draw("box at 0,0\nbox at 2,0\narrow from 1st box.e to 2nd box.w");
5969        let Shape::Path { pts, .. } = &d.shapes[2] else {
5970            panic!()
5971        };
5972        // from first box east edge to second box west edge
5973        assert!(pts[0].x > 0.0 && pts.last().unwrap().x < 2.0);
5974    }
5975
5976    #[test]
5977    fn untyped_last_references_any_kind() {
5978        // `last.c` after a circle resolves to that circle (no `last circle`).
5979        let d = draw("circle rad 0.5 at (3,1)\n\"x\" at last.c");
5980        let Shape::Text { at, .. } = d.shapes.last().unwrap() else {
5981            panic!()
5982        };
5983        assert!((at.x - 3.0).abs() < 1e-9 && (at.y - 1.0).abs() < 1e-9);
5984    }
5985
5986    #[test]
5987    fn untyped_last_corner_after_box() {
5988        // `last.n` (north of the most recent object, whatever its kind).
5989        let d = draw("box wid 2 ht 1 at (0,0)\n\"y\" at last.n");
5990        let Shape::Text { at, .. } = d.shapes.last().unwrap() else {
5991            panic!()
5992        };
5993        assert!((at.x - 0.0).abs() < 1e-9 && (at.y - 0.5).abs() < 1e-9);
5994    }
5995
5996    #[test]
5997    fn untyped_nth_last_spans_kinds() {
5998        // `2nd last` counts across kinds: box, then circle -> 2nd last is the box.
5999        let d = draw("box at (0,0)\ncircle at (2,0)\n\"z\" at 2nd last.c");
6000        let Shape::Text { at, .. } = d.shapes.last().unwrap() else {
6001            panic!()
6002        };
6003        assert!((at.x - 0.0).abs() < 1e-9, "x = {}", at.x);
6004    }
6005
6006    #[test]
6007    fn typed_last_still_filters_by_kind() {
6008        // an explicit type keyword keeps filtering: `last box` skips the circle.
6009        let d = draw("box at (0,0)\ncircle at (2,0)\n\"w\" at last box.c");
6010        let Shape::Text { at, .. } = d.shapes.last().unwrap() else {
6011            panic!()
6012        };
6013        assert!((at.x - 0.0).abs() < 1e-9, "x = {}", at.x);
6014    }
6015
6016    #[test]
6017    fn untyped_last_with_no_object_errors() {
6018        assert!(eval(&parse("\"q\" at last.c").unwrap()).is_err());
6019    }
6020}