M3.20: visual bounds cover arrowheads so overhanging heads are not culled; one arrowhead-weight helper

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-04 17:40:41 +07:00
1 parent 4283820a30
commit 014ee0382f
5 files changed
+66 -16

No files matched your search

+36 -5
View File
@@ -258,6 +258,18 @@ impl StrokeLayer {
arrow_align: ArrowAlign::Extend,
}
}
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
/// a quarter point. A head of weight `hw` is `4·hw` long and wide.
pub fn arrow_weight(&self, end: bool) -> f64 {
let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 };
(self.width * pct / 100.0).max(0.25)
}
/// How far the arrowheads can reach from the path's end points (0 without heads): the
/// head's diagonal, or with [`ArrowAlign::Extend`] its length plus the cap past the end.
pub fn arrow_reach(&self) -> f64 {
let reach = |head: Option<Arrowhead>, end: bool| head.map_or(0.0, |_| 4.5 * self.arrow_weight(end) + self.width / 2.0);
reach(self.start_arrow, false).max(reach(self.end_arrow, true))
}
}
/// One entry of the appearance stack.
@@ -339,11 +351,14 @@ impl Appearance {
self.items
.iter()
.filter_map(|i| match i {
AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(match s.align {
StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 },
StrokeAlign::Outside => s.width,
StrokeAlign::Inside => 0.0,
}),
AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(
match s.align {
StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 },
StrokeAlign::Outside => s.width,
StrokeAlign::Inside => 0.0,
}
.max(s.arrow_reach()),
),
_ => None,
})
.fold(0.0, f64::max)
@@ -418,6 +433,22 @@ mod tests {
assert_eq!(back, st);
}
#[test]
fn outset_covers_arrowheads() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
a.stroke_mut().unwrap().join = LineJoin::Round;
assert_eq!(a.outset(), 2.0);
let st = a.stroke_mut().unwrap();
st.end_arrow = Some(Arrowhead::Triangle);
st.arrow_scale = (100.0, 200.0);
assert_eq!(st.arrow_weight(false), 4.0);
assert_eq!(st.arrow_weight(true), 8.0);
// A 32 pt head whose tip sits up to its length (plus the cap) past the end point.
assert_eq!(a.outset(), 4.5 * 8.0 + 2.0);
a.stroke_mut().unwrap().width = 0.01;
assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size");
}
#[test]
fn outset_depends_on_align() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
+7 -7
View File
@@ -34,9 +34,9 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a>
let first = subs.first().filter(|s| !s.1).map(|s| s.0.clone());
let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone());
let cap = cap_extent(st.cap, st.width);
let weight = |pct: f64| (st.width * pct / 100.0).max(0.25);
let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, weight(st.arrow_scale.0), cap)));
let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, weight(st.arrow_scale.1), cap)));
let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true));
let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap)));
let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap)));
if start.is_none() && end.is_none() {
return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] };
}
@@ -44,7 +44,7 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a>
let mut heads = vec![];
// Trims per subpath index (start, end), applied in tip mode only.
let mut trims: Vec<(f64, f64)> = vec![(0.0, 0.0); subs.len()];
let mut place = |kind: Arrowhead, range: std::ops::Range<usize>, at_start: bool, inset: f64, pct: f64| {
let mut place = |kind: Arrowhead, range: std::ops::Range<usize>, at_start: bool, inset: f64, hw: f64| {
let segs = segments(&els[range]);
let (end_pt, outward) = end_of(&segs, at_start);
let (tip, dir) = if tip_mode {
@@ -54,16 +54,16 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a>
} else {
(end_pt + outward * inset, outward)
};
heads.push(Arrow { kind, outline: shape(kind, tip, dir, weight(pct)), tip, dir, inset });
heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset });
};
if let Some((k, r, i)) = start {
place(k, r, true, i, st.arrow_scale.0);
place(k, r, true, i, hw0);
if tip_mode {
trims[0].0 = i;
}
}
if let Some((k, r, i)) = end {
place(k, r, false, i, st.arrow_scale.1);
place(k, r, false, i, hw1);
if tip_mode {
trims[subs.len() - 1].1 = i;
}
+7
View File
@@ -49,6 +49,13 @@ fn profiles_come_from_the_preset_catalogue() {
assert!(spec.params.contains(&format!("\"{}\"", p.id)), "{}", p.id);
}
assert!(spec.params.contains("arrowAlign"));
// …and every arrowhead name `startArrow`/`endArrow` accept.
for a in vectorcraft_doc::Arrowhead::ALL {
let name = serde_json::to_value(a).unwrap();
assert!(spec.params.contains(name.as_str().unwrap()), "{name}");
s.execute("stroke.set", &json!({"endArrow": name})).unwrap();
assert_eq!(stroke(&s, id).end_arrow, Some(a));
}
}
#[test]
+12
View File
@@ -170,3 +170,15 @@ fn a_gradient_runs_on_into_the_head() {
let start = img.over_white((22.0 * S) as u32, (50.0 * S) as u32);
assert!(b[2] > start[2] + 100, "{b:?} vs {start:?}");
}
#[test]
fn a_head_past_the_end_counts_in_the_visual_bounds_and_is_never_culled() {
let d = arrow_doc(Arrowhead::Triangle, ArrowAlign::Extend, 1.0, |s| s.join = vectorcraft_doc::LineJoin::Round);
let h = head_of(&d);
let vb = d.layers[0].children().unwrap()[0].visual_bounds().unwrap();
let head = h.outline.bounding_box();
assert_eq!(vb.union(head), vb, "{vb:?} covers the head {head:?}");
// A view that shows only the overhanging part of the head (the line ends at 80).
let img = render_region(&d, Rect::new(83.0, 40.0, 100.0, 60.0), S);
assert!(img.ink() > 0, "the head is drawn");
}
+4 -4
View File
@@ -204,11 +204,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
let scale = st.as_ref().map(|s| s.arrow_scale).unwrap_or((100.0, 100.0));
let linked: bool = pstate(ui.ctx(), "arrow-scale-link");
// Scale and Align only mean something for a stroke with a head.
let (arrow_align, has_head) = arrow_align_state(st.as_ref());
ui.horizontal(|ui| {
row_label(ui, "Scale:");
let on = st.is_some() && (sa.is_some() || ea.is_some());
let mut ns = None;
ui.add_enabled_ui(on, |ui| {
ui.add_enabled_ui(has_head, |ui| {
if let Some(v) = widgets::plain_field(ui, "arrow-scale-s", scale.0, "%", 0, 56.0) {
ns = Some((v, if linked { v } else { scale.1 }));
}
@@ -223,11 +224,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("stroke.setAdvanced", json!({"arrowScale": [a, b]})).ok();
}
});
let (arrow_align, can_align) = arrow_align_state(st.as_ref());
ui.horizontal(|ui| {
row_label(ui, "Align:");
for (v, name, icon, tip) in ARROW_ALIGN {
if widgets::icon_button_enabled(ui, icon, tip, arrow_align == v, can_align, 22.0).clicked() {
if widgets::icon_button_enabled(ui, icon, tip, arrow_align == v, has_head, 22.0).clicked() {
set(app, json!({"arrowAlign": name}));
}
}