223 lines
7.9 KiB
Rust
223 lines
7.9 KiB
Rust
//! Drawing and path-editing tools (pencil, curvature, anchor tools, scissors, knife, line family…).
|
|
//!
|
|
//! Like every tool these only emit [`crate::Action`]s; the geometry work happens in the engine's
|
|
//! `path.*` commands (`crates/engine/src/cmd/draw2.rs`), so gestures replay through the journal.
|
|
|
|
mod anchor;
|
|
mod curvature;
|
|
mod family;
|
|
mod gesture;
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{NodeId, NodeKind};
|
|
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
|
|
|
|
use crate::{Action, Tool, ToolContext};
|
|
|
|
pub use anchor::AnchorTool;
|
|
pub use curvature::{CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
|
|
pub use family::FamilyTool;
|
|
pub use gesture::GestureTool;
|
|
|
|
/// Colour of tool feedback (Illustrator's selection blue).
|
|
pub const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
|
|
|
|
/// Create a drawing tool by id (None = not one of ours).
|
|
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
|
Some(match id {
|
|
"pencil" | "paintbrush" | "blobBrush" | "eraser" | "knife" | "smooth" | "pathEraser" | "join" => Box::new(GestureTool::new(id)),
|
|
"curvature" => Box::new(CurvatureTool::default()),
|
|
"addAnchor" | "deleteAnchor" | "anchorPoint" | "scissors" => Box::new(AnchorTool::new(id)),
|
|
"arc" | "spiral" | "rectangularGrid" | "polarGrid" => Box::new(FamilyTool::new(id)),
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// A spline through `points` (Catmull-Rom converted to cubic Béziers). Points flagged `true` are
|
|
/// corners (no handles); open ends have no handles either. Used by the Curvature tool and by the
|
|
/// engine's freehand fitting.
|
|
pub fn catmull_rom(points: &[(Point, bool)], closed: bool) -> SubPath {
|
|
let n = points.len();
|
|
let mut anchors = Vec::with_capacity(n);
|
|
for i in 0..n {
|
|
let (p, corner) = points[i];
|
|
let ends = !closed && (i == 0 || i + 1 == n);
|
|
if corner || ends || n < 3 {
|
|
anchors.push(Anchor::corner(p));
|
|
continue;
|
|
}
|
|
let prev = points[(i + n - 1) % n].0;
|
|
let next = points[(i + 1) % n].0;
|
|
let t = (next - prev) / 6.0;
|
|
if t.hypot() < 1e-9 {
|
|
anchors.push(Anchor::corner(p));
|
|
continue;
|
|
}
|
|
anchors.push(Anchor { p, h_in: p - t, h_out: p + t, kind: AnchorKind::Smooth });
|
|
}
|
|
SubPath::new(anchors, closed && n > 2)
|
|
}
|
|
|
|
/// `[[x, y], …]` for command params.
|
|
pub fn points_json(pts: &[Point]) -> Value {
|
|
Value::Array(pts.iter().map(|p| json!([p.x, p.y])).collect())
|
|
}
|
|
|
|
/// A polyline preview path.
|
|
pub fn polyline(pts: &[Point]) -> BezPath {
|
|
let mut bp = BezPath::new();
|
|
if let Some(f) = pts.first() {
|
|
bp.move_to(*f);
|
|
for p in &pts[1..] {
|
|
bp.line_to(*p);
|
|
}
|
|
}
|
|
bp
|
|
}
|
|
|
|
/// Editable path nodes, selected ones first, then the rest top-most first.
|
|
fn candidate_paths(cx: &ToolContext) -> Vec<NodeId> {
|
|
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(|n| n.path_data().is_some())).collect();
|
|
let mut rest = vec![];
|
|
cx.doc.walk(|n| {
|
|
if let NodeKind::Path { guide: false, .. } = &n.kind {
|
|
rest.push(n.id);
|
|
}
|
|
});
|
|
rest.reverse();
|
|
for id in rest {
|
|
if !v.contains(&id) {
|
|
v.push(id);
|
|
}
|
|
}
|
|
v.retain(|id| cx.doc.is_editable(*id));
|
|
v
|
|
}
|
|
|
|
/// The selected paths the Pen and the Curvature tool edit (not guides, nor locked or hidden ones).
|
|
pub(crate) fn editable_paths<'a>(cx: &'a ToolContext) -> impl Iterator<Item = NodeId> + 'a {
|
|
cx.selection
|
|
.objects
|
|
.iter()
|
|
.copied()
|
|
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
|
|
}
|
|
|
|
/// The paths whose anchors show with the Pen: the selected ones, and those inside selected groups
|
|
/// and compound paths (not guides, nor locked or hidden ones). Pen + Alt converts their anchors.
|
|
pub(crate) fn shown_paths(cx: &ToolContext) -> Vec<NodeId> {
|
|
fn inside(n: &vectorcraft_doc::Node, v: &mut Vec<NodeId>) {
|
|
match &n.kind {
|
|
NodeKind::Path { guide: false, .. } => v.push(n.id),
|
|
// A Shape Builder group's first child is its shaper, not art.
|
|
NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
|
for c in children.iter().skip(usize::from(n.extras().shaper.is_some())) {
|
|
inside(c, v);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let mut v = vec![];
|
|
for n in cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)) {
|
|
inside(n, &mut v);
|
|
}
|
|
v.retain(|id| cx.doc.is_editable(*id));
|
|
v
|
|
}
|
|
|
|
/// Nearest anchor within `tol`: (path, subpath, anchor).
|
|
pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
|
|
anchor_in(cx, candidate_paths(cx), p, tol)
|
|
}
|
|
|
|
/// Nearest anchor within `tol` on the first of `ids` that has one: (path, subpath, anchor).
|
|
pub(crate) fn anchor_in(cx: &ToolContext, ids: impl IntoIterator<Item = NodeId>, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
|
|
let mut best: Option<((NodeId, usize, usize), f64)> = None;
|
|
for id in ids {
|
|
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
|
|
for (si, ai, a) in pd.anchors() {
|
|
let d = a.p.distance(p);
|
|
if d <= tol && best.is_none_or(|b| d < b.1 - 1e-9) {
|
|
best = Some(((id, si, ai), d));
|
|
}
|
|
}
|
|
if best.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
best.map(|b| b.0)
|
|
}
|
|
|
|
/// Nearest point on a path segment within `tol`: (path, subpath, segment, t).
|
|
pub(crate) fn hit_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, f64)> {
|
|
segment_in(cx, candidate_paths(cx), p, tol)
|
|
}
|
|
|
|
/// Nearest point within `tol` on a segment of the first of `ids` that passes there: (path,
|
|
/// subpath, segment, t).
|
|
pub(crate) fn segment_in(cx: &ToolContext, ids: impl IntoIterator<Item = NodeId>, p: Point, tol: f64) -> Option<(NodeId, usize, usize, f64)> {
|
|
for id in ids {
|
|
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
|
|
if let Some((si, seg, t, _, d)) = pd.nearest(p)
|
|
&& d <= tol
|
|
{
|
|
return Some((id, si, seg, t));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Add an anchor where [`hit_segment`] or [`segment_in`] found a segment.
|
|
pub(crate) fn insert_anchor((id, si, seg, t): (NodeId, usize, usize, f64)) -> Action {
|
|
Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))
|
|
}
|
|
|
|
/// Delete the anchor [`hit_anchor`] or [`anchor_in`] found.
|
|
pub(crate) fn remove_anchor((id, si, ai): (NodeId, usize, usize)) -> Action {
|
|
Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn catmull_rom_passes_through_points() {
|
|
let pts = [(Point::new(0.0, 0.0), false), (Point::new(50.0, 50.0), false), (Point::new(100.0, 0.0), false)];
|
|
let sp = catmull_rom(&pts, false);
|
|
assert_eq!(sp.anchors.len(), 3);
|
|
assert_eq!(sp.anchors[1].p, Point::new(50.0, 50.0));
|
|
assert_eq!(sp.anchors[1].kind, AnchorKind::Smooth);
|
|
// The middle tangent is horizontal (parallel to first → last).
|
|
assert!((sp.anchors[1].h_out.y - 50.0).abs() < 1e-9);
|
|
assert!(!sp.anchors[0].has_out());
|
|
}
|
|
|
|
#[test]
|
|
fn all_tools_exist() {
|
|
for id in [
|
|
"pencil",
|
|
"paintbrush",
|
|
"blobBrush",
|
|
"eraser",
|
|
"knife",
|
|
"smooth",
|
|
"pathEraser",
|
|
"join",
|
|
"curvature",
|
|
"addAnchor",
|
|
"deleteAnchor",
|
|
"anchorPoint",
|
|
"scissors",
|
|
"arc",
|
|
"spiral",
|
|
"rectangularGrid",
|
|
"polarGrid",
|
|
] {
|
|
assert_eq!(create(id).unwrap().id(), id);
|
|
}
|
|
assert!(create("selection").is_none());
|
|
}
|
|
}
|