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