mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 10:01:58 +00:00
The Stroke panel menu's Edit Arrowheads… (stroke.arrowheads.edit) opens
Arrowheads.vectorcraft next to the preferences, creating it with one
example head when it is missing. Each symbol in the file is one head,
listed by name after the built-in heads in both arrowhead menus: it
points right, its registration point is the tip, and its height is drawn
as wide as the built-in heads. The file is read at startup and again
each time VectorCraft saves it; stroke.arrowheads.reload and
stroke.arrowheads.list read and list it.
Arrowhead gets a Custom variant, appended after the built-in names, that
carries the head's name and outline, so a document that uses one draws,
saves, reopens and exports it without the arrowheads file. stroke.set
takes {"custom": name} or the {"Custom": {name, path}} form that
document.inspect reports.
Part of #5421.
95 lines
4.0 KiB
Rust
95 lines
4.0 KiB
Rust
//! Stroke geometry in PDF export: arrowheads and the trimmed line are written as the shared
|
|
//! `vectorcraft_effects::stroke` outlines, and they take the stroke opacity once.
|
|
|
|
use vectorcraft_color::{Color, Paint};
|
|
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer};
|
|
use vectorcraft_effects::stroke::{OUTLINE_TOL, line_outline, stroke_pieces};
|
|
use vectorcraft_geom::{Point, Rect, Shape, shapes};
|
|
|
|
use crate::*;
|
|
|
|
pub(crate) fn line_doc(st: StrokeLayer) -> (Document, Node) {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let mut n = Node::path(
|
|
NodeId(0),
|
|
shapes::line(Point::new(20.0, 50.0), Point::new(80.0, 50.0)),
|
|
Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() },
|
|
);
|
|
n.id = d.alloc_id();
|
|
let layer = d.default_layer().unwrap();
|
|
d.insert(Some(layer), 0, n.clone()).unwrap();
|
|
(d, n)
|
|
}
|
|
|
|
fn arrow_stroke(kind: Arrowhead, align: ArrowAlign, opacity: f32) -> StrokeLayer {
|
|
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
|
|
st.end_arrow = Some(kind);
|
|
st.arrow_align = align;
|
|
st.opacity = opacity;
|
|
st
|
|
}
|
|
|
|
fn close(a: Rect, b: Rect) -> bool {
|
|
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 0.01)
|
|
}
|
|
|
|
/// Bounding boxes of the filled and the stroked paths of a PDF read back.
|
|
pub(crate) fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) {
|
|
let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap();
|
|
let (mut fills, mut strokes) = (vec![], vec![]);
|
|
back.walk(|m| {
|
|
let Some(bb) = m.geometric_bounds().filter(|_| matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })) else { return };
|
|
match m.appearance.items.first() {
|
|
Some(AppearanceItem::Fill(_)) => fills.push(bb),
|
|
Some(AppearanceItem::Stroke(_)) => strokes.push(bb),
|
|
None => {}
|
|
}
|
|
});
|
|
(fills, strokes)
|
|
}
|
|
|
|
#[test]
|
|
fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() {
|
|
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
|
|
for kind in Arrowhead::ALL {
|
|
let (d, n) = line_doc(arrow_stroke(kind.clone(), align, 1.0));
|
|
let bp = n.path_data().unwrap().to_bezpath();
|
|
let st = n.appearance.stroke().unwrap();
|
|
let pieces = stroke_pieces(&bp, st);
|
|
let (fills, strokes) = painted_boxes(&d);
|
|
let head = pieces.heads[0].outline.bounding_box();
|
|
let line = line_outline(&pieces.line, st, st.width, OUTLINE_TOL).bounding_box();
|
|
assert!(strokes.is_empty(), "{kind:?} {align:?}: the line is written as its outline");
|
|
assert_eq!(fills.len(), 2, "{kind:?} {align:?}: the line, then the head");
|
|
assert!(close(fills[0], line), "{kind:?} {align:?}: {:?} vs {line:?}", fills[0]);
|
|
assert!(close(fills[1], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[1]);
|
|
if align == ArrowAlign::Tip {
|
|
assert!((fills[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn line_and_head_share_one_opacity_group() {
|
|
let (d, _) = line_doc(arrow_stroke(Arrowhead::Triangle, ArrowAlign::Tip, 0.5));
|
|
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
|
let mut group_opacity = None;
|
|
let mut leaf_opacities = vec![];
|
|
back.walk(|m| match &m.kind {
|
|
NodeKind::Group { .. } if m.opacity < 1.0 => group_opacity = Some(m.opacity),
|
|
NodeKind::Path { .. } => {
|
|
leaf_opacities.push(m.opacity);
|
|
for i in &m.appearance.items {
|
|
leaf_opacities.push(match i {
|
|
AppearanceItem::Fill(f) => f.opacity,
|
|
AppearanceItem::Stroke(s) => s.opacity,
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
});
|
|
assert!(group_opacity.is_some_and(|o| (o - 0.5).abs() < 0.01), "{group_opacity:?}");
|
|
assert!(leaf_opacities.iter().all(|o| (o - 1.0).abs() < 1e-3), "{leaf_opacities:?}");
|
|
}
|