//! 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> { 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> { 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> { 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> { 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> { let mut best: Option<(f64, &Arc)> = 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 { 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, 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 { let mut v: Vec<(Vec, 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 { 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 = 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)>, regions: Vec<(po::Region, BezPath, Rect)>, } impl RegionCache { 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 { po::regions(&shapes) .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 { 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, points: Vec, touched: Vec, dragging: bool, alt: bool, } impl ShapeBuilderTool { fn touch(&mut self, cx: &ToolContext, from: Option, 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 { 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 = 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 { let mut out = vec![]; let regions = &self.cache.regions; let color = if self.dragging && self.alt { HIGHLIGHT_RED } else { HATCH }; let show: Vec = 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 { 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. #[derive(Default)] pub struct LivePaintBucketTool { cache: RegionCache, hover: LpHover, } impl Tool for LivePaintBucketTool { fn id(&self) -> &'static str { "livePaintBucket" } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { 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 { 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. #[derive(Default)] pub struct LivePaintSelectionTool { cache: RegionCache, hover: LpHover, } impl Tool for LivePaintSelectionTool { fn id(&self) -> &'static str { "livePaintSelection" } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { 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 { 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> { 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()); } }