From 9a7e7e133e6cd8a95823250587fdde91c94ea43d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:24:15 -0400 Subject: [PATCH] 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) --- crates/geom/src/preset.rs | 25 +++++++++++++++++++++++-- crates/render/src/lib.rs | 7 ++++++- crates/render/src/tests_wild.rs | 18 ++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 9789f3c..4fa5172 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -1124,7 +1124,10 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { 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 { 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"); + } + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3f2dd43..495bd75 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index a43a571..85ed586 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -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)); +}