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