mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-03 06:30:00 -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
@@ -16,10 +16,11 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{FillRule, Point, Rect, Shape as _};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
self as b, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, edge_near, face_at, is_live_paint, sample_polyline, shapes_for, sorted_roots,
|
||||
self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sample_polyline,
|
||||
sorted_roots,
|
||||
};
|
||||
use vectorcraft_trace as tr;
|
||||
|
||||
@@ -38,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (Alt-drag deletes the regions), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point)} → {ids, merged, regions}",
|
||||
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (Alt-drag deletes the regions, and the pieces of unfilled open paths within tolerance), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines}",
|
||||
has_selection_or_ids,
|
||||
sb_merge
|
||||
),
|
||||
@@ -47,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder Regions",
|
||||
[],
|
||||
None,
|
||||
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}]} faces of the planar arrangement",
|
||||
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose) and the pieces of those open paths",
|
||||
has_selection_or_ids,
|
||||
sb_regions
|
||||
),
|
||||
@@ -224,37 +225,51 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
|
||||
}
|
||||
let erase = bool_or(p, "erase", false);
|
||||
// Line pieces are only deleted (Alt), never merged.
|
||||
let line_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
|
||||
let fill = fill_param(s, p, C)?;
|
||||
let current_fill = s.paint.fill.clone();
|
||||
let roots = root_ids(s, p)?;
|
||||
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
|
||||
let (ids, merged, count) = s.edit(label, |d, sel| {
|
||||
let (shapes, leaves) = shapes_for(d, &roots);
|
||||
let (ids, merged, regions, lines) = s.edit(label, |d, sel| {
|
||||
let (map, leaves) = BuilderMap::new(d, &roots);
|
||||
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
|
||||
if shapes.is_empty() {
|
||||
if map.shapes.is_empty() {
|
||||
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
|
||||
}
|
||||
let regs = po::regions(&shapes);
|
||||
let shapes_r: Vec<(kurbo::BezPath, Rect)> = regs.iter().map(|r| (r.path.to_bezpath(), r.path.bounds().unwrap_or(Rect::ZERO))).collect();
|
||||
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
|
||||
let mut touched: Vec<usize> = vec![];
|
||||
let mut touched = Touched::default();
|
||||
for q in sample_polyline(&pts, step) {
|
||||
if let Some(i) = shapes_r.iter().position(|(bp, bb)| bb.contains(q) && bp.winding(q) != 0)
|
||||
&& !touched.contains(&i)
|
||||
{
|
||||
touched.push(i);
|
||||
}
|
||||
touched.add(map.hit(q, line_tol));
|
||||
}
|
||||
if touched.is_empty() {
|
||||
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
|
||||
}
|
||||
let picked: Vec<&po::Region> = touched.iter().map(|&i| ®s[i]).collect();
|
||||
let union = po::merge_regions(&picked);
|
||||
let affected: Vec<bool> = (0..shapes.len()).map(|i| picked.iter().any(|r| r.sources.contains(&i))).collect();
|
||||
let style_src = regs[touched[0]].top();
|
||||
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
|
||||
let union = if picked.is_empty() { PathData::default() } else { po::merge_regions(&picked) };
|
||||
// The merged shape takes the look of the front-most object over the first region, or (for an
|
||||
// area only lines enclose) of the front-most object and the current fill.
|
||||
let covered = picked.iter().find_map(|r| r.top());
|
||||
let style_src = covered.or(map.shapes.len().checked_sub(1));
|
||||
let merged_fill = fill.or_else(|| covered.is_none().then_some(current_fill));
|
||||
let mut new_nodes: Vec<Node> = vec![];
|
||||
let mut merged_id = None;
|
||||
for (i, (sh, leaf)) in shapes.iter().zip(&leaves).enumerate() {
|
||||
if !affected[i] {
|
||||
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
|
||||
let pieces: Vec<usize> = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect();
|
||||
if !pieces.is_empty() {
|
||||
// An open path loses the pieces deleted and those between merged (or deleted) regions.
|
||||
let keep: Vec<usize> =
|
||||
pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect();
|
||||
if keep.len() == pieces.len() {
|
||||
new_nodes.push(d.reid(leaf));
|
||||
} else {
|
||||
for k in keep {
|
||||
if let Some((l, _)) = map.lines.get(k) {
|
||||
new_nodes.push(shape_node(d, l.path.clone(), Some(leaf)));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !picked.iter().any(|r| r.sources.contains(&i)) {
|
||||
new_nodes.push(d.reid(leaf));
|
||||
} else {
|
||||
let rest = po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference);
|
||||
@@ -262,9 +277,9 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
new_nodes.push(shape_node(d, rest, Some(leaf)));
|
||||
}
|
||||
}
|
||||
if !erase && Some(i) == style_src {
|
||||
if !erase && !union.is_empty() && Some(i) == style_src {
|
||||
let mut m = shape_node(d, union.clone(), Some(leaf));
|
||||
if let Some(f) = &fill {
|
||||
if let Some(f) = &merged_fill {
|
||||
m.appearance.set_fill(f.clone());
|
||||
}
|
||||
merged_id = Some(m.id);
|
||||
@@ -273,9 +288,9 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let new_ids = replace_roots(d, &roots, new_nodes)?;
|
||||
sel.set(new_ids.iter().copied());
|
||||
Ok((new_ids, merged_id, touched.len()))
|
||||
Ok((new_ids, merged_id, touched.regions.len(), touched.lines.len()))
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids), "merged": merged.map(|m| m.0), "regions": count }))
|
||||
Ok(json!({ "ids": ids_json(&ids), "merged": merged.map(|m| m.0), "regions": regions, "lines": lines }))
|
||||
}
|
||||
|
||||
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
|
||||
@@ -300,20 +315,22 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
|
||||
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let (shapes, leaves) = shapes_for(d, &roots);
|
||||
let regs = po::regions(&shapes);
|
||||
let out: Vec<Value> = regs
|
||||
let (map, leaves) = BuilderMap::new(d, &roots);
|
||||
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
|
||||
let regions: Vec<Value> = map
|
||||
.regions
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let bb = r.path.bounds()?;
|
||||
Some(json!({
|
||||
.map(|(r, _, bb)| {
|
||||
json!({
|
||||
"bounds": [bb.x0, bb.y0, bb.x1, bb.y1],
|
||||
"area": po::area(&r.path, FillRule::NonZero),
|
||||
"sources": r.sources.iter().map(|&i| leaves[i].id.0).collect::<Vec<_>>(),
|
||||
}))
|
||||
"sources": r.sources.iter().filter_map(|&i| id_of(i)).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "regions": out }))
|
||||
let lines: Vec<Value> =
|
||||
map.lines.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect();
|
||||
Ok(json!({ "regions": regions, "lines": lines }))
|
||||
}
|
||||
|
||||
// ---------- Live Paint ----------
|
||||
|
||||
@@ -172,6 +172,154 @@ fn shape_builder_tool_drives_the_command() {
|
||||
assert_eq!(layer_children(&s), 1);
|
||||
}
|
||||
|
||||
/// A red 100 × 100 square crossed by a stroke-only vertical line at x = 50, both selected.
|
||||
fn square_and_line(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
|
||||
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
|
||||
let l = line(s, (50.0, -20.0), (50.0, 120.0));
|
||||
s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap();
|
||||
(a, l)
|
||||
}
|
||||
|
||||
/// The open paths among `ids`, as (anchors, bounds).
|
||||
fn open_paths(s: &Session, ids: &[NodeId]) -> Vec<(usize, Rect)> {
|
||||
ids.iter()
|
||||
.map(|i| node(s, *i))
|
||||
.filter(|n| !outline(n).0.is_closed())
|
||||
.map(|n| (outline(&n).0.anchor_count(), n.geometric_bounds().unwrap()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_line_splits_a_shape_into_regions() {
|
||||
let mut s = session();
|
||||
let (a, l) = square_and_line(&mut s);
|
||||
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
let regs = r["regions"].as_array().unwrap();
|
||||
assert_eq!(regs.len(), 2, "{r}");
|
||||
for x in regs {
|
||||
assert!((x["area"].as_f64().unwrap() - 5000.0).abs() < 1e-6);
|
||||
assert_eq!(x["sources"], json!([a.0]));
|
||||
}
|
||||
let lines = r["lines"].as_array().unwrap();
|
||||
assert_eq!(lines.len(), 3, "the line cut at both sides of the square");
|
||||
assert!(lines.iter().all(|x| x["source"] == l.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_click_on_a_side_of_a_line_splits_the_shape() {
|
||||
let mut s = session();
|
||||
square_and_line(&mut s);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50]]})).unwrap();
|
||||
assert_eq!(r["regions"], 1);
|
||||
let out = ids(&r);
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert_eq!(node(&s, merged).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 100.0));
|
||||
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let closed: Vec<f64> = out.iter().filter(|i| outline(&node(&s, **i)).0.is_closed()).map(|i| area(&s, *i)).collect();
|
||||
assert_eq!(closed.len(), 2, "both halves");
|
||||
assert!(closed.iter().all(|a| (a - 5000.0).abs() < 1.0), "{closed:?}");
|
||||
// The line only bounds the region: it stays whole.
|
||||
assert_eq!(open_paths(&s, &out), vec![(2, Rect::new(50.0, -20.0, 50.0, 120.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_merge_across_a_line_removes_the_piece_between() {
|
||||
let mut s = session();
|
||||
square_and_line(&mut s);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
|
||||
assert_eq!(r["regions"], 2);
|
||||
let out = ids(&r);
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert!((area(&s, merged) - 10000.0).abs() < 1.0);
|
||||
assert_eq!(out.len(), 3, "merged square + the line's two loose ends");
|
||||
let mut ends = open_paths(&s, &out);
|
||||
ends.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0));
|
||||
assert_eq!(ends, vec![(2, Rect::new(50.0, -20.0, 50.0, 0.0)), (2, Rect::new(50.0, 100.0, 50.0, 120.0))]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(layer_children(&s), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_alt_deletes_a_region_or_a_line_piece() {
|
||||
let mut s = session();
|
||||
square_and_line(&mut s);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 50]], "erase": true})).unwrap();
|
||||
assert!(r["merged"].is_null());
|
||||
let out = ids(&r);
|
||||
let left: Vec<NodeId> = out.iter().copied().filter(|i| outline(&node(&s, *i)).0.is_closed()).collect();
|
||||
assert_eq!(left.len(), 1);
|
||||
assert_eq!(node(&s, left[0]).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 100.0));
|
||||
assert_eq!(open_paths(&s, &out).len(), 1, "the line stays whole");
|
||||
|
||||
// Alt on the line inside the square deletes that piece only.
|
||||
let mut s = session();
|
||||
square_and_line(&mut s);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[51, 50]], "erase": true, "tolerance": 2})).unwrap();
|
||||
assert_eq!((r["regions"].as_u64(), r["lines"].as_u64()), (Some(0), Some(1)));
|
||||
let out = ids(&r);
|
||||
assert_eq!(out.len(), 3);
|
||||
let sq: Vec<NodeId> = out.iter().copied().filter(|i| outline(&node(&s, *i)).0.is_closed()).collect();
|
||||
assert_eq!(sq.len(), 1);
|
||||
assert!((area(&s, sq[0]) - 10000.0).abs() < 1.0, "the square is untouched");
|
||||
assert_eq!(open_paths(&s, &out).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_fills_an_area_lines_enclose() {
|
||||
let mut s = session();
|
||||
let l1 = line(&mut s, (0.0, 0.0), (100.0, 100.0));
|
||||
let l2 = line(&mut s, (100.0, 0.0), (0.0, 100.0));
|
||||
let l3 = line(&mut s, (-10.0, 80.0), (110.0, 80.0));
|
||||
s.execute("select.set", &json!({"ids": [l1.0, l2.0, l3.0]})).unwrap();
|
||||
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
assert_eq!(r["regions"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(r["regions"][0]["sources"], json!([]));
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]]})).unwrap();
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert!((area(&s, merged) - 900.0).abs() < 1e-6, "{}", area(&s, merged));
|
||||
assert_eq!(node(&s, merged).appearance.fill_paint(), s.paint.fill, "the current fill");
|
||||
assert!(!node(&s, merged).appearance.stroke_paint().is_none(), "the lines' stroke");
|
||||
assert_eq!(ids(&r).len(), 4, "the triangle + the three lines, which only bound it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_closes_filled_open_paths() {
|
||||
let mut s = session();
|
||||
let r = s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}]})).unwrap();
|
||||
let p = NodeId(r["id"].as_u64().unwrap());
|
||||
set_fill(&mut s, p, Color::rgb(0.0, 1.0, 0.0));
|
||||
s.execute("select.set", &json!({"ids": [p.0]})).unwrap();
|
||||
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
assert_eq!(r["regions"].as_array().unwrap().len(), 1, "the fill's area, closed by an invisible edge");
|
||||
assert!((r["regions"][0]["area"].as_f64().unwrap() - 5000.0).abs() < 1e-6);
|
||||
assert_eq!(r["lines"], json!([]));
|
||||
// Unfilled, it is only an edge.
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(p).unwrap().appearance.set_fill(Paint::None);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
assert_eq!(r["regions"], json!([]));
|
||||
assert_eq!(r["lines"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_tool_alt_deletes_line_pieces() {
|
||||
let mut s = session();
|
||||
square_and_line(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("shapeBuilder", v).unwrap();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 51.0, 50.0).with_mods(alt), v).unwrap();
|
||||
assert_eq!(s.overlays(v).len(), 1, "the line piece is highlighted");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 51.0, 50.0).with_mods(alt), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 51.0, 50.0).with_mods(alt), v).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
|
||||
assert_eq!(layer_children(&s), 3, "square + the line's two loose ends");
|
||||
}
|
||||
|
||||
// ---------- Live Paint ----------
|
||||
|
||||
fn live(s: &mut Session) -> NodeId {
|
||||
|
||||
@@ -35,6 +35,7 @@ pub struct Region { pub path: PathData, pub sources: Vec<usize> }
|
||||
pub fn regions(shapes: &[Shape]) -> Vec<Region>; // all faces of the arrangement
|
||||
pub fn region_at(shapes: &[Shape], p: Point) -> Option<Region>;
|
||||
pub fn merge_regions(regions: &[&Region]) -> PathData; // Shape Builder merge = union
|
||||
pub fn shape_builder(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>); // (regions, open-path pieces)
|
||||
```
|
||||
|
||||
Each result takes its key as follows:
|
||||
@@ -58,6 +59,8 @@ pub fn interior_point(path: &PathData) -> Option<Point>;
|
||||
|
||||
Unlike `regions`, which splits the filled areas of closed shapes, `live_paint` treats every path as an edge, open paths included. Three crossing lines enclose a triangle, a line across a rectangle splits it in two, and an area enclosed by paths becomes a face even when nothing fills it. Each face lists the inputs whose fill covers it; the list is empty for an area that is only enclosed by paths. Each edge is a piece of a path, split wherever another path meets it, and keeps the key of the front-most input it lies on.
|
||||
|
||||
`shape_builder` gives the Shape Builder's regions. With closed paths only, they are the `regions`. Once an open path is among them, they are the `live_paint` faces, so a stroke-only line across a shape splits it and lines can enclose a region of their own. The second list holds the pieces of each path that has no closed subpath, keyed by its index, so the Shape Builder can delete them one by one. The caller closes any open path whose fill should count. Inputs with more than `SHAPE_BUILDER_MAX_SEGMENTS` segments fall back to the filled areas.
|
||||
|
||||
## Offset / stroke
|
||||
|
||||
```rust
|
||||
|
||||
@@ -20,7 +20,7 @@ pub use edit::{
|
||||
};
|
||||
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
|
||||
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
|
||||
pub use planar::{interior_point, live_paint};
|
||||
pub use planar::{SHAPE_BUILDER_MAX_SEGMENTS, interior_point, live_paint, shape_builder};
|
||||
|
||||
/// Errors from fallible operations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
|
||||
@@ -288,6 +288,32 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
|
||||
build(shapes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Most segments the Shape Builder sweeps with open paths as edges: the planar map costs far more
|
||||
/// than the filled areas' arrangement, so past this only the filled areas count.
|
||||
pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 4096;
|
||||
|
||||
/// Shape Builder regions of `shapes` (back → front), and the pieces of their open paths.
|
||||
///
|
||||
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions). Open
|
||||
/// paths (the caller closes the ones whose fill should count) are cutting edges: a line across a
|
||||
/// shape splits it, lines around an area enclose a region nothing fills (empty `sources`), and each
|
||||
/// path with no closed subpath comes back cut wherever another path meets it, as pieces keyed by
|
||||
/// its index in `shapes`. With no open path, more than [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or
|
||||
/// a map that can't be built, only the filled areas count and there are no pieces.
|
||||
pub fn shape_builder(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
|
||||
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
|
||||
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
|
||||
if has_open && segments <= SHAPE_BUILDER_MAX_SEGMENTS {
|
||||
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
|
||||
let (faces, edges) = live_paint(&keyed);
|
||||
if !faces.is_empty() || !edges.is_empty() {
|
||||
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
|
||||
return (faces, edges.into_iter().filter(|e| is_line(e.key)).collect());
|
||||
}
|
||||
}
|
||||
(crate::regions(shapes), Vec::new())
|
||||
}
|
||||
|
||||
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
|
||||
for (i, s) in shapes.iter().enumerate() {
|
||||
@@ -736,6 +762,69 @@ mod tests {
|
||||
assert_eq!(edges.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_lines_cut_regions() {
|
||||
// A line across a rectangle: two regions, the line in three pieces keyed by its index.
|
||||
let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)];
|
||||
let (regions, lines) = shape_builder(&s);
|
||||
assert_eq!(regions.len(), 2);
|
||||
assert!(regions.iter().all(|r| r.sources == [0]));
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(lines.iter().all(|l| l.key == 1 && !l.path.is_closed()));
|
||||
// Lines alone enclose a region nothing fills.
|
||||
let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 0), line((-10.0, 80.0), (110.0, 80.0), 0)];
|
||||
let (regions, lines) = shape_builder(&s);
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert!(regions[0].sources.is_empty() && regions[0].top().is_none());
|
||||
assert_eq!(lines.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() {
|
||||
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)];
|
||||
assert_eq!(shape_builder(&s), (crate::regions(&s), vec![]));
|
||||
// A broken line can't be swept: what the filled areas give (nothing here, without a panic).
|
||||
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((0.0, 0.0), (f64::NAN, 1.0), 1)];
|
||||
assert_eq!(shape_builder(&s), (crate::regions(&s), vec![]));
|
||||
// Zero-length and doubled-back lines don't break it either.
|
||||
let s = [
|
||||
rect(0.0, 0.0, 100.0, 100.0, 0),
|
||||
line((50.0, 50.0), (50.0, 50.0), 1),
|
||||
line((0.0, 0.0), (100.0, 0.0), 2),
|
||||
line((50.0, 0.0), (50.0, 100.0), 3),
|
||||
];
|
||||
let (regions, _) = shape_builder(&s);
|
||||
assert_eq!(regions.len(), 2);
|
||||
assert!((regions.iter().map(area).sum::<f64>() - 10000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_stays_quick_up_to_its_cap() {
|
||||
// A wavy ring of nearly the most segments the map takes, crossed by a few lines.
|
||||
let n = SHAPE_BUILDER_MAX_SEGMENTS - 8;
|
||||
let ring: Vec<Point> = (0..n)
|
||||
.map(|k| {
|
||||
let t = k as f64 / n as f64 * std::f64::consts::TAU;
|
||||
let r = 100.0 + 5.0 * (t * 200.0).sin();
|
||||
Point::new(r * t.cos(), r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
let mut s = vec![Shape::new(PathData::single(SubPath::polyline(&ring, true)), FillRule::NonZero, 0)];
|
||||
for k in 0..4 {
|
||||
let y = -60.0 + 40.0 * f64::from(k);
|
||||
s.push(line((-150.0, y), (150.0, y + 7.0), 1));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let (regions, lines) = shape_builder(&s);
|
||||
assert!(regions.len() >= 5, "the ring cut in five bands at least (the waves make pockets)");
|
||||
assert!(lines.len() >= 12, "{}", lines.len());
|
||||
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
|
||||
// Past the cap, only the filled areas count.
|
||||
s.push(line((0.0, 0.0), (1.0, 1.0), 2));
|
||||
s[0].path.subpaths[0].anchors.extend((0..16).map(|k| vectorcraft_geom::Anchor::corner(Point::new(100.0, f64::from(k)))));
|
||||
assert!(shape_builder(&s).1.is_empty());
|
||||
}
|
||||
|
||||
fn arb_segment() -> impl Strategy<Value = Shape> {
|
||||
let c = || (0..10i32).prop_map(|v| f64::from(v) * 10.0);
|
||||
prop_oneof![
|
||||
|
||||
+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