diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 3b66df25..9e2423d2 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -101,8 +101,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { fn bake_leaf(d: &mut Document, n: &Node) -> Option { 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 { .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(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 425d3167..96d9e378 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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 { "{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)), diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index f57f949f..36abf110 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -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). diff --git a/crates/effects/src/stroke/outline.rs b/crates/effects/src/stroke/outline.rs new file mode 100644 index 00000000..39eca794 --- /dev/null +++ b/crates/effects/src/stroke/outline.rs @@ -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(®ion, FillRule::NonZero, path, rule, BoolOp::Intersect), + StrokeAlign::Outside if closed => vectorcraft_pathops::boolean(®ion, FillRule::NonZero, path, rule, BoolOp::Difference), + _ => region, + } +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 1286b3d6..2ad398a1 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -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] diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index dd712cf9..85bf5eeb 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -249,9 +249,7 @@ fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec 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 diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 3657d6d5..5e4b6b5c 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -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 { // ---------- 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 { + 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 { + 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 = vec![]; + let mut fills: Vec = vec![]; + let fill_node = |d: &mut Document, items: Vec| { + 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 { 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 { 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)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6ea09da7..4031dd20 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests_outlinestroke.rs new file mode 100644 index 00000000..d9298ffd --- /dev/null +++ b/crates/engine/src/tests_outlinestroke.rs @@ -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))); +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 344c3963..7f9b367f 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -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. diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 838180a9..ff128e9c 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -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 = 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(®ion, 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}"); + } +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index c0d3f4af..273a445e 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -54,11 +54,6 @@ fn node_bezpath(n: &Node) -> Option { } } -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 { 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 = (!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) => {