mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
724 lines
26 KiB
Rust
724 lines
26 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.chunks_exact(2) {
|
|
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)>,
|
|
}
|
|
|
|
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<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.
|
|
#[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<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.
|
|
#[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<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());
|
|
}
|
|
}
|