M3.37: Outline Stroke and the live Outline Stroke effect outline strokes as the canvas paints them

Outline Stroke (and the stroke part of Expand) now builds each stroke from the
shared canvas geometry: dash shapes, arrowheads, width profiles and inside/outside
alignment, united into one clean path (pathops::stroke_region). Every stroke item
is outlined in paint order (runs of fills stay on copies of the path), stroke
opacity, blend mode and effects move to the new fill, and brushed strokes become
their brush art. The live Effect > Path > Outline Stroke outlines the stroke it
sits on (on a fill or the object: the top stroke) with that stroke's cap, join,
dashes, alignment, profile, arrowheads and weight: GeomContext now carries the
stroke and the fill rule, built per item by the renderer, export baking and
Expand Appearance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 22:53:58 +07:00
parent 6bd27a05da
commit 18d870c7df
12 changed files with 403 additions and 99 deletions

View File

@ -101,8 +101,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
let (base, rule) = geometry(n)?;
let w = n.appearance.stroke_width();
let ctx = GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
let ctx = GeomContext::of(n);
let g = apply(&n.appearance.effects, &base, &ctx);
let mut m = n.clone();
m.appearance.effects.retain(|e| !is_geometry(&e.id));
@ -117,7 +116,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
.items
.iter()
.map(|item| {
let ig = apply(item_effects(item), &g, &ctx);
let ig = apply(item_effects(item), &g, &ctx.item(item));
let mut it = item.clone();
clear_item_effects(&mut it);
let id = d.alloc_id();

View File

@ -33,8 +33,8 @@ mod warp;
mod tests;
use serde_json::{Map, Value, json};
use vectorcraft_doc::Effect;
use vectorcraft_geom::{BezPath, PathData, Rect};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use bake::{bake_document, needs_bake};
pub use clip::clip_outline;
@ -158,7 +158,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
),
g("path.outlineStroke", "Outline Stroke", PATH, "{width?: pt (defaults to the object's stroke weight)}", json!({})),
g(
"path.outlineStroke",
"Outline Stroke",
PATH,
"{width?: pt (defaults to the stroke's weight)} the outline of the stroke the effect is on (on a fill or the object: the top stroke), with its caps, joins, dashes, alignment, width profile and arrowheads",
json!({}),
),
g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})),
g(
"stylize.scribble",
@ -269,15 +275,33 @@ pub fn has_geometry(effects: &[Effect]) -> bool {
}
/// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GeomContext {
/// Stroke weight used by `path.outlineStroke` when the effect has no `width`.
pub stroke_width: f64,
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct GeomContext<'a> {
/// The stroke `path.outlineStroke` outlines (its weight, caps, joins, dashes, alignment,
/// profile and arrowheads): the stroke the effect sits on, else the object's top painted
/// stroke. `None` outlines a plain 1 pt stroke.
pub stroke: Option<&'a StrokeLayer>,
/// The object's fill rule (inside and outside alignment).
pub rule: FillRule,
}
impl Default for GeomContext {
fn default() -> Self {
Self { stroke_width: 1.0 }
impl<'a> GeomContext<'a> {
/// The context of `n`'s object-level effects.
pub fn of(n: &'a Node) -> Self {
let rule = match &n.kind {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
_ => FillRule::NonZero,
};
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
/// stroke's own effects outline that stroke.
pub fn item(self, item: &'a AppearanceItem) -> Self {
match item {
AppearanceItem::Stroke(s) => Self { stroke: Some(s), ..self },
AppearanceItem::Fill(_) => self,
}
}
}
@ -330,11 +354,22 @@ fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -
vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
}
"path.outlineStroke" => {
let w = p.get("width").and_then(Value::as_f64).unwrap_or(ctx.stroke_width);
if w <= 0.0 {
let fallback;
let base = match ctx.stroke {
Some(s) => s,
None => {
fallback = StrokeLayer::new(vectorcraft_doc::color::Paint::None, 1.0);
&fallback
}
};
let st = match p.get("width").and_then(Value::as_f64) {
Some(w) => std::borrow::Cow::Owned(StrokeLayer { width: w, ..base.clone() }),
None => std::borrow::Cow::Borrowed(base),
};
if st.width.is_nan() || st.width <= 0.0 {
return path.clone();
}
vectorcraft_pathops::outline_stroke(path, w, vectorcraft_pathops::Cap::Butt, join(p, "joins"), num(p, "miterLimit", 10.0).max(1.0))
stroke::outline_region(path, ctx.rule, &st)
}
"convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p),
"stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)),

View File

@ -7,10 +7,12 @@
//! turns into discs or squares ([`dot_outline`]);
//! - [`width_outline`]: variable-width (profile) strokes;
//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots,
//! caps and joins).
//! caps and joins);
//! - [`outline_region`]: a stroke as one clean filled path (Outline Stroke).
mod arrow;
mod dash;
mod outline;
mod width;
use std::borrow::Cow;
@ -20,6 +22,7 @@ use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
pub use arrow::Arrow;
pub use dash::{Dashed, Dot, dash, dot_outline};
pub use outline::{OUTLINE_TOL, outline_region};
pub use width::width_outline;
/// Arc-length accuracy for dashing and trimming (document points).

View File

@ -0,0 +1,33 @@
//! Strokes as filled geometry outside the canvas:
//!
//! - [`outline_region`]: the area a stroke paints as one clean path (Outline Stroke and the live
//! Outline Stroke effect).
use vectorcraft_doc::{StrokeAlign, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_pathops::BoolOp;
use super::{aligned_width, is_closed, line_outline, stroke_pieces};
/// Flattening/fitting tolerance of outlined strokes (document points).
pub const OUTLINE_TOL: f64 = 0.01;
/// The area stroke `st` paints along `path` (fill rule `rule`) as one clean path, as on the
/// canvas: the line (width profile, dashes, caps, joins) united with the arrowheads, and kept
/// inside or outside a closed path for inside or outside alignment. Brushes are not drawn.
pub fn outline_region(path: &PathData, rule: FillRule, st: &StrokeLayer) -> PathData {
if !(st.width > 0.0 && st.width.is_finite()) || path.is_empty() {
return PathData::default();
}
let bp = path.to_bezpath();
let closed = is_closed(&bp);
let pieces = stroke_pieces(&bp, st);
let mut parts = vec![line_outline(&pieces.line, st, aligned_width(st, closed), OUTLINE_TOL)];
parts.extend(pieces.heads.into_iter().map(|h| h.outline));
let region = vectorcraft_pathops::stroke_region(&parts);
match st.align {
StrokeAlign::Inside if closed => vectorcraft_pathops::boolean(&region, FillRule::NonZero, path, rule, BoolOp::Intersect),
StrokeAlign::Outside if closed => vectorcraft_pathops::boolean(&region, FillRule::NonZero, path, rule, BoolOp::Difference),
_ => region,
}
}

View File

@ -212,11 +212,17 @@ fn offset_path_grows_and_shrinks() {
}
#[test]
fn outline_stroke_uses_context_width() {
let ctx = GeomContext { stroke_width: 10.0 };
fn outline_stroke_uses_context_stroke() {
let st = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 10.0);
let ctx = GeomContext { stroke: Some(&st), ..Default::default() };
let out = apply_geometry_with(&[fx("path.outlineStroke", json!({}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx);
let b = out.bounds().unwrap();
assert!(close(b.width(), 110.0, 0.1), "{b:?}");
// An explicit width overrides the weight; without a stroke a plain 1 pt one is outlined.
let out = apply_geometry_with(&[fx("path.outlineStroke", json!({"width": 4}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx);
assert!(close(out.bounds().unwrap().width(), 104.0, 0.1));
let out = run("path.outlineStroke", json!({}), &square());
assert!(close(out.bounds().unwrap().width(), 101.0, 0.1));
}
#[test]

View File

@ -249,9 +249,7 @@ fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec<Node
}
let Some((path, rule)) = node_geometry(&n) else { return };
let Some(bounds) = path.bounds() else { return };
let w = n.appearance.stroke_width();
let ctx = effects::GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &ctx);
let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &effects::GeomContext::of(&n));
let kind = if matches!(n.kind, NodeKind::Compound { .. }) || baked.subpaths.len() > 1 {
let children = baked
.subpaths

View File

@ -6,7 +6,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, LineCap, LineJoin, StrokeAlign, StrokeLayer};
use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_pathops as po;
@ -450,41 +450,107 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- Outline Stroke ----------
fn cap_of(c: LineCap) -> po::Cap {
match c {
LineCap::Butt => po::Cap::Butt,
LineCap::Round => po::Cap::Round,
LineCap::Square => po::Cap::Square,
}
}
fn join_of(j: LineJoin) -> po::Join {
match j {
LineJoin::Miter => po::Join::Miter,
LineJoin::Round => po::Join::Round,
LineJoin::Bevel => po::Join::Bevel,
}
/// Can Outline Stroke turn `st` into filled art?
fn outlinable(st: &StrokeLayer) -> bool {
st.visible && !st.paint.is_none() && st.width > 0.0
}
/// The filled outline of `st` stroking `path` (honours inside/outside alignment on closed paths).
pub(crate) fn stroke_outline(path: &PathData, rule: FillRule, st: &StrokeLayer) -> PathData {
let (cap, join) = (cap_of(st.cap), join_of(st.join));
let w = st.width;
if !w.is_finite() || w <= 0.0 {
return PathData::default();
/// `n` with `opacity` and `blend` applied: set on it when it has none of its own, else on a
/// group around it.
fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMode) -> Node {
if opacity >= 1.0 && blend == BlendMode::Normal {
return n;
}
match st.align {
StrokeAlign::Inside | StrokeAlign::Outside if path.is_closed() => {
let ring = po::outline_stroke(path, 2.0 * w, cap, join, st.miter_limit);
let op = if st.align == StrokeAlign::Inside { BoolOp::Intersect } else { BoolOp::Difference };
po::boolean(&ring, FillRule::NonZero, path, rule, op)
if n.opacity < 1.0 || n.blend != BlendMode::Normal {
n = Node::group(d.alloc_id(), vec![Arc::new(n)]);
}
n.opacity = opacity;
n.blend = blend;
n
}
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the
/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode
/// and effects. `None` when it paints nothing.
fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path: &PathData, rule: FillRule, st: &StrokeLayer) -> Option<Node> {
if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st);
let art = match pieces.len() {
0 => return None,
1 => pieces.remove(0),
_ => Node::group(NodeId(0), pieces.into_iter().map(Arc::new).collect()),
};
let art = d.reid(&art);
return Some(with_transparency(d, art, st.opacity, st.blend));
}
let outline = vectorcraft_render::effects::stroke::outline_region(path, rule, st);
if outline.is_empty() {
return None;
}
let mut n = shape_node(d, outline, None);
let fill = vectorcraft_doc::appearance::FillLayer {
opacity: st.opacity,
blend: st.blend,
effects: st.effects.clone(),
..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone())
};
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], effects: vec![] };
Some(n)
}
/// Path `l` with every stroke outlined, in appearance order: runs of fills stay on copies of
/// the path, each stroke becomes its outline (or brush art), grouped when there are several.
/// `None` when `l` has no stroke to outline.
fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
return None;
}
let (path, rule) = node_path(l)?;
let mut parts: Vec<Node> = vec![];
let mut fills: Vec<AppearanceItem> = vec![];
let fill_node = |d: &mut Document, items: Vec<AppearanceItem>| {
let mut f = d.reid(l);
f.appearance = Appearance { items, effects: vec![] };
f.opacity = 1.0;
f.blend = BlendMode::Normal;
f.name = None;
f.mask = None;
if let NodeKind::Path { live, .. } = &mut f.kind {
*live = None;
}
f
};
for item in &l.appearance.items {
match item {
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()),
AppearanceItem::Stroke(st) if outlinable(st) => {
if !fills.is_empty() {
parts.push(fill_node(d, std::mem::take(&mut fills)));
}
parts.extend(outlined_stroke(d, brushes, &path, rule, st));
}
_ => {}
}
_ => po::outline_stroke(path, w, cap, join, st.miter_limit),
}
if !fills.is_empty() {
parts.push(fill_node(d, fills));
}
let root = match parts.len() {
0 => return None,
1 => parts.remove(0),
_ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()),
};
let mut root = with_transparency(d, root, l.opacity, l.blend);
root.name = l.name.clone();
root.mask = l.mask.clone();
root.appearance.effects = l.appearance.effects.clone();
Some(root)
}
fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let ids = s.edit("Outline Stroke", |d, sel| {
let brushes = vectorcraft_brush::library(d);
let mut new_sel = vec![];
let mut changed = 0;
for root in &roots {
@ -496,43 +562,7 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
if matches!(l.kind, NodeKind::Text(_)) {
continue;
}
let Some(st) = l.appearance.stroke().filter(|s| s.visible && !s.paint.is_none() && s.width > 0.0).cloned() else { continue };
let Some((path, rule)) = node_path(&l) else { continue };
let outline = stroke_outline(&path, rule, &st);
if outline.is_empty() {
continue;
}
let mut stroke_node = shape_node(d, outline, Some(&l));
stroke_node.appearance =
Appearance { items: vec![AppearanceItem::Fill(vectorcraft_doc::appearance::FillLayer::new(st.paint.clone()))], effects: vec![] };
let fill = l.appearance.fill().filter(|f| f.visible && !f.paint.is_none()).cloned();
let new = if let Some(f) = fill {
let mut fill_node = l.clone();
fill_node.id = d.alloc_id();
if let Some(ch) = fill_node.children_mut() {
for c in ch.iter_mut() {
Arc::make_mut(c).id = d.alloc_id();
}
}
fill_node.appearance = Appearance { items: vec![AppearanceItem::Fill(f)], effects: vec![] };
if let NodeKind::Path { live, .. } = &mut fill_node.kind {
*live = None;
}
let gid = d.alloc_id();
let mut g = Node::group(gid, vec![Arc::new(fill_node), Arc::new(stroke_node)]);
g.opacity = l.opacity;
g.blend = l.blend;
if let Some(ch) = g.children_mut() {
for c in ch.iter_mut() {
let c = Arc::make_mut(c);
c.opacity = 1.0;
c.blend = BlendMode::Normal;
}
}
g
} else {
stroke_node
};
let Some(new) = outline_strokes(d, &brushes, &l) else { continue };
let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?;
let nid = d.insert(par, idx, new)?;
d.remove(l.id)?;

View File

@ -760,6 +760,8 @@ mod tests_menucmds;
#[cfg(test)]
mod tests_opacitymask;
#[cfg(test)]
mod tests_outlinestroke;
#[cfg(test)]
mod tests_paintproxy;
#[cfg(test)]
mod tests_panelcmds;

View File

@ -0,0 +1,164 @@
//! Outline Stroke (`object.path.outlineStroke`) and the live Outline Stroke effect produce the
//! stroke as the canvas paints it: dashes, arrowheads, width profiles, alignment, every stroke
//! item in order, with the stroke's opacity and blend mode moved to the new fill.
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Rect};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// A horizontal 100 pt line from (0, 50), black, no fill, with stroke options `opts`.
fn line(s: &mut Session, opts: Value) -> NodeId {
let r = s.execute("shape.line", &json!({"x1": 0, "y1": 50, "x2": 100, "y2": 50})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("stroke.set", &opts).unwrap();
id
}
fn outline(s: &mut Session) -> Node {
let r = s.execute("object.path.outlineStroke", &json!({})).unwrap();
let id = NodeId(r["ids"][0].as_u64().unwrap());
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
fn path_of(n: &Node) -> PathData {
match &n.kind {
NodeKind::Path { path, .. } => path.clone(),
NodeKind::Compound { children, .. } => PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()),
_ => panic!("not a path: {:?}", n.kind),
}
}
fn area(n: &Node) -> f64 {
vectorcraft_pathops::area(&path_of(n), FillRule::NonZero)
}
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn close(a: Rect, b: Rect, tol: f64) -> bool {
near(a.x0, b.x0, tol) && near(a.y0, b.y0, tol) && near(a.x1, b.x1, tol) && near(a.y1, b.y1, tol)
}
#[test]
fn a_dashed_line_gives_one_subpath_per_dash() {
let mut s = session();
line(&mut s, json!({"weight": 4, "dash": [10, 10]}));
let n = outline(&mut s);
assert_eq!(path_of(&n).subpaths.len(), 5, "dashes at 0, 20, 40, 60 and 80");
assert!(near(area(&n), 5.0 * 10.0 * 4.0, 0.5), "{}", area(&n));
assert!(close(n.geometric_bounds().unwrap(), Rect::new(0.0, 48.0, 90.0, 52.0), 0.01));
}
#[test]
fn arrowheads_are_part_of_the_outline() {
let mut s = session();
line(&mut s, json!({"weight": 2, "endArrow": "Triangle"}));
let n = outline(&mut s);
// The 2 pt line plus an 8 × 8 triangle whose tip sits past the end point.
let b = n.geometric_bounds().unwrap();
assert!(b.x1 > 103.0 && near(b.height(), 8.0, 0.05), "{b:?}");
assert!(area(&n) > 200.0 + 20.0, "{}", area(&n));
assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape");
}
#[test]
fn a_lens_profile_outlines_to_half_the_rectangle() {
let mut s = session();
line(&mut s, json!({"weight": 10, "profile": "lens"}));
let n = outline(&mut s);
assert!(near(area(&n), 0.5 * 10.0 * 100.0, 2.0), "{}", area(&n));
}
#[test]
fn inside_alignment_keeps_the_outline_inside_the_path() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("stroke.set", &json!({"weight": 4, "align": "inside"})).unwrap();
let g = outline(&mut s);
let ring = &g.children().unwrap()[1];
assert!(close(ring.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 0.01));
assert!(near(area(ring), 100.0 * 100.0 - 92.0 * 92.0, 1.0), "{}", area(ring));
}
#[test]
fn stroke_opacity_and_blend_move_to_the_new_fill() {
let mut s = session();
line(&mut s, json!({"weight": 4}));
s.execute("appearance.setItem", &json!({"index": 1, "opacity": 50, "blend": "multiply"})).unwrap();
let n = outline(&mut s);
let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() else { panic!("{:?}", n.appearance) };
assert!(near(f.opacity as f64, 0.5, 1e-6) && f.blend == BlendMode::Multiply, "{f:?}");
assert!(n.opacity == 1.0 && n.blend == BlendMode::Normal);
}
#[test]
fn every_stroke_is_outlined_in_paint_order() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("appearance.addStroke", &json!({})).unwrap();
// Items: fill, 1 pt stroke, then the new top stroke at 10 pt red.
s.execute("appearance.setItem", &json!({"index": 2, "weight": 10, "color": "#ff0000"})).unwrap();
let g = outline(&mut s);
let ch = g.children().unwrap();
assert_eq!(ch.len(), 3, "the fill, then both outlines");
assert!(ch[0].appearance.items.iter().all(AppearanceItem::is_fill));
assert!(close(ch[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 1e-9));
assert!(close(ch[1].geometric_bounds().unwrap(), Rect::new(-0.5, -0.5, 100.5, 100.5), 0.01));
assert!(close(ch[2].geometric_bounds().unwrap(), Rect::new(-5.0, -5.0, 105.0, 105.0), 0.01));
assert_eq!(ch[2].appearance.fill().unwrap().paint.color().unwrap().to_hex(), "#ff0000");
// One undo step restores the stroked rectangle.
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.layers[0].children().unwrap()[0].appearance.items.len(), 3);
}
/// The live effect on a stroke item outlines that stroke (its own weight), not the top stroke.
#[test]
fn the_live_effect_outlines_the_stroke_it_sits_on() {
let mut s = session();
let id = line(&mut s, json!({"weight": 20}));
// Items: the line's empty fill, its 20 pt stroke, then a new 2 pt top stroke.
s.execute("appearance.addStroke", &json!({})).unwrap();
s.execute("appearance.setItem", &json!({"index": 2, "weight": 2})).unwrap();
// The lower (20 pt) stroke gets the effect.
s.execute("effect.apply", &json!({"effect": "path.outlineStroke", "item": 1})).unwrap();
let doc = &s.doc().unwrap().doc;
let n = doc.node(id).unwrap();
assert!(matches!(&n.appearance.items[1], AppearanceItem::Stroke(st) if st.width == 20.0 && st.effects.len() == 1), "{:?}", n.appearance.items);
let baked = vectorcraft_render::effects::bake_document(doc).unwrap();
let parts = baked.node(id).unwrap().children().unwrap().to_vec();
assert!(near(parts[1].geometric_bounds().unwrap().height(), 20.0, 0.01), "{:?}", parts[1].geometric_bounds());
assert!(near(parts[2].geometric_bounds().unwrap().height(), 0.0, 1e-9), "the top stroke keeps the line");
// On the object, the effect outlines the top stroke.
s.execute("effect.apply", &json!({"effect": "path.outlineStroke", "item": null})).unwrap();
let doc = &s.doc().unwrap().doc;
let n = doc.node(id).unwrap();
let ctx = vectorcraft_render::effects::GeomContext::of(n);
let g = vectorcraft_render::effects::apply_geometry_with(&n.appearance.effects, n.path_data().unwrap(), Rect::new(0.0, 50.0, 100.0, 50.0), &ctx);
assert!(close(g.bounds().unwrap(), Rect::new(0.0, 49.0, 100.0, 51.0), 0.01), "{:?}", g.bounds());
}
#[test]
fn brushed_strokes_outline_to_their_brush_art() {
let mut s = session();
line(&mut s, json!({"weight": 2}));
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
s.execute("appearance.setItem", &json!({"index": 1, "opacity": 50})).unwrap();
let n = outline(&mut s);
let doc = &s.doc().unwrap().doc;
let mut fills = 0;
n.walk(&mut |m| fills += m.appearance.items.iter().filter(|i| i.is_fill()).count());
assert!(fills > 0 && n.appearance.stroke().is_none(), "brush art: {n:?}");
assert!(near(n.opacity as f64, 0.5, 1e-6), "the stroke opacity stays on the art");
assert!(!doc.layers[0].children().unwrap().iter().any(|c| vectorcraft_brush::has_brush(c)));
}

View File

@ -16,7 +16,7 @@ pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize
pub use edit::{
AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid,
};
pub use offset::{Cap, Join, offset_path, outline_stroke};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
/// Errors from fallible operations.

View File

@ -46,11 +46,31 @@ pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_l
if width <= 0.0 || !width.is_finite() || path.is_empty() {
return PathData::default();
}
let s = stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit);
if s.elements().is_empty() {
stroke_region(&[stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit)])
}
/// The area covered by stroke outlines (each filled with the non-zero rule, open subpaths
/// closed) as one clean path: overlaps removed and the stroker's pieces refitted.
pub fn stroke_region(outlines: &[BezPath]) -> PathData {
let tidy = Tidy::free(DEFAULT_PRECISION);
let parts: Vec<BezPath> = outlines.iter().map(close_all).filter(|o| !o.elements().is_empty()).collect();
if let [one] = &parts[..] {
return normalize_bez(one, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default();
}
// Each outline is normalised on its own first: a hollow arrowhead may wind against the line
// it overlaps, and the raw windings would cancel there under the non-zero rule.
let mut all = BezPath::new();
for p in &parts {
if let Ok(c) = normalize_bez(p, FillRule::NonZero) {
for ct in c.contours() {
all.extend(ct.path.iter());
}
}
}
if all.elements().is_empty() {
return PathData::default();
}
normalize_bez(&s, FillRule::NonZero).map(|c| all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION))).unwrap_or_default()
normalize_bez(&all, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default()
}
/// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it.
@ -154,3 +174,22 @@ fn dist_to(bp: &BezPath, p: kurbo::Point) -> f64 {
use kurbo::ParamCurveNearest;
bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
}
#[cfg(test)]
mod tests {
use kurbo::Shape;
use super::*;
#[test]
fn stroke_region_unites_outlines_whatever_their_winding() {
// A bar, and a square ring wound the other way that the bar runs into.
let bar = kurbo::Rect::new(0.0, 0.0, 20.0, 4.0).to_path(0.1);
let mut ring = kurbo::Rect::new(10.0, -8.0, 26.0, 12.0).to_path(0.1).reverse_subpaths();
ring.extend(kurbo::Rect::new(14.0, -4.0, 22.0, 8.0).to_path(0.1).iter());
let region = stroke_region(&[bar, ring]);
let area = crate::area(&region, FillRule::NonZero);
// Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4).
assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}");
}
}

View File

@ -54,11 +54,6 @@ fn node_bezpath(n: &Node) -> Option<BezPath> {
}
}
fn geom_ctx(n: &Node) -> GeomContext {
let w = n.appearance.stroke_width();
GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } }
}
fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath {
if !effects::has_geometry(effects) || bp.elements().is_empty() {
return bp.clone();
@ -68,7 +63,7 @@ fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath {
/// The object-level effected geometry of `n` (base path `bp`).
pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath {
apply(&n.appearance.effects, bp, &geom_ctx(n))
apply(&n.appearance.effects, bp, &GeomContext::of(n))
}
fn item_effects(item: &AppearanceItem) -> &[Effect] {
@ -81,13 +76,13 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
/// Visual bounds including geometry effects, stroke outsets and shadows/glows.
pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
let bp = node_bezpath(n)?;
let ctx = geom_ctx(n);
let ctx = GeomContext::of(n);
let g = effected_path(n, &bp);
let mut r: Option<Rect> = (!g.elements().is_empty()).then(|| g.bounding_box());
for item in &n.appearance.items {
let fx = item_effects(item);
if effects::has_geometry(fx) {
let ig = apply(fx, &g, &ctx);
let ig = apply(fx, &g, &ctx.item(item));
if !ig.elements().is_empty() {
let b = ig.bounding_box();
r = Some(r.map_or(b, |r| r.union(b)));
@ -298,7 +293,7 @@ impl Renderer {
self.hairline(ctx, f, &g, [0, 0, 0, 255]);
return;
}
let gctx = geom_ctx(n);
let gctx = GeomContext::of(n);
let rfx = effects::raster_effects(&n.appearance.effects);
// Document-space reach of the painted geometry (strokes, arrowheads…).
let outset = n.appearance.outset();
@ -452,7 +447,7 @@ impl Renderer {
fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) {
let bounds = g.bounding_box();
for item in &n.appearance.items {
let ig = apply(item_effects(item), g, gctx);
let ig = apply(item_effects(item), g, &gctx.item(item));
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
match item {
AppearanceItem::Fill(fl) => {