Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/diagram/mod.rs

19.4 KiB, 76 runs

created by r1870400018:36165, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The diagram sub-language: labelled nodes and routed edges, emitted as drawn vector paths.
2//!
3//! A diagram stands to Austenite as CeTZ stands to Typst, under one rule carried from the
4//! architecture: elements are placed by anchor and relative offset in a single dependency-ordered
5//! pass, with no constraint solver. A [`Diagram`] is built up as a list of nodes and edges and then
6//! [`build`](Diagram::build) into a [`Graphic`] -- the same `fe2o3_graphics` outline paths the body
7//! text emits, so a figure is first-class content in the SVG and the PDF, not an embedded picture.
8//!
9//! A node's label travels the prose's own shaping path: `fe2o3_font` shapes it, each glyph becomes an
10//! outline, and the outlines are baked into the figure exactly as the SVG writer's `draw_text` bakes a
11//! line of text -- flipped from the font's y-up frame onto the figure's y-down baseline. The box is
12//! auto-sized to the label. An edge is a real polyline between two ports, routed once the nodes are
13//! placed, ended with a filled-triangle arrowhead; an orthogonal edge keeps every segment axis
14//! aligned. Because placement is one ordered pass, a node depends only on nodes placed before it, and
15//! a bad reference names the node and says what is wrong.
16
17pub mod layout;
18pub mod shape;
19
20use crate::diagram::layout::{
21 Placement,
22 Route,
23};
24use crate::diagram::shape::{
25 Port,
26 Rect,
27 Shape,
28};
29use crate::font::ShapedText;
30use crate::ir::{
31 Dims,
32 DrawOp,
33 Graphic,
34 Sp,
35};
36
37use oxedyne_fe2o3_core::prelude::*;
38use oxedyne_fe2o3_font::{
39 face::Role,
40 set::FontSet,
41 shape::Dir,
42};
43use oxedyne_fe2o3_graphics::{
44 colour::Rgba,
45 path::Bounds,
46 transform::Transform,
47};
48
49use std::collections::BTreeMap;
50use std::sync::Arc;
51
52/// One end of an edge: the node it attaches to, and optionally the named port. With no port the
53/// builder chooses the side facing the other end once both are placed.
54#[derive(Clone, Debug)]
55pub struct Endpoint {
56 pub node: String,
57 pub port: Option<Port>,
58}
59
60impl Endpoint {
61 /// An end attached to a node, its port chosen automatically at build.
62 pub fn node<S: Into<String>>(node: S) -> Self {
63 Self { node: node.into(), port: None }
64 }
65
66 /// An end attached to a named port of a node.
67 pub fn port<S: Into<String>>(node: S, port: Port) -> Self {
68 Self { node: node.into(), port: Some(port) }
69 }
70}
71
72// How a node is placed, keyed by node id, before ids are resolved to indices at build.
73#[derive(Clone, Debug)]
74enum PlaceSpec {
75 At { x: Sp, y: Sp },
76 Below { of: String, gap: Sp },
77 Right { of: String, gap: Sp },
78}
79
80#[derive(Clone, Debug)]
81struct NodeSpec {
82 id: String,
83 label: String, // a `\n` in the label parts it into stacked, centred lines
84 place: PlaceSpec,
85 shape: Shape,
86 fill: Option<Rgba>, // this node's own wash, overriding the style default when set
87 size: Option<(Sp, Sp)>, // an explicit box, grown to hold the label but never shrunk below it
88}
89
90#[derive(Clone, Debug)]
91struct EdgeSpec {
92 from: Endpoint,
93 to: Endpoint,
94 label: Option<String>,
95 route: Route,
96 near_src: bool, // seat the label by the source end rather than at the longest segment's midpoint
97}
98
99/// The lengths and colours a diagram is drawn to. Every length is scaled points, so the styling never
100/// leaves the integer domain; the widths and the arrowhead sizes are points, as the stroke and the
101/// graphics boundary take them.
102#[derive(Clone, Copy, Debug)]
103pub struct DiagramStyle {
104 pub label_size: Sp, // the node label's body size
105 pub edge_label_size: Sp, // the smaller size an edge label is set at
106 pub pad_x: Sp, // horizontal gap between a label and its box side
107 pub pad_y: Sp, // vertical gap between a label and its box side
108 pub stub: Sp, // the perpendicular lead out of a port on an orthogonal edge
109 pub node_stroke: f32, // node outline width, points
110 pub edge_stroke: f32, // edge line width, points
111 pub arrow_len: f32, // arrowhead length along the edge, points
112 pub arrow_half: f32, // arrowhead half-width across the edge, points
113 pub node_fill: Option<Rgba>, // the interior wash, or None to leave the box unfilled
114 pub margin: f32, // clear border left around the whole figure, points
115}
116
117impl Default for DiagramStyle {
118 fn default() -> Self {
119 Self {
120 label_size: Sp::from_pt(11.0),
121 edge_label_size: Sp::from_pt(9.5),
122 pad_x: Sp::from_pt(9.0),
123 pad_y: Sp::from_pt(6.0),
124 stub: Sp::from_pt(10.0),
125 node_stroke: 1.0,
126 edge_stroke: 1.0,
127 arrow_len: 7.0,
128 arrow_half: 3.0,
129 node_fill: Some(Rgba::new(246, 246, 248, 255)),
130 margin: 3.0,
131 }
132 }
133}
134
135/// A flowchart under construction: nodes placed by anchor and offset, and edges joining their ports.
136/// Built once, then turned into a [`Graphic`].
137#[derive(Clone, Debug, Default)]
138pub struct Diagram {
139 nodes: Vec<NodeSpec>,
140 edges: Vec<EdgeSpec>,
141}
142
143impl Diagram {
144 pub fn new() -> Self {
145 Self::default()
146 }
147
148 /// Places a node with its centre at an absolute point in the diagram's own frame.
149 pub fn node_at<I, L>(&mut self, id: I, label: L, x: Sp, y: Sp, shape: Shape) -> &mut Self
150 where
151 I: Into<String>,
152 L: Into<String>,
153 {
154 self.nodes.push(NodeSpec {
155 id: id.into(),
156 label: label.into(),
157 place: PlaceSpec::At { x, y },
158 shape,
159 fill: None,
160 size: None,
161 });
162 self
163 }
164
165 /// Places a node centred beneath an already-added node, its box top a `gap` below the anchor's box
166 /// bottom.
167 pub fn node_below<I, L>(&mut self, id: I, label: L, of: &str, gap: Sp, shape: Shape) -> &mut Self
168 where
169 I: Into<String>,
170 L: Into<String>,
171 {
172 self.nodes.push(NodeSpec {
173 id: id.into(),
174 label: label.into(),
175 place: PlaceSpec::Below { of: of.to_string(), gap },
176 shape,
177 fill: None,
178 size: None,
179 });
180 self
181 }
182
183 /// Places a node centred to the right of an already-added node, its box left a `gap` past the
184 /// anchor's box right.
185 pub fn node_right<I, L>(&mut self, id: I, label: L, of: &str, gap: Sp, shape: Shape) -> &mut Self
186 where
187 I: Into<String>,
188 L: Into<String>,
189 {
190 self.nodes.push(NodeSpec {
191 id: id.into(),
192 label: label.into(),
193 place: PlaceSpec::Right { of: of.to_string(), gap },
194 shape,
195 fill: None,
196 size: None,
197 });
198 self
199 }
200
201 /// Sets the fill wash of the most recently added node, overriding the style default. A node with no
202 /// fill set takes the diagram style's fill.
203 pub fn fill(&mut self, colour: Rgba) -> &mut Self {
204 if let Some(n) = self.nodes.last_mut() {
205 n.fill = Some(colour);
206 }
207 self
208 }
209
210 /// Sets an explicit box for the most recently added node. The box is grown to hold the label if the
211 /// label is larger, but never shrunk below the given extent -- the way a flowchart fixes a decision's
212 /// diamond to a uniform size.
213 pub fn size(&mut self, w: Sp, h: Sp) -> &mut Self {
214 if let Some(n) = self.nodes.last_mut() {
215 n.size = Some((w, h));
216 }
217 self
218 }
219
220 /// Joins two ports with an edge, optionally labelled, routed straight or orthogonally.
221 pub fn edge(&mut self, from: Endpoint, to: Endpoint, label: Option<&str>, route: Route) -> &mut Self {
222 self.edges.push(EdgeSpec {
223 from,
224 to,
225 label: label.map(|s| s.to_string()),
226 route,
227 near_src: false,
228 });
229 self
230 }
231
232 /// Joins two ports with an edge whose label is seated by the source end rather than at the midpoint of
233 /// the longest segment -- the placement a flowchart's decision branch labels ("Y", "N") take.
234 pub fn edge_near(&mut self, from: Endpoint, to: Endpoint, label: Option<&str>, route: Route) -> &mut Self {
235 self.edges.push(EdgeSpec {
236 from,
237 to,
238 label: label.map(|s| s.to_string()),
239 route,
240 near_src: true,
241 });
242 self
243 }
244
245 /// Composes the diagram into a graphic: shape every label, size and place every node, draw the
246 /// boxes and the labels, route and arrow every edge, then normalise the whole to the figure origin.
247 /// The returned [`Graphic`]'s dimensions are its bounding box, width and height the full extent and
248 /// depth zero, so the block layer places it as one box.
249 pub fn build(&self, fonts: Arc<FontSet>, style: &DiagramStyle) -> Outcome<Graphic> {
250 // Every node id, so an edge or a relative placement can resolve to an index in one pass.
251 let mut index: BTreeMap<String, usize> = BTreeMap::new();
252 for (i, n) in self.nodes.iter().enumerate() {
253 if index.insert(n.id.clone(), i).is_some() {
254 return Err(err!(
255 "Two nodes share the id \"{}\"; each node needs a distinct id.", n.id;
256 Invalid, Input));
257 }
258 }
259
260 // Shape each label and size its box. A relative placement is resolved to the anchor's index
261 // here, which enforces the dependency order: the anchor must already be known. A label carrying a
262 // `\n` is shaped a line at a time and stacked, so the box holds the widest line and the full stack.
263 let mut labels: Vec<Vec<ShapedText>> = Vec::with_capacity(self.nodes.len());
264 let mut specs: Vec<(Placement, Sp, Sp)> = Vec::with_capacity(self.nodes.len());
265 for (i, n) in self.nodes.iter().enumerate() {
266 let mut lines: Vec<ShapedText> = Vec::new();
267 let mut max_w = Sp::ZERO;
268 let mut ext = Sp::ZERO;
269 for (li, line) in n.label.split('\n').enumerate() {
270 let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.label_size, line));
271 let ld = shaped.dims();
272 if ld.width > max_w {
273 max_w = ld.width;
274 }
275 if li > 0 {
276 ext = ext + line_gap(style.label_size);
277 }
278 ext = ext + ld.height + ld.depth;
279 lines.push(shaped);
280 }
281 let (mut w, mut h) = n.shape.size_for_label(max_w, ext, style.pad_x, style.pad_y);
282 // An explicit box grows the node but never shrinks it below what the label needs.
283 if let Some((ew, eh)) = n.size {
284 if ew > w { w = ew; }
285 if eh > h { h = eh; }
286 }
287 labels.push(lines);
288
289 let placement = match &n.place {
290 PlaceSpec::At { x, y } => Placement::At { x: *x, y: *y },
291 PlaceSpec::Below { of, gap } => {
292 Placement::Below { of: res!(anchor(&index, &n.id, of, i, "below")), gap: *gap }
293 },
294 PlaceSpec::Right { of, gap } => {
295 Placement::Right { of: res!(anchor(&index, &n.id, of, i, "right of")), gap: *gap }
296 },
297 };
298 specs.push((placement, w, h));
299 }
300 let rects = res!(layout::place(&specs));
301
302 let mut ops: Vec<DrawOp> = Vec::new();
303
304 // The boxes and their labels. Fill first, so the black outline and the black label sit over the
305 // wash rather than under it.
306 for (i, n) in self.nodes.iter().enumerate() {
307 let r = &rects[i];
308 let outline = res!(n.shape.outline(r));
309 // A node's own fill wins; otherwise the diagram style's default fill, or none.
310 let fill = n.fill.or(style.node_fill);
311 if let Some(fill) = fill {
312 ops.push(DrawOp::Fill { path: outline.clone(), colour: fill });
313 }
314 ops.push(DrawOp::Stroke { path: outline, colour: Rgba::BLACK, width: style.node_stroke });
315 res!(bake_label_centred(&mut ops, &labels[i], style.label_size, r));
316 }
317
318 // The edges, each routed once its ports are placed.
319 for e in &self.edges {
320 res!(self.draw_edge(&mut ops, &index, &rects, fonts.clone(), style, e));
321 }
322
323 // Normalise: translate the whole so its bounding box sits at (margin, margin), and report that
324 // padded box as the figure's dimensions.
325 let bb = res!(ink_bounds(&ops));
326 let t = Transform::translate(-bb.x0 + style.margin, -bb.y0 + style.margin);
327 let mut placed: Vec<DrawOp> = Vec::with_capacity(ops.len());
328 for op in ops {
329 placed.push(res!(shift(op, &t)));
330 }
331 let w = bb.width() + 2.0 * style.margin;
332 let h = bb.height() + 2.0 * style.margin;
333 Ok(Graphic::new(placed, Dims::new(
334 Sp::from_pt(w as f64),
335 Sp::from_pt(h as f64),
336 Sp::ZERO,
337 )))
338 }
339
340 /// Draws one edge: resolve its two ends to coordinates and facings, route the polyline, stroke it
341 /// short of the arrowhead, fill the arrowhead, and bake any label clear of the longest segment.
342 fn draw_edge(
343 &self,
344 ops: &mut Vec<DrawOp>,
345 index: &BTreeMap<String, usize>,
346 rects: &[Rect],
347 fonts: Arc<FontSet>,
348 style: &DiagramStyle,
349 e: &EdgeSpec,
350 )
351 -> Outcome<()>
352 {
353 let ai = res!(edge_node(index, &e.from.node));
354 let bi = res!(edge_node(index, &e.to.node));
355 let ra = &rects[ai];
356 let rb = &rects[bi];
357
358 // A reference point for each end -- its explicit port, or its centre -- so an end with no port
359 // can pick the side facing the other end.
360 let a_ref = ref_point(ra, &e.from);
361 let b_ref = ref_point(rb, &e.to);
362 // A feedback loop always leaves and re-enters on the east side, whatever the endpoints declared.
363 let (a_port, b_port) = if matches!(e.route, Route::Feedback { .. }) {
364 (Port::East, Port::East)
365 } else {
366 let a_port = match e.from.port {
367 Some(p) => p,
368 None => layout::nearest_port(ra, b_ref),
369 };
370 let b_port = match e.to.port {
371 Some(p) => p,
372 None => layout::nearest_port(rb, a_ref),
373 };
374 (a_port, b_port)
375 };
376
377 let a_xy = self.nodes[ai].shape.port(ra, a_port);
378 let b_xy = self.nodes[bi].shape.port(rb, b_port);
379 let pts = layout::route_points(
380 a_xy, layout::facing(a_port), b_xy, layout::facing(b_port), e.route, style.stub);
381
382 let n = pts.len();
383 if n < 2 {
384 return Err(err!(
385 "An edge from \"{}\" to \"{}\" routed to fewer than two points.",
386 e.from.node, e.to.node; Bug));
387 }
388 let tip = pts[n - 1];
389 let prev = pts[n - 2];
390
391 // Stroke stops at the arrowhead's base, so the line does not run under the tip.
392 let mut stroke_pts = pts.clone();
393 stroke_pts[n - 1] = layout::retract(tip, prev, style.arrow_len);
394 ops.push(DrawOp::Stroke {
395 path: res!(layout::stroke_path(&stroke_pts)),
396 colour: Rgba::BLACK,
397 width: style.edge_stroke,
398 });
399 ops.push(DrawOp::Fill {
400 path: res!(layout::arrowhead(tip, prev, style.arrow_len, style.arrow_half)),
401 colour: Rgba::BLACK,
402 });
403
404 if let Some(text) = &e.label {
405 res!(self.bake_edge_label(ops, fonts, style, &pts, text, e.near_src));
406 }
407 Ok(())
408 }
409
410 /// Bakes an edge label. By default it sits at the midpoint of the edge's longest segment, nudged clear
411 /// of the line along its perpendicular; when `near_src` is set it sits a little way along the first
412 /// segment from the source, the placement a flowchart's branch label ("Y"/"N") takes.
413 fn bake_edge_label(
414 &self,
415 ops: &mut Vec<DrawOp>,
416 fonts: Arc<FontSet>,
417 style: &DiagramStyle,
418 pts: &[(Sp, Sp)],
419 text: &str,
420 near_src: bool,
421 )
422 -> Outcome<()>
423 {
424 let shaped = res!(ShapedText::new(fonts, Role::Italic, Dir::Ltr, style.edge_label_size, text));
425 let ld = shaped.dims();
426 let anchor = if near_src {
427 layout::label_anchor_near_source(pts, style.stub)
428 } else {
429 layout::label_anchor(pts)
430 };
431 let (mid, perp) = match anchor {
432 Some(a) => a,
433 None => return Ok(()), // a zero-length edge carries no label
434 };
435
436 // The clear gap is half the label height plus a little, pushed along the perpendicular so the
437 // label sits beside a vertical edge and above or below a horizontal one.
438 let ext = ld.height + ld.depth;
439 let gap = Sp(ext.raw() * 3 / 4);
440 let off_x = Sp::from_pt((perp.0 * (gap.to_pt() as f32)) as f64);
441 let off_y = Sp::from_pt((perp.1 * (gap.to_pt() as f32)) as f64);
442 let cx = mid.0 + off_x;
443 let cy = mid.1 + off_y;
444
445 let base_x = cx - Sp(ld.width.raw() / 2);
446 let base_y = cy + Sp((ld.height.raw() - ld.depth.raw()) / 2);
447 bake_label(ops, &shaped, base_x, base_y)
448 }
449}
450
451/// Resolves a relative placement's anchor to its index, enforcing the single-pass rule: the anchor
452/// must exist and must be declared before the node that leans on it.
453fn anchor(
454 index: &BTreeMap<String, usize>,
455 id: &str,
456 of: &str,
457 i: usize,
458 rel: &str,
459)
460 -> Outcome<usize>
461{
462 let j = res!(index.get(of).ok_or_else(|| err!(
463 "Node \"{}\" is placed {} \"{}\", but no such node exists.", id, rel, of;
464 Invalid, Input, Missing)));
465 if *j >= i {
466 return Err(err!(
467 "Node \"{}\" is placed {} \"{}\", which is not declared before it; placement is a single \
468 forward pass, so a node may lean only on nodes already placed.", id, rel, of;
469 Invalid, Input));
470 }
471 Ok(*j)
472}
473
474/// Resolves an edge endpoint's node to its index.
475fn edge_node(index: &BTreeMap<String, usize>, id: &str) -> Outcome<usize> {
476 Ok(*res!(index.get(id).ok_or_else(|| err!(
477 "An edge names node \"{}\", but no such node exists.", id; Invalid, Input, Missing))))
478}
479
480/// The reference point of an edge end: its explicit port, or the box centre when the port is left open.
481fn ref_point(r: &Rect, end: &Endpoint) -> (Sp, Sp) {
482 match end.port {
483 Some(p) => shape::port_of(r, p),
484 None => shape::port_of(r, Port::Centre),
485 }
486}
487
488/// The gap left between two stacked label lines, a fifth of the label size, so a two-line label reads as
489/// one centred block rather than a crammed pair.
490fn line_gap(label_size: Sp) -> Sp {
491 Sp(label_size.raw() / 5)
492}
493
494/// Bakes a stack of shaped lines centred within a node's box: each line centred horizontally on the box
495/// centre, the whole stack centred vertically on it. A single line is the common case and lands exactly
496/// as before; extra lines are seated above and below by their own extents plus the line gap.
497fn bake_label_centred(
498 ops: &mut Vec<DrawOp>,
499 lines: &[ShapedText],
500 label_size: Sp,
501 r: &Rect,
502)
503 -> Outcome<()>
504{
505 // The stack's full height: the sum of each line's extent, parted by the line gap.
506 let mut total = Sp::ZERO;
507 for (i, line) in lines.iter().enumerate() {
508 if i > 0 {
509 total = total + line_gap(label_size);
510 }
511 let ld = line.dims();
512 total = total + ld.height + ld.depth;
513 }
514 // The top of the stack, so its middle seats on the box centre.
515 let mut y = r.centre_y() - Sp(total.raw() / 2);
516 for line in lines {
517 let ld = line.dims();
518 let base_x = r.centre_x() - Sp(ld.width.raw() / 2);
519 let base_y = y + ld.height; // baseline sits a height below the line's top
520 res!(bake_label(ops, line, base_x, base_y));
521 y = y + ld.height + ld.depth + line_gap(label_size);
522 }
523 Ok(())
524}
525
526/// Bakes a shaped run as filled glyph outlines at a baseline, exactly as the SVG writer draws a line
527/// of text: the outline is font-frame and y up, so it is flipped in y and moved onto the baseline at
528/// the glyph's own offset. `base_x` is the run's left, `base_y` its baseline, both in the figure frame.
529fn bake_label(ops: &mut Vec<DrawOp>, shaped: &ShapedText, base_x: Sp, base_y: Sp) -> Outcome<()> {
530 let bx = base_x.to_pt() as f32;
531 let by = base_y.to_pt() as f32;
532 for glyph in &shaped.run().glyphs {
533 let path = res!(shaped.outline(glyph));
534 // A glyph with no ink -- a space -- carries an advance but nothing to fill.
535 if path.is_empty() {
536 continue;
537 }
538 let t = Transform::scale(1.0, -1.0)
539 .then(&Transform::translate(bx + glyph.x, by - glyph.y));
540 let placed = res!(path.transform(&t));
541 ops.push(DrawOp::Fill { path: placed, colour: Rgba::BLACK });
542 }
543 Ok(())
544}
545
546/// The bounding box of every op's ink, in the provisional frame, from which the figure is normalised.
547fn ink_bounds(ops: &[DrawOp]) -> Outcome<Bounds> {
548 let mut bb: Option<Bounds> = None;
549 for op in ops {
550 let path = match op {
551 DrawOp::Fill { path, .. } => path,
552 DrawOp::Stroke { path, .. } => path,
553 DrawOp::Image { .. } => continue, // a raster has no vector ink to bound; a diagram emits none
554 };
555 if let Some(b) = path.bounds(&Transform::IDENTITY) {
556 bb = Some(match bb {
557 None => b,
558 Some(cur) => cur.union(b),
559 });
560 }
561 }
562 bb.ok_or_else(|| err!("The diagram produced no ink to bound."; Bug, Missing))
563}
564
565/// One op with its path carried into a new frame.
566fn shift(op: DrawOp, t: &Transform) -> Outcome<DrawOp> {
567 Ok(match op {
568 DrawOp::Fill { path, colour } => DrawOp::Fill {
569 path: res!(path.transform(t)),
570 colour,
571 },
572 DrawOp::Stroke { path, colour, width } => DrawOp::Stroke {
573 path: res!(path.transform(t)),
574 colour,
575 width,
576 },
577 // A diagram carries only vector ink, so a raster never reaches this frame shift; it passes through
578 // untouched, the arm present only for exhaustiveness over the shared draw-op vocabulary.
579 img @ DrawOp::Image { .. } => img,
580 })
581}