From ca1f6ab544533f0d875cb8095ed63d1087421817 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:53:31 -0400 Subject: [PATCH] Geom: lightning bolt, explosions, heart, cloud and document flowcharts as the standard defines them These presets were hand-drawn approximations and looked visibly different from PowerPoint's (the lightning bolt had 7 points instead of 11, the explosions were different polygons, the cloud a ring of lobes without creases). They now follow ECMA-376's preset geometry, checked against the outlines PowerPoint gives for them (black-box: merging each preset with a far-away square and reading the resulting freeform): - lightningBolt, irregularSeal1/2: the standard's polygons (21600ths); - heart: its two cubic curves; - cloud (and cloudCallout's outline): the standard's eleven arcs drawn in the path's own 43200-unit square, plus the crease arcs, stretched to the box; arcs take DrawingML's angle convention (direction from the centre), new Pb::arc_spec; - flowChartDocument, flowChartMultidocument (three pages, outlined where they show), flowChartPunchedTape (half-ellipse edges). Wild synthetic set (per-shape mean diff, 1:1 gallery): lightningBolt 64.1 -> 2.8, irregularSeal1 41.1 -> 5.3, irregularSeal2 41.2 -> 4.8, cloud 28.2 -> 4.0, multidocument 30.9 -> 7.0, punched tape 27.7 -> 4.1, heart 20.9 -> 3.9, document 15.3 -> 3.8. Whole set 5.05 -> 4.84. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/geom/src/preset.rs | 379 ++++++++++++++++++++++++++++---------- 1 file changed, 277 insertions(+), 102 deletions(-) diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 4fa51724..c72f87d8 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -6,7 +6,7 @@ //! geometry table. Adjust values use the file convention: ratios in 1/100 000 of the shorter side (or //! of the width/height), angles in 1/60 000 degree. -use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2}; +use kurbo::{Arc, BezPath, Point, Rect, Vec2}; use serde::{Deserialize, Serialize}; mod extra; @@ -388,6 +388,29 @@ impl Pb { self.cur = Point::new(c.x + wr * e.cos(), c.y + hr * e.sin()); self } + /// A DrawingML `arcTo`: angles are where the ray from the ellipse's centre at that angle + /// meets it (not the ellipse's parameter), so elliptical arcs start and end where the + /// standard's shapes expect. + fn arc_spec(&mut self, wr: f64, hr: f64, st: f64, sw: f64) -> &mut Self { + let par = |a: f64| { + let a = a.to_radians(); + (wr * a.sin()).atan2(hr * a.cos()) + }; + let t0 = par(st); + let mut d = par(st + sw) - t0; + let tau = std::f64::consts::TAU; + if sw.abs() >= 360.0 { + d = tau.copysign(sw); + } else { + while sw > 0.0 && d < 0.0 { + d += tau; + } + while sw < 0.0 && d > 0.0 { + d -= tau; + } + } + self.arc(wr, hr, t0.to_degrees(), d.to_degrees()) + } fn z(&mut self) -> &mut Self { self.p.close_path(); self @@ -605,6 +628,11 @@ fn double_arrow(vertical: bool, w: f64, h: f64, a1: f64, a2: f64) -> Geometry { Geometry { paths: vec![fill(Pb::new().poly(&pts).take())], text_rect: r, handles: vec![], sites: mid_sites(w, h) } } +/// Points given in 21600ths of the box (as ECMA-376 defines many fixed shapes), scaled to it. +fn spec_pts(pts: &[(i32, i32)], w: f64, h: f64) -> Vec<(f64, f64)> { + pts.iter().map(|(x, y)| (w * *x as f64 / 21600.0, h * *y as f64 / 21600.0)).collect() +} + fn simple(path: BezPath, text: Rect, w: f64, h: f64) -> Geometry { Geometry { paths: vec![fill(path)], text_rect: text, handles: vec![], sites: mid_sites(w, h) } } @@ -1048,17 +1076,35 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { } } "heart" => { - b.m(w / 2.0, h * 0.25); - b.c(w * 0.62, h * -0.02, w, h * 0.0, w, h * 0.32); - b.c(w, h * 0.6, w * 0.62, h * 0.78, w / 2.0, h); - b.c(w * 0.38, h * 0.78, 0.0, h * 0.6, 0.0, h * 0.32); - b.c(0.0, h * 0.0, w * 0.38, h * -0.02, w / 2.0, h * 0.25); - b.z(); + // ECMA-376 heart: two cubic curves from the notch at the top to the point. + let (dx1, dx2) = (w * 49.0 / 48.0, w * 10.0 / 48.0); + let y1 = -h / 3.0; + b.m(w / 2.0, h / 4.0) + .c(w / 2.0 + dx2, y1, w / 2.0 + dx1, h / 4.0, w / 2.0, h) + .c(w / 2.0 - dx1, h / 4.0, w / 2.0 - dx2, y1, w / 2.0, h / 4.0) + .z(); simple(b.take(), Rect::new(w * 0.2, h * 0.25, w * 0.8, h * 0.7), w, h) } "lightningBolt" => { - let pts = normalize(&[(0.36, 0.0), (0.68, 0.0), (0.53, 0.34), (0.79, 0.34), (0.24, 1.0), (0.39, 0.53), (0.15, 0.53)], w, h); - simple(b.poly(&pts).take(), Rect::new(w * 0.3, h * 0.35, w * 0.65, h * 0.6), w, h) + // ECMA-376 lightningBolt, in 21600ths of the box. + let pts = spec_pts( + &[ + (8472, 0), + (12860, 6080), + (11050, 6797), + (16577, 12007), + (14767, 12877), + (21600, 21600), + (10012, 14915), + (12222, 13987), + (5022, 9705), + (7602, 8382), + (0, 3890), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 8757.0 / 21600.0, h * 7637.0 / 21600.0, w * 13917.0 / 21600.0, h * 12963.0 / 21600.0), w, h) } "sun" => { let k = a(0).clamp(12500.0, 46875.0) / 100000.0; @@ -1378,18 +1424,45 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { } } "flowChartDocument" => { - b.m(0.0, 0.0).l(w, 0.0).l(w, h * 0.8).c(w * 0.7, h * 0.62, w * 0.35, h * 1.08, 0.0, h * 0.88).z(); - simple(b.take(), Rect::new(0.0, 0.0, w, h * 0.78), w, h) + let u = |x: f64, y: f64| (w * x / 21600.0, h * y / 21600.0); + let (p1, p2, p3, p4) = (u(21600.0, 17322.0), u(10800.0, 17322.0), u(10800.0, 23922.0), u(0.0, 20172.0)); + b.m(0.0, 0.0).l(w, 0.0).l(p1.0, p1.1).c(p2.0, p2.1, p3.0, p3.1, p4.0, p4.1).z(); + simple(b.take(), Rect::new(0.0, 0.0, w, h * 17322.0 / 21600.0), w, h) } "flowChartMultidocument" => { - let (dx, dy) = (w * 0.1, h * 0.1); - let mut back = Pb::new(); - back.poly(&[(2.0 * dx, 0.0), (w, 0.0), (w, h * 0.7), (w - dx * 0.5, h * 0.7), (w - dx * 0.5, dy * 0.5), (2.0 * dx, dy * 0.5)]); - back.poly(&[(dx, dy * 0.5), (w - dx * 0.5, dy * 0.5), (w - dx * 0.5, h * 0.78), (w - dx, h * 0.78), (w - dx, dy), (dx, dy)]); - let front = Pb::new().m(0.0, dy).l(w - dx, dy).l(w - dx, h * 0.84).c(w * 0.62, h * 0.7, w * 0.32, h * 1.08, 0.0, h * 0.9).z().take(); + // ECMA-376 multidocument (21600ths): two back pages peeking out above and right of + // the front one; the back pages' edges are outlined only where they show. + let x = |v: f64| w * v / 21600.0; + let y = |v: f64| h * v / 21600.0; + let front = |b: &mut Pb| { + b.m(0.0, y(20782.0)) + .c(x(9298.0), y(23542.0), x(9298.0), y(18022.0), x(18595.0), y(18022.0)) + .l(x(18595.0), y(3675.0)) + .l(0.0, y(3675.0)) + .z(); + }; + let mid = |b: &mut Pb| { + b.m(x(1532.0), y(3675.0)).l(x(1532.0), y(1815.0)).l(x(20000.0), y(1815.0)).l(x(20000.0), y(16252.0)); + b.c(x(19298.0), y(16252.0), x(18595.0), y(16352.0), x(18595.0), y(16352.0)); + }; + let back = |b: &mut Pb| { + b.m(x(2972.0), y(1815.0)).l(x(2972.0), 0.0).l(w, 0.0).l(w, y(14392.0)); + b.c(x(20800.0), y(14392.0), x(20000.0), y(14467.0), x(20000.0), y(14467.0)); + }; + let mut area = Pb::new(); + front(&mut area); + mid(&mut area); + area.l(x(18595.0), y(3675.0)).z(); + back(&mut area); + area.l(x(20000.0), y(1815.0)).z(); + let mut outline = Pb::new(); + outline.m(0.0, y(3675.0)).l(x(18595.0), y(3675.0)).l(x(18595.0), y(18022.0)); + outline.c(x(9298.0), y(18022.0), x(9298.0), y(23542.0), 0.0, y(20782.0)).z(); + mid(&mut outline); + back(&mut outline); Geometry { - paths: vec![fill(back.take()), fill(front)], - text_rect: Rect::new(0.0, dy, w - dx, h * 0.8), + paths: vec![fill_only(area.take()), stroke_only(outline.take())], + text_rect: Rect::new(0.0, y(3675.0), x(18595.0), y(17322.0)), handles: vec![], sites: mid_sites(w, h), } @@ -1407,10 +1480,11 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { simple(b.poly(&[(w * 0.2, 0.0), (w, 0.0), (w, h), (0.0, h), (0.0, h * 0.2)]).take(), Rect::new(0.0, h * 0.2, w, h), w, h) } "flowChartPunchedTape" => { - let a1 = h * 0.1; - b.m(0.0, a1).c(w * 0.25, a1 * 3.0, w * 0.25, -a1, w / 2.0, a1).c(w * 0.75, a1 * 3.0, w * 0.75, -a1, w, a1).l(w, h - a1); - b.c(w * 0.75, h - 3.0 * a1, w * 0.75, h + a1, w / 2.0, h - a1).c(w * 0.25, h - 3.0 * a1, w * 0.25, h + a1, 0.0, h - a1).z(); - simple(b.take(), Rect::new(0.0, h * 0.2, w, h * 0.8), w, h) + // ECMA-376: each long edge is two half-ellipses a quarter of the width wide and a + // tenth of the height deep, one bulging in and one out. + let (rx, ry) = (w / 4.0, h / 10.0); + b.m(0.0, ry).arc(rx, ry, 180.0, -180.0).arc(rx, ry, 180.0, 180.0).l(w, h - ry).arc(rx, ry, 0.0, -180.0).arc(rx, ry, 0.0, 180.0).z(); + simple(b.take(), Rect::new(0.0, h / 5.0, w, h * 0.8), w, h) } "flowChartSummingJunction" | "flowChartOr" => { let e = Pb::new().ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0).take(); @@ -1463,62 +1537,77 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, -h / 2.0 / 50000.0), v, 0.0, 50000.0)); g } - "irregularSeal1" | "irregularSeal2" => { - let pts: &[(f64, f64)] = if name == "irregularSeal1" { + "irregularSeal1" => { + // ECMA-376 irregularSeal1 ("Explosion: 8 Points"), in 21600ths of the box. + let pts = spec_pts( &[ - (0.48, 0.18), - (0.62, 0.0), - (0.64, 0.2), - (0.86, 0.1), - (0.78, 0.3), - (1.0, 0.36), - (0.82, 0.5), - (0.94, 0.72), - (0.7, 0.66), - (0.68, 0.9), - (0.54, 0.72), - (0.42, 1.0), - (0.36, 0.74), - (0.14, 0.86), - (0.22, 0.62), - (0.0, 0.6), - (0.18, 0.44), - (0.04, 0.24), - (0.3, 0.3), - (0.26, 0.06), - ] - } else { + (10800, 5800), + (14522, 0), + (14155, 5325), + (18380, 4457), + (16702, 7315), + (21097, 8137), + (17607, 10475), + (21600, 13290), + (16837, 12942), + (18145, 18095), + (14020, 14457), + (13247, 19737), + (10532, 14935), + (8485, 21600), + (7715, 15627), + (4762, 17617), + (5667, 13937), + (135, 14587), + (3722, 11775), + (0, 8615), + (4627, 7617), + (370, 2295), + (7312, 6320), + (8352, 2295), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 4627.0 / 21600.0, h * 6320.0 / 21600.0, w * 16702.0 / 21600.0, h * 13937.0 / 21600.0), w, h) + } + "irregularSeal2" => { + // ECMA-376 irregularSeal2 ("Explosion: 14 Points"), in 21600ths of the box. + let pts = spec_pts( &[ - (0.5, 0.16), - (0.58, 0.0), - (0.63, 0.18), - (0.78, 0.06), - (0.76, 0.24), - (0.96, 0.2), - (0.86, 0.38), - (1.0, 0.5), - (0.84, 0.58), - (0.92, 0.76), - (0.74, 0.72), - (0.76, 0.94), - (0.6, 0.8), - (0.5, 1.0), - (0.44, 0.8), - (0.28, 0.94), - (0.28, 0.74), - (0.08, 0.82), - (0.16, 0.62), - (0.0, 0.54), - (0.14, 0.44), - (0.02, 0.28), - (0.22, 0.3), - (0.2, 0.1), - (0.36, 0.22), - (0.4, 0.04), - ] - }; - let pts: Vec<(f64, f64)> = pts.iter().map(|(x, y)| (x * w, y * h)).collect(); - simple(b.poly(&pts).take(), Rect::new(w * 0.25, h * 0.3, w * 0.75, h * 0.7), w, h) + (11462, 4342), + (14790, 0), + (14525, 5777), + (18007, 3172), + (16380, 6532), + (21600, 6645), + (16985, 9402), + (18270, 11290), + (16380, 12310), + (18877, 15632), + (14640, 14350), + (14942, 17370), + (12180, 15935), + (11612, 18842), + (9872, 17370), + (8700, 19712), + (7527, 18125), + (4917, 21600), + (4805, 18240), + (1285, 17825), + (3330, 15370), + (0, 12877), + (3935, 11592), + (1172, 8270), + (5372, 7817), + (4502, 3625), + (8550, 6382), + (9722, 1887), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 5372.0 / 21600.0, h * 6382.0 / 21600.0, w * 14640.0 / 21600.0, h * 15935.0 / 21600.0), w, h) } "ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)), "verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)), @@ -1652,36 +1741,61 @@ fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry { g } +/// ECMA-376 cloud outline arcs (radii in 43200ths of the box, angles in 60000ths of a degree, +/// as DrawingML `arcTo` takes them), from the start point (3900, 14370). +const CLOUD_ARCS: [(f64, f64, f64, f64); 11] = [ + (6753.0, 9190.0, -11429249.0, 7426832.0), + (5333.0, 7267.0, -8646143.0, 5396714.0), + (4365.0, 5945.0, -8748475.0, 5983381.0), + (4857.0, 6595.0, -7859164.0, 7034504.0), + (5333.0, 7273.0, -4722533.0, 6541615.0), + (6775.0, 9220.0, -2776035.0, 7816140.0), + (5785.0, 7867.0, 37501.0, 6842000.0), + (6752.0, 9215.0, 1347096.0, 6910353.0), + (7720.0, 10543.0, 3974558.0, 4542661.0), + (4360.0, 5918.0, -16496525.0, 8804134.0), + (4345.0, 5945.0, -14809710.0, 9151131.0), +]; + +/// The cloud's creases: short arcs inside the outline, each from its own start point. +const CLOUD_CREASES: [(f64, f64, f64, f64, f64, f64); 11] = [ + (4693.0, 26177.0, 4345.0, 5945.0, 5204520.0, 1585770.0), + (6928.0, 34899.0, 4360.0, 5918.0, 4416628.0, 686848.0), + (16478.0, 39090.0, 6752.0, 9215.0, 8257449.0, 844866.0), + (28827.0, 34751.0, 6752.0, 9215.0, 387196.0, 959901.0), + (34129.0, 22954.0, 5785.0, 7867.0, -4217541.0, 4255042.0), + (41798.0, 15354.0, 5333.0, 7273.0, 1819082.0, 1665090.0), + (38324.0, 5426.0, 4857.0, 6595.0, -824660.0, 891534.0), + (29078.0, 3952.0, 4857.0, 6595.0, -8950887.0, 1091722.0), + (22141.0, 4720.0, 4365.0, 5945.0, -9809656.0, 1061181.0), + (14000.0, 5192.0, 6753.0, 9190.0, -4002417.0, 739161.0), + (4127.0, 15789.0, 6753.0, 9190.0, 9459261.0, 711490.0), +]; + fn cloud(name: &str, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { - let n = 10; - let pts: Vec = (0..n) - .map(|i| { - let t = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); - let wob = if i % 2 == 0 { 1.0 } else { 0.92 }; - Point::new(0.5 + 0.40 * wob * t.cos(), 0.5 + 0.36 * wob * t.sin()) - }) - .collect(); + // Drawn in the path's own 43200-unit square, then stretched to the box (as DrawingML lays out + // a path with its own w and h), so the arcs keep their shape's proportions at any aspect. + let to_box = kurbo::Affine::scale_non_uniform(w / 43200.0, h / 43200.0); + let deg = |v: f64| v / 60000.0; let mut b = Pb::new(); - let first = pts.first().copied().unwrap_or(Point::ZERO); - b.m(first.x, first.y); - for i in 0..n { - let p = pts.get(i).copied().unwrap_or(Point::ZERO); - let q = pts.get((i + 1) % n).copied().unwrap_or(Point::ZERO); - let mid = p.midpoint(q); - let out = (mid - Point::new(0.5, 0.5)).normalize(); - let bulge = (q - p).hypot() * 0.75; - let c1 = p + (mid - p) * 0.2 + out * bulge; - let c2 = q + (mid - q) * 0.2 + out * bulge; - b.c(c1.x, c1.y, c2.x, c2.y, q.x, q.y); + b.m(3900.0, 14370.0); + for (wr, hr, st, sw) in CLOUD_ARCS { + b.arc_spec(wr, hr, deg(st), deg(sw)); } b.z(); - let mut path = b.take(); - let bb = path.bounding_box(); - let sx = if bb.width() > 0.0 { w / bb.width() } else { 1.0 }; - let sy = if bb.height() > 0.0 { h / bb.height() } else { 1.0 }; - path.apply_affine(kurbo::Affine::scale_non_uniform(sx, sy) * kurbo::Affine::translate(-bb.origin().to_vec2())); - let mut paths = vec![fill(path)]; - let text_rect = Rect::new(w * 0.2, h * 0.22, w * 0.8, h * 0.78); + let mut creases = Pb::new(); + for (x, y, wr, hr, st, sw) in CLOUD_CREASES { + creases.m(x, y).arc_spec(wr, hr, deg(st), deg(sw)); + } + let mut outline = b.take(); + outline.apply_affine(to_box); + let mut paths = vec![fill(outline)]; + if name == "cloud" { + let mut c = creases.take(); + c.apply_affine(to_box); + paths.push(stroke_only(c)); + } + let text_rect = Rect::new(w * 2977.0 / 21600.0, h * 3262.0 / 21600.0, w * 17087.0 / 21600.0, h * 17337.0 / 21600.0); let mut handles = vec![]; if name == "cloudCallout" { let tip = Point::new(w / 2.0 + w * a0 / 1e5, h / 2.0 + h * a1 / 1e5); @@ -1975,6 +2089,67 @@ mod tests { assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open()); } + fn on_path(g: &Geometry, p: Point, tol: f64) -> bool { + use kurbo::ParamCurveNearest; + g.paths.iter().any(|sp| sp.path.segments().any(|seg| seg.nearest(p, 1e-6).distance_sq <= tol * tol)) + } + + #[test] + fn fixed_shapes_follow_the_standards_coordinates() { + // Points of ECMA-376's definitions (in 21600ths), checked on a 216 × 108 box. + let (w, h) = (216.0, 108.0); + let at = |x: f64, y: f64| Point::new(w * x / 21600.0, h * y / 21600.0); + let g = build("lightningBolt", w, h, &[]).unwrap(); + for (x, y) in [(8472.0, 0.0), (16577.0, 12007.0), (21600.0, 21600.0), (0.0, 3890.0)] { + assert!(on_path(&g, at(x, y), 1e-6), "lightningBolt ({x}, {y})"); + } + let g = build("irregularSeal1", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14522.0, 0.0), 1e-6) && on_path(&g, at(8485.0, 21600.0), 1e-6)); + let g = build("irregularSeal2", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14790.0, 0.0), 1e-6) && on_path(&g, at(4917.0, 21600.0), 1e-6)); + // The heart's point is the middle of the bottom edge, its notch a quarter down. + let g = build("heart", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 2.0, h), 1e-6) && on_path(&g, Point::new(w / 2.0, h / 4.0), 1e-6)); + let g = build("flowChartDocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(21600.0, 17322.0), 1e-6) && on_path(&g, at(0.0, 20172.0), 1e-6)); + // Punched tape: the top edge dips to a fifth of the height a quarter of the way along. + let g = build("flowChartPunchedTape", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 4.0, h / 5.0), 1e-3) && on_path(&g, Point::new(w * 0.75, 0.0), 1e-3)); + let g = build("flowChartMultidocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6)); + } + + #[test] + fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { + use kurbo::Shape as _; + for (w, h) in [(200.0, 200.0), (300.0, 100.0), (60.0, 180.0)] { + let g = build("cloud", w, h, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!(bb.x0.abs() < w * 0.02 && (bb.x1 - w).abs() < w * 0.02, "{w}×{h}: {bb:?}"); + assert!(bb.y0.abs() < h * 0.02 && (bb.y1 - h).abs() < h * 0.02, "{w}×{h}: {bb:?}"); + assert_eq!(g.paths.len(), 2, "outline and creases"); + assert!(g.paths[1].stroke && g.paths[1].fill == FillMode::None); + } + } + + #[test] + fn spec_arcs_take_angles_as_directions_from_the_centre() { + // A DrawingML arc of a 2:1 ellipse from 45° to 90°: 45° is the direction (1, 1) from the + // centre, which on that ellipse is the point (r/√5)·(2, 1)… scaled so it lies on it. + let mut b = Pb::new(); + b.m(0.0, 0.0).arc_spec(200.0, 100.0, 45.0, 45.0); + let p = b.take(); + let end = p.elements().last().and_then(|e| match e { + kurbo::PathEl::CurveTo(_, _, q) => Some(*q), + _ => None, + }); + // Centre: the start sits at direction 45°, i.e. at (k, k) from it with (k/200)² + (k/100)² = 1. + let k = 1.0 / (1.0f64 / 40000.0 + 1.0 / 10000.0).sqrt(); + let c = Point::new(-k, -k); + let q = end.unwrap(); + assert!((q.x - c.x).abs() < 1e-6 && (q.y - (c.y + 100.0)).abs() < 1e-6, "{q:?} vs centre {c:?}"); + } + #[test] fn brackets_and_braces_fill_out_to_their_straight_side() { // PowerPoint fills a bracket or brace (when it has a fill) between its curve and the