mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
Fix #374: Shape Builder cuts regions with open paths and lines
The Shape Builder only used the filled areas of the selected paths, so a stroke-only line or open path across a shape did nothing. Like the reference app's Shape Builder, open paths now act as cutting edges: - pathops::shape_builder: with an open path in the selection, the regions are the Live Paint planar map's faces (every path an edge), so a line across a shape splits it and lines can enclose a region of their own; it also returns each open path cut where other paths meet it. Closed shapes alone, more than SHAPE_BUILDER_MAX_SEGMENTS segments, or a map that can't be built fall back to the filled-area arrangement. - Filled open paths count as closed (an invisible closing edge) where that encloses an area; unfilled open paths and straight lines stay edges. - shapeBuilder.merge: merging or deleting regions on both sides of a line piece removes that piece (the line is split, its other pieces kept); an area only lines enclose takes the current fill and the front-most object's look. Alt (erase) within `tolerance` of a line piece deletes it. Returns `lines` (pieces deleted). - shapeBuilder.regions also lists the line pieces. - The tool shares BuilderMap/Touched with the command, highlights the line piece under the pointer while Alt is held and passes its tolerance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1d04ee0e84
commit
28fc79d57a
+191
-38
@@ -1,7 +1,8 @@
|
||||
//! Shape Builder (Shift+M), Live Paint Bucket (K) and Live Paint Selection (Shift+L).
|
||||
//!
|
||||
//! The tools only emit commands (`shapeBuilder.merge`, `livePaint.fill`, `livePaint.strokeEdge`,
|
||||
//! `select.*`); the planar arrangement comes from `vectorcraft_pathops::regions`.
|
||||
//! `select.*`); the planar arrangement comes from `vectorcraft_pathops::shape_builder` (Shape
|
||||
//! Builder) and `vectorcraft_pathops::live_paint` (Live Paint).
|
||||
//!
|
||||
//! **Live Paint groups** are ordinary groups (no special node kind): the group's name starts with
|
||||
//! [`LIVE_PAINT_PREFIX`]; its first child is a hidden group named [`SOURCES_NAME`] holding the
|
||||
@@ -151,6 +152,141 @@ pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec<po::Shape>, V
|
||||
(shapes, used)
|
||||
}
|
||||
|
||||
/// Shape Builder inputs: [`shapes_for`], with the open subpaths of filled paths closed where that
|
||||
/// encloses an area (the fill bounds a region, as if an invisible edge closed it). Unfilled open
|
||||
/// paths and straight lines stay open and cut the regions they cross.
|
||||
fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec<po::Shape>, Vec<&'a Node>) {
|
||||
let (mut shapes, leaves) = shapes_for(doc, roots);
|
||||
for (s, l) in shapes.iter_mut().zip(&leaves) {
|
||||
if l.appearance.fill_paint().is_none() {
|
||||
continue;
|
||||
}
|
||||
for sp in s.path.subpaths.iter_mut().filter(|sp| !sp.closed) {
|
||||
let mut closed = sp.clone();
|
||||
closed.closed = true;
|
||||
let mut bp = BezPath::new();
|
||||
closed.to_bezpath_into(&mut bp);
|
||||
let size = bp.bounding_box().size();
|
||||
sp.closed = bp.area().abs() > 1e-9 * (size.width * size.width + size.height * size.height);
|
||||
}
|
||||
}
|
||||
(shapes, leaves)
|
||||
}
|
||||
|
||||
/// A planar-map face with its outline and bounds, ready for hit tests.
|
||||
pub type Face = (po::Region, BezPath, Rect);
|
||||
|
||||
fn faces_of(regions: Vec<po::Region>) -> Vec<Face> {
|
||||
regions
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let bp = r.path.to_bezpath();
|
||||
let b = r.path.bounds()?;
|
||||
Some((r, bp, b))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The Shape Builder's planar map of a selection.
|
||||
#[derive(Default)]
|
||||
pub struct BuilderMap {
|
||||
/// The inputs, back → front (see [`po::shape_builder`]).
|
||||
pub shapes: Vec<po::Shape>,
|
||||
pub regions: Vec<Face>,
|
||||
/// Pieces of the unfilled open paths, keyed by input index, with their bounds.
|
||||
pub lines: Vec<(po::Shape, Rect)>,
|
||||
}
|
||||
|
||||
/// What a Shape Builder gesture touches at a point.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Hit {
|
||||
Region(usize),
|
||||
Line(usize),
|
||||
}
|
||||
|
||||
impl BuilderMap {
|
||||
/// The map of `roots` (sorted back → front), with the leaves the inputs came from.
|
||||
pub fn new<'a>(doc: &'a Document, roots: &[NodeId]) -> (Self, Vec<&'a Node>) {
|
||||
let (shapes, leaves) = shape_builder_shapes(doc, roots);
|
||||
if shapes.is_empty() {
|
||||
return (Self::default(), leaves);
|
||||
}
|
||||
let (regions, lines) = po::shape_builder(&shapes);
|
||||
let lines = lines.into_iter().filter_map(|l| Some((l.path.bounds()?, l))).map(|(b, l)| (l, b)).collect();
|
||||
(Self { shapes, regions: faces_of(regions), lines }, leaves)
|
||||
}
|
||||
|
||||
/// The region containing `p`.
|
||||
pub fn region_at(&self, p: Point) -> Option<usize> {
|
||||
self.regions.iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0)
|
||||
}
|
||||
|
||||
/// The line piece nearest `p`, within `tol`.
|
||||
pub fn line_near(&self, p: Point, tol: f64) -> Option<usize> {
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
for (i, (l, b)) in self.lines.iter().enumerate() {
|
||||
if b.inflate(tol, tol).contains(p)
|
||||
&& let Some((.., d)) = l.path.nearest(p)
|
||||
&& d <= tol
|
||||
&& best.is_none_or(|b| d < b.0)
|
||||
{
|
||||
best = Some((d, i));
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
/// What a gesture at `p` touches: with `line_tol` (erasing), a line piece that close, else the
|
||||
/// region under it.
|
||||
pub fn hit(&self, p: Point, line_tol: Option<f64>) -> Option<Hit> {
|
||||
line_tol.and_then(|t| self.line_near(p, t)).map(Hit::Line).or_else(|| self.region_at(p).map(Hit::Region))
|
||||
}
|
||||
|
||||
/// Does line piece `i` run between two of `regions` (an edge that merging or deleting them
|
||||
/// removes)?
|
||||
pub fn line_between(&self, i: usize, regions: &[usize]) -> bool {
|
||||
let Some(sp) = self.lines.get(i).and_then(|(l, _)| l.path.subpaths.first()) else { return false };
|
||||
let n = sp.segment_count();
|
||||
if n == 0 {
|
||||
return false;
|
||||
}
|
||||
let c = sp.segment(n / 2);
|
||||
use kurbo::{ParamCurve as _, ParamCurveDeriv as _};
|
||||
let (m, d) = (c.eval(0.5), c.deriv().eval(0.5).to_vec2());
|
||||
let len = d.hypot();
|
||||
if !(len.is_finite() && len > 0.0) {
|
||||
return false;
|
||||
}
|
||||
// A little to each side: inside the faces next to it, which share it exactly.
|
||||
let off = d.turn_90() * (0.05f64.min(0.05 * c.p0.distance(c.p3)).max(1e-6) / len);
|
||||
[m + off, m - off].into_iter().all(|q| self.region_at(q).is_some_and(|r| regions.contains(&r)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The regions and line pieces a Shape Builder gesture has touched, in the order touched.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Touched {
|
||||
pub regions: Vec<usize>,
|
||||
pub lines: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Touched {
|
||||
pub fn add(&mut self, hit: Option<Hit>) {
|
||||
let (list, i) = match hit {
|
||||
Some(Hit::Region(i)) => (&mut self.regions, i),
|
||||
Some(Hit::Line(i)) => (&mut self.lines, i),
|
||||
None => return,
|
||||
};
|
||||
if !list.contains(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.regions.is_empty() && self.lines.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected roots sorted back → front in document order.
|
||||
pub fn sorted_roots(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut v: Vec<(Vec<usize>, NodeId)> = ids.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect();
|
||||
@@ -238,13 +374,12 @@ pub fn hatch(path: &BezPath, gap: f64) -> BezPath {
|
||||
out
|
||||
}
|
||||
|
||||
/// Regions of the current selection, cached by document identity and selection.
|
||||
/// The planar map of the current selection, cached by document identity and selection.
|
||||
#[derive(Default)]
|
||||
struct RegionCache {
|
||||
key: Option<(usize, Vec<NodeId>)>,
|
||||
regions: Vec<(po::Region, BezPath, Rect)>,
|
||||
/// Live Paint faces (every path an edge, open ones included) rather than Shape Builder
|
||||
/// regions (the filled areas of the shapes).
|
||||
map: BuilderMap,
|
||||
/// Live Paint faces (every path an edge, filled or not) rather than Shape Builder regions.
|
||||
live: bool,
|
||||
}
|
||||
|
||||
@@ -252,58 +387,57 @@ impl RegionCache {
|
||||
fn live_paint() -> Self {
|
||||
Self { live: true, ..Default::default() }
|
||||
}
|
||||
fn get(&mut self, cx: &ToolContext) -> &[(po::Region, BezPath, Rect)] {
|
||||
fn get(&mut self, cx: &ToolContext) -> &BuilderMap {
|
||||
let key = (cx.doc as *const Document as usize, cx.selection.objects.clone());
|
||||
if self.key.as_ref() != Some(&key) {
|
||||
let roots = sorted_roots(cx.doc, &cx.selection.objects);
|
||||
let (shapes, _) = shapes_for(cx.doc, &roots);
|
||||
self.regions = if shapes.is_empty() {
|
||||
vec![]
|
||||
self.map = if self.live {
|
||||
let (shapes, _) = shapes_for(cx.doc, &roots);
|
||||
let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) };
|
||||
BuilderMap { regions, ..Default::default() }
|
||||
} else {
|
||||
let regions = if self.live { po::live_paint(&shapes).0 } else { po::regions(&shapes) };
|
||||
regions
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let bp = r.path.to_bezpath();
|
||||
let b = r.path.bounds()?;
|
||||
Some((r, bp, b))
|
||||
})
|
||||
.collect()
|
||||
BuilderMap::new(cx.doc, &roots).0
|
||||
};
|
||||
self.key = Some(key);
|
||||
}
|
||||
&self.regions
|
||||
&self.map
|
||||
}
|
||||
fn at(&mut self, cx: &ToolContext, p: Point) -> Option<usize> {
|
||||
self.get(cx).iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0)
|
||||
self.get(cx).region_at(p)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Shape Builder ----------
|
||||
|
||||
/// Shape Builder tool: drag across regions to merge them, Alt-drag to delete them.
|
||||
/// Shape Builder tool: drag across regions to merge them, Alt-drag to delete them (and the pieces
|
||||
/// of open paths it passes over).
|
||||
#[derive(Default)]
|
||||
pub struct ShapeBuilderTool {
|
||||
cache: RegionCache,
|
||||
hover: Option<usize>,
|
||||
hover: Option<Hit>,
|
||||
points: Vec<Point>,
|
||||
touched: Vec<usize>,
|
||||
touched: Touched,
|
||||
dragging: bool,
|
||||
alt: bool,
|
||||
}
|
||||
|
||||
/// How close (screen px) the pointer must come to a line piece to delete it.
|
||||
const LINE_TOL_PX: f64 = 4.0;
|
||||
|
||||
impl ShapeBuilderTool {
|
||||
fn line_tol(cx: &ToolContext, alt: bool) -> Option<f64> {
|
||||
alt.then(|| cx.tol(LINE_TOL_PX))
|
||||
}
|
||||
|
||||
fn touch(&mut self, cx: &ToolContext, from: Option<Point>, to: Point) {
|
||||
let pts = match from {
|
||||
Some(a) => sample_polyline(&[a, to], cx.tol(2.0)),
|
||||
None => vec![to],
|
||||
};
|
||||
let tol = Self::line_tol(cx, self.alt);
|
||||
for p in pts {
|
||||
if let Some(i) = self.cache.at(cx, p)
|
||||
&& !self.touched.contains(&i)
|
||||
{
|
||||
self.touched.push(i);
|
||||
}
|
||||
let hit = self.cache.get(cx).hit(p, tol);
|
||||
self.touched.add(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,7 +451,7 @@ impl Tool for ShapeBuilderTool {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.hover = self.cache.at(cx, p);
|
||||
self.hover = self.cache.get(cx).hit(p, Self::line_tol(cx, ev.mods.alt));
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
@@ -327,7 +461,7 @@ impl Tool for ShapeBuilderTool {
|
||||
self.dragging = true;
|
||||
self.alt = ev.mods.alt;
|
||||
self.points = vec![p];
|
||||
self.touched.clear();
|
||||
self.touched = Touched::default();
|
||||
self.touch(cx, None, p);
|
||||
vec![]
|
||||
}
|
||||
@@ -353,7 +487,12 @@ impl Tool for ShapeBuilderTool {
|
||||
self.cache.key = None;
|
||||
let roots = sorted_roots(cx.doc, &cx.selection.objects);
|
||||
let pts: Vec<Value> = points.iter().map(|q| json!([q.x, q.y])).collect();
|
||||
vec![Action::Exec("shapeBuilder.merge".into(), json!({ "ids": json_ids(&roots), "points": pts, "erase": self.alt || ev.mods.alt }))]
|
||||
let erase = self.alt || ev.mods.alt;
|
||||
let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase });
|
||||
if let Some(t) = Self::line_tol(cx, erase) {
|
||||
params["tolerance"] = json!(t);
|
||||
}
|
||||
vec![Action::Exec("shapeBuilder.merge".into(), params)]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
@@ -361,13 +500,26 @@ impl Tool for ShapeBuilderTool {
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
let regions = &self.cache.regions;
|
||||
let map = &self.cache.map;
|
||||
let color = if self.dragging && self.alt { HIGHLIGHT_RED } else { HATCH };
|
||||
let show: Vec<usize> = if self.dragging { self.touched.clone() } else { self.hover.into_iter().collect() };
|
||||
for i in show {
|
||||
if let Some((_, bp, _)) = regions.get(i) {
|
||||
out.push(Overlay::Path { path: hatch(bp, cx.tol(4.0)), color, width: 1.0, dashed: false });
|
||||
out.push(Overlay::Path { path: bp.clone(), color, width: 1.0, dashed: false });
|
||||
let show: Vec<Hit> = if self.dragging {
|
||||
self.touched.regions.iter().map(|&i| Hit::Region(i)).chain(self.touched.lines.iter().map(|&i| Hit::Line(i))).collect()
|
||||
} else {
|
||||
self.hover.into_iter().collect()
|
||||
};
|
||||
for hit in show {
|
||||
match hit {
|
||||
Hit::Region(i) => {
|
||||
if let Some((_, bp, _)) = map.regions.get(i) {
|
||||
out.push(Overlay::Path { path: hatch(bp, cx.tol(4.0)), color, width: 1.0, dashed: false });
|
||||
out.push(Overlay::Path { path: bp.clone(), color, width: 1.0, dashed: false });
|
||||
}
|
||||
}
|
||||
Hit::Line(i) => {
|
||||
if let Some((l, _)) = map.lines.get(i) {
|
||||
out.push(Overlay::Path { path: l.path.to_bezpath(), color: HIGHLIGHT_RED, width: 3.0, dashed: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.dragging && self.points.len() > 1 {
|
||||
@@ -434,6 +586,7 @@ fn lp_overlays(h: &LpHover, cache: &RegionCache) -> Vec<Overlay> {
|
||||
LpHover::None => vec![],
|
||||
LpHover::Part(bp) => vec![Overlay::Path { path: bp.clone(), color: HIGHLIGHT_RED, width: 4.0, dashed: false }],
|
||||
LpHover::Region(i) => cache
|
||||
.map
|
||||
.regions
|
||||
.get(*i)
|
||||
.map(|(_, bp, _)| vec![Overlay::Path { path: bp.clone(), color: HIGHLIGHT_RED, width: 4.0, dashed: false }])
|
||||
@@ -623,7 +776,7 @@ mod tests {
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 50.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 50.0));
|
||||
assert_eq!(t.touched.len(), 2);
|
||||
assert_eq!(t.touched.regions.len(), 2);
|
||||
assert!(t.busy());
|
||||
let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0));
|
||||
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
|
||||
|
||||
Reference in New Issue
Block a user