Shapes: add 38 missing preset geometries

lineInv, nonIsoscelesTrapezoid, pieWedge, the left-right / up-down / quad
arrow callouts, circularArrow / leftCircularArrow / leftRightCircularArrow,
the four curved arrows, swooshArrow, callout1-3, accentCallout1-3,
borderCallout2-3, accentBorderCallout1-3, ellipseRibbon / ellipseRibbon2,
leftRightRibbon, flowChartOfflineStorage, gear6 / gear9, funnel, the corner
tabs (corner, square, plaque) and chartX / chartStar / chartPlus no longer
fall back to plain rectangles.

Each outline is our own construction in crates/geom/src/preset/extra.rs,
fitted black-box against PowerPoint renders of synthetic probe decks at
default, changed and extreme adjust values and several aspect ratios
(outline within ~1pt on 300-480pt shapes). Tests cover catalog presence,
hostile sizes/adjusts and measured outline points.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-12 18:13:21 -04:00
1 parent dedb20890b
commit 1fb63e13f3
5 files changed
+845 -4

No files matched your search

+46 -1
View File
@@ -9,6 +9,8 @@
use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2};
use serde::{Deserialize, Serialize};
mod extra;
/// How a sub-path is filled. `Lighten`/`Darken` variants shade the shape's fill (3-D looking faces).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -148,6 +150,7 @@ pub static CATALOG: &[PresetInfo] = &[
p!("bentConnector3", "Connector: Elbow", Lines, [50000]),
p!("curvedConnector3", "Connector: Curved", Lines, [50000]),
p!("bentConnector2", "Connector: Elbow (one bend)", Lines, []),
p!("lineInv", "Line: Rising", Lines, []),
p!("rect", "Rectangle", Rectangles, []),
p!("roundRect", "Rectangle: Rounded Corners", Rectangles, [16667]),
p!("snip1Rect", "Rectangle: Single Corner Snipped", Rectangles, [16667]),
@@ -163,6 +166,7 @@ pub static CATALOG: &[PresetInfo] = &[
p!("rtTriangle", "Right Triangle", BasicShapes, []),
p!("parallelogram", "Parallelogram", BasicShapes, [25000]),
p!("trapezoid", "Trapezoid", BasicShapes, [25000]),
p!("nonIsoscelesTrapezoid", "Trapezoid: Uneven Sides", BasicShapes, [25000, 25000]),
p!("diamond", "Diamond", BasicShapes, []),
p!("pentagon", "Regular Pentagon", BasicShapes, []),
p!("hexagon", "Hexagon", BasicShapes, [25000]),
@@ -171,9 +175,13 @@ pub static CATALOG: &[PresetInfo] = &[
p!("decagon", "Decagon", BasicShapes, []),
p!("dodecagon", "Dodecagon", BasicShapes, []),
p!("pie", "Partial Circle", BasicShapes, [0, 16200000]),
p!("pieWedge", "Quarter Wedge", BasicShapes, []),
p!("chord", "Chord", BasicShapes, [2700000, 16200000]),
p!("teardrop", "Teardrop", BasicShapes, [100000]),
p!("frame", "Frame", BasicShapes, [12500]),
p!("cornerTabs", "Corner Tabs: Triangles", BasicShapes, []),
p!("squareTabs", "Corner Tabs: Squares", BasicShapes, []),
p!("plaqueTabs", "Corner Tabs: Quarter Circles", BasicShapes, []),
p!("halfFrame", "Half Frame", BasicShapes, [33333, 33333]),
p!("corner", "L-Shape", BasicShapes, [50000, 50000]),
p!("diagStripe", "Diagonal Stripe", BasicShapes, [50000]),
@@ -192,6 +200,9 @@ pub static CATALOG: &[PresetInfo] = &[
p!("sun", "Sun", BasicShapes, [25000]),
p!("moon", "Moon", BasicShapes, [50000]),
p!("cloud", "Cloud", BasicShapes, []),
p!("gear6", "Gear: 6 Teeth", BasicShapes, [15000, 3526]),
p!("gear9", "Gear: 9 Teeth", BasicShapes, [10000, 1763]),
p!("funnel", "Funnel", BasicShapes, []),
p!("arc", "Arc", BasicShapes, [16200000, 0]),
p!("bracketPair", "Double Bracket", BasicShapes, [16667]),
p!("bracePair", "Double Brace", BasicShapes, [8333]),
@@ -199,6 +210,9 @@ pub static CATALOG: &[PresetInfo] = &[
p!("rightBracket", "Right Bracket", BasicShapes, [8333]),
p!("leftBrace", "Left Brace", BasicShapes, [8333, 50000]),
p!("rightBrace", "Right Brace", BasicShapes, [8333, 50000]),
p!("chartX", "Chart Marker: Cross Lines", BasicShapes, []),
p!("chartStar", "Chart Marker: Star Lines", BasicShapes, []),
p!("chartPlus", "Chart Marker: Plus Lines", BasicShapes, []),
p!("rightArrow", "Arrow: Right", BlockArrows, [50000, 50000]),
p!("leftArrow", "Arrow: Left", BlockArrows, [50000, 50000]),
p!("upArrow", "Arrow: Up", BlockArrows, [50000, 50000]),
@@ -209,16 +223,27 @@ pub static CATALOG: &[PresetInfo] = &[
p!("leftRightUpArrow", "Arrow: Left-Right-Up", BlockArrows, [25000, 25000, 25000]),
p!("bentArrow", "Arrow: Bent", BlockArrows, [25000, 25000, 25000, 43750]),
p!("uturnArrow", "Arrow: U-Turn", BlockArrows, [25000, 25000, 25000, 43750, 75000]),
p!("curvedRightArrow", "Arrow: Curved Right", BlockArrows, [25000, 50000, 25000]),
p!("curvedLeftArrow", "Arrow: Curved Left", BlockArrows, [25000, 50000, 25000]),
p!("curvedUpArrow", "Arrow: Curved Up", BlockArrows, [25000, 50000, 25000]),
p!("curvedDownArrow", "Arrow: Curved Down", BlockArrows, [25000, 50000, 25000]),
p!("leftUpArrow", "Arrow: Left-Up", BlockArrows, [25000, 25000, 25000]),
p!("bentUpArrow", "Arrow: Bent-Up", BlockArrows, [25000, 25000, 25000]),
p!("stripedRightArrow", "Arrow: Striped Right", BlockArrows, [50000, 50000]),
p!("notchedRightArrow", "Arrow: Notched Right", BlockArrows, [50000, 50000]),
p!("homePlate", "Arrow: Pentagon", BlockArrows, [50000]),
p!("chevron", "Arrow: Chevron", BlockArrows, [50000]),
p!("circularArrow", "Arrow: Circular", BlockArrows, [12500, 1142319, 20457681, 10800000, 12500]),
p!("leftCircularArrow", "Arrow: Circular Counter-clockwise", BlockArrows, [12500, -1142319, 1142319, 10800000, 12500]),
p!("leftRightCircularArrow", "Arrow: Circular Two-headed", BlockArrows, [12500, 1142319, 20457681, 11942319, 12500]),
p!("swooshArrow", "Arrow: Swoosh", BlockArrows, [25000, 16667]),
p!("rightArrowCallout", "Callout: Right Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("downArrowCallout", "Callout: Down Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("leftArrowCallout", "Callout: Left Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("upArrowCallout", "Callout: Up Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("leftRightArrowCallout", "Callout: Left-Right Arrow", BlockArrows, [25000, 25000, 25000, 48123]),
p!("upDownArrowCallout", "Callout: Up-Down Arrow", BlockArrows, [25000, 25000, 25000, 48123]),
p!("quadArrowCallout", "Callout: Quad Arrow", BlockArrows, [18515, 18515, 18515, 48123]),
p!("mathPlus", "Plus Sign", EquationShapes, [23520]),
p!("mathMinus", "Minus Sign", EquationShapes, [23520]),
p!("mathMultiply", "Multiplication Sign", EquationShapes, [23520]),
@@ -253,6 +278,7 @@ pub static CATALOG: &[PresetInfo] = &[
p!("flowChartMagneticDisk", "Flowchart: Magnetic Disk", Flowchart, []),
p!("flowChartMagneticDrum", "Flowchart: Direct Access Storage", Flowchart, []),
p!("flowChartDisplay", "Flowchart: Display", Flowchart, []),
p!("flowChartOfflineStorage", "Flowchart: Offline Storage", Flowchart, []),
p!("irregularSeal1", "Explosion: 8 Points", StarsAndBanners, []),
p!("irregularSeal2", "Explosion: 14 Points", StarsAndBanners, []),
p!("star4", "Star: 4 Points", StarsAndBanners, [12500]),
@@ -267,6 +293,9 @@ pub static CATALOG: &[PresetInfo] = &[
p!("star32", "Star: 32 Points", StarsAndBanners, [37500]),
p!("ribbon2", "Ribbon: Tilted Up", StarsAndBanners, [16667, 50000]),
p!("ribbon", "Ribbon: Tilted Down", StarsAndBanners, [16667, 50000]),
p!("ellipseRibbon2", "Ribbon: Curved and Tilted Up", StarsAndBanners, [25000, 50000, 12500]),
p!("ellipseRibbon", "Ribbon: Curved and Tilted Down", StarsAndBanners, [25000, 50000, 12500]),
p!("leftRightRibbon", "Ribbon: Left-Right Fold", StarsAndBanners, [50000, 50000, 16667]),
p!("verticalScroll", "Scroll: Vertical", StarsAndBanners, [12500]),
p!("horizontalScroll", "Scroll: Horizontal", StarsAndBanners, [12500]),
p!("wave", "Wave", StarsAndBanners, [12500, 0]),
@@ -276,6 +305,22 @@ pub static CATALOG: &[PresetInfo] = &[
p!("wedgeEllipseCallout", "Speech Bubble: Oval", Callouts, [-20833, 62500]),
p!("cloudCallout", "Thought Bubble: Cloud", Callouts, [-20833, 62500]),
p!("borderCallout1", "Callout: Line", Callouts, [18750, -8333, 112500, -38333]),
p!("borderCallout2", "Callout: Bent Line", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!("borderCallout3", "Callout: Double Bent Line", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
p!("accentCallout1", "Callout: Line with Accent Bar", Callouts, [18750, -8333, 112500, -38333]),
p!("accentCallout2", "Callout: Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!("accentCallout3", "Callout: Double Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
p!("callout1", "Callout: Line with No Border", Callouts, [18750, -8333, 112500, -38333]),
p!("callout2", "Callout: Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!("callout3", "Callout: Double Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
p!("accentBorderCallout1", "Callout: Line with Border and Accent Bar", Callouts, [18750, -8333, 112500, -38333]),
p!("accentBorderCallout2", "Callout: Bent Line with Border and Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!(
"accentBorderCallout3",
"Callout: Double Bent Line with Border and Accent Bar",
Callouts,
[18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]
),
p!("actionButtonBackPrevious", "Action Button: Go Back or Previous", ActionButtons, []),
p!("actionButtonForwardNext", "Action Button: Go Forward or Next", ActionButtons, []),
p!("actionButtonBeginning", "Action Button: Go to Beginning", ActionButtons, []),
@@ -1535,7 +1580,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take();
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] }
}
_ => return None,
_ => return extra::build(name, w, h, &a),
};
Some(g)
}
+796
View File
@@ -0,0 +1,796 @@
//! More preset outlines: line callouts, arrow callouts, curved and circular arrows, curved
//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own
//! construction, fitted to how the shapes look and to what their adjust values mean.
use super::*;
/// Builds the presets this module knows; `None` for any other name.
pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option<Geometry> {
let ss = w.min(h);
let full = Rect::new(0.0, 0.0, w, h);
let mut b = Pb::new();
let g = match name {
"lineInv" => {
let l = b.m(0.0, h).l(w, 0.0).take();
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::new(0.0, h), Point::new(w, 0.0)] }
}
"nonIsoscelesTrapezoid" => {
let max = 50000.0 * w / ss.max(1e-9);
let (v1, v2) = (a(0).clamp(0.0, max), a(1).clamp(0.0, max));
let (d1, d2) = (ss * v1 / 1e5, ss * v2 / 1e5);
let mut g = simple(b.poly(&[(0.0, h), (d1, 0.0), (w - d2, 0.0), (w, h)]).take(), Rect::new(d1 * 0.66, h * 0.2, w - d2 * 0.66, h), w, h);
g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 1e5, 0.0), v1, 0.0, max));
g.handles.push(Handle::new(1, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v2, 0.0, max));
g
}
"pieWedge" => {
b.m(0.0, h).arc(w, h, 180.0, 90.0).l(w, h).z();
simple(b.take(), Rect::new(w * 0.29289, h * 0.29289, w, h), w, h)
}
"flowChartOfflineStorage" => {
let tri = b.poly(&[(0.0, 0.0), (w, 0.0), (w / 2.0, h)]).take();
let bar = Pb::new().m(w * 0.4, h * 0.8).l(w * 0.6, h * 0.8).take();
Geometry {
paths: vec![fill(tri), stroke_only(bar)],
text_rect: Rect::new(w / 4.0, 0.0, w * 0.75, h * 0.8),
handles: vec![],
sites: vec![Point::new(w / 2.0, 0.0), Point::new(w / 4.0, h / 2.0), Point::new(w / 2.0, h), Point::new(w * 0.75, h / 2.0)],
}
}
"chartX" | "chartStar" | "chartPlus" => {
let body = corner_rect(w, h, [Corner::Square; 4]);
let mut l = Pb::new();
if name != "chartPlus" {
l.m(0.0, 0.0).l(w, h).m(0.0, h).l(w, 0.0);
}
if name != "chartX" {
l.m(w / 2.0, 0.0).l(w / 2.0, h);
}
if name == "chartPlus" {
l.m(0.0, h / 2.0).l(w, h / 2.0);
}
Geometry { paths: vec![fill_only(body), stroke_only(l.take())], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
}
"cornerTabs" | "squareTabs" | "plaqueTabs" => {
// Each tab's size is a twentieth of the diagonal.
let d = (w.hypot(h) / 20.0).min(w / 2.0).min(h / 2.0);
match name {
"cornerTabs" => {
b.poly(&[(0.0, 0.0), (d, 0.0), (0.0, d)]);
b.poly(&[(w - d, 0.0), (w, 0.0), (w, d)]);
b.poly(&[(w, h - d), (w, h), (w - d, h)]);
b.poly(&[(0.0, h - d), (d, h), (0.0, h)]);
}
"squareTabs" => {
b.poly(&[(0.0, 0.0), (d, 0.0), (d, d), (0.0, d)]);
b.poly(&[(w - d, 0.0), (w, 0.0), (w, d), (w - d, d)]);
b.poly(&[(w - d, h - d), (w, h - d), (w, h), (w - d, h)]);
b.poly(&[(0.0, h - d), (d, h - d), (d, h), (0.0, h)]);
}
_ => {
if d > 0.0 {
b.m(0.0, 0.0).l(d, 0.0).arc(d, d, 0.0, 90.0).z();
b.m(w, 0.0).l(w, d).arc(d, d, 90.0, 90.0).z();
b.m(w, h).l(w - d, h).arc(d, d, 180.0, 90.0).z();
b.m(0.0, h).l(0.0, h - d).arc(d, d, 270.0, 90.0).z();
} else {
b.m(0.0, 0.0).z();
}
}
}
Geometry { paths: vec![fill(b.take())], text_rect: full.inset(-d), handles: vec![], sites: mid_sites(w, h) }
}
"callout1"
| "callout2"
| "callout3"
| "accentCallout1"
| "accentCallout2"
| "accentCallout3"
| "borderCallout2"
| "borderCallout3"
| "accentBorderCallout1"
| "accentBorderCallout2"
| "accentBorderCallout3" => {
let n = match name.chars().last() {
Some('2') => 2,
Some('3') => 3,
_ => 1,
};
line_callout(w, h, a, n + 1, name.starts_with("accent"), name.contains("order"))
}
"leftRightArrowCallout" | "upDownArrowCallout" => {
let vertical = name == "upDownArrowCallout";
let (l, t) = if vertical { (h, w) } else { (w, h) };
let ss = l.min(t);
let head = (ss * a(1).max(0.0) / 1e5).min(t / 2.0);
let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0);
let hl = (ss * a(2).max(0.0) / 1e5).min(l / 2.0);
let boxl = (l * a(3).max(0.0) / 1e5).min(l - 2.0 * hl);
let (c, b0, b1) = (t / 2.0, (l - boxl) / 2.0, (l + boxl) / 2.0);
let pts = [
(0.0, c),
(hl, c - head),
(hl, c - shaft / 2.0),
(b0, c - shaft / 2.0),
(b0, 0.0),
(b1, 0.0),
(b1, c - shaft / 2.0),
(l - hl, c - shaft / 2.0),
(l - hl, c - head),
(l, c),
(l - hl, c + head),
(l - hl, c + shaft / 2.0),
(b1, c + shaft / 2.0),
(b1, t),
(b0, t),
(b0, c + shaft / 2.0),
(hl, c + shaft / 2.0),
(hl, c + head),
];
let d = if vertical { Dir::Down } else { Dir::Right };
let pts = orient(d, w, h, &pts);
let tr = orient(d, w, h, &[(b0, 0.0), (b1, t)]);
simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h)
}
"quadArrowCallout" => {
let head = (ss * a(1).max(0.0) / 1e5).min(ss / 2.0);
let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0);
let hl = (ss * a(2).max(0.0) / 1e5).min(ss / 2.0);
let k = a(3).clamp(0.0, 100000.0) / 1e5;
let (bw, bh) = ((w * k).min(w - 2.0 * hl).max(shaft), (h * k).min(h - 2.0 * hl).max(shaft));
let (cx, cy) = (w / 2.0, h / 2.0);
let (bx0, bx1, by0, by1) = (cx - bw / 2.0, cx + bw / 2.0, cy - bh / 2.0, cy + bh / 2.0);
let (s2, hd) = (shaft / 2.0, head);
let pts = [
(0.0, cy),
(hl, cy - hd),
(hl, cy - s2),
(bx0, cy - s2),
(bx0, by0),
(cx - s2, by0),
(cx - s2, hl),
(cx - hd, hl),
(cx, 0.0),
(cx + hd, hl),
(cx + s2, hl),
(cx + s2, by0),
(bx1, by0),
(bx1, cy - s2),
(w - hl, cy - s2),
(w - hl, cy - hd),
(w, cy),
(w - hl, cy + hd),
(w - hl, cy + s2),
(bx1, cy + s2),
(bx1, by1),
(cx + s2, by1),
(cx + s2, h - hl),
(cx + hd, h - hl),
(cx, h),
(cx - hd, h - hl),
(cx - s2, h - hl),
(cx - s2, by1),
(bx0, by1),
(bx0, cy + s2),
(hl, cy + s2),
(hl, cy + hd),
];
simple(b.poly(&pts).take(), Rect::new(bx0, by0, bx1, by1), w, h)
}
"curvedRightArrow" | "curvedLeftArrow" | "curvedUpArrow" | "curvedDownArrow" => curved_arrow(name, w, h, a(0), a(1), a(2)),
"swooshArrow" => swoosh(w, h, a(0), a(1)),
"ellipseRibbon" | "ellipseRibbon2" => ellipse_ribbon(name == "ellipseRibbon2", w, h, a(0), a(1), a(2)),
"leftRightRibbon" => left_right_ribbon(w, h, a(0), a(1), a(2)),
"gear6" | "gear9" => gear(if name == "gear6" { 6 } else { 9 }, w, h, a(0), a(1)),
"funnel" => funnel(w, h),
"circularArrow" | "leftCircularArrow" | "leftRightCircularArrow" => circular_arrow(name, w, h, [a(0), a(1), a(2), a(3), a(4)]),
_ => return None,
};
Some(g)
}
/// An ellipse-centred frame for the circular arrows: the band's middle runs on the ellipse
/// `(rx, ry)`, its edges `th/2` either side.
struct Ring {
c: Point,
rx: f64,
ry: f64,
th: f64,
hw: f64,
}
impl Ring {
/// The point at visual angle `deg` on the ellipse grown by `d`.
fn at(&self, deg: f64, d: f64) -> Point {
let (ax, ay) = ((self.rx + d).max(0.0), (self.ry + d).max(0.0));
let t = vis(deg, ax, ay).to_radians();
Point::new(self.c.x + ax * t.cos(), self.c.y + ay * t.sin())
}
/// Parametric angle (degrees) of `p` on the ellipse grown by `d`.
fn param(&self, p: Point, d: f64) -> f64 {
let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9));
((p.y - self.c.y) / ay).atan2((p.x - self.c.x) / ax).to_degrees()
}
/// Where the line `b + s·u` meets the ellipse grown by `d`, taking the crossing nearest `b`
/// on the side `sign` (+1 outwards, −1 inwards).
fn cross(&self, b: Point, u: Vec2, d: f64, sign: f64) -> Point {
let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9));
let (px, py) = ((b.x - self.c.x) / ax, (b.y - self.c.y) / ay);
let (ux, uy) = (u.x / ax, u.y / ay);
let qa = ux * ux + uy * uy;
let qb = 2.0 * (px * ux + py * uy);
let qc = px * px + py * py - 1.0;
let disc = qb * qb - 4.0 * qa * qc;
if qa < 1e-18 || disc < 0.0 {
// The line misses (a long head on a thin ring): the edge then carries on from the
// base centre itself, as the reference does.
return b;
}
let r = disc.sqrt();
let (s0, s1) = ((-qb - r) / (2.0 * qa), (-qb + r) / (2.0 * qa));
let s = if sign > 0.0 {
if s0 >= 0.0 { s0 } else { s1 }
} else if s1 <= 0.0 {
s1
} else {
s0
};
b + u * s
}
/// An arrow head whose base centre sits on the middle ellipse at `base` degrees and whose tip
/// sits there at `base + sweep`. The base runs along the line from the centre to the tip.
/// Returns (band outer edge, outer wing, tip, inner wing, band inner edge).
fn head(&self, base: f64, sweep: f64) -> [Point; 5] {
let b = self.at(base, 0.0);
let tip = self.at(base + sweep, 0.0);
let d = tip - self.c;
let u = if d.hypot() > 1e-12 { d / d.hypot() } else { Vec2::new(1.0, 0.0) };
let half = self.th / 2.0;
[self.cross(b, u, half, 1.0), b + u * self.hw, tip, b - u * self.hw, self.cross(b, u, -half, -1.0)]
}
}
/// Sweep (degrees) from `a` to `b` turning one way: positive when `dir > 0`.
fn turn(a: f64, b: f64, dir: f64) -> f64 {
if dir > 0.0 { (b - a).rem_euclid(360.0) } else { -(a - b).rem_euclid(360.0) }
}
/// The circular arrows: a band along an ellipse with one head (`circularArrow` clockwise,
/// `leftCircularArrow` anticlockwise) or a head at each end (`leftRightCircularArrow`).
/// Adjusts: band thickness, head sweep (degrees), end angle, start angle, head half-width.
fn circular_arrow(name: &str, w: f64, h: f64, a: [f64; 5]) -> Geometry {
let ss = w.min(h);
if ss <= 0.0 {
return simple(corner_rect(w, h, [Corner::Square; 4]), Rect::new(0.0, 0.0, w, h), w, h);
}
let a5 = a[4].clamp(0.0, 25000.0);
let hw = ss * a5 / 1e5;
let th = ss * a[0].clamp(0.0, 2.0 * a5) / 1e5;
let ring = Ring { c: Point::new(w / 2.0, h / 2.0), rx: (w / 2.0 - hw).max(th / 2.0), ry: (h / 2.0 - hw).max(th / 2.0), th, hw };
let deg = |v: f64| (v / 60000.0) % 360.0;
let (sweep, end, start) = (deg(a[1]), deg(a[2]), deg(a[3]));
let half = th / 2.0;
let mut b = Pb::new();
// Arcs of the ellipse grown by `d`, drawn on from the current point (which is on it except in
// the degenerate case above, where the arc shifts with it like the reference's).
let edge_arc = |b: &mut Pb, from: Point, to: Point, d: f64, dir: f64| {
let (ax, ay) = ((ring.rx + d).max(0.0), (ring.ry + d).max(0.0));
let (s, e) = (ring.param(from, d), ring.param(to, d));
b.arc(ax, ay, s, turn(s, e, dir));
};
let outer_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, half, dir);
let inner_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, -half, dir);
if name == "leftRightCircularArrow" {
let hs = ring.head(start, -sweep);
let he = ring.head(end, sweep);
b.m(hs[0].x, hs[0].y);
outer_arc(&mut b, hs[0], he[0], 1.0);
b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y);
inner_arc(&mut b, he[4], hs[4], -1.0);
b.l(hs[3].x, hs[3].y).l(hs[2].x, hs[2].y).l(hs[1].x, hs[1].y).z();
} else {
let dir = if name == "circularArrow" { 1.0 } else { -1.0 };
let he = ring.head(end, sweep);
let (o0, i0) = (ring.at(start, half), ring.at(start, -half));
b.m(o0.x, o0.y);
outer_arc(&mut b, o0, he[0], dir);
b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y);
inner_arc(&mut b, he[4], i0, -dir);
b.z();
}
simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h)
}
/// A funnel: an elliptical rim (with an open mouth) across the top quarter, and straight sides
/// down to a small rounded spout. The side lines leave the rim and meet the spout at fixed
/// angles, fitted to the reference look.
fn funnel(w: f64, h: f64) -> Geometry {
const RIM: f64 = 173.0; // where the left side leaves the rim, degrees on the rim ellipse
const SPOUT: f64 = 163.4; // where it meets the spout, degrees on the spout ellipse
let ss = w.min(h);
let (cx, rim_y) = (w / 2.0, h / 4.0);
let (sx, sy) = (w / 8.0, h / 15.0);
let spout_y = h - sy;
let at = |rx: f64, ry: f64, cy: f64, deg: f64| {
let t = deg.to_radians();
Point::new(cx + rx * t.cos(), cy + ry * t.sin())
};
let p0 = at(cx, rim_y, rim_y, RIM);
let p1 = at(sx, sy, spout_y, 180.0 - SPOUT);
let mut b = Pb::new();
b.m(p0.x, p0.y).arc(cx, rim_y, RIM, 540.0 - 2.0 * RIM);
b.l(p1.x, p1.y).arc(sx, sy, 180.0 - SPOUT, 2.0 * SPOUT - 180.0).z();
let d = ss / 20.0;
b.ellipse(cx, rim_y, (cx - d).max(0.0), (rim_y - d).max(0.0));
Geometry { paths: vec![fill_eo(b.take())], text_rect: Rect::new(w / 4.0, h / 2.0, w * 0.75, h - sy), handles: vec![], sites: mid_sites(w, h) }
}
/// A gear: an elliptical body with `n` teeth, the first pointing straight up. Each tooth leaves
/// the body at two points an equal angle either side of its centre, and its tip corners sit a
/// tooth height further out along the body's normal and a flank lean further in.
fn gear(n: usize, w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
let ss = w.min(h);
let th = ss * a0.clamp(0.0, 20000.0) / 1e5; // tooth height
let lean_max = if n == 6 { 5380.0 } else { 2730.0 };
let l = ss * a1.clamp(0.0, a0.clamp(0.0, 20000.0).min(lean_max)) / 1e5; // flank lean
let (cx, cy) = (w / 2.0, h / 2.0);
let (rx, ry) = ((cx - th).max(0.0), (cy - th).max(0.0));
let r = (ss / 2.0 - th).max(1e-9);
let half = ((th + l) / 2.0 / r).atan();
let step = std::f64::consts::TAU / n as f64;
let on_body = |t: f64| Point::new(cx + rx * t.cos(), cy + ry * t.sin());
let mut b = Pb::new();
for k in 0..n {
let t = -std::f64::consts::FRAC_PI_2 + step * k as f64;
// The tooth stands along the body's normal at its centre.
let nrm = Vec2::new(t.cos() * ry, t.sin() * rx);
let u = if nrm.hypot() > 1e-12 { nrm / nrm.hypot() } else { Vec2::new(t.cos(), t.sin()) };
let v = Vec2::new(-u.y, u.x);
let (r0, r1) = (on_body(t - half), on_body(t + half));
let (c0, c1) = (r0 + u * th + v * l, r1 + u * th - v * l);
if k == 0 {
b.m(r0.x, r0.y);
} else {
b.arc(rx, ry, (t - step + half).to_degrees(), (step - 2.0 * half).to_degrees());
}
b.l(c0.x, c0.y).l(c1.x, c1.y).l(r1.x, r1.y);
}
let t_last = -std::f64::consts::FRAC_PI_2 + step * (n - 1) as f64;
b.arc(rx, ry, (t_last + half).to_degrees(), (step - 2.0 * half).to_degrees()).z();
let k = std::f64::consts::FRAC_1_SQRT_2;
let text = Rect::new(cx - rx * k, cy - ry * k, cx + rx * k, cy + ry * k);
simple(b.take(), text, w, h)
}
/// Two arrows on a band that steps down in the middle: the left arrow sits high, the right one low,
/// and the band rolls over between them in a shaded half-ellipse curl.
fn left_right_ribbon(w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
let ss = w.min(h);
let sh = h * a2.clamp(0.0, 33333.0) / 1e5; // step between the two halves
let bt = (h * a0.max(0.0) / 1e5).min(h - sh); // band thickness
let cx = w / 2.0;
let (rx, ry) = (w / 32.0, (sh / 4.0).min(w / 24.0));
let hb = (ss * a1.max(0.0) / 1e5).min(cx - rx); // head length
let yc = (h - sh) / 2.0;
let (lt, lb) = (yc - bt / 2.0, yc + bt / 2.0);
let (rt, rb) = (lt + sh, lb + sh);
// The left half: filled as a closed outline, stroked only where it can show.
let mut edge = Pb::new();
edge.m(cx - rx, lb).l(hb, lb).l(hb, h - sh).l(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt);
edge.arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -90.0);
let edge = edge.take();
let mut left = Pb::new();
left.m(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt).arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -180.0);
left.l(cx, lb.max(lt + 4.0 * ry)).l(hb, lb).l(hb, h - sh).z();
let left = left.take();
let mut curl = Pb::new();
curl.m(cx + rx, lt + ry).l(cx + rx, rt).l(cx, lt + 4.0 * ry).arc(rx, ry, 90.0, 180.0).arc(rx, ry, 90.0, -90.0).z();
let mut right = Pb::new();
right.m(w, yc + sh).l(w - hb, h).l(w - hb, rb).l(cx, rb).arc(rx, ry, 90.0, 90.0).l(cx - rx, rt - ry).arc(rx, ry, 180.0, -90.0);
right.l(w - hb, rt).l(w - hb, sh).z();
Geometry {
paths: vec![fill_only(left), stroke_only(edge), shade(curl.take(), FillMode::DarkenLess), fill(right.take())],
text_rect: Rect::new(hb.min(cx - rx), rt, (w - hb).max(cx + rx), lb.max(rt)),
handles: vec![],
sites: vec![Point::new(0.0, yc), Point::new(cx, rt), Point::new(w, yc + sh), Point::new(cx, lb)],
}
}
/// Adds a piece of the parabola `y = y0 + d·(1 − ((x − w/2)/(w/2))²)` from the current point (at
/// `xa`) to `xb`, as one quadratic curve.
fn sag(b: &mut Pb, w: f64, d: f64, y0: f64, xa: f64, xb: f64) {
let hw = (w / 2.0).max(1e-9);
let f = |x: f64| y0 + d * (1.0 - ((x - hw) / hw).powi(2));
let slope = -2.0 * d * (xa - hw) / (hw * hw);
b.q((xa + xb) / 2.0, f(xa) + slope * (xb - xa) / 2.0, xb, f(xb));
}
/// A ribbon whose edges sag along one parabola: a front band in the middle and two notched ends
/// behind it, joined by shaded folds. Built sagging downwards; `up` mirrors it vertically.
fn ellipse_ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
let th = h * a0.clamp(0.0, 100000.0) / 1e5; // band drop at the centre
// Sag of every edge: never more than the drop, never so little that the band would rise
// above the ends by more than half their height.
let d = (h * a2.max(0.0) / 1e5).max(th - (h - th) / 2.0).clamp(0.0, th);
let bw = w * a1.clamp(25000.0, 75000.0) / 1e5;
let (x0, x1) = ((w - bw) / 2.0, (w + bw) / 2.0);
let ear = w / 8.0;
let (xf, xg) = (x0 + ear, x1 - ear);
let e = h - th; // band height
let off = th - d; // band top below the ends' top
let t = |x: f64| {
let hw = (w / 2.0).max(1e-9);
d * (1.0 - ((x - hw) / hw).powi(2))
};
let mut ends = Pb::new();
ends.m(0.0, 0.0);
sag(&mut ends, w, d, 0.0, 0.0, xf);
ends.l(xf, t(xf) + off).l(x0, t(x0) + off).l(x0, t(x0) + e);
sag(&mut ends, w, d, e, x0, 0.0);
ends.l(ear, t(ear) + e / 2.0).z();
ends.m(w, 0.0).l(w - ear, t(w - ear) + e / 2.0).l(w, e);
sag(&mut ends, w, d, e, w, x1);
ends.l(x1, t(x1) + off).l(xg, t(xg) + off).l(xg, t(xg));
sag(&mut ends, w, d, 0.0, xg, w);
ends.z();
let mut folds = Pb::new();
folds.poly(&[(x0, t(x0) + off), (xf, t(xf)), (xf, t(xf) + off)]);
folds.poly(&[(x1, t(x1) + off), (xg, t(xg)), (xg, t(xg) + off)]);
let mut band = Pb::new();
band.m(x0, t(x0) + off);
sag(&mut band, w, d, off, x0, x1);
band.l(x1, t(x1) + off + e);
sag(&mut band, w, d, off + e, x1, x0);
band.z();
let mut paths = vec![fill(ends.take()), shade(folds.take(), FillMode::DarkenLess), fill(band.take())];
let mut text = Rect::new(x0, th, x1, t(x0) + off + e);
if up {
let m = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, h]);
for p in &mut paths {
p.path.apply_affine(m);
}
text = m.transform_rect_bbox(text);
}
Geometry { paths, text_rect: text, handles: vec![], sites: mid_sites(w, h) }
}
/// A sweeping arrow rising from the bottom-left corner. The band's two edges are quadratic curves
/// from the corner; the head's base leans slightly forward and reaches one eighth of the shorter
/// side above and below the band. The tilt and the tip's lift were fitted to the reference look.
fn swoosh(w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
const TILT: f64 = 0.1126; // tan of the head base's lean from vertical
const LIFT: f64 = 0.051; // tip height above the band's centre, per unit of height
let ss = w.min(h);
let e = ss / 8.0;
let th = (h * a0.max(0.0) / 1e5).min((h - 2.0 * e).max(0.0));
// The head is at least long enough to keep its leaning base inside the frame.
let hl = (ss * a1.max(0.0) / 1e5).max((e + th) * TILT).min((w - e * TILT).max(0.0));
let qx = w - hl;
let mut b = Pb::new();
b.m(0.0, h)
.q(w / 6.0, h / 3.0, qx, e)
.l(qx - e * TILT, 0.0)
.l(w, (e + th / 2.0 - LIFT * h).clamp(0.0, 2.0 * e + th))
.l((qx + (e + th) * TILT).min(w), 2.0 * e + th)
.l((qx + th * TILT).min(w), e + th)
.q(w / 4.0, (th + h / 12.0 + e).min(h), 0.0, h)
.z();
simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h)
}
/// A band that sweeps around half an ellipse and ends in an arrow head. Built pointing right
/// (`curvedRightArrow`) in a `cw`×`ch` frame, then turned for the other directions.
///
/// Both halves of the band hang off one ellipse touching the far side: its centre sits on the
/// head side's edge, its horizontal radius is the full length, and the band has a constant
/// vertical thickness. The back half (shaded) starts flat against the head side; the front half
/// runs to the head, centred on the band where it ends.
fn curved_arrow(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
let along_x = matches!(name, "curvedRightArrow" | "curvedLeftArrow");
let (cw, ch) = if along_x { (w, h) } else { (h, w) };
let ss = cw.min(ch);
let head = (ss * a1.max(0.0) / 1e5).min(ch / 2.0);
let th = (ss * a0.max(0.0) / 1e5).min(head);
let hl = (ss * a2.max(0.0) / 1e5).min(cw);
let hh = head / 2.0;
// The band's centre line, carried on to the head side, ends at the tip, which sits half a
// head width above the bottom.
let r = ((ch - hh - th / 2.0) / 2.0).max(0.0);
// Parametric angle where the front band reaches the head base.
let te = if cw > 0.0 { (-(hl / cw)).clamp(-1.0, 1.0).acos().to_degrees() } else { 90.0 };
let mut back = Pb::new();
back.m(cw, 0.0).arc(cw, r, 270.0, -90.0).l(0.0, r + th).arc(cw, r, 180.0, 90.0).z();
let mut front = Pb::new();
front.m(0.0, r).arc(cw, r, 180.0, te - 180.0);
let yb = front.cur.y;
let yc = yb + th / 2.0;
front.l(cw - hl, yc - hh).l(cw, 2.0 * r + th / 2.0).l(cw - hl, yc + hh).l(cw - hl, yb + th).arc(cw, r, te, 180.0 - te).z();
let m = match name {
"curvedLeftArrow" => kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]),
"curvedUpArrow" => kurbo::Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]),
"curvedDownArrow" => kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]),
_ => kurbo::Affine::IDENTITY,
};
let (mut back, mut front) = (back.take(), front.take());
back.apply_affine(m);
front.apply_affine(m);
Geometry {
paths: vec![shade(back, FillMode::DarkenLess), fill(front)],
text_rect: Rect::new(0.0, 0.0, w, h),
handles: vec![],
sites: mid_sites(w, h),
}
}
/// A rectangle with a callout leader through `pts` points (`(y, x)` adjust pairs in 1/100 000 of
/// the height and width), optionally with an accent bar at the first point's x and a border.
fn line_callout(w: f64, h: f64, a: &dyn Fn(usize) -> f64, pts: usize, accent: bool, border: bool) -> Geometry {
let body = corner_rect(w, h, [Corner::Square; 4]);
// Leaders may reach well outside the box, but not without bound.
let pt = |i: usize| (w * a(2 * i + 1).clamp(-1e7, 1e7) / 1e5, h * a(2 * i).clamp(-1e7, 1e7) / 1e5);
let mut l = Pb::new();
let (x0, y0) = pt(0);
l.m(x0, y0);
for i in 1..pts {
let (x, y) = pt(i);
l.l(x, y);
}
let mut paths = vec![if border { fill(body) } else { fill_only(body) }];
if accent {
paths.push(stroke_only(Pb::new().m(x0, 0.0).l(x0, h).take()));
}
paths.push(stroke_only(l.take()));
Geometry { paths, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: mid_sites(w, h) }
}
#[cfg(test)]
mod tests {
use super::super::{CATALOG, FillMode, build, info};
use kurbo::{BezPath, PathEl, Point, Shape};
const NEW: [&str; 38] = [
"lineInv",
"nonIsoscelesTrapezoid",
"pieWedge",
"leftRightArrowCallout",
"upDownArrowCallout",
"quadArrowCallout",
"circularArrow",
"leftCircularArrow",
"leftRightCircularArrow",
"curvedRightArrow",
"curvedLeftArrow",
"curvedUpArrow",
"curvedDownArrow",
"swooshArrow",
"callout1",
"callout2",
"callout3",
"accentCallout1",
"accentCallout2",
"accentCallout3",
"borderCallout2",
"borderCallout3",
"accentBorderCallout1",
"accentBorderCallout2",
"accentBorderCallout3",
"ellipseRibbon",
"ellipseRibbon2",
"leftRightRibbon",
"flowChartOfflineStorage",
"gear6",
"gear9",
"funnel",
"cornerTabs",
"squareTabs",
"plaqueTabs",
"chartX",
"chartStar",
"chartPlus",
];
fn outline(name: &str, w: f64, h: f64, adj: &[f64]) -> BezPath {
build(name, w, h, adj).map(|g| g.outline()).unwrap_or_default()
}
/// Distance from `p` to the nearest point of the (flattened) outline.
fn dist(path: &BezPath, p: Point) -> f64 {
let mut best = f64::MAX;
let mut prev = Point::ZERO;
let mut start = Point::ZERO;
kurbo::flatten(path.iter(), 0.01, |el| {
let (a, b) = match el {
PathEl::MoveTo(q) => {
prev = q;
start = q;
return;
}
PathEl::LineTo(q) => (prev, q),
PathEl::ClosePath => (prev, start),
_ => return,
};
let d = b - a;
let t = if d.hypot2() > 0.0 { ((p - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
best = best.min((a + d * t - p).hypot());
prev = b;
});
best
}
/// Asserts every point lies within 1pt of the outline.
fn on(name: &str, w: f64, h: f64, adj: &[f64], pts: &[(f64, f64)]) {
let path = outline(name, w, h, adj);
for &(x, y) in pts {
let d = dist(&path, Point::new(x, y));
assert!(d < 1.0, "{name} {w}x{h} {adj:?}: ({x}, {y}) is {d:.2} from the outline");
}
}
#[test]
fn new_presets_are_in_the_catalog() {
for n in NEW {
let i = info(n).unwrap_or_else(|| panic!("{n} missing from the catalog"));
assert!(!i.label.is_empty());
}
assert!(CATALOG.len() >= 183);
}
#[test]
fn new_presets_build_within_bounds_at_many_sizes_and_adjusts() {
let sizes = [(480.0, 300.0), (300.0, 300.0), (200.0, 460.0), (100.0, 460.0), (1.0, 1.0), (0.0, 0.0), (0.0, 50.0), (60.0, 0.0)];
let adjusts: [&[f64]; 8] = [
&[],
&[0.0; 8],
&[100000.0; 8],
&[-50000.0; 8],
&[1e12, -1e12, 1e12, -1e12, 1e12, -1e12, 1e12, -1e12],
&[f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN],
&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 5.0, 6.0, 7.0, 8.0],
&[30000.0, 60000.0, 20000.0, 9000000.0, 20000.0, 40000.0, 120000.0, -30000.0],
];
for n in NEW {
// Line callouts' leaders may reach anywhere, by design.
let callout = n.contains("allout") && !n.contains("Arrow");
for (w, h) in sizes {
for adj in adjusts {
let g = build(n, w, h, adj).unwrap_or_else(|| panic!("{n} did not build"));
assert!(!g.paths.is_empty(), "{n} has no paths");
for s in &g.paths {
let bb = s.path.bounding_box();
assert!(bb.x0.is_finite() && bb.y0.is_finite() && bb.x1.is_finite() && bb.y1.is_finite(), "{n} {w}x{h} {adj:?}: {bb:?}");
if !callout {
// Circular arrows with a head longer than their ring can hold carry the
// inner edge on from the head, as the reference does, so may spill.
let spill = if n.contains("ircular") { 0.5 } else { 0.01 };
let tol = 0.5 + w.max(h) * spill;
assert!(
bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol,
"{n} {w}x{h} {adj:?} out of bounds: {bb:?}"
);
}
}
let t = g.text_rect;
assert!(t.x0.is_finite() && t.y0.is_finite() && t.x1.is_finite() && t.y1.is_finite(), "{n} text rect {t:?}");
}
}
let g = build(n, 480.0, 300.0, &[]).unwrap_or_else(|| panic!("{n}"));
assert!(g.outline().elements().len() >= 2, "{n} default outline is empty");
}
}
// The spot checks below are outline points measured on reference renders.
#[test]
fn simple_new_presets_match_reference_points() {
on("lineInv", 480.0, 300.0, &[], &[(0.0, 300.0), (480.0, 0.0)]);
on("nonIsoscelesTrapezoid", 480.0, 300.0, &[], &[(75.0, 0.0), (405.0, 0.0), (0.0, 300.0)]);
on("nonIsoscelesTrapezoid", 200.0, 460.0, &[80000.0, 20000.0], &[(100.0, 0.0), (160.0, 0.0)]);
on("pieWedge", 480.0, 300.0, &[], &[(64.3, 150.0), (0.0, 300.0), (480.0, 0.0)]);
on("flowChartOfflineStorage", 480.0, 300.0, &[], &[(240.0, 300.0), (192.0, 240.0), (288.0, 240.0)]);
on("squareTabs", 480.0, 300.0, &[], &[(28.3, 28.3), (451.7, 271.7)]);
on("cornerTabs", 480.0, 300.0, &[], &[(23.3, 5.0)]);
on("plaqueTabs", 480.0, 300.0, &[], &[(27.85, 5.0)]);
on("chartStar", 480.0, 300.0, &[], &[(240.0, 0.0), (240.0, 300.0), (48.0, 30.0)]);
on("chartPlus", 480.0, 300.0, &[], &[(0.0, 150.0), (240.0, 0.0)]);
// Chart markers and plain callouts leave the box unstroked.
for n in ["chartX", "callout1", "accentCallout2"] {
let g = build(n, 100.0, 100.0, &[]).unwrap_or_else(|| panic!("{n}"));
assert!(!g.paths[0].stroke, "{n}");
}
}
#[test]
fn line_callouts_follow_their_adjusts() {
on("callout3", 480.0, 300.0, &[], &[(-40.0, 56.25), (-80.0, 56.25), (-80.0, 300.0), (-40.0, 338.9)]);
let adj = [50000.0, 110000.0, 20000.0, 120000.0, -20000.0, 80000.0, 30000.0, 40000.0];
let g = build("accentBorderCallout3", 480.0, 300.0, &adj).unwrap_or_else(|| panic!("accentBorderCallout3"));
assert_eq!(g.paths.len(), 3);
assert!(g.paths[0].stroke);
let accent = g.paths[1].path.bounding_box();
assert!((accent.x0 - 528.0).abs() < 1e-6 && accent.y0 == 0.0 && accent.y1 == 300.0, "{accent:?}");
on("accentBorderCallout3", 480.0, 300.0, &adj, &[(576.0, 60.0), (384.0, -60.0), (192.0, 90.0)]);
}
#[test]
fn arrow_callouts_match_reference_points() {
on("leftRightArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (75.0, 75.0), (124.5, 0.0), (355.5, 300.0), (480.0, 150.0)]);
on("leftRightArrowCallout", 200.0, 460.0, &[], &[(50.0, 180.0), (52.0, 0.0), (148.0, 460.0)]);
on("upDownArrowCallout", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 77.8), (480.0, 222.2), (240.0, 300.0)]);
on("quadArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (124.5, 77.8), (240.0, 0.0), (480.0, 150.0), (240.0, 300.0)]);
}
#[test]
fn curved_arrows_match_reference_points() {
on(
"curvedRightArrow",
480.0,
300.0,
&[],
&[(480.0, 0.0), (480.0, 75.0), (480.0, 225.0), (405.0, 149.0), (405.0, 299.0), (100.0, 151.5), (300.0, 6.7)],
);
on("curvedRightArrow", 200.0, 460.0, &[30000.0, 60000.0, 40000.0], &[(200.0, 400.0), (120.0, 324.5), (120.0, 443.5), (200.0, 60.0)]);
// The band never outgrows the head; the head never outgrows half the frame.
on("curvedRightArrow", 200.0, 460.0, &[60000.0, 30000.0, 80000.0], &[(200.0, 60.0)]);
on("curvedLeftArrow", 480.0, 300.0, &[], &[(0.0, 225.0), (0.0, 0.0)]);
on("curvedUpArrow", 480.0, 300.0, &[], &[(405.0, 0.0), (0.0, 0.0), (75.0, 0.0)]);
on("curvedDownArrow", 480.0, 300.0, &[], &[(405.0, 300.0), (0.0, 300.0)]);
let g = build("curvedRightArrow", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("curvedRightArrow"));
assert_eq!(g.paths[0].fill, FillMode::DarkenLess);
}
#[test]
fn swoosh_matches_reference_points() {
on("swooshArrow", 480.0, 300.0, &[], &[(0.0, 300.0), (425.8, 0.0), (480.0, 59.7), (442.8, 150.0), (202.0, 105.5), (202.0, 159.0)]);
on("swooshArrow", 480.0, 300.0, &[40000.0, 16667.0], &[(480.0, 82.2), (447.8, 195.0)]);
on("swooshArrow", 200.0, 460.0, &[], &[(163.9, 0.0), (200.0, 59.4)]);
}
#[test]
fn ribbons_match_reference_points() {
on(
"ellipseRibbon",
480.0,
300.0,
&[],
&[(0.0, 0.0), (120.0, 28.1), (60.0, 128.9), (240.0, 300.0), (120.0, 66.1), (180.0, 72.2), (180.0, 35.2)],
);
on("ellipseRibbon", 480.0, 300.0, &[30000.0, 60000.0, 20000.0], &[(120.0, 45.0), (240.0, 300.0), (96.0, 68.4)]);
on("ellipseRibbon", 480.0, 300.0, &[80000.0, 20000.0, 60000.0], &[(240.0, 240.0), (1.0, 61.75)]);
on("ellipseRibbon2", 480.0, 300.0, &[], &[(0.0, 300.0), (240.0, 0.0)]);
on(
"leftRightRibbon",
480.0,
300.0,
&[],
&[(0.0, 125.0), (150.0, 0.0), (150.0, 50.0), (255.0, 62.5), (225.0, 87.5), (480.0, 175.0), (330.0, 300.0)],
);
on("leftRightRibbon", 480.0, 300.0, &[90000.0, 10000.0, 60000.0], &[(0.0, 100.0), (480.0, 200.0), (240.0, 300.0)]);
}
#[test]
fn gears_and_funnel_match_reference_points() {
// Square gear6: body radius 105, top tooth tip 3.5 inside the frame, tip half-width ~16.3.
on("gear6", 300.0, 300.0, &[], &[(150.0, 3.5), (133.7, 3.5), (166.3, 3.5), (45.0, 150.0)]);
on("gear9", 300.0, 300.0, &[], &[(150.0, 1.0), (30.0, 150.0)]);
on("gear6", 480.0, 300.0, &[], &[(240.0, 3.5), (201.0, 3.5)]);
on("funnel", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 75.0), (240.0, 15.0), (15.0, 75.0), (240.0, 300.0), (60.5, 150.0)]);
let g = build("funnel", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("funnel"));
assert!(g.paths[0].even_odd);
}
#[test]
fn circular_arrows_match_reference_points() {
on("circularArrow", 300.0, 300.0, &[], &[(19.0, 150.0), (56.5, 150.0), (150.0, 19.0), (262.4, 150.0), (218.6, 113.4), (293.6, 113.4)]);
on(
"circularArrow",
300.0,
300.0,
&[20000.0, 1800000.0, 18000000.0, 5400000.0, 20000.0],
&[(150.0, 270.0), (150.0, 210.0), (247.0, 42.1), (143.0, 102.1), (227.9, 105.0)],
);
on("leftCircularArrow", 300.0, 300.0, &[], &[(37.6, 150.0), (262.4, 150.0), (150.0, 281.0)]);
on("leftRightCircularArrow", 300.0, 300.0, &[], &[(262.4, 150.0), (37.6, 150.0), (150.0, 19.0)]);
// The band never grows past twice the head's half-width.
on("circularArrow", 300.0, 300.0, &[25000.0, 1142319.0, 20457681.0, 10800000.0, 5000.0], &[(0.0, 150.0), (30.0, 150.0)]);
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
| Layer | Crates | What they do |
|---|---|---|
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~150 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~180 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
| L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer |
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
+1 -1
View File
@@ -63,7 +63,7 @@ the round trip even though DeckCraft doesn't model it.
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
| Notes slides and notes master | yes | yes | No notes-master editing view |
| Handout master | no | no | |
| Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` |
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`) |
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
+1 -1
View File
@@ -212,7 +212,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
header/footer fields, embedded media, unlimited undo on shared slide snapshots.
- **Themes:** 8 original themes with colour and font schemes, custom colours and fonts,
backgrounds (solid, gradient, picture, pattern), slide size presets.
- **Shapes:** ~150 preset geometries with adjustment handles, text boxes, pictures (crop,
- **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop,
corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line,
area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites,
freeform/curve/scribble, merge shapes, ink, action buttons, WordArt; flipped shapes keep their