Files
vectorcraft/crates/tools/src/builder.rs
T
bflatasticandClaude Opus 5.5 700b6786c4 Fix #206: Live Paint treats every path as an edge, open lines included
The Live Paint Bucket now fills areas enclosed by any paths. Crossing
lines enclose faces it can fill, a line across a shape splits that shape's
face in two, and an area enclosed by paths becomes a face even when nothing
fills it (it starts unfilled). Edges are the paths cut wherever another path
meets them, keeping their stroke, so Shift-click paints each piece. Closed
shapes give the same faces as before.

Root cause: Live Paint built its faces from the Pathfinder regions, which
divide the filled areas of closed shapes. An open path counted only as the
area it would fill if closed, which for a straight line is nothing, so lines
divided nothing and the bucket found no face to fill. A new planar map in
vectorcraft-pathops (live_paint) sweeps every path as an edge: an open path
goes in traced there and back, with crossing counts that never cancel; faces
are walked on the resulting graph (holes included), and their fills and the
edges' sources are found geometrically. The sweep runs in an exactly sheared
frame, because it mishandles a horizontal line traced there and back.
Make, Merge, the bucket and Live Paint Selection all use it;
interior_point moves to vectorcraft-pathops with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:08:39 +07:00

741 lines
27 KiB
Rust

//! 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`.
//!
//! **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
//! original paths (the planar map's inputs, kept for Release / Merge / re-computation), followed
//! by the face paths (named [`FACE_NAME`], fill only) and the edge paths (named [`EDGE_NAME`],
//! stroke only, open). Everything else in the document sees a plain group of paths, so rendering,
//! export, transforms and undo need no special cases.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape as _};
use vectorcraft_pathops as po;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
/// Names starting with this mark a Live Paint group.
pub const LIVE_PAINT_PREFIX: &str = "Live Paint";
/// Default name of a new Live Paint group.
pub const LIVE_PAINT_NAME: &str = "Live Paint Group";
/// The hidden child group holding the source paths.
pub const SOURCES_NAME: &str = "Live Paint Sources";
pub const FACE_NAME: &str = "Face";
pub const EDGE_NAME: &str = "Edge";
/// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red.
pub const HATCH: [u8; 3] = [0x80, 0x80, 0x80];
pub const HIGHLIGHT_RED: [u8; 3] = [0xff, 0x2a, 0x2a];
/// Is `n` a Live Paint group?
pub fn is_live_paint(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref().is_some_and(|s| s.starts_with(LIVE_PAINT_PREFIX))
}
fn named(n: &Node, name: &str) -> bool {
n.name.as_deref() == Some(name)
}
/// Face children of a Live Paint group (paint order).
pub fn faces(group: &Node) -> Vec<&Arc<Node>> {
group.children().map(|c| c.iter().filter(|n| named(n, FACE_NAME)).collect()).unwrap_or_default()
}
/// Edge children of a Live Paint group.
pub fn edges(group: &Node) -> Vec<&Arc<Node>> {
group.children().map(|c| c.iter().filter(|n| named(n, EDGE_NAME)).collect()).unwrap_or_default()
}
/// The hidden sources group of a Live Paint group.
pub fn sources(group: &Node) -> Option<&Arc<Node>> {
group.children()?.iter().find(|n| named(n, SOURCES_NAME))
}
/// Does the path (filled) contain `p`?
pub fn path_contains(path: &PathData, rule: FillRule, p: Point) -> bool {
if path.bounds().is_none_or(|b| !b.inflate(1e-6, 1e-6).contains(p)) {
return false;
}
let w = path.to_bezpath().winding(p);
match rule {
FillRule::EvenOdd => w % 2 != 0,
_ => w != 0,
}
}
/// The face of `group` under `p` (front-most).
pub fn face_at(group: &Node, p: Point) -> Option<&Arc<Node>> {
faces(group).into_iter().rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p)))
}
/// The edge of `group` nearest to `p` within `tol`.
pub fn edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc<Node>> {
let mut best: Option<(f64, &Arc<Node>)> = None;
for e in edges(group) {
if let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p))
&& d <= tol
&& best.is_none_or(|b| d < b.0)
{
best = Some((d, e));
}
}
best.map(|b| b.1)
}
/// The visible Live Paint group with a face under `p` (front-most in paint order).
pub fn live_paint_at(doc: &Document, p: Point) -> Option<NodeId> {
let mut hit = None;
doc.walk(|n| {
if is_live_paint(n) && doc.is_visible(n.id) && face_at(n, p).is_some() {
hit = Some(n.id);
}
});
hit
}
/// Paths, compound paths (as one shape) of a subtree in paint order; guides, clipping paths and
/// hidden objects are skipped. Live Paint groups contribute their faces.
pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
if !n.visible {
return;
}
match &n.kind {
NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {}
NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children {
leaf_shapes(c, out);
}
}
_ => {}
}
}
/// Filled outline of a path or compound path node.
pub fn node_outline(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {
let subs = children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect();
Some((PathData::new(subs), *rule))
}
_ => None,
}
}
/// Pathfinder shapes for the given roots (back → front by the order given), keyed by leaf index,
/// with the leaves they came from.
pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec<po::Shape>, Vec<&'a Node>) {
let mut lv = vec![];
for id in roots {
if let Some(n) = doc.node(*id) {
leaf_shapes(n, &mut lv);
}
}
let mut shapes = vec![];
let mut used = vec![];
for l in lv {
if let Some((p, r)) = node_outline(l)
&& !p.is_empty()
&& p.subpaths.iter().all(|s| s.anchors.iter().all(|a| a.p.x.is_finite() && a.p.y.is_finite()))
{
shapes.push(po::Shape::new(p, r, used.len() as u64));
used.push(l);
}
}
(shapes, used)
}
/// 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();
v.sort();
v.dedup_by(|a, b| a.1 == b.1);
v.into_iter().map(|(_, id)| id).collect()
}
/// Sample points along a polyline every `step` units (always including the vertices).
pub fn sample_polyline(pts: &[Point], step: f64) -> Vec<Point> {
let mut out = vec![];
let step = step.max(1e-3);
for (i, &p) in pts.iter().enumerate() {
if i > 0 {
let a = pts[i - 1];
let n = ((a.distance(p) / step).ceil() as usize).min(4000);
for k in 1..n {
out.push(a.lerp(p, k as f64 / n as f64));
}
}
out.push(p);
}
out
}
/// Diagonal hatch lines filling `path` (the Shape Builder's grey mesh highlight), `gap` apart.
pub fn hatch(path: &BezPath, gap: f64) -> BezPath {
let mut out = BezPath::new();
let b = path.bounding_box();
if b.width() <= 0.0 || b.height() <= 0.0 {
return out;
}
// Rotate by -45° so the hatch lines become horizontal scanlines.
let s = std::f64::consts::FRAC_1_SQRT_2;
let rot = |p: Point| Point::new((p.x + p.y) * s, (p.y - p.x) * s);
let unrot = |p: Point| Point::new((p.x - p.y) * s, (p.x + p.y) * s);
let mut edges: Vec<(Point, Point)> = vec![];
let mut first = Point::ZERO;
let mut last = Point::ZERO;
kurbo::flatten(path.iter(), gap.max(1e-3) * 0.1, |el| match el {
kurbo::PathEl::MoveTo(p) => {
if last != first {
edges.push((rot(last), rot(first)));
}
first = p;
last = p;
}
kurbo::PathEl::LineTo(p) => {
edges.push((rot(last), rot(p)));
last = p;
}
kurbo::PathEl::ClosePath => {
if last != first {
edges.push((rot(last), rot(first)));
}
last = first;
}
_ => {}
});
if last != first {
edges.push((rot(last), rot(first)));
}
let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY);
for (a, c) in &edges {
y0 = y0.min(a.y.min(c.y));
y1 = y1.max(a.y.max(c.y));
}
let gap = gap.max((y1 - y0) / 400.0).max(1e-6);
let mut y = (y0 / gap).floor() * gap + gap * 0.5;
let mut xs: Vec<f64> = vec![];
while y < y1 {
xs.clear();
for (a, c) in &edges {
if (a.y <= y) != (c.y <= y) {
xs.push(a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x));
}
}
xs.sort_by(f64::total_cmp);
for pair in xs.as_chunks::<2>().0 {
out.move_to(unrot(Point::new(pair[0], y)));
out.line_to(unrot(Point::new(pair[1], y)));
}
y += gap;
}
out
}
/// Regions 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).
live: bool,
}
impl RegionCache {
fn live_paint() -> Self {
Self { live: true, ..Default::default() }
}
fn get(&mut self, cx: &ToolContext) -> &[(po::Region, BezPath, Rect)] {
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![]
} 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()
};
self.key = Some(key);
}
&self.regions
}
fn at(&mut self, cx: &ToolContext, p: Point) -> Option<usize> {
self.get(cx).iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0)
}
}
// ---------- Shape Builder ----------
/// Shape Builder tool: drag across regions to merge them, Alt-drag to delete them.
#[derive(Default)]
pub struct ShapeBuilderTool {
cache: RegionCache,
hover: Option<usize>,
points: Vec<Point>,
touched: Vec<usize>,
dragging: bool,
alt: bool,
}
impl ShapeBuilderTool {
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],
};
for p in pts {
if let Some(i) = self.cache.at(cx, p)
&& !self.touched.contains(&i)
{
self.touched.push(i);
}
}
}
}
impl Tool for ShapeBuilderTool {
fn id(&self) -> &'static str {
"shapeBuilder"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = self.cache.at(cx, p);
vec![]
}
PointerKind::Down => {
if cx.selection.is_empty() {
return vec![];
}
self.dragging = true;
self.alt = ev.mods.alt;
self.points = vec![p];
self.touched.clear();
self.touch(cx, None, p);
vec![]
}
PointerKind::Drag if self.dragging => {
let last = self.points.last().copied();
if last.is_none_or(|q| q.distance(p) >= cx.tol(1.0)) {
self.touch(cx, last, p);
self.points.push(p);
}
vec![]
}
PointerKind::Up if self.dragging => {
self.dragging = false;
if self.points.last().is_none_or(|q| *q != p) {
let last = self.points.last().copied();
self.touch(cx, last, p);
self.points.push(p);
}
let points = std::mem::take(&mut self.points);
if std::mem::take(&mut self.touched).is_empty() {
return vec![];
}
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 }))]
}
_ => vec![],
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
let regions = &self.cache.regions;
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 });
}
}
if self.dragging && self.points.len() > 1 {
let mut bp = BezPath::new();
bp.move_to(self.points[0]);
for q in &self.points[1..] {
bp.line_to(*q);
}
out.push(Overlay::Path { path: bp, color, width: 1.0, dashed: false });
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
fn busy(&self) -> bool {
self.dragging
}
}
// ---------- Live Paint Bucket ----------
/// What the Live Paint tools highlight under the cursor.
#[derive(Clone, Debug, Default, PartialEq)]
enum LpHover {
#[default]
None,
/// A face (or edge) of an existing Live Paint group.
Part(BezPath),
/// A region of the (not yet live) selection.
Region(usize),
}
fn lp_hover(cx: &ToolContext, cache: &mut RegionCache, p: Point, edge: bool) -> LpHover {
if let Some(g) = live_paint_at(cx.doc, p).and_then(|id| cx.doc.node(id)) {
let part = if edge { edge_near(g, p, cx.tol(4.0)) } else { face_at(g, p) };
return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default();
}
if edge {
// Edges of every Live Paint group near the pointer.
let mut found = None;
cx.doc.walk(|n| {
if is_live_paint(n)
&& let Some(e) = edge_near(n, p, cx.tol(4.0))
{
found = e.path_data().map(|pd| pd.to_bezpath());
}
});
if let Some(bp) = found {
return LpHover::Part(bp);
}
}
let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint));
if !sel_is_live && let Some(i) = cache.at(cx, p) {
return LpHover::Region(i);
}
LpHover::None
}
fn lp_overlays(h: &LpHover, cache: &RegionCache) -> Vec<Overlay> {
match h {
LpHover::None => vec![],
LpHover::Part(bp) => vec![Overlay::Path { path: bp.clone(), color: HIGHLIGHT_RED, width: 4.0, dashed: false }],
LpHover::Region(i) => cache
.regions
.get(*i)
.map(|(_, bp, _)| vec![Overlay::Path { path: bp.clone(), color: HIGHLIGHT_RED, width: 4.0, dashed: false }])
.unwrap_or_default(),
}
}
/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click
/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a
/// Live Paint group first.
pub struct LivePaintBucketTool {
cache: RegionCache,
hover: LpHover,
}
impl Default for LivePaintBucketTool {
fn default() -> Self {
Self { cache: RegionCache::live_paint(), hover: LpHover::None }
}
}
impl Tool for LivePaintBucketTool {
fn id(&self) -> &'static str {
"livePaintBucket"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = lp_hover(cx, &mut self.cache, p, ev.mods.shift);
vec![]
}
PointerKind::Down => {
self.hover = LpHover::None;
let point = json!([p.x, p.y]);
if ev.mods.shift {
let mut group = live_paint_at(cx.doc, p);
if group.is_none() {
cx.doc.walk(|n| {
if is_live_paint(n) && edge_near(n, p, cx.tol(4.0)).is_some() {
group = Some(n.id);
}
});
}
return match group {
Some(g) => {
vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))]
}
None => vec![],
};
}
if let Some(g) = live_paint_at(cx.doc, p) {
return vec![Action::Exec("livePaint.fill".into(), json!({ "group": g.0, "point": point }))];
}
if self.cache.at(cx, p).is_some() {
self.cache.key = None;
let roots = sorted_roots(cx.doc, &cx.selection.objects);
return vec![Action::Exec("livePaint.fill".into(), json!({ "ids": json_ids(&roots), "point": point }))];
}
vec![]
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
lp_overlays(&self.hover, &self.cache)
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
}
// ---------- Live Paint Selection ----------
/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge.
pub struct LivePaintSelectionTool {
cache: RegionCache,
hover: LpHover,
}
impl Default for LivePaintSelectionTool {
fn default() -> Self {
Self { cache: RegionCache::live_paint(), hover: LpHover::None }
}
}
impl Tool for LivePaintSelectionTool {
fn id(&self) -> &'static str {
"livePaintSelection"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = match lp_hover(cx, &mut self.cache, p, ev.mods.alt) {
h @ LpHover::Part(_) => h,
_ => LpHover::None,
};
vec![]
}
PointerKind::Down => {
let mut hit = None;
if let Some(g) = live_paint_at(cx.doc, p).and_then(|id| cx.doc.node(id)) {
let part = if ev.mods.alt { edge_near(g, p, cx.tol(4.0)) } else { face_at(g, p) };
hit = part.map(|n| n.id);
}
match (hit, ev.mods.shift) {
(Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
(Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))],
(None, true) => vec![],
(None, false) => vec![Action::Exec("select.none".into(), json!({}))],
}
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
lp_overlays(&self.hover, &self.cache)
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Arrow
}
}
/// Create a builder-family tool by id (None = not one of ours).
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
"shapeBuilder" => Box::new(ShapeBuilderTool::default()),
"livePaintBucket" => Box::new(LivePaintBucketTool::default()),
"livePaintSelection" => Box::new(LivePaintSelectionTool::default()),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::{Appearance, Selection};
use vectorcraft_geom::shapes;
fn two_rects() -> (Document, NodeId, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art())).unwrap();
let b = d.alloc_id();
d.insert(Some(l), 1, Node::path(b, shapes::rectangle(Rect::new(50.0, 0.0, 150.0, 100.0)), Appearance::default_art())).unwrap();
(d, a, b)
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn shape_builder_hover_highlights_region() {
let (d, a, b) = two_rects();
let mut s = Selection::default();
s.set([a, b]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
assert!(t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)).is_empty());
let ov = t.overlays(&cx);
assert_eq!(ov.len(), 2);
let Overlay::Path { path, .. } = &ov[1] else { panic!() };
let bb = path.bounding_box();
assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "{bb:?}");
t.pointer(&cx, &ev(PointerKind::Move, 400.0, 400.0));
assert!(t.overlays(&cx).is_empty());
}
#[test]
fn shape_builder_drag_emits_merge() {
let (d, a, b) = two_rects();
let mut s = Selection::default();
s.set([b, a]);
let p = paint();
let cx = cx(&d, &s, &p);
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!(t.busy());
let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0));
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!(id, "shapeBuilder.merge");
assert_eq!(v["erase"], false);
assert_eq!(v["ids"], json!([a.0, b.0]), "sorted back to front");
assert!(v["points"].as_array().unwrap().len() >= 2);
}
#[test]
fn shape_builder_alt_drag_erases_and_empty_drag_does_nothing() {
let (d, a, b) = two_rects();
let mut s = Selection::default();
s.set([a, b]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0).with_mods(alt));
let acts = t.pointer(&cx, &ev(PointerKind::Up, 125.0, 50.0).with_mods(alt));
let Action::Exec(_, v) = &acts[0] else { panic!() };
assert_eq!(v["erase"], true);
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0));
assert!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)).is_empty());
}
#[test]
fn hatch_stays_inside_region() {
let bp = shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)).to_bezpath();
let h = hatch(&bp, 4.0);
let bb = h.bounding_box();
assert!(bb.x0 >= -1e-6 && bb.y0 >= -1e-6 && bb.x1 <= 40.0 + 1e-6 && bb.y1 <= 20.0 + 1e-6);
assert!(h.elements().len() > 10);
}
fn live_group_doc() -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let f1 = d.alloc_id();
let f2 = d.alloc_id();
let e = d.alloc_id();
let mut face1 = Node::path(f1, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default());
face1.name = Some(FACE_NAME.into());
let mut face2 = Node::path(f2, shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 50.0)), Appearance::default());
face2.name = Some(FACE_NAME.into());
let mut edge = Node::path(e, shapes::line(Point::new(50.0, 0.0), Point::new(50.0, 50.0)), Appearance::default());
edge.name = Some(EDGE_NAME.into());
let gid = d.alloc_id();
let mut g = Node::group(gid, vec![Arc::new(face1), Arc::new(face2), Arc::new(edge)]);
g.name = Some(LIVE_PAINT_NAME.into());
d.insert(Some(l), 0, g).unwrap();
(d, gid, f2, e)
}
#[test]
fn bucket_click_fills_face_and_shift_click_strokes_edge() {
let (d, gid, f2, _e) = live_group_doc();
assert_eq!(live_paint_at(&d, Point::new(75.0, 25.0)), Some(gid));
assert_eq!(face_at(d.node(gid).unwrap(), Point::new(75.0, 25.0)).unwrap().id, f2);
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LivePaintBucketTool::default();
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
let ov = t.overlays(&cx);
assert!(matches!(&ov[0], Overlay::Path { color: HIGHLIGHT_RED, .. }));
let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0));
let Action::Exec(id, v) = &acts[0] else { panic!() };
assert_eq!(id, "livePaint.fill");
assert_eq!(v["group"], gid.0);
let shift = Mods { shift: true, ..Default::default() };
let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(shift));
let Action::Exec(id, _) = &acts[0] else { panic!() };
assert_eq!(id, "livePaint.strokeEdge");
}
#[test]
fn bucket_on_plain_selection_makes_live_paint() {
let (d, a, b) = two_rects();
let mut s = Selection::default();
s.set([a, b]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LivePaintBucketTool::default();
let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0));
let Action::Exec(id, v) = &acts[0] else { panic!() };
assert_eq!(id, "livePaint.fill");
assert_eq!(v["ids"], json!([a.0, b.0]));
}
#[test]
fn live_paint_selection_selects_faces() {
let (d, _gid, f2, e) = live_group_doc();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LivePaintSelectionTool::default();
let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0));
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [f2.0] }))]);
let alt = Mods { alt: true, ..Default::default() };
let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt));
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [e.0] }))]);
let acts = t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0));
assert_eq!(acts, vec![Action::Exec("select.none".into(), json!({}))]);
}
#[test]
fn create_builds_the_three_tools() {
for id in ["shapeBuilder", "livePaintBucket", "livePaintSelection"] {
assert_eq!(create(id).unwrap().id(), id);
}
assert!(create("pen").is_none());
}
}