Geom/Render: brackets and braces fill; arrowheads follow open paths, not brackets

- Left/right brackets and braces drew only their curve: a filled bracket
  showed no fill. PowerPoint fills them out to the straight side they open
  to (the fill path is the curve closed), outlining only the curve.
- Arrowheads were drawn on any shape with nothing filled (brackets among
  them) but not on open freeforms that had a fill. PowerPoint puts them on
  lines, arcs and open freeforms whether filled or not, never on brackets.

Wild synthetic set: preset galleries 9.44 -> 4.65, 9.55 -> 4.94,
9.60 -> 5.38; extreme-adjust galleries 10.7 -> 6.6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 05:24:15 -04:00
parent 47dfaaca61
commit 9a7e7e133e
3 changed files with 47 additions and 3 deletions

View File

@ -1124,7 +1124,10 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
if name == "rightBracket" {
path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]));
}
Geometry { paths: vec![stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
// Filled (when the shape has a fill) out to the straight side, as PowerPoint does.
let mut area = path.clone();
area.close_path();
Geometry { paths: vec![fill_only(area), stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
}
"leftBrace" | "rightBrace" => {
let r = (ss * a(0).clamp(0.0, 25000.0 * h / ss.max(1e-9)) / 1e5).min(h / 4.0);
@ -1136,7 +1139,10 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
if name == "rightBrace" {
path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]));
}
Geometry { paths: vec![stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
// Filled (when the shape has a fill) out to the straight side, as PowerPoint does.
let mut area = path.clone();
area.close_path();
Geometry { paths: vec![fill_only(area), stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
}
"rightArrow" => arrow(Dir::Right, w, h, a(0), a(1)),
"leftArrow" => arrow(Dir::Left, w, h, a(0), a(1)),
@ -1968,4 +1974,19 @@ mod tests {
assert!(build("line", 10.0, 10.0, &[]).unwrap().is_open());
assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open());
}
#[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
// straight side it opens to; only the curve is outlined.
for name in ["leftBracket", "rightBracket", "leftBrace", "rightBrace"] {
let g = build(name, 40.0, 100.0, &[]).unwrap();
let filled: Vec<_> = g.paths.iter().filter(|p| p.fill != FillMode::None).collect();
assert_eq!(filled.len(), 1, "{name}");
assert!(!filled[0].stroke, "{name}: the filled area is not outlined");
let area = filled[0].path.area().abs();
assert!(area > 40.0 * 100.0 * 0.4 && area < 40.0 * 100.0, "{name}: {area}");
assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined");
}
}
}

View File

@ -974,7 +974,12 @@ impl Renderer {
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
let lw = line.width.unwrap_or(0.75).max(0.0);
let (base, rel) = self.line_space(m, depth);
let ends = s.is_line() || geo.is_open();
// Arrowheads go on lines, arcs and open freeforms (filled or not), never on closed
// outlines or on presets like brackets.
let ends = s.is_line()
|| geo.is_open()
|| (matches!(s.geom, Geom::Custom { .. })
&& geo.paths.iter().find(|p| p.stroke).is_some_and(|p| !p.path.elements().iter().any(|e| matches!(e, PathEl::ClosePath))));
let trims = if ends { head_trims(&line, lw) } else { (0.0, 0.0) };
let has_heads = ends && [&line.head, &line.tail].iter().any(|e| e.as_ref().is_some_and(|e| e.kind != "none"));
// A see-through line and its arrowheads are one shape in PowerPoint: drawn opaque into

View File

@ -313,3 +313,21 @@ fn children_filled_with_the_group_fill_show_one_fill_across_the_group() {
assert!(r[2] > 200 && r[0] < 60, "right end {r:?}");
assert!(close(img.pixel(200, 150), WHITE, 5), "the gap between the children stays empty");
}
#[test]
fn open_freeforms_take_arrowheads_even_when_filled() {
// An open custom path with a fill and a tail arrow: PowerPoint draws the arrow.
let end = deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "med".into(), len: "med".into() };
let sh = Shape {
id: ShapeId(19),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
geom: Geom::Custom { paths: vec![CustomPath { w: 100.0, h: 100.0, d: "M 0 100 L 50 0 L 100 100".into(), ..Default::default() }] },
fill: solid(Rgba::rgb(0, 200, 0)),
line: Some(Line { fill: solid(RED), width: Some(4.0), tail: Some(end), ..Default::default() }),
..Default::default()
};
let img = render(vec![sh]);
// The line ends at (300, 200) heading down-right at 45°; the 12 pt head's base corner reaches
// (295.7, 187.3), 3.5 pt off the line's axis: outside the 4 pt line and outside the fill.
assert!(close(img.pixel(294, 189), RED, 60), "arrowhead drawn {:?}", img.pixel(294, 189));
}