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::geom::{Bbox, Point};
19use crate::ir::*;
20use crate::token::{self, Corner, Dir, EnvVar, Func1, Func2, LineType, Prim};
21
22/// An evaluation error.
23#[derive(Debug, Clone, PartialEq)]
24pub struct EvalError {
25    pub msg: String,
26}
27
28impl std::fmt::Display for EvalError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(f, "{}", self.msg)
31    }
32}
33
34type ER<T> = Result<T, EvalError>;
35
36const DP_TEXT_RATIO: f64 = 0.66;
37
38fn err<T>(msg: impl Into<String>) -> ER<T> {
39    Err(EvalError { msg: msg.into() })
40}
41
42/// Evaluate a parsed picture into a [`Drawing`].
43pub fn eval(pic: &Picture) -> ER<Drawing> {
44    let mut st = State::new();
45    st.macros = pic.macros.clone();
46    st.base_dir = pic.base_dir.clone();
47    st.eval_stmts(&pic.stmts)?;
48    let want_w = match &pic.width {
49        Some(e) => Some(st.eval_expr(e)?),
50        None => None,
51    };
52    let want_h = match &pic.height {
53        Some(e) => Some(st.eval_expr(e)?),
54        None => None,
55    };
56    let (maxw, maxh) = (st.env.get(EnvVar::Maxpswid), st.env.get(EnvVar::Maxpsht));
57    let canvas_margin = st.canvas_margin()?;
58    let mut d = Drawing {
59        shapes: st.shapes,
60        bbox: st.bbox,
61        prelude_thick: st.env.get(EnvVar::Linethick),
62        canvas_margin,
63        anims: st.anims,
64        diagnostics: st.diagnostics,
65    };
66    apply_ps_size(&mut d, want_w, want_h);
67    clamp_to_maxps(&mut d, maxw, maxh);
68    Ok(d)
69}
70
71/// Clamp the drawing to the `maxpswid`/`maxpsht` page bounds: if it exceeds
72/// either, scale the whole picture down uniformly to fit (never up), matching
73/// pic's PostScript page-fit behaviour.
74fn clamp_to_maxps(d: &mut Drawing, maxw: f64, maxh: f64) {
75    for _ in 0..4 {
76        if d.bbox.is_empty() {
77            return;
78        }
79        let (w, h) = (canvas_width(d), canvas_height(d));
80        let mut factor = 1.0_f64;
81        if maxw > 0.0 && w > maxw {
82            factor = factor.min(maxw / w);
83        }
84        if maxh > 0.0 && h > maxh {
85            factor = factor.min(maxh / h);
86        }
87        if factor >= 1.0 - 1e-9 {
88            return;
89        }
90        for sh in &mut d.shapes {
91            scale_shape(sh, factor);
92        }
93        d.prelude_thick *= factor;
94        d.canvas_margin.scale_by(factor);
95        d.bbox = drawing_painted_bbox(&d.shapes);
96    }
97}
98
99/// Apply `.PS <width> [<height>]` sizing: uniformly scale the whole drawing so
100/// it matches the requested width (or height if only height is given). Font size
101/// is left unchanged.
102fn apply_ps_size(d: &mut Drawing, want_w: Option<f64>, want_h: Option<f64>) {
103    if d.bbox.is_empty() {
104        return;
105    }
106    let factor = match (want_w, want_h) {
107        (Some(w), _) if w > 0.0 && d.bbox.width() > 0.0 => w / d.bbox.width(),
108        (None, Some(h)) if h > 0.0 && d.bbox.height() > 0.0 => h / d.bbox.height(),
109        _ => return,
110    };
111    if (factor - 1.0).abs() < 1e-9 {
112        return;
113    }
114    for sh in &mut d.shapes {
115        scale_shape(sh, factor);
116    }
117    d.prelude_thick *= factor;
118    d.canvas_margin.scale_by(factor);
119    d.bbox = drawing_painted_bbox(&d.shapes);
120}
121
122fn canvas_width(d: &Drawing) -> f64 {
123    d.bbox.width() + d.canvas_margin.horizontal()
124}
125
126fn canvas_height(d: &Drawing) -> f64 {
127    d.bbox.height() + d.canvas_margin.vertical()
128}
129
130/// The dimension variables that track `scale`.
131const SCALED_VARS: [EnvVar; 22] = [
132    EnvVar::Arcrad,
133    EnvVar::Arrowht,
134    EnvVar::Arrowwid,
135    EnvVar::Boxht,
136    EnvVar::Boxrad,
137    EnvVar::Boxwid,
138    EnvVar::Circlerad,
139    EnvVar::Dashwid,
140    EnvVar::Ellipseht,
141    EnvVar::Ellipsewid,
142    EnvVar::Lineht,
143    EnvVar::Linewid,
144    EnvVar::Moveht,
145    EnvVar::Movewid,
146    EnvVar::Textht,
147    EnvVar::Textwid,
148    EnvVar::Textoffset,
149    EnvVar::Margin,
150    EnvVar::Topmargin,
151    EnvVar::Rightmargin,
152    EnvVar::Bottommargin,
153    EnvVar::Leftmargin,
154];
155
156// ---- environment variables -------------------------------------------------
157
158#[derive(Clone)]
159struct EnvVars {
160    v: HashMap<u8, f64>,
161}
162
163fn ev_key(e: EnvVar) -> u8 {
164    e as u8
165}
166
167impl EnvVars {
168    fn new() -> Self {
169        use EnvVar::*;
170        let defaults = [
171            (Arcrad, 0.25),
172            (Arrowht, 0.1),
173            (Arrowwid, 0.05),
174            (Boxht, 0.5),
175            (Boxrad, 0.0),
176            (Boxwid, 0.75),
177            (Circlerad, 0.25),
178            (Dashwid, 0.05),
179            (Ellipseht, 0.5),
180            (Ellipsewid, 0.75),
181            (Lineht, 0.5),
182            (Linewid, 0.5),
183            (Moveht, 0.5),
184            (Movewid, 0.5),
185            (Textht, (11.0 / 72.0) * DP_TEXT_RATIO),
186            (Textoffset, 2.0 / 72.0),
187            (Textwid, 0.0),
188            (Arrowhead, 1.0),
189            (Fillval, 0.5),
190            (Linethick, 0.8),
191            (Maxpsht, 11.0),
192            (Maxpswid, 8.5),
193            (Scale, 1.0),
194            (Margin, 0.0),
195            (Topmargin, 0.0),
196            (Rightmargin, 0.0),
197            (Bottommargin, 0.0),
198            (Leftmargin, 0.0),
199        ];
200        let mut v = HashMap::new();
201        for (e, d) in defaults {
202            v.insert(ev_key(e), d);
203        }
204        EnvVars { v }
205    }
206    fn get(&self, e: EnvVar) -> f64 {
207        *self.v.get(&ev_key(e)).unwrap_or(&0.0)
208    }
209    fn set(&mut self, e: EnvVar, val: f64) {
210        self.v.insert(ev_key(e), val);
211    }
212}
213
214// ---- placed-object bookkeeping ---------------------------------------------
215
216#[derive(Clone, Copy, PartialEq, Eq)]
217enum PKind {
218    Box,
219    Circle,
220    Ellipse,
221    Line,
222    Move,
223    Spline,
224    Arc,
225    Block,
226    Text,
227}
228
229#[derive(Clone)]
230struct Placed {
231    kind: PKind,
232    center: Point,
233    bbox: Bbox,
234    start: Point,
235    end: Point,
236    thick: f64,
237    points: Vec<Point>,
238    radius: f64,
239    box_rad: f64,
240    line_wid: f64,
241    line_ht: f64,
242    /// Index of the primary shape in `shapes` (None for point-only labels).
243    shape: Option<usize>,
244    /// For blocks: inner labels (sub-objects), translated into parent
245    /// coordinates, so `B.A` / `last [].Outer` resolve. Empty otherwise.
246    members: HashMap<String, Placed>,
247}
248
249impl Placed {
250    fn corner(&self, c: Corner) -> Point {
251        match self.kind {
252            PKind::Circle | PKind::Ellipse => self.ellipse_corner(c),
253            PKind::Line | PKind::Move | PKind::Spline => self.linear_corner(c),
254            PKind::Arc => self.arc_corner(c),
255            PKind::Box => self.box_corner(c),
256            PKind::Block | PKind::Text => self.bbox_corner(c),
257        }
258    }
259
260    fn bbox_corner(&self, c: Corner) -> Point {
261        let (lo, hi) = (self.bbox.min, self.bbox.max);
262        let mid = self.center;
263        match c {
264            Corner::N => Point::new(mid.x, hi.y),
265            Corner::S => Point::new(mid.x, lo.y),
266            Corner::E => Point::new(hi.x, mid.y),
267            Corner::W => Point::new(lo.x, mid.y),
268            Corner::Ne => Point::new(hi.x, hi.y),
269            Corner::Se => Point::new(hi.x, lo.y),
270            Corner::Nw => Point::new(lo.x, hi.y),
271            Corner::Sw => Point::new(lo.x, lo.y),
272            Corner::Center => mid,
273            Corner::Start => self.start,
274            Corner::End => self.end,
275        }
276    }
277
278    fn ellipse_corner(&self, c: Corner) -> Point {
279        let (rx, ry) = (self.bbox.width() / 2.0, self.bbox.height() / 2.0);
280        let diag = |sx: f64, sy: f64| Point::new(sx * rx * FRAC_1_SQRT_2, sy * ry * FRAC_1_SQRT_2);
281        let off = match c {
282            Corner::N => Point::new(0.0, ry),
283            Corner::S => Point::new(0.0, -ry),
284            Corner::E => Point::new(rx, 0.0),
285            Corner::W => Point::new(-rx, 0.0),
286            Corner::Ne => diag(1.0, 1.0),
287            Corner::Se => diag(1.0, -1.0),
288            Corner::Nw => diag(-1.0, 1.0),
289            Corner::Sw => diag(-1.0, -1.0),
290            Corner::Center => Point::ZERO,
291            Corner::Start => return self.start,
292            Corner::End => return self.end,
293        };
294        self.center + off
295    }
296
297    fn linear_corner(&self, c: Corner) -> Point {
298        if self.points.is_empty() {
299            return self.bbox_corner(c);
300        }
301        match c {
302            Corner::Center => (self.points[0] + *self.points.last().unwrap()) * 0.5,
303            Corner::Start => self.points[0],
304            Corner::End => *self.points.last().unwrap(),
305            _ => {
306                let mut best = self.points[0];
307                for p in self.points.iter().skip(1) {
308                    let better = match c {
309                        Corner::N => p.y > best.y,
310                        Corner::S => p.y < best.y,
311                        Corner::E => p.x > best.x,
312                        Corner::W => p.x < best.x,
313                        Corner::Ne => {
314                            (p.y > best.y && p.x >= best.x) || (p.y >= best.y && p.x > best.x)
315                        }
316                        Corner::Se => {
317                            (p.y < best.y && p.x >= best.x) || (p.y <= best.y && p.x > best.x)
318                        }
319                        Corner::Sw => {
320                            (p.y < best.y && p.x <= best.x) || (p.y <= best.y && p.x < best.x)
321                        }
322                        Corner::Nw => {
323                            (p.y > best.y && p.x <= best.x) || (p.y >= best.y && p.x < best.x)
324                        }
325                        Corner::Center | Corner::Start | Corner::End => false,
326                    };
327                    if better {
328                        best = *p;
329                    }
330                }
331                best
332            }
333        }
334    }
335
336    fn arc_corner(&self, c: Corner) -> Point {
337        let diag = self.radius * FRAC_1_SQRT_2;
338        let off = match c {
339            Corner::N => Point::new(0.0, self.radius),
340            Corner::S => Point::new(0.0, -self.radius),
341            Corner::E => Point::new(self.radius, 0.0),
342            Corner::W => Point::new(-self.radius, 0.0),
343            Corner::Ne => Point::new(diag, diag),
344            Corner::Se => Point::new(diag, -diag),
345            Corner::Nw => Point::new(-diag, diag),
346            Corner::Sw => Point::new(-diag, -diag),
347            Corner::Center => Point::ZERO,
348            Corner::Start => return self.start,
349            Corner::End => return self.end,
350        };
351        self.center + off
352    }
353
354    fn box_corner(&self, c: Corner) -> Point {
355        match c {
356            Corner::Ne | Corner::Se | Corner::Nw | Corner::Sw if self.box_rad > 0.0 => {
357                let inset = self
358                    .box_rad
359                    .min(self.bbox.width().abs().min(self.bbox.height().abs()) / 2.0)
360                    * (1.0 - FRAC_1_SQRT_2);
361                let x = self.bbox.width() / 2.0 - inset;
362                let y = self.bbox.height() / 2.0 - inset;
363                let off = match c {
364                    Corner::Ne => Point::new(x, y),
365                    Corner::Se => Point::new(x, -y),
366                    Corner::Nw => Point::new(-x, y),
367                    Corner::Sw => Point::new(-x, -y),
368                    _ => Point::ZERO,
369                };
370                self.center + off
371            }
372            _ => self.bbox_corner(c),
373        }
374    }
375
376    fn attr_width(&self) -> f64 {
377        match self.kind {
378            PKind::Line | PKind::Move | PKind::Spline => self.line_wid,
379            _ => self.bbox.width(),
380        }
381    }
382
383    fn attr_height(&self) -> f64 {
384        match self.kind {
385            PKind::Line | PKind::Move | PKind::Spline => self.line_ht,
386            _ => self.bbox.height(),
387        }
388    }
389
390    fn attr_radius(&self) -> f64 {
391        match self.kind {
392            PKind::Box => self.box_rad,
393            PKind::Circle => self.bbox.width() / 2.0,
394            PKind::Arc => self.radius,
395            _ => 0.0,
396        }
397    }
398
399    fn attr_diameter(&self) -> f64 {
400        match self.kind {
401            PKind::Circle => self.bbox.width(),
402            PKind::Arc => self.radius * 2.0,
403            _ => 0.0,
404        }
405    }
406
407    fn attr_length(&self) -> f64 {
408        match self.kind {
409            PKind::Line | PKind::Move | PKind::Spline => self.start.dist(self.end),
410            _ => 0.0,
411        }
412    }
413}
414
415// ---- evaluator state -------------------------------------------------------
416
417struct State {
418    pos: Point,
419    dir: Dir,
420    vars: HashMap<String, f64>,
421    inherited_vars: HashSet<String>,
422    export_vars: HashSet<String>,
423    env: EnvVars,
424    macros: Macros,
425    base_dir: Option<std::path::PathBuf>,
426    /// Labels visible from an enclosing scope (read-only, in absolute parent
427    /// coordinates). A block may reference outer labels but must not let them
428    /// affect its own `last`/nth/bbox, so they live here, not in `placed`.
429    outer_labels: HashMap<String, Placed>,
430    shapes: Vec<Shape>,
431    placed: Vec<Placed>,
432    labels: HashMap<String, usize>,
433    /// Visible geometry and text only; this becomes the final drawing/viewBox.
434    bbox: Bbox,
435    /// All evaluated geometry, including invisible helpers, for block sizing
436    /// and anchor placement.
437    layout_bbox: Bbox,
438    // animation state
439    anims: Vec<Anim>,
440    diagnostics: Vec<String>,
441    anim_cursor: f64,
442    anim_end: HashMap<usize, f64>,
443    rng: GlibcRand,
444}
445
446const DEFAULT_ANIM_DUR: f64 = 0.6;
447
448fn dir_unit(d: Dir) -> Point {
449    match d {
450        Dir::Right => Point::new(1.0, 0.0),
451        Dir::Left => Point::new(-1.0, 0.0),
452        Dir::Up => Point::new(0.0, 1.0),
453        Dir::Down => Point::new(0.0, -1.0),
454    }
455}
456fn horizontal(d: Dir) -> bool {
457    matches!(d, Dir::Right | Dir::Left)
458}
459
460impl State {
461    fn new() -> Self {
462        let mut vars = HashMap::new();
463        install_dpic_compat_vars(&mut vars);
464        State {
465            pos: Point::ZERO,
466            dir: Dir::Right,
467            vars,
468            inherited_vars: HashSet::new(),
469            export_vars: HashSet::new(),
470            env: EnvVars::new(),
471            macros: HashMap::new(),
472            base_dir: None,
473            outer_labels: HashMap::new(),
474            shapes: Vec::new(),
475            placed: Vec::new(),
476            labels: HashMap::new(),
477            bbox: Bbox::new(),
478            layout_bbox: Bbox::new(),
479            anims: Vec::new(),
480            diagnostics: Vec::new(),
481            anim_cursor: 0.0,
482            anim_end: HashMap::new(),
483            rng: GlibcRand::new(1),
484        }
485    }
486
487    fn eval_stmts(&mut self, stmts: &[Stmt]) -> ER<()> {
488        for s in stmts {
489            self.eval_stmt(s)?;
490        }
491        Ok(())
492    }
493
494    /// Parse a deferred `if`/`for` body now, expanding macros along this path.
495    fn parse_body(&mut self, body: &Body) -> ER<Vec<Stmt>> {
496        crate::parser::parse_body_tokens(body, &mut self.macros, self.base_dir.as_deref())
497            .map_err(|e| EvalError { msg: e.to_string() })
498    }
499
500    fn eval_stmt(&mut self, s: &Stmt) -> ER<()> {
501        match s {
502            Stmt::Direction(d) => {
503                self.dir = *d;
504            }
505            Stmt::Assign(list) => {
506                for a in list {
507                    self.eval_assignment(a)?;
508                }
509            }
510            Stmt::Place { label, pos } => {
511                let p = self.eval_pos(pos)?;
512                let key = self.label_key(label)?;
513                let mut bb = Bbox::new();
514                bb.add(p);
515                let idx = self.placed.len();
516                self.placed.push(Placed {
517                    kind: PKind::Text,
518                    center: p,
519                    bbox: bb,
520                    start: p,
521                    end: p,
522                    thick: 0.0,
523                    points: Vec::new(),
524                    radius: 0.0,
525                    box_rad: 0.0,
526                    line_wid: 0.0,
527                    line_ht: 0.0,
528                    shape: None,
529                    members: HashMap::new(),
530                });
531                self.labels.insert(key, idx);
532            }
533            Stmt::Group(stmts) => {
534                let (pos, dir) = (self.pos, self.dir);
535                self.eval_stmts(stmts)?;
536                self.pos = pos;
537                self.dir = dir;
538            }
539            Stmt::Object { label, object } => {
540                let idx = self.eval_object(object)?;
541                if let Some(l) = label {
542                    let key = self.label_key(l)?;
543                    self.labels.insert(key, idx);
544                }
545            }
546            Stmt::Animate(a) => self.eval_animate(a)?,
547            Stmt::If {
548                cond,
549                then_body,
550                else_body,
551            } => {
552                // only the taken branch is parsed (dead branches may be
553                // syntactically invalid, e.g. an empty default-argument body)
554                if self.eval_expr(cond)? != 0.0 {
555                    let stmts = self.parse_body(then_body)?;
556                    self.eval_stmts(&stmts)?;
557                } else if let Some(e) = else_body {
558                    let stmts = self.parse_body(e)?;
559                    self.eval_stmts(&stmts)?;
560                }
561            }
562            Stmt::For {
563                var,
564                subscript,
565                from,
566                to,
567                by,
568                mult,
569                body,
570            } => {
571                let body = self.parse_body(body)?;
572                let from = self.eval_expr(from)?;
573                let to = self.eval_expr(to)?;
574                let by = self.eval_expr(by)?;
575                let mut v = from;
576                let mut iters = 0u64;
577                const EPS: f64 = 1e-9;
578                loop {
579                    let cont = if *mult {
580                        if by >= 1.0 {
581                            v <= to + EPS
582                        } else {
583                            v >= to - EPS
584                        }
585                    } else if by >= 0.0 {
586                        v <= to + EPS
587                    } else {
588                        v >= to - EPS
589                    };
590                    if !cont {
591                        break;
592                    }
593                    let key = self.indexed_name(var, subscript.as_ref())?;
594                    self.vars.insert(key, v);
595                    self.eval_stmts(&body)?;
596                    let prev = v;
597                    v = if *mult { v * by } else { v + by };
598                    if (v - prev).abs() < f64::EPSILON {
599                        break; // no progress (by 0, or *1) — avoid infinite loop
600                    }
601                    iters += 1;
602                    if iters > 1_000_000 {
603                        return err("for loop exceeded 1,000,000 iterations");
604                    }
605                }
606            }
607            Stmt::Print(item) => match item {
608                PrintItem::Expr(e) => {
609                    let v = self.eval_expr(e)?;
610                    self.diagnostics.push(fmt_num(v));
611                }
612                PrintItem::Str(se) => {
613                    let s = self.eval_stringexpr(se)?;
614                    self.diagnostics.push(s);
615                }
616            },
617            Stmt::Exec { command, arg_frame } => {
618                let src = unescape_exec_source(&self.eval_stringexpr(command)?);
619                let stmts = crate::parser::parse_exec_source(
620                    &src,
621                    &self.macros,
622                    self.base_dir.as_deref(),
623                    arg_frame.as_deref(),
624                )
625                .map_err(|e| EvalError { msg: e.to_string() })?;
626                self.eval_stmts(&stmts)?;
627            }
628            Stmt::Reset(list) => {
629                if list.is_empty() {
630                    self.env = EnvVars::new();
631                } else {
632                    let d = EnvVars::new();
633                    for e in list {
634                        self.env.set(*e, d.get(*e));
635                    }
636                }
637            }
638        }
639        Ok(())
640    }
641
642    fn eval_animate(&mut self, a: &Animate) -> ER<()> {
643        let idx = self.place_index(&a.target)?;
644        let shape = self.placed[idx].shape.ok_or_else(|| EvalError {
645            msg: "cannot animate a point (no drawn shape)".into(),
646        })?;
647        let dur = match &a.duration {
648            Some(e) => self.eval_expr(e)?,
649            None => DEFAULT_ANIM_DUR,
650        };
651        let mut start = match &a.timing {
652            Timing::Sequential => self.anim_cursor,
653            Timing::At(e) => self.eval_expr(e)?,
654            Timing::After(p) => {
655                let i = self.place_index(p)?;
656                let sh = self.placed[i].shape.ok_or_else(|| EvalError {
657                    msg: "`after` target has no animation".into(),
658                })?;
659                *self.anim_end.get(&sh).unwrap_or(&0.0)
660            }
661        };
662        if let Some(d) = &a.delay {
663            start += self.eval_expr(d)?;
664        }
665        let end = start + dur;
666        self.anim_cursor = end;
667        self.anim_end.insert(shape, end);
668        self.anims.push(Anim {
669            shape,
670            effect: stringexpr_lit(&a.effect),
671            start,
672            duration: dur,
673        });
674        Ok(())
675    }
676
677    fn scale_value(&self) -> ER<f64> {
678        let scale = self.env.get(EnvVar::Scale);
679        if scale.abs() < 1e-12 {
680            return err("scale must be non-zero");
681        }
682        Ok(scale)
683    }
684
685    /// Convert a pic dimension from the current user units to internal inches.
686    fn to_internal_dim(&self, v: f64) -> ER<f64> {
687        Ok(v / self.scale_value()?)
688    }
689
690    fn env_dim(&self, e: EnvVar) -> ER<f64> {
691        self.to_internal_dim(self.env.get(e))
692    }
693
694    fn canvas_margin(&self) -> ER<CanvasMargin> {
695        let all = self.env_dim(EnvVar::Margin)?;
696        Ok(CanvasMargin {
697            top: all + self.env_dim(EnvVar::Topmargin)?,
698            right: all + self.env_dim(EnvVar::Rightmargin)?,
699            bottom: all + self.env_dim(EnvVar::Bottommargin)?,
700            left: all + self.env_dim(EnvVar::Leftmargin)?,
701        })
702    }
703
704    fn expr_dim(&mut self, e: &Expr) -> ER<f64> {
705        let v = self.eval_expr(e)?;
706        self.to_internal_dim(v)
707    }
708
709    /// Convert an internal geometric length back to pic's current user units.
710    fn to_user_dim(&self, v: f64) -> f64 {
711        v * self.env.get(EnvVar::Scale)
712    }
713
714    fn eval_assignment(&mut self, a: &Assignment) -> ER<()> {
715        let rhs = self.eval_expr(&a.value)?;
716        match &a.target {
717            AssignTarget::Var(name, subscript) => {
718                let key = self.indexed_name(name, subscript.as_ref())?;
719                let cur = match self.vars.get(&key).copied() {
720                    Some(v) => v,
721                    None if matches!(a.op, AssignOp::Set) => 0.0,
722                    None => return err(format!("variable not found `{key}`")),
723                };
724                let val = apply_op(a.op, cur, rhs)?;
725                if !matches!(a.op, AssignOp::Set) && self.inherited_vars.contains(&key) {
726                    self.export_vars.insert(key.clone());
727                }
728                self.vars.insert(key, val);
729            }
730            AssignTarget::Env(e) => {
731                let cur = self.env.get(*e);
732                let val = apply_op(a.op, cur, rhs)?;
733                if matches!(e, EnvVar::Scale) {
734                    if val.abs() < 1e-12 {
735                        return err("scale must be non-zero");
736                    }
737                    if cur.abs() >= 1e-12 {
738                        self.scale_existing_geometry(cur / val);
739                    }
740                    // Changing `scale` rescales all scaled dimension variables by
741                    // the ratio (dpic semantics). They remain in user units;
742                    // geometry converts them to internal inches by dividing by
743                    // the current scale at use sites.
744                    let ratio = if cur != 0.0 { val / cur } else { val };
745                    for sv in SCALED_VARS {
746                        let v = self.env.get(sv);
747                        self.env.set(sv, v * ratio);
748                    }
749                }
750                self.env.set(*e, val);
751            }
752        }
753        Ok(())
754    }
755
756    fn scale_existing_geometry(&mut self, factor: f64) {
757        if (factor - 1.0).abs() < 1e-12 {
758            return;
759        }
760        self.pos = self.pos * factor;
761        for sh in &mut self.shapes {
762            scale_shape(sh, factor);
763        }
764        for pl in &mut self.placed {
765            scale_placed(pl, factor);
766        }
767        scale_bbox_in_place(&mut self.bbox, factor);
768        scale_bbox_in_place(&mut self.layout_bbox, factor);
769    }
770
771    // ---- objects -----------------------------------------------------------
772
773    fn eval_object(&mut self, obj: &Object) -> ER<usize> {
774        match &obj.kind {
775            ObjectKind::Primitive(p) => match p {
776                Prim::Box | Prim::Circle | Prim::Ellipse => self.closed(*p, obj),
777                Prim::Line | Prim::Arrow | Prim::Move | Prim::Spline => self.open(*p, obj),
778                Prim::Arc => self.arc(obj),
779            },
780            ObjectKind::Text => self.text_obj(obj),
781            ObjectKind::Block(stmts) => self.block(stmts, obj),
782            ObjectKind::Empty => self.block(&[], obj),
783            ObjectKind::Continue => self.continue_obj(obj),
784        }
785    }
786
787    /// `continue`: append another segment to the most recent line/spline,
788    /// extending it from its current end in the current (or given) direction.
789    fn continue_obj(&mut self, obj: &Object) -> ER<usize> {
790        let idx = self
791            .shapes
792            .iter()
793            .rposition(|s| matches!(s, Shape::Path { .. } | Shape::Spline { .. }))
794            .ok_or_else(|| EvalError {
795                msg: "`continue` has no previous line to extend".into(),
796            })?;
797        let start = match &self.shapes[idx] {
798            Shape::Path { pts, .. } | Shape::Spline { pts, .. } => *pts.last().unwrap(),
799            _ => unreachable!(),
800        };
801        let deflen_h = self.env_dim(EnvVar::Linewid)?;
802        let deflen_v = self.env_dim(EnvVar::Lineht)?;
803
804        let mut pts = vec![start];
805        let mut pend = Point::ZERO;
806        let mut any = false;
807        let mut last_dir = self.dir;
808        for a in &obj.attrs {
809            match a {
810                Attr::Direction(d, opt) => {
811                    let dist = match opt {
812                        Some(e) => self.expr_dim(e)?,
813                        None => {
814                            if horizontal(*d) {
815                                deflen_h
816                            } else {
817                                deflen_v
818                            }
819                        }
820                    };
821                    pend = pend + dir_unit(*d) * dist;
822                    last_dir = *d;
823                    any = true;
824                }
825                Attr::Then => {
826                    let np = *pts.last().unwrap() + pend;
827                    pts.push(np);
828                    pend = Point::ZERO;
829                }
830                Attr::To(pos) => {
831                    if pend != Point::ZERO {
832                        let np = *pts.last().unwrap() + pend;
833                        pts.push(np);
834                        pend = Point::ZERO;
835                    }
836                    pts.push(self.eval_pos(pos)?);
837                    any = true;
838                }
839                Attr::By(pos) => {
840                    let dp = self.eval_pos(pos)?;
841                    pend = pend + (dp - Point::ZERO);
842                    any = true;
843                }
844                Attr::Dist(e) => {
845                    let dist = self.expr_dim(e)?;
846                    pend = pend + dir_unit(last_dir) * dist;
847                    any = true;
848                }
849                _ => {}
850            }
851        }
852        if pend != Point::ZERO {
853            let np = *pts.last().unwrap() + pend;
854            pts.push(np);
855        }
856        if pts.len() == 1 && !any {
857            let dist = if horizontal(self.dir) {
858                deflen_h
859            } else {
860                deflen_v
861            };
862            pts.push(start + dir_unit(self.dir) * dist);
863            last_dir = self.dir;
864        }
865
866        let new: Vec<Point> = pts[1..].to_vec();
867        let visible = match &mut self.shapes[idx] {
868            Shape::Path { pts: p, style, .. } | Shape::Spline { pts: p, style, .. } => {
869                p.extend_from_slice(&new);
870                (!style.invis, stroke_half_width(style))
871            }
872            _ => unreachable!(),
873        };
874        let end = *pts.last().unwrap();
875        let mut bb = Bbox::new();
876        for q in &new {
877            self.layout_bbox.add(*q);
878            bb.add(*q);
879        }
880        if visible.0 {
881            self.bbox.union(&painted_bbox(&bb, visible.1));
882        }
883        self.pos = end;
884        self.dir = last_dir;
885
886        let pidx = self.placed.iter().position(|pl| pl.shape == Some(idx));
887        if let Some(pi) = pidx {
888            self.placed[pi].end = end;
889            self.placed[pi].bbox.add(end);
890            self.placed[pi].center = (self.placed[pi].start + end) * 0.5;
891            self.placed[pi].points.extend_from_slice(&new);
892        }
893        Ok(pidx.unwrap_or(idx))
894    }
895
896    fn closed(&mut self, p: Prim, obj: &Object) -> ER<usize> {
897        let style = self.style_of(obj)?;
898        let text = self.text_of(obj)?;
899        let dir = self.dir_of(obj);
900        let scale = self.scale_of(obj)?;
901
902        // dimensions — `same` reuses the previous like-object's size as the
903        // default; explicit ht/wid/rad still override.
904        let prev = if obj.attrs.iter().any(|a| matches!(a, Attr::Same)) {
905            self.last_dims_of(p)
906        } else {
907            None
908        };
909        let (mut w, mut h, mut rad);
910        match p {
911            Prim::Circle => {
912                let def_r = prev
913                    .map(|(pw, _)| pw / 2.0)
914                    .unwrap_or(self.env_dim(EnvVar::Circlerad)?);
915                let r = self.dim(obj, DimKind::Rad)?.unwrap_or(def_r);
916                let r = self.dim(obj, DimKind::Diam)?.map(|d| d / 2.0).unwrap_or(r);
917                w = 2.0 * r;
918                h = 2.0 * r;
919                rad = r;
920            }
921            Prim::Ellipse => {
922                let (dw, dh) = prev.unwrap_or((
923                    self.env_dim(EnvVar::Ellipsewid)?,
924                    self.env_dim(EnvVar::Ellipseht)?,
925                ));
926                w = self.dim(obj, DimKind::Wid)?.unwrap_or(dw);
927                h = self.dim(obj, DimKind::Ht)?.unwrap_or(dh);
928                rad = 0.0;
929            }
930            _ => {
931                let (dw, dh) =
932                    prev.unwrap_or((self.env_dim(EnvVar::Boxwid)?, self.env_dim(EnvVar::Boxht)?));
933                w = self.dim(obj, DimKind::Wid)?.unwrap_or(dw);
934                h = self.dim(obj, DimKind::Ht)?.unwrap_or(dh);
935                rad = self
936                    .dim(obj, DimKind::Rad)?
937                    .unwrap_or(self.env_dim(EnvVar::Boxrad)?);
938            }
939        }
940        w *= scale;
941        h *= scale;
942        rad *= scale;
943
944        let extent = if horizontal(dir) { w } else { h };
945        let center = self.place_closed_center(p, obj, dir, extent, (w, h), rad)?;
946
947        let mut bb = Bbox::new();
948        bb.add(center - Point::new(w / 2.0, h / 2.0));
949        bb.add(center + Point::new(w / 2.0, h / 2.0));
950        let layout_bb = if matches!(p, Prim::Box) {
951            dpic_box_layout_bbox(center, w, h)
952        } else {
953            bb
954        };
955        self.layout_bbox.union(&layout_bb);
956        if closed_shape_is_visible(&style) {
957            self.bbox
958                .union(&painted_bbox(&bb, stroke_half_width(&style)));
959        }
960        self.union_text(center, &text);
961
962        let shape = match p {
963            Prim::Circle => Shape::Circle {
964                c: center,
965                r: rad,
966                style,
967                text,
968            },
969            Prim::Ellipse => Shape::Ellipse {
970                c: center,
971                w,
972                h,
973                style,
974                text,
975            },
976            _ => Shape::Box {
977                c: center,
978                w,
979                h,
980                rad,
981                style,
982                text,
983            },
984        };
985        self.shapes.push(shape);
986
987        let half = dir_unit(dir) * (extent / 2.0);
988        let start = center - half;
989        let end = center + half;
990        self.pos = end;
991        self.dir = dir;
992        let kind = match p {
993            Prim::Circle => PKind::Circle,
994            Prim::Ellipse => PKind::Ellipse,
995            _ => PKind::Box,
996        };
997        let sh = self.shapes.len() - 1;
998        let idx = self.record(kind, center, bb, start, end, 0.0, Some(sh));
999        if matches!(kind, PKind::Box) {
1000            self.placed[idx].box_rad = rad;
1001        }
1002        Ok(idx)
1003    }
1004
1005    fn open(&mut self, p: Prim, obj: &Object) -> ER<usize> {
1006        let mut style = self.style_of(obj)?;
1007        let text = self.text_of(obj)?;
1008        let line_wid = style.arrow_wid;
1009        let line_ht = style.arrow_ht;
1010        let is_move = matches!(p, Prim::Move);
1011        if is_move {
1012            style.invis = true;
1013            style.invis_bounds = true;
1014        }
1015        let (deflen_h, deflen_v) = if is_move {
1016            (
1017                self.env_dim(EnvVar::Movewid)?,
1018                self.env_dim(EnvVar::Moveht)?,
1019            )
1020        } else {
1021            (
1022                self.env_dim(EnvVar::Linewid)?,
1023                self.env_dim(EnvVar::Lineht)?,
1024            )
1025        };
1026        let same_vec = if obj.attrs.iter().any(|a| matches!(a, Attr::Same)) {
1027            self.last_open_vector(p)
1028        } else {
1029            None
1030        };
1031
1032        // starting point
1033        let start = self.find_from(obj)?.unwrap_or(self.pos);
1034        let mut pts = vec![start];
1035        let mut pend = Point::ZERO;
1036        let mut any = false;
1037        let mut last_dir = self.dir;
1038
1039        for a in &obj.attrs {
1040            match a {
1041                Attr::Direction(d, opt) => {
1042                    let dist = match opt {
1043                        Some(e) => self.expr_dim(e)?,
1044                        None => {
1045                            if horizontal(*d) {
1046                                deflen_h
1047                            } else {
1048                                deflen_v
1049                            }
1050                        }
1051                    };
1052                    pend = pend + dir_unit(*d) * dist;
1053                    last_dir = *d;
1054                    any = true;
1055                }
1056                Attr::Then => {
1057                    let np = *pts.last().unwrap() + pend;
1058                    pts.push(np);
1059                    pend = Point::ZERO;
1060                }
1061                Attr::To(pos) => {
1062                    if pend != Point::ZERO {
1063                        let np = *pts.last().unwrap() + pend;
1064                        pts.push(np);
1065                        pend = Point::ZERO;
1066                    }
1067                    pts.push(self.eval_pos(pos)?);
1068                    any = true;
1069                }
1070                Attr::By(pos) => {
1071                    let d = self.eval_pos(pos)?;
1072                    pend = pend + (d - Point::ZERO);
1073                    any = true;
1074                }
1075                Attr::Dist(e) => {
1076                    let dist = self.expr_dim(e)?;
1077                    pend = pend + dir_unit(last_dir) * dist;
1078                    any = true;
1079                }
1080                _ => {}
1081            }
1082        }
1083        if pend != Point::ZERO {
1084            let np = *pts.last().unwrap() + pend;
1085            pts.push(np);
1086        }
1087        if pts.len() == 1 && !any {
1088            if let Some(v) = same_vec.filter(|v| v.len() > 1e-12) {
1089                pts.push(start + v);
1090                last_dir = nearest_dir(v);
1091            } else {
1092                // bare line/arrow/move in the current direction
1093                let dist = if horizontal(self.dir) {
1094                    deflen_h
1095                } else {
1096                    deflen_v
1097                };
1098                pts.push(start + dir_unit(self.dir) * dist);
1099                last_dir = self.dir;
1100            }
1101        }
1102
1103        // `chop`: positive values trim each end; negative values extend it,
1104        // as in dpic examples such as `chop 0 chop -0.1`.
1105        if let Some((start_chop, end_chop)) = self.chop_of(obj)?
1106            && pts.len() >= 2
1107        {
1108            let n = pts.len();
1109            if start_chop != 0.0 {
1110                let d0 = pts[1] - pts[0];
1111                let l0 = d0.len();
1112                if start_chop < 0.0 || l0 > start_chop {
1113                    pts[0] = pts[0] + d0 / l0 * start_chop;
1114                }
1115            }
1116            if end_chop != 0.0 {
1117                let d1 = pts[n - 2] - pts[n - 1];
1118                let l1 = d1.len();
1119                if end_chop < 0.0 || l1 > end_chop {
1120                    pts[n - 1] = pts[n - 1] + d1 / l1 * end_chop;
1121                }
1122            }
1123        }
1124
1125        // arrowheads
1126        let arrows = self.arrows_of(obj, matches!(p, Prim::Arrow));
1127
1128        let mut bb = Bbox::new();
1129        for pt in &pts {
1130            bb.add(*pt);
1131        }
1132        self.layout_bbox.union(&bb);
1133        if !style.invis {
1134            self.bbox
1135                .union(&painted_bbox(&bb, stroke_half_width(&style)));
1136        } else if style.invis_bounds {
1137            self.bbox.union(&bb);
1138        }
1139
1140        let end = *pts.last().unwrap();
1141        let center = (pts[0] + end) * 0.5;
1142        self.union_text(center, &text);
1143        let kind = match p {
1144            Prim::Spline => PKind::Spline,
1145            Prim::Move => PKind::Move,
1146            _ => PKind::Line,
1147        };
1148        let tension = if matches!(p, Prim::Spline) {
1149            let mut t = None;
1150            for a in &obj.attrs {
1151                if let Attr::SplineTension(e) = a {
1152                    t = Some(self.eval_expr(e)?);
1153                }
1154            }
1155            t
1156        } else {
1157            None
1158        };
1159        let shape = if matches!(p, Prim::Spline) {
1160            Shape::Spline {
1161                pts: pts.clone(),
1162                tension,
1163                arrows,
1164                style,
1165                text,
1166            }
1167        } else {
1168            Shape::Path {
1169                pts: pts.clone(),
1170                arrows,
1171                style,
1172                text,
1173            }
1174        };
1175        self.shapes.push(shape);
1176
1177        self.pos = end;
1178        self.dir = last_dir;
1179        let sh = self.shapes.len() - 1;
1180        let idx = self.record(kind, center, bb, pts[0], end, 0.0, Some(sh));
1181        self.placed[idx].points = pts;
1182        self.placed[idx].line_wid = line_wid;
1183        self.placed[idx].line_ht = line_ht;
1184        Ok(idx)
1185    }
1186
1187    fn arc(&mut self, obj: &Object) -> ER<usize> {
1188        let mut style = self.style_of(obj)?;
1189        if let Some(wid) = self.dim(obj, DimKind::Wid)? {
1190            style.arrow_wid = wid;
1191        }
1192        if let Some(ht) = self.dim(obj, DimKind::Ht)? {
1193            style.arrow_ht = ht;
1194        }
1195        let text = self.text_of(obj)?;
1196        let start = self.find_from(obj)?.unwrap_or(self.pos);
1197        let cw = self.arc_cw_of(obj);
1198        let arc_dir = self.dir_of(obj);
1199        let rad_attr = self.dim(obj, DimKind::Rad)?;
1200        let to = self.dest_of(obj)?;
1201        let explicit_center = if to.is_some() {
1202            self.arc_explicit_center(obj)?
1203        } else {
1204            None
1205        };
1206
1207        // (center, radius, start angle, end angle)
1208        let (center, r, a0, a1) = if let Some(end) = to {
1209            // arc from `start` to `to`, optional radius
1210            let chord = end - start;
1211            let clen = chord.len();
1212            if clen < 1e-9 {
1213                return err("degenerate arc: `from` and `to` coincide");
1214            }
1215            let r = rad_attr
1216                .unwrap_or(self.env_dim(EnvVar::Arcrad)?)
1217                .max(clen / 2.0);
1218            let dx = chord.x;
1219            let dy = chord.y;
1220            let ts = dx * dx + dy * dy;
1221            let mut t = ((4.0 * r * r - ts).max(0.0) / ts).sqrt();
1222            let arc_sign = if cw { -1.0 } else { 1.0 };
1223            // Dpic uses the prevailing direction to choose between the two
1224            // possible circle centers for a chord/radius pair.
1225            match arc_dir {
1226                Dir::Up => {
1227                    if arc_sign * ((-dx) - (t * dy)) < 0.0 {
1228                        t = -t;
1229                    }
1230                }
1231                Dir::Down => {
1232                    if arc_sign * ((-dx) - (t * dy)) > 0.0 {
1233                        t = -t;
1234                    }
1235                }
1236                Dir::Right => {
1237                    if arc_sign * (dy - (t * dx)) < 0.0 {
1238                        t = -t;
1239                    }
1240                }
1241                Dir::Left => {
1242                    if arc_sign * (dy - (t * dx)) > 0.0 {
1243                        t = -t;
1244                    }
1245                }
1246            }
1247            let center = start + Point::new(0.5 * (dx + t * dy), 0.5 * (dy - t * dx));
1248            let (center, r) = if let Some(center) = explicit_center {
1249                (center, end.dist(center))
1250            } else {
1251                (center, r)
1252            };
1253            let (a0, a1) = arc_angles(center, start, end, cw);
1254            (center, r, a0, a1)
1255        } else {
1256            // default: a quarter turn from the current heading
1257            let r = rad_attr.unwrap_or(self.env_dim(EnvVar::Arcrad)?);
1258            let din = dir_unit(self.dir);
1259            let normal = if cw {
1260                Point::new(din.y, -din.x)
1261            } else {
1262                Point::new(-din.y, din.x)
1263            };
1264            let center = start + normal * r;
1265            let a0 = (start - center).y.atan2((start - center).x);
1266            let sweep = if cw { -PI / 2.0 } else { PI / 2.0 };
1267            (center, r, a0, a0 + sweep)
1268        };
1269
1270        let at = |t: f64| center + Point::new(t.cos(), t.sin()) * r;
1271        let end = at(a1);
1272        let arrows = self.arrows_of(obj, false);
1273
1274        let mut bb = Bbox::new();
1275        bb.add(start);
1276        bb.add(end);
1277        for k in 0..=12 {
1278            bb.add(at(a0 + (a1 - a0) * (k as f64 / 12.0)));
1279        }
1280        self.layout_bbox.union(&bb);
1281        if !style.invis {
1282            self.bbox
1283                .union(&painted_bbox(&bb, stroke_half_width(&style)));
1284        }
1285        self.union_text(center, &text);
1286
1287        self.shapes.push(Shape::Arc {
1288            c: center,
1289            r,
1290            a0,
1291            a1,
1292            cw,
1293            arrows,
1294            style,
1295            text,
1296        });
1297
1298        // new heading is the tangent at the end point
1299        let tang = if a1 >= a0 {
1300            Point::new(-a1.sin(), a1.cos())
1301        } else {
1302            Point::new(a1.sin(), -a1.cos())
1303        };
1304        self.dir = nearest_dir(tang);
1305        self.pos = end;
1306        let sh = self.shapes.len() - 1;
1307        let idx = self.record(PKind::Arc, center, bb, start, end, 0.0, Some(sh));
1308        self.placed[idx].radius = r;
1309        Ok(idx)
1310    }
1311
1312    fn arc_cw_of(&self, obj: &Object) -> bool {
1313        obj.attrs
1314            .iter()
1315            .rev()
1316            .find_map(|a| match a {
1317                Attr::Cw => Some(true),
1318                Attr::Ccw => Some(false),
1319                _ => None,
1320            })
1321            .unwrap_or(false)
1322    }
1323
1324    fn arc_explicit_center(&mut self, obj: &Object) -> ER<Option<Point>> {
1325        for a in &obj.attrs {
1326            match a {
1327                Attr::At(pos) => return Ok(Some(self.eval_pos(pos)?)),
1328                Attr::With {
1329                    anchor: WithAnchor::Plain | WithAnchor::Corner(Corner::Center),
1330                    at,
1331                } => {
1332                    return Ok(Some(self.eval_pos(at)?));
1333                }
1334                _ => {}
1335            }
1336        }
1337        Ok(None)
1338    }
1339
1340    fn text_obj(&mut self, obj: &Object) -> ER<usize> {
1341        let text = self.text_of(obj)?;
1342        let dir = self.dir_of(obj);
1343        let w = self
1344            .dim(obj, DimKind::Wid)?
1345            .unwrap_or(self.env_dim(EnvVar::Textwid)?);
1346        let h = match self.dim(obj, DimKind::Ht)? {
1347            Some(h) => h,
1348            None => self.env_dim(EnvVar::Textht)? * text.len().max(1) as f64,
1349        };
1350        let extent = if horizontal(dir) { w } else { h };
1351        let at = self.place_center(obj, dir, extent, w, h)?;
1352        let mut bb = Bbox::new();
1353        bb.add(at - Point::new(w / 2.0, h / 2.0));
1354        bb.add(at + Point::new(w / 2.0, h / 2.0));
1355        self.layout_bbox.union(&bb);
1356        let text_bb = text_object_bbox(at, &text, w, h);
1357        self.bbox.union(&text_bb);
1358        self.shapes.push(Shape::Text {
1359            at,
1360            text,
1361            bbox: text_bb,
1362            w,
1363            h,
1364            standalone: true,
1365        });
1366        let half = dir_unit(dir) * (extent / 2.0);
1367        let start = at - half;
1368        let end = at + half;
1369        self.pos = end;
1370        self.dir = dir;
1371        let sh = self.shapes.len() - 1;
1372        Ok(self.record(PKind::Text, at, bb, start, end, 0.0, Some(sh)))
1373    }
1374
1375    /// Union an estimated text extent into the drawing bbox, so wide labels and
1376    /// bare text objects aren't clipped by the SVG viewBox.
1377    fn union_text(&mut self, center: Point, lines: &[TextLine]) {
1378        let bb = text_bbox(center, lines);
1379        self.bbox.union(&bb);
1380    }
1381
1382    fn block(&mut self, stmts: &[Stmt], obj: &Object) -> ER<usize> {
1383        let block_text = self.text_of(obj)?;
1384        // Evaluate the block in a local scope at its own origin. Labels from
1385        // the containing scope are visible for references such as `$1.start`
1386        // inside macro-generated blocks, but are not captured as new members.
1387        let mut sub = State::new();
1388        sub.env = self.env.clone();
1389        sub.vars = self.vars.clone();
1390        sub.inherited_vars = self.vars.keys().cloned().collect();
1391        sub.export_vars.clear();
1392        sub.macros = self.macros.clone();
1393        sub.base_dir = self.base_dir.clone();
1394        sub.rng = self.rng.clone();
1395        // expose this scope's labels (read-only, absolute coords) to the block
1396        sub.outer_labels = self.outer_labels.clone();
1397        for (name, &i) in &self.labels {
1398            sub.outer_labels
1399                .insert(name.clone(), self.placed[i].clone());
1400        }
1401        sub.eval_stmts(stmts)?;
1402        // Variables, parameters and direction changes inside `[ ... ]` are
1403        // local to the block. Random draws still consume the shared sequence.
1404        self.rng = sub.rng.clone();
1405        self.diagnostics.append(&mut sub.diagnostics);
1406        for key in &sub.export_vars {
1407            if let Some(val) = sub.vars.get(key).copied() {
1408                self.vars.insert(key.clone(), val);
1409                if self.inherited_vars.contains(key) {
1410                    self.export_vars.insert(key.clone());
1411                }
1412            }
1413        }
1414
1415        let layout_sub_bb = if sub.layout_bbox.is_empty() {
1416            let mut b = Bbox::new();
1417            b.add(Point::ZERO);
1418            b
1419        } else {
1420            sub.layout_bbox
1421        };
1422        let local_center = (layout_sub_bb.min + layout_sub_bb.max) * 0.5;
1423        let w = layout_sub_bb.width();
1424        let h = layout_sub_bb.height();
1425
1426        let dir = self.dir_of(obj);
1427        let extent = if horizontal(dir) { w } else { h };
1428        let target = self.block_center(obj, dir, extent, w, h, local_center, &mut sub)?;
1429        let shift = target - local_center;
1430
1431        let first_shape = self.shapes.len();
1432        // capture inner labels (translated into parent space) before the block's
1433        // shapes are moved out, so `B.A` / `last [].Outer` can resolve.
1434        let mut members: HashMap<String, Placed> = HashMap::new();
1435        for (name, &i) in &sub.labels {
1436            let mut pl = sub.placed[i].clone();
1437            rebase_placed(&mut pl, shift, first_shape);
1438            members.insert(name.clone(), pl);
1439        }
1440        for mut sh in sub.shapes.into_iter() {
1441            translate_shape(&mut sh, shift);
1442            self.shapes.push(sh);
1443        }
1444        let shape = if self.shapes.len() > first_shape {
1445            Some(first_shape)
1446        } else {
1447            None
1448        };
1449        let mut bb = Bbox::new();
1450        bb.add(layout_sub_bb.min + shift);
1451        bb.add(layout_sub_bb.max + shift);
1452        self.layout_bbox.union(&bb);
1453        if !sub.bbox.is_empty() {
1454            let mut visible_bb = Bbox::new();
1455            visible_bb.add(sub.bbox.min + shift);
1456            visible_bb.add(sub.bbox.max + shift);
1457            self.bbox.union(&visible_bb);
1458        }
1459        let block_text_bb = text_bbox(target, &block_text);
1460        self.bbox.union(&block_text_bb);
1461        if has_visible_text(&block_text) {
1462            self.shapes.push(Shape::Text {
1463                at: target,
1464                text: block_text,
1465                bbox: block_text_bb,
1466                w: 0.0,
1467                h: 0.0,
1468                standalone: false,
1469            });
1470        }
1471
1472        let half = dir_unit(dir) * (extent / 2.0);
1473        let start = target - half;
1474        let end = target + half;
1475        self.pos = end;
1476        self.dir = dir;
1477        let idx = self.record(PKind::Block, target, bb, start, end, 0.0, shape);
1478        self.placed[idx].members = members;
1479        Ok(idx)
1480    }
1481
1482    #[allow(clippy::too_many_arguments)]
1483    fn record(
1484        &mut self,
1485        kind: PKind,
1486        center: Point,
1487        bbox: Bbox,
1488        start: Point,
1489        end: Point,
1490        thick: f64,
1491        shape: Option<usize>,
1492    ) -> usize {
1493        let idx = self.placed.len();
1494        self.placed.push(Placed {
1495            kind,
1496            center,
1497            bbox,
1498            start,
1499            end,
1500            thick,
1501            points: Vec::new(),
1502            radius: 0.0,
1503            box_rad: 0.0,
1504            line_wid: 0.0,
1505            line_ht: 0.0,
1506            shape,
1507            members: HashMap::new(),
1508        });
1509        idx
1510    }
1511
1512    // ---- attribute helpers -------------------------------------------------
1513
1514    fn dim(&mut self, obj: &Object, kind: DimKind) -> ER<Option<f64>> {
1515        for a in &obj.attrs {
1516            if let Attr::Dim(k, e) = a
1517                && *k == kind
1518            {
1519                return Ok(Some(match kind {
1520                    DimKind::Thick | DimKind::Scaled => self.eval_expr(e)?,
1521                    DimKind::Ht | DimKind::Wid | DimKind::Rad | DimKind::Diam => {
1522                        self.expr_dim(e)?
1523                    }
1524                }));
1525            }
1526        }
1527        Ok(None)
1528    }
1529
1530    fn scale_of(&mut self, obj: &Object) -> ER<f64> {
1531        Ok(self.dim(obj, DimKind::Scaled)?.unwrap_or(1.0))
1532    }
1533
1534    fn dir_of(&self, obj: &Object) -> Dir {
1535        obj.attrs
1536            .iter()
1537            .rev()
1538            .find_map(|a| match a {
1539                Attr::Direction(d, _) => Some(*d),
1540                _ => None,
1541            })
1542            .unwrap_or(self.dir)
1543    }
1544
1545    fn find_from(&mut self, obj: &Object) -> ER<Option<Point>> {
1546        for a in &obj.attrs {
1547            if let Attr::From(pos) = a {
1548                return Ok(Some(self.eval_pos(pos)?));
1549            }
1550        }
1551        Ok(None)
1552    }
1553
1554    fn at_of(&mut self, obj: &Object) -> ER<Option<Point>> {
1555        for a in &obj.attrs {
1556            if let Attr::At(pos) = a {
1557                return Ok(Some(self.eval_pos(pos)?));
1558            }
1559        }
1560        Ok(None)
1561    }
1562
1563    fn dest_of(&mut self, obj: &Object) -> ER<Option<Point>> {
1564        for a in &obj.attrs {
1565            if let Attr::To(pos) = a {
1566                return Ok(Some(self.eval_pos(pos)?));
1567            }
1568        }
1569        Ok(None)
1570    }
1571
1572    /// The start/end `chop` amounts. `chop r1 chop r2` trims each end
1573    /// independently; a single `chop` applies to both ends.
1574    fn chop_of(&mut self, obj: &Object) -> ER<Option<(f64, f64)>> {
1575        let mut vals = Vec::new();
1576        for a in &obj.attrs {
1577            if let Attr::Chop(opt) = a {
1578                let amt = match opt {
1579                    Some(e) => self.expr_dim(e)?,
1580                    None => self.env_dim(EnvVar::Circlerad)?,
1581                };
1582                vals.push(amt);
1583            }
1584        }
1585        Ok(match vals.as_slice() {
1586            [] => None,
1587            [one] => Some((*one, *one)),
1588            [start, end, ..] => Some((*start, *end)),
1589        })
1590    }
1591
1592    /// Width/height of the last placed object of the same closed kind (for `same`).
1593    fn last_dims_of(&self, p: Prim) -> Option<(f64, f64)> {
1594        let want = match p {
1595            Prim::Box => PKind::Box,
1596            Prim::Circle => PKind::Circle,
1597            Prim::Ellipse => PKind::Ellipse,
1598            _ => return None,
1599        };
1600        self.placed
1601            .iter()
1602            .rev()
1603            .find(|pl| pl.kind == want)
1604            .map(|pl| (pl.bbox.width(), pl.bbox.height()))
1605    }
1606
1607    /// End-to-end vector of the last placed open object of the same kind.
1608    fn last_open_vector(&self, p: Prim) -> Option<Point> {
1609        let want = match p {
1610            Prim::Move => PKind::Move,
1611            Prim::Spline => PKind::Spline,
1612            Prim::Line | Prim::Arrow => PKind::Line,
1613            _ => return None,
1614        };
1615        self.placed
1616            .iter()
1617            .rev()
1618            .find(|pl| pl.kind == want)
1619            .map(|pl| pl.end - pl.start)
1620    }
1621
1622    /// Compute the center of a closed object given direction and extents.
1623    fn place_center(&mut self, obj: &Object, dir: Dir, extent: f64, w: f64, h: f64) -> ER<Point> {
1624        self.place_with_corner_offset(obj, dir, extent, w, h, corner_offset)
1625    }
1626
1627    fn place_closed_center(
1628        &mut self,
1629        p: Prim,
1630        obj: &Object,
1631        dir: Dir,
1632        extent: f64,
1633        dims: (f64, f64),
1634        rad: f64,
1635    ) -> ER<Point> {
1636        let (w, h) = dims;
1637        self.place_with_corner_offset(obj, dir, extent, w, h, |c, w, h| {
1638            closed_corner_offset(p, c, w, h, rad)
1639        })
1640    }
1641
1642    fn place_with_corner_offset(
1643        &mut self,
1644        obj: &Object,
1645        dir: Dir,
1646        extent: f64,
1647        w: f64,
1648        h: f64,
1649        corner: impl Fn(Corner, f64, f64) -> Point,
1650    ) -> ER<Point> {
1651        if let Some(at) = self.at_of(obj)? {
1652            return Ok(at);
1653        }
1654        for a in &obj.attrs {
1655            if let Attr::With { anchor, at } = a {
1656                let ap = self.eval_pos(at)?;
1657                let off = match anchor {
1658                    WithAnchor::Corner(c) => corner(*c, w, h),
1659                    WithAnchor::Pair(x, y) => Point::new(self.expr_dim(x)?, self.expr_dim(y)?),
1660                    WithAnchor::Place(_) => {
1661                        return err("`with .label` anchors are only valid on blocks");
1662                    }
1663                    WithAnchor::Plain => Point::ZERO,
1664                };
1665                return Ok(ap - off);
1666            }
1667        }
1668        Ok(self.pos + dir_unit(dir) * (extent / 2.0))
1669    }
1670
1671    #[allow(clippy::too_many_arguments)]
1672    fn block_center(
1673        &mut self,
1674        obj: &Object,
1675        dir: Dir,
1676        extent: f64,
1677        w: f64,
1678        h: f64,
1679        local_center: Point,
1680        sub: &mut State,
1681    ) -> ER<Point> {
1682        if let Some(at) = self.at_of(obj)? {
1683            return Ok(at);
1684        }
1685        for a in &obj.attrs {
1686            if let Attr::With { anchor, at } = a {
1687                let ap = self.eval_pos(at)?;
1688                let off = match anchor {
1689                    WithAnchor::Corner(c) => corner_offset(*c, w, h),
1690                    WithAnchor::Pair(x, y) => {
1691                        Point::new(self.expr_dim(x)?, self.expr_dim(y)?) - local_center
1692                    }
1693                    WithAnchor::Place(place) => sub.place_point(place)? - local_center,
1694                    WithAnchor::Plain => Point::ZERO,
1695                };
1696                return Ok(ap - off);
1697            }
1698        }
1699        Ok(self.pos + dir_unit(dir) * (extent / 2.0))
1700    }
1701
1702    fn arrows_of(&self, obj: &Object, default_end: bool) -> Arrowheads {
1703        let mut found = None;
1704        for a in &obj.attrs {
1705            if let Attr::Arrowhead(h, _) = a {
1706                found = Some(match h {
1707                    token::Arrow::Left => Arrowheads::Start,
1708                    token::Arrow::Right => Arrowheads::End,
1709                    token::Arrow::Double => Arrowheads::Both,
1710                });
1711            }
1712        }
1713        found.unwrap_or(if default_end {
1714            Arrowheads::End
1715        } else {
1716            Arrowheads::None
1717        })
1718    }
1719
1720    fn style_of(&mut self, obj: &Object) -> ER<Style> {
1721        // arrowhead dimensions follow the current `arrowht`/`arrowwid` globals
1722        let mut s = Style {
1723            arrow_ht: self.env_dim(EnvVar::Arrowht)?,
1724            arrow_wid: self.env_dim(EnvVar::Arrowwid)?,
1725            // `arrowhead = 0` draws an open (two-stroke) head; anything else
1726            // (default 2) is a filled triangle.
1727            arrow_filled: self.env.get(EnvVar::Arrowhead).round() as i64 != 0,
1728            ..Default::default()
1729        };
1730        let lt = self.env.get(EnvVar::Linethick);
1731        if lt > 0.0 {
1732            s.thick = Some(lt);
1733        }
1734        for a in &obj.attrs {
1735            match a {
1736                Attr::LineStyle(lt, opt) => match lt {
1737                    LineType::Solid => s.dash = Dash::Solid,
1738                    LineType::Dashed => {
1739                        let w = match opt {
1740                            Some(e) => self.expr_dim(e)?,
1741                            None => self.env_dim(EnvVar::Dashwid)?,
1742                        };
1743                        s.dash = Dash::Dashed(w);
1744                    }
1745                    LineType::Dotted => {
1746                        s.dash = Dash::Dotted(match opt {
1747                            Some(e) => Some(self.expr_dim(e)?),
1748                            None => None,
1749                        });
1750                    }
1751                    LineType::Invis => s.invis = true,
1752                },
1753                Attr::Fill(opt) => {
1754                    let g = match opt {
1755                        Some(e) => self.eval_expr(e)?,
1756                        None => self.env.get(EnvVar::Fillval),
1757                    };
1758                    s.fill = Some(Fill::Gray(g));
1759                    s.fill_open = true;
1760                }
1761                Attr::Color(kind, se) => {
1762                    let name = self.eval_color_expr(se)?;
1763                    match kind {
1764                        token::Color::Outlined => s.stroke = Some(name),
1765                        token::Color::Colored => {
1766                            s.stroke = Some(name.clone());
1767                            s.fill = Some(Fill::Color(name));
1768                        }
1769                        token::Color::Shaded => {
1770                            s.fill = Some(Fill::Color(name));
1771                            s.fill_open = true;
1772                        }
1773                    }
1774                }
1775                Attr::Dim(DimKind::Thick, e) => s.thick = Some(self.eval_expr(e)?),
1776                Attr::Arrowhead(_, Some(e)) => {
1777                    s.arrow_filled = self.eval_expr(e)?.round() as i64 != 0;
1778                }
1779                _ => {}
1780            }
1781        }
1782        Ok(s)
1783    }
1784
1785    fn eval_color_expr(&mut self, se: &StringExpr) -> ER<String> {
1786        if let StringExpr::Lit(name) = se
1787            && let Some(body) = self.macros.get(name).cloned()
1788        {
1789            if let Some(lit) = single_token_macro_string(&body) {
1790                return Ok(lit);
1791            }
1792            let parsed = crate::parser::parse_stringexpr_tokens(
1793                &body,
1794                &mut self.macros,
1795                self.base_dir.as_deref(),
1796            )
1797            .map_err(|e| EvalError { msg: e.to_string() })?;
1798            return self.eval_stringexpr(&parsed);
1799        }
1800        self.eval_stringexpr(se)
1801    }
1802
1803    fn text_of(&mut self, obj: &Object) -> ER<Vec<TextLine>> {
1804        let mut lines: Vec<TextLine> = Vec::new();
1805        let mut pending_halign = 0i8;
1806        let mut pending_valign = 0i8;
1807        for a in &obj.attrs {
1808            match a {
1809                Attr::TextPos(tp) => {
1810                    if let Some(line) = lines.last_mut() {
1811                        apply_text_pos(&mut line.halign, &mut line.valign, *tp);
1812                    } else {
1813                        apply_text_pos(&mut pending_halign, &mut pending_valign, *tp);
1814                    }
1815                }
1816                Attr::Text(se) => {
1817                    let s = self.eval_stringexpr(se)?;
1818                    lines.push(TextLine {
1819                        s,
1820                        halign: pending_halign,
1821                        valign: pending_valign,
1822                        text_offset: self.env_dim(EnvVar::Textoffset)?,
1823                    });
1824                    pending_halign = 0;
1825                    pending_valign = 0;
1826                }
1827                _ => {}
1828            }
1829        }
1830        Ok(lines)
1831    }
1832
1833    // ---- positions & places ------------------------------------------------
1834
1835    fn eval_pos(&mut self, pos: &Position) -> ER<Point> {
1836        match pos {
1837            Position::Pair(x, y) => Ok(Point::new(self.expr_dim(x)?, self.expr_dim(y)?)),
1838            Position::Place(loc) => self.eval_loc(loc),
1839            Position::Between { frac, a, b, .. } => {
1840                let f = self.eval_expr(frac)?;
1841                let pa = self.eval_pos(a)?;
1842                let pb = self.eval_pos(b)?;
1843                Ok(pa.lerp(pb, f))
1844            }
1845            Position::Sum(sign, a, b) => {
1846                let pa = self.eval_pos(a)?;
1847                let pb = self.eval_pos(b)?;
1848                Ok(match sign {
1849                    Sign::Plus => pa + pb,
1850                    Sign::Minus => pa - pb,
1851                })
1852            }
1853            Position::Scale(p, s, div) => {
1854                let pp = self.eval_pos(p)?;
1855                let sv = self.eval_expr(s)?;
1856                if *div {
1857                    if sv == 0.0 {
1858                        return err("division by zero in position");
1859                    }
1860                    Ok(pp / sv)
1861                } else {
1862                    Ok(pp * sv)
1863                }
1864            }
1865        }
1866    }
1867
1868    fn eval_loc(&mut self, loc: &Location) -> ER<Point> {
1869        match loc {
1870            Location::Place(p) => self.place_point(p),
1871            Location::Paren(pos) => self.eval_pos(pos),
1872            Location::ParenPair(p1, p2) => {
1873                Ok(Point::new(self.eval_pos(p1)?.x, self.eval_pos(p2)?.y))
1874            }
1875        }
1876    }
1877
1878    fn place_point(&mut self, p: &Place) -> ER<Point> {
1879        match p {
1880            Place::Here => Ok(self.pos),
1881            Place::Name { name, subscript } => {
1882                let key = self.indexed_name(name, subscript.as_deref())?;
1883                Ok(self.resolve_label(&key)?.center)
1884            }
1885            Place::Nth { count, obj } => {
1886                let idx = self.nth_index(count, obj)?;
1887                Ok(self.placed[idx].center)
1888            }
1889            Place::Corner(inner, c) => {
1890                let pl = self.resolve_obj(inner)?;
1891                Ok(pl.corner(*c))
1892            }
1893            Place::CornerOf(c, inner) => {
1894                let pl = self.resolve_obj(inner)?;
1895                Ok(pl.corner(*c))
1896            }
1897            Place::Member(..) => Ok(self.resolve_obj(p)?.center),
1898        }
1899    }
1900
1901    /// Resolve an object-valued place to its [`Placed`] record, descending into
1902    /// block members for `B.A` / `last [].Outer`.
1903    fn resolve_obj(&mut self, p: &Place) -> ER<Placed> {
1904        match p {
1905            Place::Name { name, subscript } => {
1906                let key = self.indexed_name(name, subscript.as_deref())?;
1907                self.resolve_label(&key)
1908            }
1909            Place::Nth { count, obj } => {
1910                let idx = self.nth_index(count, obj)?;
1911                Ok(self.placed[idx].clone())
1912            }
1913            Place::Corner(inner, _) | Place::CornerOf(_, inner) => self.resolve_obj(inner),
1914            Place::Member(base, sub) => {
1915                let b = self.resolve_obj(base)?;
1916                let key = match sub.as_ref() {
1917                    Place::Name { name, subscript } => {
1918                        self.indexed_name(name, subscript.as_deref())?
1919                    }
1920                    _ => return err("a block sub-label must be a name"),
1921                };
1922                b.members.get(&key).cloned().ok_or_else(|| EvalError {
1923                    msg: format!("no sub-label `{key}` in that block"),
1924                })
1925            }
1926            Place::Here => err("`Here` is a point, not an object"),
1927        }
1928    }
1929
1930    fn place_index(&mut self, p: &Place) -> ER<usize> {
1931        match p {
1932            Place::Name { name, subscript } => {
1933                let key = self.indexed_name(name, subscript.as_deref())?;
1934                self.label_index(&key)
1935            }
1936            Place::Nth { count, obj } => self.nth_index(count, obj),
1937            Place::Corner(inner, _) | Place::CornerOf(_, inner) => self.place_index(inner),
1938            Place::Here => err("`Here` is a point, not an object"),
1939            Place::Member(_, _) => err("block sub-labels (B.A) are not supported yet"),
1940        }
1941    }
1942
1943    fn label_index(&self, name: &str) -> ER<usize> {
1944        self.labels.get(name).copied().ok_or_else(|| EvalError {
1945            msg: format!("unknown label `{name}`"),
1946        })
1947    }
1948
1949    /// Resolve a label to its [`Placed`], falling back to enclosing-scope labels
1950    /// (absolute coordinates) so a block can reference outer labels.
1951    fn resolve_label(&self, key: &str) -> ER<Placed> {
1952        if let Some(&idx) = self.labels.get(key) {
1953            Ok(self.placed[idx].clone())
1954        } else if let Some(pl) = self.outer_labels.get(key) {
1955            Ok(pl.clone())
1956        } else {
1957            err(format!("unknown label `{key}`"))
1958        }
1959    }
1960
1961    fn label_key(&mut self, label: &Label) -> ER<String> {
1962        self.indexed_name(&label.name, label.subscript.as_ref())
1963    }
1964
1965    fn indexed_name(&mut self, name: &str, subscript: Option<&Expr>) -> ER<String> {
1966        match subscript {
1967            Some(Expr::Index(items)) => {
1968                let mut parts = Vec::with_capacity(items.len());
1969                for e in items {
1970                    parts.push(fmt_num(self.eval_expr(e)?));
1971                }
1972                Ok(format!("{name}[{}]", parts.join(",")))
1973            }
1974            Some(e) => Ok(format!("{name}[{}]", fmt_num(self.eval_expr(e)?))),
1975            None => Ok(name.to_string()),
1976        }
1977    }
1978
1979    fn nth_index(&mut self, count: &Nth, obj: &PrimObj) -> ER<usize> {
1980        // Untyped `last` matches the most recent object of any kind; a typed
1981        // reference (`last box`) filters to that kind.
1982        let want = primobj_kind(obj);
1983        let matches: Vec<usize> = self
1984            .placed
1985            .iter()
1986            .enumerate()
1987            .filter(|(_, pl)| want.is_none_or(|w| pl.kind == w))
1988            .map(|(i, _)| i)
1989            .collect();
1990        if matches.is_empty() {
1991            return match want {
1992                Some(w) => err(format!("no {:?} object to reference", want_name(w))),
1993                None => err("no object to reference".to_string()),
1994            };
1995        }
1996        let idx = match count {
1997            Nth::Last => *matches.last().unwrap(),
1998            Nth::Count(e, from_last) => {
1999                let n = self.eval_expr(e)?.round() as i64;
2000                if n < 1 {
2001                    return err("ordinal must be >= 1");
2002                }
2003                let k = (n - 1) as usize;
2004                if *from_last {
2005                    if k >= matches.len() {
2006                        return err("ordinal out of range");
2007                    }
2008                    matches[matches.len() - 1 - k]
2009                } else {
2010                    if k >= matches.len() {
2011                        return err("ordinal out of range");
2012                    }
2013                    matches[k]
2014                }
2015            }
2016        };
2017        Ok(idx)
2018    }
2019
2020    // ---- string expressions ------------------------------------------------
2021
2022    fn eval_stringexpr(&mut self, se: &StringExpr) -> ER<String> {
2023        Ok(match se {
2024            StringExpr::Lit(s) => s.clone(),
2025            StringExpr::Concat(a, b) => {
2026                format!("{}{}", self.eval_stringexpr(a)?, self.eval_stringexpr(b)?)
2027            }
2028            StringExpr::Arg(n) => format!("${n}"), // should have been expanded
2029            StringExpr::Sprintf(fmt, args) => {
2030                let f = self.eval_stringexpr(fmt)?;
2031                let mut vals = Vec::with_capacity(args.len());
2032                for e in args {
2033                    vals.push(self.eval_printf_arg(e)?);
2034                }
2035                sprintf_fmt(&f, &vals)
2036            }
2037            StringExpr::SvgFont(_) => String::new(),
2038        })
2039    }
2040
2041    fn eval_printf_arg(&mut self, e: &Expr) -> ER<PrintfArg> {
2042        match e {
2043            Expr::Str(se) => Ok(PrintfArg::Str(self.eval_stringexpr(se)?)),
2044            _ => Ok(PrintfArg::Num(self.eval_expr(e)?)),
2045        }
2046    }
2047
2048    // ---- expressions -------------------------------------------------------
2049
2050    /// Evaluate an operand to its string form (for `==`/`!=`); a numeric operand
2051    /// is rendered textually so `"$1" == "2"` style comparisons work uniformly.
2052    fn expr_str(&mut self, e: &Expr) -> ER<String> {
2053        match e {
2054            Expr::Str(se) => self.eval_stringexpr(se),
2055            _ => Ok(fmt_num(self.eval_expr(e)?)),
2056        }
2057    }
2058
2059    fn eval_expr(&mut self, e: &Expr) -> ER<f64> {
2060        Ok(match e {
2061            Expr::Num(v) => *v,
2062            Expr::Str(_) => return err("a string is only valid as an `==`/`!=` operand"),
2063            Expr::Index(_) => return err("a comma subscript is only valid inside `name[...]`"),
2064            Expr::Var(name, subscript) => {
2065                let key = self.indexed_name(name, subscript.as_deref())?;
2066                self.vars.get(&key).copied().ok_or_else(|| EvalError {
2067                    msg: format!("variable not found `{key}`"),
2068                })?
2069            }
2070            Expr::Env(v) => self.env.get(*v),
2071            Expr::Unary(op, a) => {
2072                let x = self.eval_expr(a)?;
2073                match op {
2074                    UnOp::Neg => -x,
2075                    UnOp::Pos => x,
2076                    UnOp::Not => bool_f(x == 0.0),
2077                }
2078            }
2079            Expr::Bin(op, a, b) => {
2080                // string equality: pic compares string operands with `==`/`!=`
2081                if matches!(op, BinOp::Eq | BinOp::Ne)
2082                    && (matches!(a.as_ref(), Expr::Str(_)) || matches!(b.as_ref(), Expr::Str(_)))
2083                {
2084                    let sa = self.expr_str(a)?;
2085                    let sb = self.expr_str(b)?;
2086                    return Ok(bool_f(if matches!(op, BinOp::Eq) {
2087                        sa == sb
2088                    } else {
2089                        sa != sb
2090                    }));
2091                }
2092                let x = self.eval_expr(a)?;
2093                let y = self.eval_expr(b)?;
2094                match op {
2095                    BinOp::Add => x + y,
2096                    BinOp::Sub => x - y,
2097                    BinOp::Mul => x * y,
2098                    BinOp::Div => {
2099                        if y == 0.0 {
2100                            return err("division by zero");
2101                        }
2102                        x / y
2103                    }
2104                    BinOp::Mod => dpic_mod(x, y, "modulo by zero")?,
2105                    BinOp::Pow => dpic_pow(x, y)?,
2106                    BinOp::Eq => bool_f(x == y),
2107                    BinOp::Ne => bool_f(x != y),
2108                    BinOp::Lt => bool_f(x < y),
2109                    BinOp::Le => bool_f(x <= y),
2110                    BinOp::Gt => bool_f(x > y),
2111                    BinOp::Ge => bool_f(x >= y),
2112                    BinOp::And => bool_f(x != 0.0 && y != 0.0),
2113                    BinOp::Or => bool_f(x != 0.0 || y != 0.0),
2114                }
2115            }
2116            Expr::Func1(f, a) => {
2117                let x = self.eval_expr(a)?;
2118                match f {
2119                    Func1::Abs => x.abs(),
2120                    Func1::Acos => x.acos(),
2121                    Func1::Asin => x.asin(),
2122                    Func1::Cos => x.cos(),
2123                    Func1::Exp => 10f64.powf(x),
2124                    Func1::Expe => x.exp(),
2125                    Func1::Int => x.trunc(),
2126                    Func1::Log => x.log10(),
2127                    Func1::Loge => x.ln(),
2128                    Func1::Sign => {
2129                        if x >= 0.0 {
2130                            1.0
2131                        } else {
2132                            -1.0
2133                        }
2134                    }
2135                    Func1::Sin => x.sin(),
2136                    Func1::Sqrt => x.sqrt(),
2137                    Func1::Tan => x.tan(),
2138                    Func1::Floor => x.floor(),
2139                }
2140            }
2141            Expr::Func2(f, a, b) => {
2142                let x = self.eval_expr(a)?;
2143                let y = self.eval_expr(b)?;
2144                match f {
2145                    Func2::Atan2 => x.atan2(y),
2146                    Func2::Max => x.max(y),
2147                    Func2::Min => x.min(y),
2148                    Func2::Pmod => x.rem_euclid(y),
2149                }
2150            }
2151            Expr::Rand(seed) => {
2152                let seed = match seed {
2153                    Some(e) => Some(self.eval_expr(e)?),
2154                    None => None,
2155                };
2156                self.rand(seed)
2157            }
2158            Expr::Assign(name, subscript, v) => {
2159                let val = self.eval_expr(v)?;
2160                let key = self.indexed_name(name, subscript.as_deref())?;
2161                self.vars.insert(key, val);
2162                val
2163            }
2164            Expr::DotX(loc) => {
2165                let x = self.eval_loc(loc)?.x;
2166                self.to_user_dim(x)
2167            }
2168            Expr::DotY(loc) => {
2169                let y = self.eval_loc(loc)?.y;
2170                self.to_user_dim(y)
2171            }
2172            Expr::PlaceAttr(place, param) => {
2173                let pl = self.resolve_obj(place)?;
2174                match param {
2175                    token::Param::Width => self.to_user_dim(pl.attr_width()),
2176                    token::Param::Height => self.to_user_dim(pl.attr_height()),
2177                    token::Param::Radius => self.to_user_dim(pl.attr_radius()),
2178                    token::Param::Diameter => self.to_user_dim(pl.attr_diameter()),
2179                    token::Param::Length => self.to_user_dim(pl.attr_length()),
2180                    token::Param::Thickness => pl.thick,
2181                }
2182            }
2183        })
2184    }
2185
2186    fn rand(&mut self, seed: Option<f64>) -> f64 {
2187        if let Some(seed) = seed {
2188            self.rng.seed(seed.trunc() as i64);
2189        }
2190        self.rng.next_f64()
2191    }
2192}
2193
2194// ---- free helpers ----------------------------------------------------------
2195
2196fn apply_op(op: AssignOp, cur: f64, rhs: f64) -> ER<f64> {
2197    Ok(match op {
2198        AssignOp::Set | AssignOp::ColonSet => rhs,
2199        AssignOp::Add => cur + rhs,
2200        AssignOp::Sub => cur - rhs,
2201        AssignOp::Mul => cur * rhs,
2202        AssignOp::Div => {
2203            if rhs == 0.0 {
2204                return err("division by zero in assignment");
2205            }
2206            cur / rhs
2207        }
2208        AssignOp::Rem => dpic_mod(cur, rhs, "modulo by zero in assignment")?,
2209    })
2210}
2211
2212#[derive(Clone)]
2213struct GlibcRand {
2214    state: [u32; 31],
2215    f: usize,
2216    r: usize,
2217}
2218
2219impl GlibcRand {
2220    fn new(seed: i64) -> Self {
2221        let mut rng = GlibcRand {
2222            state: [0; 31],
2223            f: 3,
2224            r: 0,
2225        };
2226        rng.seed(seed);
2227        rng
2228    }
2229
2230    fn seed(&mut self, seed: i64) {
2231        let seed = if seed == 0 { 1 } else { seed };
2232        let mut x = seed.rem_euclid(2_147_483_647) as u32;
2233        if x == 0 {
2234            x = 1;
2235        }
2236        self.state[0] = x;
2237        for i in 1..31 {
2238            let prev = self.state[i - 1] as i64;
2239            let hi = prev / 127_773;
2240            let lo = prev % 127_773;
2241            let mut next = 16_807 * lo - 2_836 * hi;
2242            if next < 0 {
2243                next += 2_147_483_647;
2244            }
2245            self.state[i] = next as u32;
2246        }
2247        self.f = 3;
2248        self.r = 0;
2249        for _ in 0..310 {
2250            self.next_i31();
2251        }
2252    }
2253
2254    fn next_i31(&mut self) -> u32 {
2255        let x = self.state[self.f].wrapping_add(self.state[self.r]);
2256        self.state[self.f] = x;
2257        let out = (x >> 1) & 0x7fff_ffff;
2258        self.f = (self.f + 1) % 31;
2259        self.r = (self.r + 1) % 31;
2260        out
2261    }
2262
2263    fn next_f64(&mut self) -> f64 {
2264        self.next_i31() as f64 / 2_147_483_647.0
2265    }
2266}
2267
2268fn bool_f(b: bool) -> f64 {
2269    if b { 1.0 } else { 0.0 }
2270}
2271
2272fn dpic_round(x: f64) -> i64 {
2273    if x < 0.0 {
2274        -((-x + 0.5).floor() as i64)
2275    } else {
2276        (x + 0.5).floor() as i64
2277    }
2278}
2279
2280fn dpic_mod(x: f64, y: f64, zero_msg: &'static str) -> ER<f64> {
2281    let i = dpic_round(x);
2282    let j = dpic_round(y);
2283    if j == 0 {
2284        return err(zero_msg);
2285    }
2286    Ok((i - (i / j) * j) as f64)
2287}
2288
2289fn dpic_pow(x: f64, y: f64) -> ER<f64> {
2290    if x == 0.0 && y < 0.0 {
2291        return err("zero cannot be raised to a negative power");
2292    }
2293    let iy = dpic_round(y);
2294    if iy as f64 == y {
2295        return Ok(dpic_int_pow(x, iy));
2296    }
2297    if x < 0.0 {
2298        return err("negative base with non-integer exponent");
2299    }
2300    Ok(x.powf(y))
2301}
2302
2303fn dpic_int_pow(x: f64, y: i64) -> f64 {
2304    if y == 0 {
2305        return 1.0;
2306    }
2307    if x == 0.0 || y == 1 {
2308        return x;
2309    }
2310    if y < 0 {
2311        return dpic_int_pow(1.0 / x, -y);
2312    }
2313    if y == 2 {
2314        return x * x;
2315    }
2316    if y & 1 == 1 {
2317        x * dpic_int_pow(x, y - 1)
2318    } else {
2319        let half = dpic_int_pow(x, y >> 1);
2320        half * half
2321    }
2322}
2323
2324fn apply_text_pos(halign: &mut i8, valign: &mut i8, pos: token::TextPos) {
2325    match pos {
2326        token::TextPos::Ljust => *halign = -1,
2327        token::TextPos::Rjust => *halign = 1,
2328        token::TextPos::Center => {
2329            *halign = 0;
2330            *valign = 0;
2331        }
2332        token::TextPos::Above => *valign = 1,
2333        token::TextPos::Below => *valign = -1,
2334    }
2335}
2336
2337fn has_visible_text(lines: &[TextLine]) -> bool {
2338    lines.iter().any(|line| !line.s.is_empty())
2339}
2340
2341fn closed_shape_is_visible(style: &Style) -> bool {
2342    !style.invis || style.fill.is_some()
2343}
2344
2345fn stroke_half_width(style: &Style) -> f64 {
2346    if style.invis {
2347        0.0
2348    } else {
2349        style.thick.unwrap_or(0.8) / 144.0
2350    }
2351}
2352
2353fn painted_bbox(bb: &Bbox, pad: f64) -> Bbox {
2354    if bb.is_empty() || pad <= 0.0 {
2355        return *bb;
2356    }
2357    let mut out = Bbox::new();
2358    out.add(bb.min - Point::new(pad, pad));
2359    out.add(bb.max + Point::new(pad, pad));
2360    out
2361}
2362
2363fn dpic_box_layout_bbox(center: Point, w: f64, h: f64) -> Bbox {
2364    fn axis(center: f64, extent: f64) -> (f64, f64) {
2365        let lo = center - extent / 2.0;
2366        let hi = center + extent / 2.0;
2367        if lo <= hi { (lo, hi) } else { (0.0, 0.0) }
2368    }
2369
2370    let (x0, x1) = axis(center.x, w);
2371    let (y0, y1) = axis(center.y, h);
2372    let mut bb = Bbox::new();
2373    bb.add(Point::new(x0, y0));
2374    bb.add(Point::new(x1, y1));
2375    bb
2376}
2377
2378fn drawing_painted_bbox(shapes: &[Shape]) -> Bbox {
2379    let mut out = Bbox::new();
2380    for sh in shapes {
2381        out.union(&shape_painted_bbox(sh));
2382    }
2383    out
2384}
2385
2386fn shape_painted_bbox(sh: &Shape) -> Bbox {
2387    let mut out = Bbox::new();
2388    match sh {
2389        Shape::Box {
2390            c,
2391            w,
2392            h,
2393            style,
2394            text,
2395            ..
2396        } => {
2397            let mut bb = Bbox::new();
2398            bb.add(*c - Point::new(*w / 2.0, *h / 2.0));
2399            bb.add(*c + Point::new(*w / 2.0, *h / 2.0));
2400            if closed_shape_is_visible(style) {
2401                out.union(&painted_bbox(&bb, stroke_half_width(style)));
2402            }
2403            out.union(&text_bbox(*c, text));
2404        }
2405        Shape::Circle { c, r, style, text } => {
2406            let mut bb = Bbox::new();
2407            bb.add(*c - Point::new(*r, *r));
2408            bb.add(*c + Point::new(*r, *r));
2409            if closed_shape_is_visible(style) {
2410                out.union(&painted_bbox(&bb, stroke_half_width(style)));
2411            }
2412            out.union(&text_bbox(*c, text));
2413        }
2414        Shape::Ellipse {
2415            c,
2416            w,
2417            h,
2418            style,
2419            text,
2420        } => {
2421            let mut bb = Bbox::new();
2422            bb.add(*c - Point::new(*w / 2.0, *h / 2.0));
2423            bb.add(*c + Point::new(*w / 2.0, *h / 2.0));
2424            if closed_shape_is_visible(style) {
2425                out.union(&painted_bbox(&bb, stroke_half_width(style)));
2426            }
2427            out.union(&text_bbox(*c, text));
2428        }
2429        Shape::Path {
2430            pts, style, text, ..
2431        }
2432        | Shape::Spline {
2433            pts, style, text, ..
2434        } => {
2435            let mut bb = Bbox::new();
2436            for p in pts {
2437                bb.add(*p);
2438            }
2439            if !style.invis {
2440                out.union(&painted_bbox(&bb, stroke_half_width(style)));
2441            } else if style.invis_bounds {
2442                out.union(&bb);
2443            }
2444            if let (Some(first), Some(last)) = (pts.first(), pts.last()) {
2445                out.union(&text_bbox((*first + *last) * 0.5, text));
2446            }
2447        }
2448        Shape::Arc {
2449            c,
2450            r,
2451            a0,
2452            a1,
2453            style,
2454            text,
2455            ..
2456        } => {
2457            let at = |t: f64| *c + Point::new(t.cos(), t.sin()) * *r;
2458            let mut bb = Bbox::new();
2459            for k in 0..=12 {
2460                bb.add(at(*a0 + (*a1 - *a0) * (k as f64 / 12.0)));
2461            }
2462            if !style.invis {
2463                out.union(&painted_bbox(&bb, stroke_half_width(style)));
2464            }
2465            out.union(&text_bbox(*c, text));
2466        }
2467        Shape::Text { bbox, .. } => out.union(bbox),
2468    }
2469    out
2470}
2471
2472fn text_bbox(center: Point, lines: &[TextLine]) -> Bbox {
2473    let mut bb = Bbox::new();
2474    if !has_visible_text(lines) {
2475        return bb;
2476    }
2477    const EM: f64 = 11.0 / 72.0;
2478    let char_w = 0.6 * EM;
2479    let line_h = 1.2 * EM;
2480    let xheight = 0.66 * EM;
2481    let n = lines.len() as f64;
2482    for (i, line) in lines.iter().enumerate() {
2483        if line.s.is_empty() {
2484            continue;
2485        }
2486        let w = line.s.chars().count() as f64 * char_w;
2487        let base_y = center.y - (i as f64 - (n - 1.0) / 2.0) * line_h;
2488        let y = base_y + line.valign as f64 * (xheight / 2.0 + line.text_offset);
2489        let x = center.x
2490            + match line.halign {
2491                -1 => line.text_offset,
2492                1 => -line.text_offset,
2493                _ => 0.0,
2494            };
2495        let (min_x, max_x) = match line.halign {
2496            -1 => (x, x + w),
2497            1 => (x - w, x),
2498            _ => (x - w / 2.0, x + w / 2.0),
2499        };
2500        bb.add(Point::new(min_x, y - line_h / 2.0));
2501        bb.add(Point::new(max_x, y + line_h / 2.0));
2502    }
2503    bb
2504}
2505
2506fn text_object_bbox(center: Point, lines: &[TextLine], w: f64, h: f64) -> Bbox {
2507    let text_bb = text_bbox(center, lines);
2508    if text_bb.is_empty() {
2509        return text_bb;
2510    }
2511    let min_x = if w > 0.0 {
2512        center.x - w / 2.0
2513    } else {
2514        text_bb.min.x
2515    };
2516    let max_x = if w > 0.0 {
2517        center.x + w / 2.0
2518    } else {
2519        text_bb.max.x
2520    };
2521    let y_bb = if h > 0.0 {
2522        text_object_vertical_bbox(center, lines, h)
2523    } else {
2524        text_bb
2525    };
2526    let min_y = y_bb.min.y;
2527    let max_y = y_bb.max.y;
2528    let mut bb = Bbox::new();
2529    bb.add(Point::new(min_x, min_y));
2530    bb.add(Point::new(max_x, max_y));
2531    bb
2532}
2533
2534fn text_object_vertical_bbox(center: Point, lines: &[TextLine], h: f64) -> Bbox {
2535    let mut bb = Bbox::new();
2536    if !has_visible_text(lines) {
2537        return bb;
2538    }
2539    let n = lines.len() as f64;
2540    let v = n - 1.0 + DP_TEXT_RATIO;
2541    let lineskip = if v.abs() > 1e-12 { h / v } else { 11.0 / 72.0 };
2542    let xheight = lineskip * DP_TEXT_RATIO;
2543    let mut baseline_y = center.y + (v * lineskip / 2.0) - xheight;
2544    for line in lines {
2545        if line.s.is_empty() {
2546            baseline_y -= lineskip;
2547            continue;
2548        }
2549        let just_offset = xheight / 2.0 + line.text_offset;
2550        let y = baseline_y + (line.valign as f64) * just_offset;
2551        bb.add(Point::new(center.x, y));
2552        bb.add(Point::new(center.x, y + xheight));
2553        baseline_y -= lineskip;
2554    }
2555    bb
2556}
2557
2558/// Render a number the way pic's `%g`/string contexts do (no trailing zeros).
2559fn fmt_num(v: f64) -> String {
2560    format!("{v}")
2561}
2562
2563fn install_dpic_compat_vars(vars: &mut HashMap<String, f64>) {
2564    // dpic backend option constants are zero-based in the order used by dpic's
2565    // own `case(dpicopt, ...)` examples. rpic renders SVG.
2566    let opts = [
2567        ("optMFpic", 0.0),
2568        ("optMpost", 1.0),
2569        ("optPDF", 2.0),
2570        ("optPGF", 3.0),
2571        ("optPict2e", 4.0),
2572        ("optPS", 5.0),
2573        ("optPSfrag", 6.0),
2574        ("optPSTricks", 7.0),
2575        ("optSVG", 8.0),
2576        ("optTeX", 9.0),
2577        ("opttTeX", 10.0),
2578        ("optxfig", 11.0),
2579    ];
2580    for (name, val) in opts {
2581        vars.insert(name.to_string(), val);
2582    }
2583    vars.insert("dpicopt".to_string(), 8.0);
2584}
2585
2586fn corner_offset(c: Corner, w: f64, h: f64) -> Point {
2587    let (hw, hh) = (w / 2.0, h / 2.0);
2588    match c {
2589        Corner::N => Point::new(0.0, hh),
2590        Corner::S => Point::new(0.0, -hh),
2591        Corner::E => Point::new(hw, 0.0),
2592        Corner::W => Point::new(-hw, 0.0),
2593        Corner::Ne => Point::new(hw, hh),
2594        Corner::Se => Point::new(hw, -hh),
2595        Corner::Nw => Point::new(-hw, hh),
2596        Corner::Sw => Point::new(-hw, -hh),
2597        Corner::Center | Corner::Start | Corner::End => Point::ZERO,
2598    }
2599}
2600
2601fn closed_corner_offset(p: Prim, c: Corner, w: f64, h: f64, rad: f64) -> Point {
2602    match p {
2603        Prim::Circle | Prim::Ellipse => ellipse_corner_offset(c, w, h),
2604        Prim::Box => box_corner_offset(c, w, h, rad),
2605        _ => corner_offset(c, w, h),
2606    }
2607}
2608
2609fn arc_angles(center: Point, start: Point, end: Point, cw: bool) -> (f64, f64) {
2610    let a0 = (start - center).y.atan2((start - center).x);
2611    let mut a1 = (end - center).y.atan2((end - center).x);
2612    if cw {
2613        while a1 > a0 {
2614            a1 -= 2.0 * PI;
2615        }
2616    } else {
2617        while a1 < a0 {
2618            a1 += 2.0 * PI;
2619        }
2620    }
2621    (a0, a1)
2622}
2623
2624fn ellipse_corner_offset(c: Corner, w: f64, h: f64) -> Point {
2625    let (rx, ry) = (w / 2.0, h / 2.0);
2626    let diag = |sx: f64, sy: f64| Point::new(sx * rx * FRAC_1_SQRT_2, sy * ry * FRAC_1_SQRT_2);
2627    match c {
2628        Corner::N => Point::new(0.0, ry),
2629        Corner::S => Point::new(0.0, -ry),
2630        Corner::E => Point::new(rx, 0.0),
2631        Corner::W => Point::new(-rx, 0.0),
2632        Corner::Ne => diag(1.0, 1.0),
2633        Corner::Se => diag(1.0, -1.0),
2634        Corner::Nw => diag(-1.0, 1.0),
2635        Corner::Sw => diag(-1.0, -1.0),
2636        Corner::Center | Corner::Start | Corner::End => Point::ZERO,
2637    }
2638}
2639
2640fn box_corner_offset(c: Corner, w: f64, h: f64, rad: f64) -> Point {
2641    if rad > 0.0 && matches!(c, Corner::Ne | Corner::Se | Corner::Nw | Corner::Sw) {
2642        let inset = rad.min(w.abs().min(h.abs()) / 2.0) * (1.0 - FRAC_1_SQRT_2);
2643        let x = w / 2.0 - inset;
2644        let y = h / 2.0 - inset;
2645        return match c {
2646            Corner::Ne => Point::new(x, y),
2647            Corner::Se => Point::new(x, -y),
2648            Corner::Nw => Point::new(-x, y),
2649            Corner::Sw => Point::new(-x, -y),
2650            _ => Point::ZERO,
2651        };
2652    }
2653    corner_offset(c, w, h)
2654}
2655
2656/// The placed-object kind a `PrimObj` selects, or `None` for untyped `Any`
2657/// (matches every kind).
2658fn primobj_kind(o: &PrimObj) -> Option<PKind> {
2659    Some(match o {
2660        PrimObj::Prim(p) => match p {
2661            Prim::Box => PKind::Box,
2662            Prim::Circle => PKind::Circle,
2663            Prim::Ellipse => PKind::Ellipse,
2664            Prim::Line | Prim::Arrow => PKind::Line,
2665            Prim::Move => PKind::Move,
2666            Prim::Spline => PKind::Spline,
2667            Prim::Arc => PKind::Arc,
2668        },
2669        PrimObj::Block | PrimObj::EmptyBrack => PKind::Block,
2670        PrimObj::Str(_) => PKind::Text,
2671        PrimObj::Any => return None,
2672    })
2673}
2674
2675fn want_name(k: PKind) -> &'static str {
2676    match k {
2677        PKind::Box => "box",
2678        PKind::Circle => "circle",
2679        PKind::Ellipse => "ellipse",
2680        PKind::Line => "line",
2681        PKind::Move => "move",
2682        PKind::Spline => "spline",
2683        PKind::Arc => "arc",
2684        PKind::Block => "block",
2685        PKind::Text => "text",
2686    }
2687}
2688
2689fn nearest_dir(v: Point) -> Dir {
2690    if v.x.abs() >= v.y.abs() {
2691        if v.x >= 0.0 { Dir::Right } else { Dir::Left }
2692    } else if v.y >= 0.0 {
2693        Dir::Up
2694    } else {
2695        Dir::Down
2696    }
2697}
2698
2699enum PrintfArg {
2700    Num(f64),
2701    Str(String),
2702}
2703
2704impl PrintfArg {
2705    fn num(&self) -> f64 {
2706        match self {
2707            PrintfArg::Num(v) => *v,
2708            PrintfArg::Str(s) => s.parse::<f64>().unwrap_or(0.0),
2709        }
2710    }
2711
2712    fn string(&self) -> String {
2713        match self {
2714            PrintfArg::Num(v) => fmt_num(*v),
2715            PrintfArg::Str(s) => s.clone(),
2716        }
2717    }
2718}
2719
2720/// Minimal printf-style formatter supporting `%d %i %f %e %g %s %%` with
2721/// optional `.precision`. Width/flags are accepted but ignored.
2722fn sprintf_fmt(fmt: &str, vals: &[PrintfArg]) -> String {
2723    let mut out = String::new();
2724    let mut chars = fmt.chars().peekable();
2725    let mut ai = 0usize;
2726    while let Some(c) = chars.next() {
2727        if c != '%' {
2728            out.push(c);
2729            continue;
2730        }
2731        let mut spec = String::new();
2732        while let Some(&n) = chars.peek() {
2733            if "+-0123456789. #".contains(n) {
2734                spec.push(n);
2735                chars.next();
2736            } else {
2737                break;
2738            }
2739        }
2740        let conv = match chars.next() {
2741            Some(c) => c,
2742            None => break,
2743        };
2744        if conv == '%' {
2745            out.push('%');
2746            continue;
2747        }
2748        let arg = vals.get(ai);
2749        ai += 1;
2750        let prec = spec.split('.').nth(1).and_then(|p| {
2751            p.chars()
2752                .take_while(|c| c.is_ascii_digit())
2753                .collect::<String>()
2754                .parse::<usize>()
2755                .ok()
2756        });
2757        match conv {
2758            'd' | 'i' => out.push_str(&format!(
2759                "{}",
2760                arg.map(PrintfArg::num).unwrap_or(0.0).round() as i64
2761            )),
2762            'f' | 'F' => out.push_str(&format!(
2763                "{:.*}",
2764                prec.unwrap_or(6),
2765                arg.map(PrintfArg::num).unwrap_or(0.0)
2766            )),
2767            'e' | 'E' => out.push_str(&format!(
2768                "{:.*e}",
2769                prec.unwrap_or(6),
2770                arg.map(PrintfArg::num).unwrap_or(0.0)
2771            )),
2772            'g' | 'G' => out.push_str(&format!("{}", arg.map(PrintfArg::num).unwrap_or(0.0))),
2773            's' => out.push_str(&arg.map(PrintfArg::string).unwrap_or_default()),
2774            other => {
2775                out.push('%');
2776                out.push(other);
2777            }
2778        }
2779    }
2780    out
2781}
2782
2783fn stringexpr_lit(se: &StringExpr) -> String {
2784    match se {
2785        StringExpr::Lit(s) => s.clone(),
2786        StringExpr::Concat(a, b) => format!("{}{}", stringexpr_lit(a), stringexpr_lit(b)),
2787        StringExpr::Arg(n) => format!("${n}"),
2788        StringExpr::Sprintf(fmt, _) => stringexpr_lit(fmt),
2789        StringExpr::SvgFont(_) => String::new(),
2790    }
2791}
2792
2793fn single_token_macro_string(toks: &[crate::lexer::Spanned]) -> Option<String> {
2794    let mut toks = toks
2795        .iter()
2796        .filter(|s| !matches!(s.tok, token::Token::Newline | token::Token::Eof));
2797    let tok = &toks.next()?.tok;
2798    if toks.next().is_some() {
2799        return None;
2800    }
2801    match tok {
2802        token::Token::Str(s) | token::Token::Name(s) | token::Token::Label(s) => Some(s.clone()),
2803        _ => None,
2804    }
2805}
2806
2807fn unescape_exec_source(src: &str) -> String {
2808    let mut out = String::with_capacity(src.len());
2809    let mut chars = src.chars();
2810    while let Some(c) = chars.next() {
2811        if c == '\\'
2812            && let Some('"') = chars.clone().next()
2813        {
2814            chars.next();
2815            out.push('"');
2816        } else {
2817            out.push(c);
2818        }
2819    }
2820    out
2821}
2822
2823/// Shift a [`Placed`] (and its block members) by `d` and re-index its shape
2824/// references by `shape_off`, mapping a block's local records into the parent.
2825fn rebase_placed(pl: &mut Placed, d: Point, shape_off: usize) {
2826    pl.center = pl.center + d;
2827    pl.start = pl.start + d;
2828    pl.end = pl.end + d;
2829    for p in &mut pl.points {
2830        *p = *p + d;
2831    }
2832    let mut bb = Bbox::new();
2833    bb.add(pl.bbox.min + d);
2834    bb.add(pl.bbox.max + d);
2835    pl.bbox = bb;
2836    if let Some(s) = pl.shape {
2837        pl.shape = Some(s + shape_off);
2838    }
2839    for m in pl.members.values_mut() {
2840        rebase_placed(m, d, shape_off);
2841    }
2842}
2843
2844fn scale_placed(pl: &mut Placed, f: f64) {
2845    pl.center = pl.center * f;
2846    pl.start = pl.start * f;
2847    pl.end = pl.end * f;
2848    for p in &mut pl.points {
2849        *p = *p * f;
2850    }
2851    pl.radius *= f;
2852    pl.box_rad *= f;
2853    pl.line_wid *= f;
2854    pl.line_ht *= f;
2855    scale_bbox_in_place(&mut pl.bbox, f);
2856    for m in pl.members.values_mut() {
2857        scale_placed(m, f);
2858    }
2859}
2860
2861fn translate_shape(sh: &mut Shape, d: Point) {
2862    let mv = |p: &mut Point| *p = *p + d;
2863    match sh {
2864        Shape::Box { c, .. } | Shape::Circle { c, .. } | Shape::Ellipse { c, .. } => mv(c),
2865        Shape::Path { pts, .. } | Shape::Spline { pts, .. } => {
2866            for p in pts {
2867                mv(p);
2868            }
2869        }
2870        Shape::Arc { c, .. } => mv(c),
2871        Shape::Text { at, bbox, .. } => {
2872            mv(at);
2873            bbox.min = bbox.min + d;
2874            bbox.max = bbox.max + d;
2875        }
2876    }
2877}
2878
2879fn scale_bbox_in_place(bb: &mut Bbox, f: f64) {
2880    if bb.is_empty() {
2881        return;
2882    }
2883    let mut b = Bbox::new();
2884    b.add(bb.min * f);
2885    b.add(bb.max * f);
2886    *bb = b;
2887}
2888
2889fn scale_style(style: &mut Style, f: f64) {
2890    let f = f.abs();
2891    style.arrow_ht *= f;
2892    style.arrow_wid *= f;
2893    match &mut style.dash {
2894        Dash::Dashed(w) => *w *= f,
2895        Dash::Dotted(Some(w)) => *w *= f,
2896        Dash::Solid | Dash::Dotted(None) => {}
2897    }
2898}
2899
2900/// Uniformly scale a shape's geometry about the origin (font size unchanged).
2901fn scale_shape(sh: &mut Shape, f: f64) {
2902    match sh {
2903        Shape::Box {
2904            c,
2905            w,
2906            h,
2907            rad,
2908            style,
2909            ..
2910        } => {
2911            *c = *c * f;
2912            *w *= f;
2913            *h *= f;
2914            *rad *= f;
2915            scale_style(style, f);
2916        }
2917        Shape::Circle { c, r, style, .. } => {
2918            *c = *c * f;
2919            *r *= f;
2920            scale_style(style, f);
2921        }
2922        Shape::Ellipse { c, w, h, style, .. } => {
2923            *c = *c * f;
2924            *w *= f;
2925            *h *= f;
2926            scale_style(style, f);
2927        }
2928        Shape::Path { pts, style, .. } | Shape::Spline { pts, style, .. } => {
2929            for p in pts {
2930                *p = *p * f;
2931            }
2932            scale_style(style, f);
2933        }
2934        Shape::Arc { c, r, style, .. } => {
2935            *c = *c * f;
2936            *r *= f;
2937            scale_style(style, f);
2938        }
2939        Shape::Text { at, bbox, w, h, .. } => {
2940            *at = *at * f;
2941            scale_bbox_in_place(bbox, f);
2942            *w *= f;
2943            *h *= f;
2944        }
2945    }
2946}
2947
2948#[cfg(test)]
2949mod tests {
2950    use super::*;
2951    use crate::parser::{parse, parse_in_dir};
2952
2953    fn draw(src: &str) -> Drawing {
2954        eval(&parse(src).unwrap()).unwrap()
2955    }
2956
2957    fn scalar(src: &str) -> ER<f64> {
2958        let prog = parse(&format!("x = {src}")).unwrap();
2959        let mut st = State::new();
2960        st.eval_stmts(&prog.stmts)?;
2961        Ok(st.vars["x"])
2962    }
2963
2964    fn assert_box_size(shape: &Shape, want_w: f64, want_h: f64) {
2965        let Shape::Box { w, h, .. } = shape else {
2966            panic!()
2967        };
2968        assert!((*w - want_w).abs() < 1e-9, "w = {w}, want {want_w}");
2969        assert!((*h - want_h).abs() < 1e-9, "h = {h}, want {want_h}");
2970    }
2971
2972    const DEFAULT_STROKE_IN: f64 = 0.8 / 72.0;
2973
2974    #[test]
2975    fn pipeline_chains_left_to_right() {
2976        let d = draw(".PS\nellipse \"document\"\narrow\nbox \"PIC\"\narrow\nbox \"TROFF\"\n.PE");
2977        // 5 shapes
2978        assert_eq!(d.shapes.len(), 5);
2979        // first ellipse centered at (0.375, 0): ellipsewid/2
2980        if let Shape::Ellipse { c, .. } = &d.shapes[0] {
2981            assert!((c.x - 0.375).abs() < 1e-9, "ellipse x = {}", c.x);
2982            assert!(c.y.abs() < 1e-9);
2983        } else {
2984            panic!("expected ellipse");
2985        }
2986        // bbox grows to the right
2987        assert!(d.bbox.width() > 2.0);
2988        assert!((d.bbox.height() - (0.5 + DEFAULT_STROKE_IN)).abs() < 1e-9);
2989    }
2990
2991    #[test]
2992    fn box_at_absolute() {
2993        let d = draw("box ht 0.3 wid 0.5 at 1,2");
2994        let Shape::Box { c, w, h, .. } = &d.shapes[0] else {
2995            panic!()
2996        };
2997        assert_eq!(*c, Point::new(1.0, 2.0));
2998        assert!((*w - 0.5).abs() < 1e-9 && (*h - 0.3).abs() < 1e-9);
2999    }
3000
3001    #[test]
3002    fn diamond_is_closed() {
3003        let d = draw("line up right then down right then down left then up left");
3004        let Shape::Path { pts, .. } = &d.shapes[0] else {
3005            panic!()
3006        };
3007        assert_eq!(pts.len(), 5);
3008        // returns to start
3009        assert!(pts[0].dist(*pts.last().unwrap()) < 1e-9);
3010    }
3011
3012    #[test]
3013    fn corners_and_labels() {
3014        let d = draw("A: box wid 1 ht 1 at 0,0\nbox wid 0.5 ht 0.5 with .sw at A.ne");
3015        // second box sw corner at A.ne (0.5,0.5) => its center at (0.75,0.75)
3016        let Shape::Box { c, .. } = &d.shapes[1] else {
3017            panic!()
3018        };
3019        assert!(
3020            (c.x - 0.75).abs() < 1e-9 && (c.y - 0.75).abs() < 1e-9,
3021            "c = {c:?}"
3022        );
3023    }
3024
3025    #[test]
3026    fn circle_corner_anchors_are_on_the_circumference() {
3027        let d = draw("C: circle rad 1 at 0,0\nline from C.sw to C.ne");
3028        let Shape::Path { pts, .. } = &d.shapes[1] else {
3029            panic!()
3030        };
3031        let a = 1.0 / 2.0_f64.sqrt();
3032        assert_eq!(pts.len(), 2);
3033        assert!((pts[0].x + a).abs() < 1e-9 && (pts[0].y + a).abs() < 1e-9);
3034        assert!((pts[1].x - a).abs() < 1e-9 && (pts[1].y - a).abs() < 1e-9);
3035    }
3036
3037    #[test]
3038    fn type_specific_corner_anchors_match_dpic() {
3039        let d = draw("L: line from (0,0) to (1,0) then to (1,1)\ncircle rad .01 at L.nw");
3040        let Shape::Circle { c, .. } = &d.shapes[1] else {
3041            panic!()
3042        };
3043        assert!(c.dist(Point::new(0.0, 0.0)) < 1e-9, "line nw = {c:?}");
3044
3045        let d = draw("A: arc cw rad 0.5 from (0,0) to (0.5,0.5)\ncircle rad .01 at A.s");
3046        let Shape::Circle { c, .. } = &d.shapes[1] else {
3047            panic!()
3048        };
3049        assert!(c.dist(Point::new(0.5, -0.5)) < 1e-9, "arc s = {c:?}");
3050
3051        let d = draw("B: box wid 1 ht 1 rad 0.3 at (0,0)\ncircle rad .01 at B.ne");
3052        let Shape::Circle { c, .. } = &d.shapes[1] else {
3053            panic!()
3054        };
3055        let inset = 0.3 * (1.0 - FRAC_1_SQRT_2);
3056        let expected = Point::new(0.5 - inset, 0.5 - inset);
3057        assert!(c.dist(expected) < 1e-9, "rounded box ne = {c:?}");
3058    }
3059
3060    #[test]
3061    fn with_corner_uses_ellipse_geometry_for_placed_object() {
3062        let d = draw("ellipse; ellipse with .nw at last ellipse.se");
3063        let Shape::Ellipse { c: first, .. } = &d.shapes[0] else {
3064            panic!()
3065        };
3066        let Shape::Ellipse { c: second, .. } = &d.shapes[1] else {
3067            panic!()
3068        };
3069        let expected = *first + Point::new(0.75 * FRAC_1_SQRT_2, -0.5 * FRAC_1_SQRT_2);
3070        assert!(
3071            second.dist(expected) < 1e-9,
3072            "second center = {second:?}, expected = {expected:?}"
3073        );
3074    }
3075
3076    #[test]
3077    fn for_loop_repeats() {
3078        let d = draw("for i = 1 to 3 do { box }");
3079        assert_eq!(d.shapes.len(), 3);
3080    }
3081
3082    #[test]
3083    fn for_loop_can_assign_subscripted_counter() {
3084        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");
3085        let Shape::Box { w, .. } = &d.shapes[0] else {
3086            panic!()
3087        };
3088        assert!((*w - 6.0).abs() < 1e-9, "w = {w}");
3089    }
3090
3091    #[test]
3092    fn if_else_branches() {
3093        let d1 = draw("x = 1\nif x > 0 then { box } else { circle }");
3094        assert!(matches!(d1.shapes[0], Shape::Box { .. }));
3095        let d2 = draw("x = 0\nif x > 0 then { box } else { circle }");
3096        assert!(matches!(d2.shapes[0], Shape::Circle { .. }));
3097    }
3098
3099    #[test]
3100    fn define_macro_with_args() {
3101        let d = draw("define elem { box wid $1 }\nelem(0.5)\nelem(1.25)");
3102        assert_eq!(d.shapes.len(), 2);
3103        let Shape::Box { w: w0, .. } = &d.shapes[0] else {
3104            panic!()
3105        };
3106        let Shape::Box { w: w1, .. } = &d.shapes[1] else {
3107            panic!()
3108        };
3109        assert!((*w0 - 0.5).abs() < 1e-9 && (*w1 - 1.25).abs() < 1e-9);
3110    }
3111
3112    #[test]
3113    fn define_accepts_arbitrary_delimiter() {
3114        let d = draw("define elem / box /\nelem");
3115        assert_eq!(d.shapes.len(), 1);
3116        assert!(matches!(d.shapes[0], Shape::Box { .. }));
3117    }
3118
3119    #[test]
3120    fn labelled_call_to_multiline_body_macro() {
3121        // A multi-line `{ … }` body picks up newlines after `{` / before `}`.
3122        // They must not leak into a labelled call (`Q: m()` -> `Q: ⏎ <obj>`),
3123        // which would be a parse error. The block's terminals must still resolve.
3124        let d = draw(
3125            "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",
3126        );
3127        // the block drew its box, and Q.R (a block sub-label) resolved for the text
3128        assert!(d.shapes.iter().any(|s| matches!(s, Shape::Box { .. })));
3129        let Shape::Text { at, .. } = d.shapes.last().unwrap() else {
3130            panic!()
3131        };
3132        // Q placed with .L (west) at (1,1); .R (east) is one box-width to the right
3133        assert!(
3134            (at.x - 1.4).abs() < 1e-6 && (at.y - 1.0).abs() < 1e-6,
3135            "at = {at:?}"
3136        );
3137    }
3138
3139    #[test]
3140    fn copied_forward_macro_expands_in_deferred_multiline_call() {
3141        let dir = std::env::temp_dir().join(format!("rpic_forward_macro_{}", std::process::id()));
3142        std::fs::create_dir_all(&dir).unwrap();
3143        std::fs::write(
3144            dir.join("lib.pic"),
3145            "define outer { if gate then { inner(0.2,\n  0.3) } }\ndefine inner { box wid $1 ht $2 }\n",
3146        )
3147        .unwrap();
3148
3149        let pic = parse_in_dir(
3150            "if 1 then { copy \"lib.pic\" }\ngate = 1\nouter()",
3151            Some(dir.as_path()),
3152        )
3153        .unwrap();
3154        let d = eval(&pic).unwrap();
3155        let _ = std::fs::remove_dir_all(&dir);
3156
3157        let Shape::Box { w, h, .. } = &d.shapes[0] else {
3158            panic!()
3159        };
3160        assert!((*w - 0.2).abs() < 1e-9 && (*h - 0.3).abs() < 1e-9);
3161    }
3162
3163    #[test]
3164    fn deferred_body_uses_macro_frame_from_own_expansion() {
3165        let d = draw(
3166            "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()",
3167        );
3168        let Shape::Box { w, .. } = &d.shapes[0] else {
3169            panic!()
3170        };
3171        assert!((*w - 2.0).abs() < 1e-9, "w = {w}");
3172    }
3173
3174    #[test]
3175    fn subscripted_variables_store_by_index() {
3176        let d = draw("P[1] = 0.4\nP[2] = 0.9\nP[2] += 0.1\nbox wid P[2] ht P[1]");
3177        let Shape::Box { w, h, .. } = &d.shapes[0] else {
3178            panic!()
3179        };
3180        assert!((*w - 1.0).abs() < 1e-9 && (*h - 0.4).abs() < 1e-9);
3181    }
3182
3183    #[test]
3184    fn multidimensional_variables_store_by_index_tuple() {
3185        let d = draw("M[1,2] = 0.7\nj = 2\nM[1,j] += 0.2\nbox wid M[1,2] ht 0.3");
3186        let Shape::Box { w, .. } = &d.shapes[0] else {
3187            panic!()
3188        };
3189        assert!((*w - 0.9).abs() < 1e-9, "w = {w}");
3190    }
3191
3192    #[test]
3193    fn subscripted_label_places_resolve_by_index() {
3194        let d =
3195            draw("for i = 1 to 2 do { A[i]: circle rad 0.01 at (i,0) }\nline from A[1] to A[2]");
3196        let Shape::Path { pts, .. } = &d.shapes[2] else {
3197            panic!()
3198        };
3199        assert!(
3200            pts[0].dist(Point::new(1.0, 0.0)) < 1e-9,
3201            "start {:?}",
3202            pts[0]
3203        );
3204        assert!(pts[1].dist(Point::new(2.0, 0.0)) < 1e-9, "end {:?}", pts[1]);
3205    }
3206
3207    #[test]
3208    fn for_loop_places_in_a_row() {
3209        // boxes step right by default; bbox should be ~3*boxwid wide
3210        let d = draw("for i = 1 to 3 do { box; move }");
3211        assert!(d.bbox.width() > 2.0);
3212    }
3213
3214    #[test]
3215    fn animate_timing() {
3216        let d = draw(
3217            "A: box\narrow\nbox\nanimate A with \"fade\" for 0.5\nanimate last arrow with \"draw\"\nanimate 2nd box with \"pop\" after A",
3218        );
3219        assert_eq!(d.anims.len(), 3);
3220        // A: fade, start 0, dur 0.5, targets shape 0
3221        assert_eq!(d.anims[0].effect, "fade");
3222        assert_eq!(d.anims[0].shape, 0);
3223        assert!((d.anims[0].start).abs() < 1e-9 && (d.anims[0].duration - 0.5).abs() < 1e-9);
3224        // arrow: draw, sequential after A -> start 0.5, default dur 0.6, shape 1
3225        assert_eq!(d.anims[1].shape, 1);
3226        assert!((d.anims[1].start - 0.5).abs() < 1e-9);
3227        // 2nd box: pop, after A (ends at 0.5) -> start 0.5, shape 2
3228        assert_eq!(d.anims[2].shape, 2);
3229        assert!((d.anims[2].start - 0.5).abs() < 1e-9);
3230    }
3231
3232    #[test]
3233    fn continue_extends_previous_line() {
3234        // issue #7: `continue` appends a segment to the last line (no new shape)
3235        let d = draw("line right 1\ncontinue down 0.5");
3236        assert_eq!(d.shapes.len(), 1, "should extend, not add a shape");
3237        let Shape::Path { pts, .. } = &d.shapes[0] else {
3238            panic!()
3239        };
3240        assert_eq!(pts.len(), 3);
3241        assert!(pts[2].dist(Point::new(1.0, -0.5)) < 1e-9, "{:?}", pts[2]);
3242        // bare continue extends in the current direction by linewid
3243        let d2 = draw("line right 1\ncontinue");
3244        let Shape::Path { pts, .. } = &d2.shapes[0] else {
3245            panic!()
3246        };
3247        assert!((pts.last().unwrap().x - 1.5).abs() < 1e-9);
3248    }
3249
3250    #[test]
3251    fn arc_from_to_endpoints_and_radius() {
3252        // issue #6: `arc from A to B` passes through both endpoints
3253        let d = draw("A:(0,0)\nB:(1,1)\narc from A to B");
3254        let Shape::Arc { c, r, a0, a1, .. } = &d.shapes[0] else {
3255            panic!()
3256        };
3257        assert!((*r - 2.0_f64.sqrt() / 2.0).abs() < 1e-9, "r = {r}");
3258        let s = *c + Point::new(a0.cos(), a0.sin()) * *r;
3259        let e = *c + Point::new(a1.cos(), a1.sin()) * *r;
3260        assert!(s.dist(Point::new(0.0, 0.0)) < 1e-9, "start {s:?}");
3261        assert!(e.dist(Point::new(1.0, 1.0)) < 1e-9, "end {e:?}");
3262
3263        let d_short = draw("arc from (0,0) to (0.3,0)");
3264        let Shape::Arc { r, .. } = &d_short.shapes[0] else {
3265            panic!()
3266        };
3267        assert!((*r - 0.25).abs() < 1e-9, "r = {r}");
3268
3269        let d_custom = draw("arcrad = 0.5\narc from (0,0) to (0.3,0)");
3270        let Shape::Arc { r, .. } = &d_custom.shapes[0] else {
3271            panic!()
3272        };
3273        assert!((*r - 0.5).abs() < 1e-9, "r = {r}");
3274
3275        // explicit radius is honored
3276        let d2 = draw("A:(0,0)\nB:(1,0)\narc from A to B rad 2");
3277        let Shape::Arc { r, .. } = &d2.shapes[0] else {
3278            panic!()
3279        };
3280        assert!((*r - 2.0).abs() < 1e-9, "r = {r}");
3281    }
3282
3283    #[test]
3284    fn arc_direction_disambiguates_from_to_radius() {
3285        let d = draw(
3286            "arc left from (0.5,0) to (0,0.5) rad 0.5\n\
3287             arc right from (0.5,0) to (0,0.5) rad 0.5 dashed",
3288        );
3289        let Shape::Arc {
3290            c: left,
3291            a0: left_a0,
3292            a1: left_a1,
3293            ..
3294        } = &d.shapes[0]
3295        else {
3296            panic!()
3297        };
3298        let Shape::Arc {
3299            c: right,
3300            a0: right_a0,
3301            a1: right_a1,
3302            ..
3303        } = &d.shapes[1]
3304        else {
3305            panic!()
3306        };
3307
3308        assert!(left.dist(Point::new(0.0, 0.0)) < 1e-9, "left = {left:?}");
3309        assert!(right.dist(Point::new(0.5, 0.5)) < 1e-9, "right = {right:?}");
3310        assert!((left_a1 - left_a0).abs() < PI);
3311        assert!((right_a1 - right_a0).abs() > PI);
3312    }
3313
3314    #[test]
3315    fn arc_with_center_at_disambiguates_large_clockwise_sweep() {
3316        let d = draw("arc cw rad 1 from (0,-1) to (1,0) with .c at (0,0)");
3317        let Shape::Arc { c, r, a0, a1, .. } = &d.shapes[0] else {
3318            panic!()
3319        };
3320        let start = *c + Point::new(a0.cos(), a0.sin()) * *r;
3321        let end = *c + Point::new(a1.cos(), a1.sin()) * *r;
3322
3323        assert!(c.dist(Point::ZERO) < 1e-9, "center = {c:?}");
3324        assert!((*r - 1.0).abs() < 1e-9, "r = {r}");
3325        assert!(
3326            start.dist(Point::new(0.0, -1.0)) < 1e-9,
3327            "start = {start:?}"
3328        );
3329        assert!(end.dist(Point::new(1.0, 0.0)) < 1e-9, "end = {end:?}");
3330        assert!(*a1 - *a0 < -PI, "sweep = {}", *a1 - *a0);
3331    }
3332
3333    #[test]
3334    fn arc_width_height_attrs_size_arrowheads() {
3335        let d = draw("arc <-> wid .5 ht .75");
3336        let Shape::Arc { style, .. } = &d.shapes[0] else {
3337            panic!()
3338        };
3339
3340        assert!(
3341            (style.arrow_wid - 0.5).abs() < 1e-9,
3342            "wid = {}",
3343            style.arrow_wid
3344        );
3345        assert!(
3346            (style.arrow_ht - 0.75).abs() < 1e-9,
3347            "ht = {}",
3348            style.arrow_ht
3349        );
3350    }
3351
3352    #[test]
3353    fn scale_converts_user_units_to_inches() {
3354        // `scale = 2` means two user units per inch: defaults stay the same
3355        // physical size, while explicit dimensions and coordinates are halved.
3356        let d = draw("scale = 2\nbox");
3357        let Shape::Box { w, h, .. } = &d.shapes[0] else {
3358            panic!()
3359        };
3360        assert!(
3361            (*w - 0.75).abs() < 1e-9 && (*h - 0.5).abs() < 1e-9,
3362            "{w} x {h}"
3363        );
3364
3365        let d = draw("scale = 2\nbox wid 2 ht 1");
3366        let Shape::Box { w, h, .. } = &d.shapes[0] else {
3367            panic!()
3368        };
3369        assert!((*w - 1.0).abs() < 1e-9 && (*h - 0.5).abs() < 1e-9);
3370
3371        let d = draw("scale = 2\nline from (0,0) to (2,0)");
3372        let Shape::Path { pts, .. } = &d.shapes[0] else {
3373            panic!()
3374        };
3375        assert!((pts.last().unwrap().x - 1.0).abs() < 1e-9, "{pts:?}");
3376
3377        let d = draw("scale = 2\nA: (2,0)\nbox wid A.x ht .2");
3378        let Shape::Box { w, .. } = &d.shapes[0] else {
3379            panic!()
3380        };
3381        assert!((*w - 1.0).abs() < 1e-9, "w = {w}");
3382
3383        let d = draw("scale = 2\nbox wid 2 ht 1\nscale = 1\nbox wid 1 ht .5");
3384        let Shape::Box { w, .. } = &d.shapes[0] else {
3385            panic!()
3386        };
3387        assert!((*w - 2.0).abs() < 1e-9, "w = {w}");
3388        let Shape::Box { c, .. } = &d.shapes[1] else {
3389            panic!()
3390        };
3391        assert!((c.x - 2.5).abs() < 1e-9, "center = {c:?}");
3392    }
3393
3394    #[test]
3395    fn same_reuses_previous_dims() {
3396        // issue #4: `box same` reuses the previous box's dimensions
3397        let d = draw("box wid 1 ht 0.4 at 0,0\nbox same at 2,0");
3398        let Shape::Box { w, h, .. } = &d.shapes[1] else {
3399            panic!()
3400        };
3401        assert!(
3402            (*w - 1.0).abs() < 1e-9 && (*h - 0.4).abs() < 1e-9,
3403            "{w} x {h}"
3404        );
3405    }
3406
3407    #[test]
3408    fn same_reuses_previous_open_vector() {
3409        let d = draw("line up 1\nright\nline same");
3410        let Shape::Path { pts, .. } = &d.shapes[1] else {
3411            panic!()
3412        };
3413        assert!(pts[0].dist(Point::new(0.0, 1.0)) < 1e-9, "{pts:?}");
3414        assert!(pts[1].dist(Point::new(0.0, 2.0)) < 1e-9, "{pts:?}");
3415    }
3416
3417    #[test]
3418    fn spline_expr_is_tension_not_distance() {
3419        // issue #63: `spline <expr>` is a dpic tension parameter, not a bare
3420        // distance. The control polygon (and thus start/end) must be unchanged,
3421        // and the tension recorded.
3422        let d = draw("spline 0.5 from 0,0 to 1,1 to 2,0");
3423        let Shape::Spline { pts, tension, .. } = &d.shapes[0] else {
3424            panic!("expected a spline")
3425        };
3426        assert_eq!(*tension, Some(0.5));
3427        assert_eq!(pts.len(), 3, "tension must not add a segment: {pts:?}");
3428        assert!(pts[0].dist(Point::new(0.0, 0.0)) < 1e-9, "{pts:?}");
3429        assert!(pts[2].dist(Point::new(2.0, 0.0)) < 1e-9, "{pts:?}");
3430    }
3431
3432    #[test]
3433    fn spline_variable_tension_does_not_drift() {
3434        // The doc/spline.pic idiom: `for x … { spline x from 0,0 … }`. Each
3435        // tensioned spline must keep the same start/end as the untensioned one
3436        // (only the curvature changes), instead of `x` shifting the geometry.
3437        let plain = draw("spline from 0,0 up 1.5 then right 2 then down 1.5");
3438        let Shape::Spline {
3439            pts: p0,
3440            tension: t0,
3441            ..
3442        } = &plain.shapes[0]
3443        else {
3444            panic!()
3445        };
3446        assert_eq!(*t0, None);
3447
3448        let tensioned = draw("x = 0.6\nspline x from 0,0 up 1.5 then right 2 then down 1.5");
3449        let Shape::Spline {
3450            pts: p1,
3451            tension: t1,
3452            ..
3453        } = &tensioned.shapes[0]
3454        else {
3455            panic!()
3456        };
3457        assert_eq!(*t1, Some(0.6));
3458        assert_eq!(p0.len(), p1.len());
3459        assert!(p0.first().unwrap().dist(*p1.first().unwrap()) < 1e-9);
3460        assert!(p0.last().unwrap().dist(*p1.last().unwrap()) < 1e-9);
3461    }
3462
3463    #[test]
3464    fn chop_trims_line_endpoints() {
3465        // issue #4: `chop` trims circlerad (0.25) off each end
3466        let d = draw("circle at 0,0\ncircle at 2,0\nline from 1st circle to 2nd circle chop");
3467        let Shape::Path { pts, .. } = &d.shapes[2] else {
3468            panic!()
3469        };
3470        assert!((pts[0].x - 0.25).abs() < 1e-9, "start {:?}", pts[0]);
3471        assert!(
3472            (pts.last().unwrap().x - 1.75).abs() < 1e-9,
3473            "end {:?}",
3474            pts.last()
3475        );
3476
3477        let d = draw("line from (0,0) to (2,0) chop 0 chop .5");
3478        let Shape::Path { pts, .. } = &d.shapes[0] else {
3479            panic!()
3480        };
3481        assert!((pts[0].x - 0.0).abs() < 1e-9, "{pts:?}");
3482        assert!((pts[1].x - 1.5).abs() < 1e-9, "{pts:?}");
3483
3484        let d = draw("line from (0,0) to (2,0) chop .5 chop 0");
3485        let Shape::Path { pts, .. } = &d.shapes[0] else {
3486            panic!()
3487        };
3488        assert!((pts[0].x - 0.5).abs() < 1e-9, "{pts:?}");
3489        assert!((pts[1].x - 2.0).abs() < 1e-9, "{pts:?}");
3490
3491        let d = draw("line from (0,0) to (2,0) chop -.25 chop -.5");
3492        let Shape::Path { pts, .. } = &d.shapes[0] else {
3493            panic!()
3494        };
3495        assert!((pts[0].x + 0.25).abs() < 1e-9, "{pts:?}");
3496        assert!((pts[1].x - 2.5).abs() < 1e-9, "{pts:?}");
3497    }
3498
3499    #[test]
3500    fn unknown_variables_are_errors() {
3501        assert!(eval(&parse("box wid typo ht 0.2").unwrap()).is_err());
3502        assert!(eval(&parse("typo += 1").unwrap()).is_err());
3503    }
3504
3505    #[test]
3506    fn rand_advances_and_seed_repeats() {
3507        let mut st = State::new();
3508        let a = st.eval_expr(&Expr::Rand(None)).unwrap();
3509        let b = st.eval_expr(&Expr::Rand(None)).unwrap();
3510        assert!((0.0..1.0).contains(&a));
3511        assert!((0.0..1.0).contains(&b));
3512        assert_ne!(a, b);
3513
3514        let seeded_a = st
3515            .eval_expr(&Expr::Rand(Some(Box::new(Expr::Num(1.0)))))
3516            .unwrap();
3517        let seeded_b = st
3518            .eval_expr(&Expr::Rand(Some(Box::new(Expr::Num(1.0)))))
3519            .unwrap();
3520        assert_eq!(seeded_a, seeded_b);
3521        assert!((seeded_a - 0.840_187_717).abs() < 1e-9, "{seeded_a}");
3522        let next = st.eval_expr(&Expr::Rand(None)).unwrap();
3523        assert!((next - 0.394_382_927).abs() < 1e-9, "{next}");
3524    }
3525
3526    #[test]
3527    fn arithmetic_matches_dpic_edge_cases() {
3528        assert_eq!(scalar("5.5 % 2").unwrap(), 0.0);
3529        assert_eq!(scalar("2.5 % 2").unwrap(), 1.0);
3530        assert_eq!(scalar("-2.5 % 2").unwrap(), -1.0);
3531        assert!(scalar("5 % 0.4").is_err());
3532
3533        let mut st = State::new();
3534        st.eval_stmts(&parse("x = 5.5; x %= 2").unwrap().stmts)
3535            .unwrap();
3536        assert_eq!(st.vars["x"], 0.0);
3537
3538        assert_eq!(scalar("sign(0)").unwrap(), 1.0);
3539        assert_eq!(scalar("sign(-0.1)").unwrap(), -1.0);
3540
3541        assert_eq!(scalar("(-2)^3").unwrap(), -8.0);
3542        assert_eq!(scalar("(-2)^2").unwrap(), 4.0);
3543        assert_eq!(scalar("0^0").unwrap(), 1.0);
3544        assert!(scalar("(-2)^0.5").is_err());
3545        assert!(scalar("0^-1").is_err());
3546    }
3547
3548    #[test]
3549    fn standalone_text_occupies_invisible_box() {
3550        let d = draw("textwid = 1; textht = .2\n\"x\"\nbox wid .2 ht .2");
3551        let Shape::Text { at, .. } = &d.shapes[0] else {
3552            panic!()
3553        };
3554        assert!((at.x - 0.5).abs() < 1e-9, "text at {at:?}");
3555        let Shape::Box { c, .. } = &d.shapes[1] else {
3556            panic!()
3557        };
3558        assert!((c.x - 1.1).abs() < 1e-9, "box center {c:?}");
3559    }
3560
3561    #[test]
3562    fn text_position_modifies_the_preceding_string_only() {
3563        let d = draw("\"LLLL\" ljust");
3564        let Shape::Text { text, .. } = &d.shapes[0] else {
3565            panic!()
3566        };
3567        assert_eq!(text[0].halign, -1);
3568
3569        let d = draw("\"RRRR\" rjust");
3570        let Shape::Text { text, .. } = &d.shapes[0] else {
3571            panic!()
3572        };
3573        assert_eq!(text[0].halign, 1);
3574
3575        let d = draw("box wid 1 ht .6 \"AAAA\" above \"BBBB\" below");
3576        let Shape::Box { text, .. } = &d.shapes[0] else {
3577            panic!()
3578        };
3579        assert_eq!(text[0].valign, 1);
3580        assert_eq!(text[1].valign, -1);
3581
3582        let d = draw("box \"AAAA\" above \"BBBB\"");
3583        let Shape::Box { text, .. } = &d.shapes[0] else {
3584            panic!()
3585        };
3586        assert_eq!(text[0].valign, 1);
3587        assert_eq!(text[1].valign, 0);
3588    }
3589
3590    #[test]
3591    fn style_globals_and_dash_lengths_apply() {
3592        let d = draw("linethick = 3\nline dashed .2\nline dotted .05");
3593        let Shape::Path { style, .. } = &d.shapes[0] else {
3594            panic!()
3595        };
3596        assert_eq!(style.thick, Some(3.0));
3597        assert_eq!(style.dash, Dash::Dashed(0.2));
3598        let Shape::Path { style, .. } = &d.shapes[1] else {
3599            panic!()
3600        };
3601        assert_eq!(style.dash, Dash::Dotted(Some(0.05)));
3602    }
3603
3604    #[test]
3605    fn color_attribute_expands_runtime_macro_string() {
3606        let d = draw(
3607            "r = 0; g = 0; b = 0.6\n\
3608             if dpicopt == optSVG then {\n\
3609               define customcolor { sprintf(\"rgb(%g,%g,%g)\", int(r*255), int(g*255), int(b*255)) }\n\
3610             }\n\
3611             arc color customcolor",
3612        );
3613        let Shape::Arc { style, .. } = &d.shapes[0] else {
3614            panic!()
3615        };
3616        assert_eq!(style.stroke.as_deref(), Some("rgb(0,0,153)"));
3617        assert_eq!(style.fill, Some(Fill::Color("rgb(0,0,153)".into())));
3618    }
3619
3620    #[test]
3621    fn ps_width_scales_drawing() {
3622        // issue #4, dpic oracle: `.PS 6` scales the painted picture, so the
3623        // box geometry is slightly under 6in once default stroke is reserved.
3624        let d = draw(".PS 6\nbox\n.PE");
3625        let Shape::Box { w, .. } = &d.shapes[0] else {
3626            panic!()
3627        };
3628        assert!((*w - 5.912_408_759).abs() < 1e-9, "w = {w}");
3629        assert!(
3630            (d.bbox.width() - 5.923_519_870).abs() < 1e-9,
3631            "w = {}",
3632            d.bbox.width()
3633        );
3634    }
3635
3636    #[test]
3637    fn text_extent_in_bbox() {
3638        // issue #5: a bare label must yield a non-degenerate bbox (no clipping)
3639        let d = draw("\"a long label here\"");
3640        assert!(d.bbox.width() > 0.5, "w = {}", d.bbox.width());
3641        assert!(d.bbox.height() > 0.1, "h = {}", d.bbox.height());
3642        // text wider than its box widens the bbox beyond the box
3643        let d2 = draw("box wid 0.2 ht 0.2 \"a very wide label\"");
3644        assert!(d2.bbox.width() > 0.3, "w = {}", d2.bbox.width());
3645    }
3646
3647    #[test]
3648    fn text_object_width_bounds_rendered_bbox() {
3649        // dpic oracle: a standalone text object's `wid` controls its bbox;
3650        // the literal text width is not used when an explicit width is given.
3651        let d = draw("\"abcdefghij\" wid 0.1");
3652        assert!(
3653            (d.bbox.width() - 0.1).abs() < 1e-9,
3654            "w = {}",
3655            d.bbox.width()
3656        );
3657
3658        let d = draw(".PS 1\n\"abcdefghij\" wid 0.1\n.PE");
3659        assert!(
3660            (d.bbox.width() - 1.0).abs() < 1e-9,
3661            "w = {}",
3662            d.bbox.width()
3663        );
3664    }
3665
3666    #[test]
3667    fn text_position_and_offset_expand_bbox_in_the_rendered_direction() {
3668        let d = draw("textoffset = 0.1\n\"abc\" ljust at (0,0)");
3669        assert!(d.bbox.min.x >= 0.1 - 1e-9, "{:?}", d.bbox);
3670
3671        let d = draw("textoffset = 0.1\n\"abc\" rjust at (0,0)");
3672        assert!(d.bbox.max.x <= -0.1 + 1e-9, "{:?}", d.bbox);
3673
3674        let d = draw("textoffset = 0.1\n\"abc\" above at (0,0)");
3675        assert!(d.bbox.min.y > 0.0, "{:?}", d.bbox);
3676
3677        let d = draw("textoffset = 0.1\n\"abc\" below at (0,0)");
3678        assert!(d.bbox.max.y < 0.0, "{:?}", d.bbox);
3679    }
3680
3681    #[test]
3682    fn invisible_geometry_does_not_expand_drawing_bbox() {
3683        let d = draw("box invis wid 1000 ht 1000 at (0,0)\nbox wid 1 ht 1 at (0,0)");
3684        assert!(
3685            (d.bbox.width() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
3686            "w = {}",
3687            d.bbox.width()
3688        );
3689        assert!(
3690            (d.bbox.height() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
3691            "h = {}",
3692            d.bbox.height()
3693        );
3694
3695        let d2 = draw("line invis from (0,0) to (1000,1000)\nbox wid 1 ht 1 at (0,0)");
3696        assert!(
3697            (d2.bbox.width() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
3698            "w = {}",
3699            d2.bbox.width()
3700        );
3701        assert!(
3702            (d2.bbox.height() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
3703            "h = {}",
3704            d2.bbox.height()
3705        );
3706
3707        let d3 = draw("I: box invis wid 1000 ht 1000 at (0,0)\nbox wid 1 ht 1 with .sw at I.ne");
3708        let Shape::Box { c, .. } = &d3.shapes[1] else {
3709            panic!()
3710        };
3711        assert!(c.dist(Point::new(500.5, 500.5)) < 1e-9, "c = {c:?}");
3712        assert!(
3713            (d3.bbox.width() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
3714            "w = {}",
3715            d3.bbox.width()
3716        );
3717        assert!(
3718            (d3.bbox.height() - (1.0 + DEFAULT_STROKE_IN)).abs() < 1e-9,
3719            "h = {}",
3720            d3.bbox.height()
3721        );
3722    }
3723
3724    #[test]
3725    fn move_expands_drawing_bbox_like_dpic() {
3726        let d = draw("line from (0,0) to (1,0)\nmove left 0.4 from (0,0)");
3727        assert!(
3728            (d.bbox.width() - (1.4 + DEFAULT_STROKE_IN / 2.0)).abs() < 1e-9,
3729            "w = {}",
3730            d.bbox.width()
3731        );
3732    }
3733
3734    #[test]
3735    fn division_by_zero_errors() {
3736        // a zero divisor must error rather than silently produce NaN coordinates
3737        assert!(eval(&parse("box wid 1/0").unwrap()).is_err());
3738        assert!(eval(&parse("A:(0,0)\nB:(0,0)\nx = (B.x-A.x)/(B.y-A.y)").unwrap()).is_err());
3739    }
3740
3741    #[test]
3742    fn place_dot_in_coordinate_pair() {
3743        // (A.x, A.y - 1) — place scalar accessors inside a coordinate pair (issue #3)
3744        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)");
3745        let Shape::Box { c, .. } = &d.shapes[1] else {
3746            panic!()
3747        };
3748        assert!(
3749            (c.x - 2.0).abs() < 1e-9 && (c.y - 2.0).abs() < 1e-9,
3750            "c = {c:?}"
3751        );
3752    }
3753
3754    #[test]
3755    fn bare_coordinate_pair_places_label() {
3756        let d = draw("P: 1,2\nbox wid 0.2 ht 0.2 at P");
3757        let Shape::Box { c, .. } = &d.shapes[0] else {
3758            panic!()
3759        };
3760        assert!(c.dist(Point::new(1.0, 2.0)) < 1e-9, "c = {c:?}");
3761    }
3762
3763    #[test]
3764    fn block_sub_labels_resolve() {
3765        // `B.A` and `B.A.corner` reach a labelled object inside a block
3766        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");
3767        let Shape::Box { c, .. } = &d.shapes.last().unwrap() else {
3768            panic!()
3769        };
3770        // the block is placed with its center at (0.5,0); inner A.ne is then
3771        // (1.0,0.5), and the small box centers 0.1 beyond that corner.
3772        assert!(
3773            (c.x - 1.1).abs() < 1e-9 && (c.y - 0.6).abs() < 1e-9,
3774            "c = {c:?}"
3775        );
3776    }
3777
3778    #[test]
3779    fn block_can_anchor_on_own_member() {
3780        // The block bbox center is not A.c, so this catches the two-pass anchor
3781        // resolution rather than accidentally aligning the block center.
3782        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");
3783        let Shape::Box { c, .. } = &d.shapes[0] else {
3784            panic!()
3785        };
3786        assert!(c.dist(Point::new(2.0, 3.0)) < 1e-9, "A center = {c:?}");
3787        let Shape::Circle { c, .. } = &d.shapes[1] else {
3788            panic!()
3789        };
3790        assert!(c.dist(Point::new(4.0, 3.0)) < 1e-9, "circle center = {c:?}");
3791    }
3792
3793    #[test]
3794    fn block_pair_anchor_uses_local_coordinates() {
3795        // dpic oracle: for `[ ... ] with (x,y) at P`, `(x,y)` is a point in
3796        // the block's local coordinate system, not an offset from its center.
3797        let d = draw("[ box wid 2 ht 1 at (1,0) ] with (0,0) at (10,20)");
3798        let Shape::Box { c, .. } = &d.shapes[0] else {
3799            panic!()
3800        };
3801        assert!(c.dist(Point::new(11.0, 20.0)) < 1e-9, "c = {c:?}");
3802    }
3803
3804    #[test]
3805    fn block_layout_matches_dpic_for_negative_box_width() {
3806        let d = draw("move 1\n[ box wid -0.5 ht 0.5 ]; box wid 0.75 ht 0.75");
3807        let boxes: Vec<Point> = d
3808            .shapes
3809            .iter()
3810            .filter_map(|shape| match shape {
3811                Shape::Box { c, .. } => Some(*c),
3812                _ => None,
3813            })
3814            .collect();
3815        assert_eq!(boxes.len(), 2);
3816        assert!(
3817            boxes[0].dist(Point::new(0.75, 0.0)) < 1e-9,
3818            "negative box center = {:?}",
3819            boxes[0]
3820        );
3821        assert!(
3822            boxes[1].dist(Point::new(1.375, 0.0)) < 1e-9,
3823            "following box center = {:?}",
3824            boxes[1]
3825        );
3826    }
3827
3828    #[test]
3829    fn block_object_renders_attached_text() {
3830        let d = draw("[ box ] \"block label\"");
3831        assert!(d.shapes.iter().any(|s| {
3832            matches!(
3833                s,
3834                Shape::Text { text, .. } if text.iter().any(|line| line.s == "block label")
3835            )
3836        }));
3837    }
3838
3839    #[test]
3840    fn block_anchors_ignore_attached_text_extents() {
3841        // dpic oracle: text contributes to the painted bbox, but not to block
3842        // anchors such as `last [].s`; those come from the geometric objects.
3843        let d = draw(
3844            r#"B: [ right; box "{\bf veryveryverywide}"; move; box ]
3845box wid 0.1 ht 0.1 at B.s"#,
3846        );
3847        let Shape::Box { c, .. } = d.shapes.last().unwrap() else {
3848            panic!()
3849        };
3850        assert!(c.dist(Point::new(1.0, -0.25)) < 1e-9, "c = {c:?}");
3851    }
3852
3853    #[test]
3854    fn nested_macro_block_can_reference_parent_label() {
3855        let d = draw(
3856            "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) ]",
3857        );
3858        let Shape::Path { pts, .. } = &d.shapes[0] else {
3859            panic!()
3860        };
3861        let Shape::Circle { c, .. } = &d.shapes[1] else {
3862            panic!()
3863        };
3864        assert!(
3865            c.dist(pts[0]) < 1e-9,
3866            "circle = {c:?}, arrow start = {:?}",
3867            pts[0]
3868        );
3869    }
3870
3871    #[test]
3872    fn position_vector_arithmetic() {
3873        // (w,h)/2 and p + q with correct precedence
3874        let d = draw("box wid 0.2 ht 0.2 at (2,4)/2 + (1,0)");
3875        let Shape::Box { c, .. } = &d.shapes[0] else {
3876            panic!()
3877        };
3878        assert!(
3879            (c.x - 2.0).abs() < 1e-9 && (c.y - 2.0).abs() < 1e-9,
3880            "c = {c:?}"
3881        );
3882    }
3883
3884    #[test]
3885    fn interpolation_angle_brackets() {
3886        let d = draw("A:(0,0)\nB:(2,0)\nbox wid 0.1 ht 0.1 at 0.5 <A,B>");
3887        let Shape::Box { c, .. } = &d.shapes.last().unwrap() else {
3888            panic!()
3889        };
3890        assert!((c.x - 1.0).abs() < 1e-9, "c = {c:?}");
3891    }
3892
3893    #[test]
3894    fn string_equality_in_if() {
3895        // the `"$1"==""` default-argument idiom (here without a macro)
3896        let d1 = draw("if \"a\" == \"\" then { box } else { circle }");
3897        assert!(matches!(d1.shapes[0], Shape::Circle { .. }));
3898        let d2 = draw("if \"\" == \"\" then { box } else { circle }");
3899        assert!(matches!(d2.shapes[0], Shape::Box { .. }));
3900    }
3901
3902    #[test]
3903    fn dpicopt_defaults_to_svg_backend() {
3904        let d = draw("if dpicopt == optSVG then { box } else { circle }");
3905        assert!(matches!(d.shapes[0], Shape::Box { .. }));
3906    }
3907
3908    #[test]
3909    fn svg_font_stub_and_string_sprintf_are_harmless() {
3910        let d = draw("box sprintf(\"x%s\", svg_font(\"Times\", 12))");
3911        let Shape::Box { text, .. } = &d.shapes[0] else {
3912            panic!()
3913        };
3914        assert_eq!(text[0].s, "x");
3915    }
3916
3917    #[test]
3918    fn inch_suffix_and_bare_distance() {
3919        // `.5i` is half an inch; `move 1` / `move -0.1` advance the pen
3920        let d = draw("box wid .5i ht .5i");
3921        let Shape::Box { w, .. } = &d.shapes[0] else {
3922            panic!()
3923        };
3924        assert!((*w - 0.5).abs() < 1e-9, "w = {w}");
3925        let d2 = draw("right\nmove 1\nbox at Here");
3926        let Shape::Box { c, .. } = &d2.shapes.last().unwrap() else {
3927            panic!()
3928        };
3929        assert!(c.x > 0.9, "moved to {c:?}");
3930    }
3931
3932    #[test]
3933    fn embedded_assignment_returns_value() {
3934        let d = draw("if (s = 3) > 1 then { box wid s ht 0.1 }");
3935        let Shape::Box { w, .. } = &d.shapes[0] else {
3936            panic!()
3937        };
3938        assert!((*w - 3.0).abs() < 1e-9, "w = {w}");
3939    }
3940
3941    #[test]
3942    fn copy_includes_a_file() {
3943        // `copy "file"` splices another pic file relative to the base directory
3944        let dir = std::env::temp_dir().join(format!("rpic_copy_{}", std::process::id()));
3945        std::fs::create_dir_all(&dir).unwrap();
3946        std::fs::write(dir.join("inc.pic"), "box wid 0.5 ht 0.5\n").unwrap();
3947        let pic = parse_in_dir("copy \"inc.pic\"\ncircle", Some(dir.as_path())).unwrap();
3948        let d = eval(&pic).unwrap();
3949        assert!(d.shapes.iter().any(|s| matches!(s, Shape::Box { .. })));
3950        assert!(d.shapes.iter().any(|s| matches!(s, Shape::Circle { .. })));
3951        let _ = std::fs::remove_dir_all(&dir);
3952    }
3953
3954    #[test]
3955    fn paren_position_coordinate() {
3956        // `.x`/`.y` on a parenthesised position expression
3957        let d = draw("A:(0,0)\nB:(3,1)\nbox wid (B-A).x ht (B-A).y at 0,-2");
3958        let Shape::Box { w, h, .. } = &d.shapes[0] else {
3959            panic!()
3960        };
3961        assert!(
3962            (*w - 3.0).abs() < 1e-9 && (*h - 1.0).abs() < 1e-9,
3963            "{w} x {h}"
3964        );
3965    }
3966
3967    #[test]
3968    fn arrowhead_size_follows_globals() {
3969        // arrowht/arrowwid control the rendered arrowhead, not a hardcoded size
3970        let d = draw("arrowht = 0.3; arrowwid = 0.2\narrow right 1");
3971        let Shape::Path { style, .. } = &d.shapes[0] else {
3972            panic!()
3973        };
3974        assert!(
3975            (style.arrow_ht - 0.3).abs() < 1e-9,
3976            "ht = {}",
3977            style.arrow_ht
3978        );
3979        assert!(
3980            (style.arrow_wid - 0.2).abs() < 1e-9,
3981            "wid = {}",
3982            style.arrow_wid
3983        );
3984    }
3985
3986    #[test]
3987    fn scaling_existing_geometry_scales_arrowhead_metadata() {
3988        // manual/man35 draws at a temporary scale, then restores `scale`.
3989        // The points are scaled at restore time, and the arrowhead dimensions
3990        // attached to the already-emitted path must scale with them.
3991        let factor = 6.6 / 8.2;
3992        let d = draw("scale = 6.6/8.2\nline <-\nscale = 1");
3993        let Shape::Path { style, .. } = &d.shapes[0] else {
3994            panic!()
3995        };
3996        assert!(
3997            (style.arrow_ht - 0.1 * factor).abs() < 1e-9,
3998            "ht = {}",
3999            style.arrow_ht
4000        );
4001        assert!(
4002            (style.arrow_wid - 0.05 * factor).abs() < 1e-9,
4003            "wid = {}",
4004            style.arrow_wid
4005        );
4006    }
4007
4008    #[test]
4009    fn dpic_default_env_values_are_readable() {
4010        assert!((scalar("textoffset").unwrap() - 2.0 / 72.0).abs() < 1e-9);
4011        assert!((scalar("textht").unwrap() - (11.0 / 72.0) * 0.66).abs() < 1e-9);
4012        assert!((scalar("arrowhead").unwrap() - 1.0).abs() < 1e-9);
4013        assert!((scalar("linethick").unwrap() - 0.8).abs() < 1e-9);
4014        assert_eq!(scalar("margin").unwrap(), 0.0);
4015        assert_eq!(scalar("topmargin").unwrap(), 0.0);
4016        assert_eq!(scalar("rightmargin").unwrap(), 0.0);
4017        assert_eq!(scalar("bottommargin").unwrap(), 0.0);
4018        assert_eq!(scalar("leftmargin").unwrap(), 0.0);
4019    }
4020
4021    #[test]
4022    fn canvas_margin_vars_are_scaled_dimensions() {
4023        let d = draw("margin = 1; topmargin = 0.5; rightmargin = 0.25; line right");
4024        assert_eq!(
4025            d.canvas_margin,
4026            CanvasMargin {
4027                top: 1.5,
4028                right: 1.25,
4029                bottom: 1.0,
4030                left: 1.0,
4031            }
4032        );
4033
4034        let d = draw("scale = 2; margin = 1; topmargin = 1; line right");
4035        assert_eq!(
4036            d.canvas_margin,
4037            CanvasMargin {
4038                top: 1.0,
4039                right: 0.5,
4040                bottom: 0.5,
4041                left: 0.5,
4042            }
4043        );
4044
4045        let d = draw("margin = 1; scale = 2; line right");
4046        assert_eq!(
4047            d.canvas_margin,
4048            CanvasMargin {
4049                top: 1.0,
4050                right: 1.0,
4051                bottom: 1.0,
4052                left: 1.0,
4053            }
4054        );
4055    }
4056
4057    #[test]
4058    fn print_statements_collect_diagnostics() {
4059        let d = draw("print 5.5\nprint 5.5%2\nprint \"hello\"\nprint sprintf(\"x=%g\", 1.25)");
4060        assert_eq!(d.diagnostics, ["5.5", "0", "hello", "x=1.25"]);
4061
4062        let d = draw("[ print \"inside\"; box ]\nprint 7");
4063        assert_eq!(d.diagnostics, ["inside", "7"]);
4064    }
4065
4066    #[test]
4067    fn open_object_width_height_attrs_are_arrowhead_dimensions() {
4068        let d = draw("arrowwid = 0.2; arrowht = 0.3\nA: line right 2\nbox wid (A.wid) ht (A.ht)");
4069        assert_box_size(&d.shapes[1], 0.2, 0.3);
4070
4071        let d = draw("arrowwid = 0.12; arrowht = 0.34\nA: move right 2\nbox wid (A.wid) ht (A.ht)");
4072        assert_box_size(&d.shapes[1], 0.12, 0.34);
4073
4074        let d = draw(
4075            "arrowwid = 0.23; arrowht = 0.31\nA: spline from (0,0) to (2,1)\nbox wid (A.wid) ht (A.ht)",
4076        );
4077        assert_box_size(&d.shapes[1], 0.23, 0.31);
4078    }
4079
4080    #[test]
4081    fn radius_and_diameter_attrs_are_type_specific() {
4082        let d = draw("B: box rad 0.1 wid 1 ht 1\nbox wid (B.rad) ht 0.3");
4083        assert_box_size(&d.shapes[1], 0.1, 0.3);
4084
4085        let d = draw("C: arc rad 0.7 from (0,0) to (0,1.4)\nbox wid (C.rad) ht (C.diam)");
4086        assert_box_size(&d.shapes[1], 0.7, 1.4);
4087    }
4088
4089    #[test]
4090    fn invalid_type_scalar_attrs_match_dpic_zero() {
4091        let prog = parse(
4092            "E: ellipse wid 2 ht 1\nB: box wid 1 ht 1\nA: arc rad .5\n\
4093             e_rad = E.rad\ne_diam = E.diam\nb_diam = B.diam\na_len = A.len",
4094        )
4095        .unwrap();
4096        let mut st = State::new();
4097        st.eval_stmts(&prog.stmts).unwrap();
4098        assert_eq!(st.vars["e_rad"], 0.0);
4099        assert_eq!(st.vars["e_diam"], 0.0);
4100        assert_eq!(st.vars["b_diam"], 0.0);
4101        assert_eq!(st.vars["a_len"], 0.0);
4102    }
4103
4104    #[test]
4105    fn arrowhead_type_open_vs_filled() {
4106        // default is a filled head; `arrowhead = 0` is an open (two-stroke) head
4107        let d = draw("arrow right 1");
4108        let Shape::Path { style, .. } = &d.shapes[0] else {
4109            panic!()
4110        };
4111        assert!(style.arrow_filled, "default should be filled");
4112        let d2 = draw("arrowhead = 0\narrow right 1");
4113        let Shape::Path { style, .. } = &d2.shapes[0] else {
4114            panic!()
4115        };
4116        assert!(!style.arrow_filled, "arrowhead=0 should be open");
4117
4118        let d3 = draw("arrowhead = 0\nline <- 1 up");
4119        let Shape::Path { style, .. } = &d3.shapes[0] else {
4120            panic!()
4121        };
4122        assert!(style.arrow_filled, "`<- 1` should override the global");
4123
4124        let d4 = draw("line <- 0 up");
4125        let Shape::Path { style, .. } = &d4.shapes[0] else {
4126            panic!()
4127        };
4128        assert!(!style.arrow_filled, "`<- 0` should override the global");
4129    }
4130
4131    #[test]
4132    fn maxps_clamps_oversized_drawing() {
4133        // larger than the default 8.5x11in page → scaled down to fit
4134        let d = draw("box wid 20 ht 30");
4135        assert!(
4136            d.bbox.width() <= 8.5 + 1e-6 && d.bbox.height() <= 11.0 + 1e-6,
4137            "{}x{}",
4138            d.bbox.width(),
4139            d.bbox.height()
4140        );
4141        // raising the limits disables the clamp
4142        let d2 = draw("maxpsht = 200; maxpswid = 50\nbox wid 20 ht 30");
4143        assert!(
4144            (d2.bbox.height() - (30.0 + DEFAULT_STROKE_IN)).abs() < 1e-6,
4145            "h = {}",
4146            d2.bbox.height()
4147        );
4148        // a small drawing is untouched
4149        let d3 = draw("box wid 2 ht 1");
4150        assert!((d3.bbox.width() - (2.0 + DEFAULT_STROKE_IN)).abs() < 1e-6);
4151
4152        let d4 = draw("maxpswid = 2; maxpsht = 100\nmargin = 1\nbox wid 1 ht 0.5");
4153        assert!(
4154            d4.bbox.width() + d4.canvas_margin.horizontal() <= 2.0 + 1e-6,
4155            "canvas width = {}",
4156            d4.bbox.width() + d4.canvas_margin.horizontal()
4157        );
4158        assert!(
4159            d4.canvas_margin.left < 1.0 && d4.canvas_margin.right < 1.0,
4160            "{:?}",
4161            d4.canvas_margin
4162        );
4163    }
4164
4165    #[test]
4166    fn block_variable_assignments_are_local() {
4167        let d = draw("x = 1\n[ x = 5 ]\nbox wid x ht 0.3");
4168        let Shape::Box { w, .. } = d.shapes.last().unwrap() else {
4169            panic!()
4170        };
4171        assert!((*w - 1.0).abs() < 1e-9, "w = {w}");
4172
4173        assert!(eval(&parse("[ x = 5 ]\nbox wid x ht 0.3").unwrap()).is_err());
4174    }
4175
4176    #[test]
4177    fn block_env_assignments_are_local() {
4178        let d = draw("[ boxwid = 2; box ]\nbox");
4179        let Shape::Box { w, .. } = &d.shapes[0] else {
4180            panic!()
4181        };
4182        assert!((*w - 2.0).abs() < 1e-9, "inner w = {w}");
4183        let Shape::Box { w, .. } = &d.shapes[1] else {
4184            panic!()
4185        };
4186        assert!((*w - 0.75).abs() < 1e-9, "outer w = {w}");
4187    }
4188
4189    #[test]
4190    fn block_mutating_var_assignments_update_inherited_vars() {
4191        let d = draw("x = 1\n[ x := 5 ]\nbox wid x ht 0.3");
4192        let Shape::Box { w, .. } = d.shapes.last().unwrap() else {
4193            panic!()
4194        };
4195        assert!((*w - 5.0).abs() < 1e-9, "w = {w}");
4196
4197        let d = draw("x = 1\n[ x += 2 ]\nbox wid x ht 0.3");
4198        let Shape::Box { w, .. } = d.shapes.last().unwrap() else {
4199            panic!()
4200        };
4201        assert!((*w - 3.0).abs() < 1e-9, "w = {w}");
4202
4203        assert!(eval(&parse("[ x = 1; x += 2 ]\nbox wid x ht 0.3").unwrap()).is_err());
4204
4205        let d = draw("boxwid = 0.75\n[ boxwid := 2; box ]\nbox");
4206        let Shape::Box { w, .. } = &d.shapes[1] else {
4207            panic!()
4208        };
4209        assert!((*w - 0.75).abs() < 1e-9, "outer w = {w}");
4210    }
4211
4212    #[test]
4213    fn figuras_examples_compile() {
4214        // a few of André Leite's circuit_macros figures (examples/figuras/),
4215        // adapted with the compatibility shim — they must keep compiling/drawing.
4216        for src in [
4217            include_str!("../../../examples/figuras/fig01.pic"),
4218            include_str!("../../../examples/figuras/fig36.pic"),
4219            include_str!("../../../examples/figuras/fig40.pic"),
4220        ] {
4221            let d = eval(&parse(src).unwrap()).unwrap();
4222            assert!(!d.shapes.is_empty());
4223        }
4224    }
4225
4226    #[test]
4227    fn figuras_element_examples_compile() {
4228        // André Leite's circuit_macros figures that use the *element API*
4229        // (resistor(dir len), bi_tr, opamp, …). These render with the circuit
4230        // library (-c) plus the compatibility shim, which reuses the native
4231        // element geometry. The shim is `copy`-d in by each file; here we splice
4232        // it in directly and prepend the circuit library.
4233        let shim = include_str!("../../../examples/figuras/circuit_macros.pic");
4234        for body in [
4235            include_str!("../../../examples/figuras/fig21.pic"),
4236            include_str!("../../../examples/figuras/fig23.pic"),
4237            include_str!("../../../examples/figuras/fig26.pic"),
4238            include_str!("../../../examples/figuras/fig27.pic"),
4239            include_str!("../../../examples/figuras/fig28.pic"),
4240            include_str!("../../../examples/figuras/fig30.pic"),
4241            include_str!("../../../examples/figuras/fig33.pic"),
4242            include_str!("../../../examples/figuras/fig45.pic"),
4243            include_str!("../../../examples/figuras/fig46.pic"),
4244            include_str!("../../../examples/figuras/fig09.pic"),
4245            include_str!("../../../examples/figuras/fig11.pic"),
4246        ] {
4247            let body = body.replace("copy \"circuit_macros.pic\"", shim);
4248            let src = format!("{}\n{}", crate::CIRCUITS, body);
4249            let d = eval(&parse(&src).unwrap()).unwrap();
4250            assert!(!d.shapes.is_empty());
4251        }
4252    }
4253
4254    #[test]
4255    fn lib3d_examples_compile() {
4256        // The lib3D shim (3D -> 2D axonometric projection) and its demos must
4257        // keep compiling and drawing. The demos `copy` the shim; splice it in.
4258        let shim = include_str!("../../../examples/lib3d/lib3d.pic");
4259        for body in [
4260            include_str!("../../../examples/lib3d/frame.pic"),
4261            include_str!("../../../examples/lib3d/views.pic"),
4262        ] {
4263            let src = body.replace("copy \"lib3d.pic\"", shim);
4264            let d = eval(&parse(&src).unwrap()).unwrap();
4265            assert!(!d.shapes.is_empty());
4266        }
4267    }
4268
4269    #[test]
4270    fn brace_ncount_as_place() {
4271        // `{expr}th last box` — a brace-counted ordinal used as a place
4272        let d = draw(
4273            "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",
4274        );
4275        let Shape::Path { pts, .. } = d.shapes.last().unwrap() else {
4276            panic!()
4277        };
4278        assert!(pts[0].x > 2.0 && pts.last().unwrap().x < 4.0, "{pts:?}");
4279    }
4280
4281    #[test]
4282    fn dpic_unit_suffix() {
4283        // `72bp__` == 72 * scale/72 == 1 inch
4284        let d = draw("box wid 72bp__ ht 0.3");
4285        let Shape::Box { w, .. } = &d.shapes[0] else {
4286            panic!()
4287        };
4288        assert!((*w - 1.0).abs() < 1e-9, "w = {w}");
4289    }
4290
4291    #[test]
4292    fn block_sees_outer_labels() {
4293        // a label defined before a block is visible (read-only) inside it
4294        let d = draw("A: (0,0)\n[ line from A to (2,0) ]");
4295        assert!(
4296            d.shapes.iter().any(|s| matches!(s, Shape::Path { .. })),
4297            "block should draw a line referencing the outer label A"
4298        );
4299        // outer labels must not pollute the block's `last`/nth: a box drawn
4300        // before the block isn't the block's `last box`.
4301        assert!(eval(&parse("box\n[ circle; \"x\" at last box ]").unwrap()).is_err());
4302    }
4303
4304    #[test]
4305    fn arg_count_macro() {
4306        // `$+` is the number of arguments passed to the current macro
4307        let d = draw("define cnt { $+ }\nx = cnt(a, b, c)\nbox wid x ht 0.3");
4308        let Shape::Box { w, .. } = &d.shapes[0] else {
4309            panic!()
4310        };
4311        assert!((*w - 3.0).abs() < 1e-9, "w = {w}");
4312    }
4313
4314    #[test]
4315    fn exec_evaluates_generated_pic_in_macro_arg_scope() {
4316        let d = draw(
4317            "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]",
4318        );
4319        let Shape::Box { w, h, .. } = &d.shapes[0] else {
4320            panic!()
4321        };
4322        assert!(
4323            (*w - 1.0).abs() < 1e-9 && (*h - 3.0).abs() < 1e-9,
4324            "{w} x {h}"
4325        );
4326    }
4327
4328    #[test]
4329    fn exec_unescapes_generated_quoted_text() {
4330        let d = draw("exec sprintf(\"\\\"x\\\" at Here\")");
4331        let Shape::Text { text, .. } = &d.shapes[0] else {
4332            panic!()
4333        };
4334        assert_eq!(text[0].s, "x");
4335    }
4336
4337    #[test]
4338    fn macro_token_pasting_concatenates_adjacent_args() {
4339        let d = draw("define mark { $1$2: (1,0) }\nmark(A,B)\nbox wid 0.2 ht 0.2 at AB");
4340        let Shape::Box { c, .. } = &d.shapes[0] else {
4341            panic!()
4342        };
4343        assert!(c.dist(Point::new(1.0, 0.0)) < 1e-9, "c = {c:?}");
4344    }
4345
4346    #[test]
4347    fn macro_string_substitution_preserves_dot_prefixed_arguments() {
4348        let d = draw("define label { \"$1\"; \"$2\" }\nlabel(.ne,above)");
4349        let labels: Vec<&str> = d
4350            .shapes
4351            .iter()
4352            .filter_map(|shape| match shape {
4353                Shape::Text { text, .. } => Some(text[0].s.as_str()),
4354                _ => None,
4355            })
4356            .collect();
4357        assert_eq!(labels, [".ne", "above"]);
4358    }
4359
4360    #[test]
4361    fn recursive_macro_terminates() {
4362        // a self-calling macro bounded by `if`: textual pre-expansion would
4363        // diverge, but lazy (eval-time) expansion of the taken branch stops it.
4364        let d = draw("define rec { if $1 <= 0 then { circle } else { box; rec($1-1) } }\nrec(3)");
4365        let boxes = d
4366            .shapes
4367            .iter()
4368            .filter(|s| matches!(s, Shape::Box { .. }))
4369            .count();
4370        let circles = d
4371            .shapes
4372            .iter()
4373            .filter(|s| matches!(s, Shape::Circle { .. }))
4374            .count();
4375        assert_eq!((boxes, circles), (3, 1), "shapes = {:?}", d.shapes.len());
4376    }
4377
4378    #[test]
4379    fn default_argument_idiom() {
4380        // empty argument: the dead `else { w = $1 }` becomes `w =`, which must
4381        // not be parsed because the then-branch is taken.
4382        let d = draw(
4383            "define b { if \"$1\"==\"\" then { w = 1 } else { w = $1 }\n box wid w ht 0.2 }\nb()",
4384        );
4385        let Shape::Box { w, .. } = &d.shapes[0] else {
4386            panic!()
4387        };
4388        assert!((*w - 1.0).abs() < 1e-9, "w = {w}");
4389        // and with an argument supplied, the else-branch value is used
4390        let d2 = draw(
4391            "define b { if \"$1\"==\"\" then { w = 1 } else { w = $1 }\n box wid w ht 0.2 }\nb(2.5)",
4392        );
4393        let Shape::Box { w, .. } = &d2.shapes[0] else {
4394            panic!()
4395        };
4396        assert!((*w - 2.5).abs() < 1e-9, "w = {w}");
4397    }
4398
4399    #[test]
4400    fn last_ordinal() {
4401        let d = draw("box at 0,0\nbox at 2,0\narrow from 1st box.e to 2nd box.w");
4402        let Shape::Path { pts, .. } = &d.shapes[2] else {
4403            panic!()
4404        };
4405        // from first box east edge to second box west edge
4406        assert!(pts[0].x > 0.0 && pts.last().unwrap().x < 2.0);
4407    }
4408
4409    #[test]
4410    fn untyped_last_references_any_kind() {
4411        // `last.c` after a circle resolves to that circle (no `last circle`).
4412        let d = draw("circle rad 0.5 at (3,1)\n\"x\" at last.c");
4413        let Shape::Text { at, .. } = d.shapes.last().unwrap() else {
4414            panic!()
4415        };
4416        assert!((at.x - 3.0).abs() < 1e-9 && (at.y - 1.0).abs() < 1e-9);
4417    }
4418
4419    #[test]
4420    fn untyped_last_corner_after_box() {
4421        // `last.n` (north of the most recent object, whatever its kind).
4422        let d = draw("box wid 2 ht 1 at (0,0)\n\"y\" at last.n");
4423        let Shape::Text { at, .. } = d.shapes.last().unwrap() else {
4424            panic!()
4425        };
4426        assert!((at.x - 0.0).abs() < 1e-9 && (at.y - 0.5).abs() < 1e-9);
4427    }
4428
4429    #[test]
4430    fn untyped_nth_last_spans_kinds() {
4431        // `2nd last` counts across kinds: box, then circle -> 2nd last is the box.
4432        let d = draw("box at (0,0)\ncircle at (2,0)\n\"z\" at 2nd last.c");
4433        let Shape::Text { at, .. } = d.shapes.last().unwrap() else {
4434            panic!()
4435        };
4436        assert!((at.x - 0.0).abs() < 1e-9, "x = {}", at.x);
4437    }
4438
4439    #[test]
4440    fn typed_last_still_filters_by_kind() {
4441        // an explicit type keyword keeps filtering: `last box` skips the circle.
4442        let d = draw("box at (0,0)\ncircle at (2,0)\n\"w\" at last box.c");
4443        let Shape::Text { at, .. } = d.shapes.last().unwrap() else {
4444            panic!()
4445        };
4446        assert!((at.x - 0.0).abs() < 1e-9, "x = {}", at.x);
4447    }
4448
4449    #[test]
4450    fn untyped_last_with_no_object_errors() {
4451        assert!(eval(&parse("\"q\" at last.c").unwrap()).is_err());
4452    }
4453}