Skip to main content

rpic_core/
ir.rs

1//! Intermediate representation: the placed-primitive tree produced by the
2//! evaluator and consumed by the render backends. All coordinates are absolute,
3//! in pic units (inches), y pointing up.
4
5use crate::diagnostic::Diagnostic;
6use crate::geom::{Bbox, Point};
7
8/// A fully evaluated drawing.
9#[derive(Debug, Clone, PartialEq)]
10pub struct Drawing {
11    pub shapes: Vec<Shape>,
12    /// Render layer per shape. Lower layers are emitted first; equal layers keep
13    /// source order and shape ids stable.
14    pub shape_layers: Vec<i32>,
15    /// rpic extension: CSS class hook per shape, emitted on the shape's
16    /// `<g id="sN">` group. Inert unless the host document styles it; `None`
17    /// keeps the group byte-identical to classic output.
18    pub shape_classes: Vec<Option<String>>,
19    /// rpic extension: hyperlink per shape. When set, the shape's
20    /// `<g id="sN">` group is wrapped in `<a href="…">` in the SVG output;
21    /// raster/PDF backends ignore it (usvg flattens `<a>` to a group). `None`
22    /// keeps the output byte-identical to classic pic.
23    pub shape_links: Vec<Option<String>>,
24    /// Source span of the statement that produced each shape (`None` for
25    /// shapes without one). Drives the per-object geometry export; never
26    /// affects rendering.
27    pub shape_spans: Vec<Option<crate::diagnostic::Span>>,
28    pub bbox: Bbox,
29    /// Global `linethick` in points after picture-wide sizing, used only for
30    /// dpic-style backend prelude padding. Per-shape strokes keep their own
31    /// unscaled point thickness.
32    pub prelude_thick: f64,
33    /// Extra canvas whitespace in inches. This is an rpic extension inspired by
34    /// Pikchr: it affects native backend framing only, not pic geometry.
35    pub canvas_margin: CanvasMargin,
36    /// rpic extension: a fixed page rectangle (`canvas from … to …`) in model
37    /// space. When set, the SVG viewBox is derived from it instead of the
38    /// content bounds; content outside is clipped by the viewBox.
39    pub canvas: Option<Bbox>,
40    pub anims: Vec<Anim>,
41    /// rpic extension (`draggable`): objects the host should make interactively
42    /// draggable (GSAP Draggable). Surfaced in the compile bundle as top-level
43    /// `interactions`; empty for the vast majority of drawings.
44    pub interactions: Vec<Interaction>,
45    /// rpic extension (`animate scroll`): a timeline-level hint that the host
46    /// should scrub the animation on scroll rather than autoplay. Surfaced in
47    /// the compile bundle as top-level `scroll`; the host wires ScrollTrigger.
48    pub anim_scroll: bool,
49    /// Lines emitted by pic `print` statements, without trailing newlines.
50    pub diagnostics: Vec<String>,
51    /// Non-fatal compiler warnings for accepted but likely unintended input.
52    pub warnings: Vec<Diagnostic>,
53}
54
55/// Extra whitespace around the rendered canvas, in internal inches.
56#[derive(Debug, Clone, Copy, PartialEq, Default)]
57pub struct CanvasMargin {
58    pub top: f64,
59    pub right: f64,
60    pub bottom: f64,
61    pub left: f64,
62}
63
64impl CanvasMargin {
65    pub fn horizontal(self) -> f64 {
66        self.left + self.right
67    }
68
69    pub fn vertical(self) -> f64 {
70        self.top + self.bottom
71    }
72
73    pub fn scale_by(&mut self, factor: f64) {
74        self.top *= factor;
75        self.right *= factor;
76        self.bottom *= factor;
77        self.left *= factor;
78    }
79}
80
81/// A resolved animation entry. `shape` indexes into [`Drawing::shapes`]; the
82/// SVG backend gives that shape the id `s{shape}` so the player can target it.
83#[derive(Debug, Clone, PartialEq)]
84pub struct Anim {
85    pub shape: usize,
86    pub effect: String,
87    /// Absolute start time in seconds.
88    pub start: f64,
89    pub duration: f64,
90    /// GSAP `repeat`: `-1` loops forever, `0` plays once.
91    pub repeat: i64,
92    /// GSAP `yoyo`: alternate direction each repeat.
93    pub yoyo: bool,
94    /// GSAP easing name overriding the per-effect default, if given.
95    pub ease: Option<String>,
96    /// For the `move` effect: index of the shape whose geometry to follow.
97    /// The SVG backend gives it the id `s{path}`, the MotionPath target.
98    pub path: Option<usize>,
99    /// For the `highlight` effect: the CSS target colour (`to <colour>`).
100    pub color: Option<String>,
101    /// Play as an exit (reverse) rather than an entrance.
102    pub out: bool,
103    /// For the `slide` effect: the direction it enters from
104    /// (`"left"`/`"right"`/`"up"`/`"down"`).
105    pub from: Option<String>,
106    /// For the `morph` effect: index of the shape whose geometry to morph into.
107    /// The SVG backend gives it the id `s{morph}`, the MorphSVG target.
108    pub morph: Option<usize>,
109    /// For the `type` effect: split the label by whole words (`by word`) rather
110    /// than by character. The SVG backend wraps each unit in a `.rpic-ch` tspan
111    /// the player staggers.
112    pub type_word: bool,
113    /// For the `scramble` effect: a custom scramble charset (`by "01"`); `None`
114    /// uses the plugin's `upperCase`. Present in the manifest only when set.
115    pub scramble_chars: Option<String>,
116    /// For the `wiggle` effect: the oscillation count (`wiggles <n>`); `None`
117    /// lets the player default it. Present in the manifest only when set.
118    pub wiggles: Option<i64>,
119    /// For the `draw` effect: reveal only the `[from,to]` sub-segment of the
120    /// stroke, as fractions in `[0,1]` (`draw from 40% to 60%`). Each rides the
121    /// manifest only when set; absent means the segment's natural endpoint.
122    pub draw_from: Option<f64>,
123    pub draw_to: Option<f64>,
124}
125
126/// A `draggable` interaction (rpic extension): the host makes shape `s{shape}`
127/// grabbable via GSAP Draggable. Interaction, not a timeline effect, so it
128/// rides its own `interactions` manifest, not `animations`.
129#[derive(Debug, Clone, PartialEq)]
130pub struct Interaction {
131    pub shape: usize,
132    /// Throw with momentum (GSAP InertiaPlugin).
133    pub inertia: bool,
134    /// Constrain dragging to another shape's box (`s{bounds}`).
135    pub bounds: Option<usize>,
136    /// Axis lock: `"x"` or `"y"`; `None` drags freely.
137    pub axis: Option<&'static str>,
138}
139
140/// Line dash style.
141#[derive(Debug, Clone, Copy, PartialEq, Default)]
142pub enum Dash {
143    #[default]
144    Solid,
145    /// Dash/gap base length in inches.
146    Dashed(f64),
147    /// Explicit dot spacing in inches; `None` means dpic's stroke-relative default.
148    Dotted(Option<f64>),
149}
150
151/// Fill specification.
152#[derive(Debug, Clone, PartialEq)]
153pub enum Fill {
154    /// Gray level, pic convention: 0 = black, 1 = white.
155    Gray(f64),
156    /// A named/explicit color.
157    Color(String),
158}
159
160/// Hatch fill pattern.
161#[derive(Debug, Clone, PartialEq)]
162pub struct Hatch {
163    pub cross: bool,
164    /// Line angle in degrees, measured in pic coordinates.
165    pub angle: f64,
166    /// Distance between hatch lines in inches.
167    pub sep: f64,
168    /// Hatch stroke width in points.
169    pub width: f64,
170    pub color: String,
171}
172
173/// Two-stop linear gradient fill (rpic extension, PSTricks-inspired).
174#[derive(Debug, Clone, PartialEq)]
175pub struct Gradient {
176    pub from: String,
177    pub to: String,
178    /// Angle in degrees, measured in pic coordinates: 0 = left to right,
179    /// 90 = bottom to top (matching how `hatchangle` measures).
180    pub angle: f64,
181}
182
183/// Visual style shared by all shapes.
184#[derive(Debug, Clone, PartialEq)]
185pub struct Style {
186    /// Stroke color (CSS), or `None` to use the default.
187    pub stroke: Option<String>,
188    pub fill: Option<Fill>,
189    pub hatch: Option<Hatch>,
190    pub gradient: Option<Gradient>,
191    /// Fill opacity, applied only to filled or hatched regions.
192    pub fill_opacity: Option<f64>,
193    /// Whether open paths/splines/arcs should emit a filled area. `color` on an
194    /// open object only changes the stroke; `fill` and `shaded` fill.
195    pub fill_open: bool,
196    pub dash: Dash,
197    /// Stroke thickness in points; `None` = backend default.
198    pub thick: Option<f64>,
199    /// Invisible (used by `move` and `invis`): geometry is still available for
200    /// placement and anchors, but is not drawn.
201    pub invis: bool,
202    /// Invisible geometry that still contributes to output bounds. Dpic uses
203    /// this for `move`; explicit `invis` helpers remain bounds-neutral.
204    pub invis_bounds: bool,
205    /// Arrowhead dimensions in inches (`arrowht`/`arrowwid`), used when this
206    /// shape carries arrowheads.
207    pub arrow_ht: f64,
208    pub arrow_wid: f64,
209    /// Filled (solid triangle, `arrowhead=2`) vs open (two strokes,
210    /// `arrowhead=0`).
211    pub arrow_filled: bool,
212}
213
214impl Default for Style {
215    fn default() -> Self {
216        Style {
217            stroke: None,
218            fill: None,
219            hatch: None,
220            gradient: None,
221            fill_opacity: None,
222            fill_open: false,
223            dash: Dash::Solid,
224            thick: None,
225            invis: false,
226            invis_bounds: false,
227            arrow_ht: 0.1,
228            arrow_wid: 0.05,
229            arrow_filled: true,
230        }
231    }
232}
233
234/// Which ends of a path carry an arrowhead.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum Arrowheads {
237    #[default]
238    None,
239    Start,
240    End,
241    Both,
242}
243
244/// A line of attached text with placement hints.
245#[derive(Debug, Clone, PartialEq)]
246pub struct TextLine {
247    pub s: String,
248    /// rpic `texlabels` extension: when set, this line is a typeset math
249    /// formula and `s` keeps the original literal for fallback/diagnostics.
250    pub math: Option<crate::math::MathSpan>,
251    /// horizontal: -1 = ljust, 0 = center, +1 = rjust.
252    pub halign: i8,
253    /// vertical: +1 = above, 0 = center, -1 = below.
254    pub valign: i8,
255    /// Extra text-position offset in inches (`textoffset`).
256    pub text_offset: f64,
257    /// rpic extension: bold face (`bold`).
258    pub bold: bool,
259    /// rpic extension: italic face (`italic`).
260    pub italic: bool,
261    /// rpic extension: font family — `Some("monospace")` from `mono`, or the
262    /// family given to `font "…"`. `None` follows the root `<svg>` family.
263    pub family: Option<String>,
264    /// rpic extension: explicit size in points (`fontsize`); `None` keeps
265    /// classic sizing (11 pt attached, height-derived standalone).
266    pub size_pt: Option<f64>,
267    /// rpic extension: rotation in degrees, CCW (`rotated`); applied about
268    /// the line's anchor point in the SVG.
269    pub rotate: Option<f64>,
270    /// rpic extension: `aligned` — rotate to the host segment's angle. Set
271    /// during text collection; the linear-object eval resolves it into
272    /// `rotate` once the segment's start/end are known.
273    pub aligned: bool,
274}
275
276/// Classic label size in points — the baseline `fontsize` scales against.
277pub(crate) const FONT_PT_CLASSIC: f64 = 11.0;
278
279// ---- shared text metrics (#291) ---------------------------------------------
280// The single source of truth for label sizing, consumed by BOTH the evaluator
281// (layout/canvas bbox) and the SVG backend (rendered ink bounds). They must
282// agree, or label bounds silently desync from label geometry — keep any font
283// sizing change here, never in a per-module copy.
284
285/// dpic's x-height : em ratio.
286pub(crate) const DP_TEXT_RATIO: f64 = 0.66;
287/// The classic label em in inches (11 pt at 72 pt/in).
288pub(crate) const TEXT_EM_IN: f64 = FONT_PT_CLASSIC / 72.0;
289/// Estimated average glyph advance, as a fraction of the em.
290pub(crate) const TEXT_CHAR_W_RATIO: f64 = 0.6;
291/// Line height, as a fraction of the em.
292pub(crate) const TEXT_LINE_H_RATIO: f64 = 1.2;
293
294impl TextLine {
295    /// Width scale vs. the classic 11 pt regular estimate: explicit
296    /// `fontsize` scales linearly; bold glyphs run ~5% wider.
297    pub(crate) fn width_factor(&self) -> f64 {
298        let size = self.size_pt.map_or(1.0, |pt| pt / FONT_PT_CLASSIC);
299        let weight = if self.bold { 1.05 } else { 1.0 };
300        size * weight
301    }
302
303    /// Height scale (explicit `fontsize` vs. classic 11 pt).
304    pub(crate) fn height_factor(&self) -> f64 {
305        self.size_pt.map_or(1.0, |pt| pt / FONT_PT_CLASSIC)
306    }
307
308    /// Estimated ink width in inches: a math span's measured width, or
309    /// chars × average advance × the size/weight factor — the one estimator
310    /// behind the evaluator's layout bbox and the SVG backend's ink bounds.
311    pub(crate) fn ink_width_in(&self) -> f64 {
312        match &self.math {
313            Some(m) => m.width,
314            None => {
315                self.s.chars().count() as f64 * TEXT_CHAR_W_RATIO * TEXT_EM_IN * self.width_factor()
316            }
317        }
318    }
319}
320
321/// A placed drawing primitive.
322#[derive(Debug, Clone, PartialEq)]
323pub enum Shape {
324    Box {
325        c: Point,
326        w: f64,
327        h: f64,
328        rad: f64,
329        style: Style,
330        text: Vec<TextLine>,
331    },
332    Circle {
333        c: Point,
334        r: f64,
335        style: Style,
336        text: Vec<TextLine>,
337    },
338    Ellipse {
339        c: Point,
340        w: f64,
341        h: f64,
342        style: Style,
343        text: Vec<TextLine>,
344    },
345    /// Straight polyline (line / arrow / move).
346    Path {
347        pts: Vec<Point>,
348        /// rpic extension: this path was closed with the `close` attribute.
349        closed: bool,
350        arrows: Arrowheads,
351        style: Style,
352        text: Vec<TextLine>,
353    },
354    Spline {
355        pts: Vec<Point>,
356        /// dpic spline tension. `None` = the classic pic quadratic B-spline
357        /// (straight first/last half-segments, tangent at segment midpoints);
358        /// `Some(t)` = dpic's tensioned cubic spline through `t`.
359        tension: Option<f64>,
360        arrows: Arrowheads,
361        style: Style,
362        text: Vec<TextLine>,
363    },
364    Arc {
365        c: Point,
366        r: f64,
367        /// start and end angles in radians.
368        a0: f64,
369        a1: f64,
370        cw: bool,
371        arrows: Arrowheads,
372        style: Style,
373        text: Vec<TextLine>,
374    },
375    Brace {
376        a: Point,
377        b: Point,
378        cubics: Vec<[Point; 4]>,
379        label_at: Point,
380        style: Style,
381        text: Vec<TextLine>,
382    },
383    Text {
384        at: Point,
385        text: Vec<TextLine>,
386        bbox: Bbox,
387        w: f64,
388        h: f64,
389        standalone: bool,
390    },
391}
392
393impl Shape {
394    /// Whether the shape paints anything. `false` for `move`/`invis` helpers
395    /// (their `style.invis` is set) and empty text. Used by `animate … stagger`
396    /// to fan only across a block's visible children, skipping spines.
397    pub fn is_visible(&self) -> bool {
398        match self {
399            Shape::Box { style, .. }
400            | Shape::Circle { style, .. }
401            | Shape::Ellipse { style, .. }
402            | Shape::Path { style, .. }
403            | Shape::Spline { style, .. }
404            | Shape::Arc { style, .. }
405            | Shape::Brace { style, .. } => !style.invis,
406            Shape::Text { text, .. } => !text.is_empty(),
407        }
408    }
409}