mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
4283820a30
commit
014ee0382f
5 files changed
+66
-16
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user