diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 1bff9875..e59d8f29 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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, 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); diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index 59ccd7da..080ddeb3 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -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, at_start: bool, inset: f64, pct: f64| { + let mut place = |kind: Arrowhead, range: std::ops::Range, 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; } diff --git a/crates/engine/src/tests_strokegeom.rs b/crates/engine/src/tests_strokegeom.rs index 42867cc7..ee43644c 100644 --- a/crates/engine/src/tests_strokegeom.rs +++ b/crates/engine/src/tests_strokegeom.rs @@ -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] diff --git a/crates/render/tests/stroke_geometry.rs b/crates/render/tests/stroke_geometry.rs index 886078e7..80f326e1 100644 --- a/crates/render/tests/stroke_geometry.rs +++ b/crates/render/tests/stroke_geometry.rs @@ -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"); +} diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 4f6e0be2..e9a466d0 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -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})); } }