Files
vectorcraft/crates/tools/src/draw2.rs
T
dotCooCoo 7d56260b47 Shaper: the Lasso, Smart Guides and the anchor tools skip a Shaper Group's original shapes
A Shaper Group keeps its original shapes as its first child, at no
opacity, and clicks do not pick them outside Construction mode. The
Lasso, the Add Anchor Point, Delete Anchor Point, Anchor Point and
Scissors tools still picked their anchor points and segments, also
where the group erased the face, and Smart Guides, Snap to Point and
the transform tools' reference point snapped to them. The Lasso and
the anchor tools also picked art outside the isolated group or object
in isolation mode.

The Lasso and the anchor tools now visit only the art a click can pick:
the isolated object in isolation mode, and a Shaper Group without its
original shapes unless they are isolated (Construction mode). Smart
Guides and Snap to Point leave the original shapes out of their
targets, and Spacing Guides leave out their bounds when a face moves.
The transform tools' reference point snaps to an original shape only
when that shape is selected, or under the pointer in Construction
mode. The Shaper Group test document moves into the shared test
helpers.

Docs: docs/shaper.md, docs/target-app-parity.md.
2026-10-18 12:34:37 +07:00

574 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, Vec2};
use crate::{Action, Tool, ToolContext};
pub use anchor::AnchorTool;
pub use curvature::{CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move, curvature_smooth};
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 handle's length as a fraction of its segment's chord where the handle runs along the chord.
/// It grows as the handle turns away from the chord ([`arc_handle`]).
pub(crate) const HANDLE_K: f64 = 1.0 / 3.0;
/// How long a handle leaving along `dir` should be for its segment, `chord`, to follow a circular
/// arc: `|chord| / (3 cos²(α/2))`, `α` the angle between the handle and the chord. A third of the
/// chord along it, up to two thirds at 90° (the cap: a handle turned further is treated as at 90°).
fn arc_handle(chord: Vec2, dir: Vec2) -> f64 {
let (l, ld) = (chord.hypot(), dir.hypot());
if !(l > 1e-9 && ld > 1e-12) {
return 0.0;
}
// cos²(α/2) = (1 + cos α) / 2.
let cos = (dir.dot(chord) / (ld * l)).clamp(0.0, 1.0);
2.0 * HANDLE_K * l / (1.0 + cos)
}
/// The sine of half the angle between a smooth point's chords (sin 15°) below which its handles
/// shorten: a turn tighter than 30° is cut back toward a corner instead of swinging round the
/// circle through the three points, which for a narrow V is a near-whole circle.
pub(crate) const TIGHT_TURN: f64 = 0.258_819_045_102_520_8;
/// The handles `(in, out)` of a smooth point `p` between `prev` and `next`, so the curve through
/// the three follows the circle through them: both along that circle's tangent at `p`, the
/// direction of `(next − p)/|next − p|² − (prev − p)/|prev − p|²` (the shorter side weighs more),
/// each as long as [`arc_handle`] says for its own segment; in a turn tighter than 30°
/// ([`TIGHT_TURN`]) shorter, by the square of how far it is under, so a narrow V comes to a point.
/// Three points in a line give a straight tangent. None when there is no direction: a chord of no length, or a fold straight back on
/// itself. The point is then a corner.
pub(crate) fn smooth_handles(prev: Point, p: Point, next: Point) -> Option<(Point, Point)> {
let (a, b) = (prev - p, next - p);
let (la2, lb2) = (a.hypot2(), b.hypot2());
if !(la2.is_finite() && lb2.is_finite()) || la2 <= 1e-18 || lb2 <= 1e-18 {
return None;
}
// In a line with `p` not between its neighbours, the path folds straight back.
if a.cross(b).abs() <= 1e-9 * la2.sqrt() * lb2.sqrt() && a.dot(b) > 0.0 {
return None;
}
let t = b / lb2 - a / la2;
let lt = t.hypot();
if !lt.is_finite() || lt * la2.sqrt().min(lb2.sqrt()) <= 1e-9 {
return None;
}
let u = t / lt;
// sin(φ/2) = √((1 − cos φ) / 2), φ the angle at `p` between the chords.
let cos = (a.dot(b) / (la2.sqrt() * lb2.sqrt())).clamp(-1.0, 1.0);
let k = (((1.0 - cos) / 2.0).sqrt() / TIGHT_TURN).min(1.0).powi(2);
let (h_in, h_out) = (p - u * (k * arc_handle(a, -u)), p + u * (k * arc_handle(b, u)));
finite(h_in, h_out)
}
/// The one handle an end `p` of an open path gets so its segment, to the neighbour `q`, follows
/// the same circle as the curve at `q`: `q`'s tangent mirrored in the chord, so the end leaves the
/// chord at the angle the curve meets `q` at, and as long as [`arc_handle`] says. Where `q`'s own
/// handle on their segment falls short of the circle's (a tight turn at `q`), the end handle turns
/// toward the chord and shortens in that share, so the end segment straightens too. `forward` when
/// `p` comes before `q` on the path (its first anchor), else after it (its last). None when `q`
/// has no handle to take a tangent from (a corner): the segment stays straight.
pub(crate) fn end_handle(p: Point, q: &Anchor, forward: bool) -> Option<Point> {
let t = if q.has_out() {
q.h_out - q.p
} else if q.has_in() {
q.p - q.h_in
} else {
return None;
};
let (t, chord) = (if forward { t } else { -t }, q.p - p);
let (lt, lc) = (t.hypot(), chord.hypot());
if !(lt > 1e-12 && lc > 1e-9) {
return None;
}
let (t, c) = (t / lt, chord / lc);
let d = c * (2.0 * t.dot(c)) - t;
let back = if forward { q.h_in } else { q.h_out } - q.p;
let full = arc_handle(p - q.p, back);
let share = if back.hypot() > 1e-9 && full > 1e-9 { (back.hypot() / full).min(1.0) } else { 1.0 };
// Short of the circle, the end also turns toward its chord in that share.
let d = c * (1.0 - share) + d * share;
let d = d / d.hypot().max(1e-12);
let h = p + d * (share * arc_handle(chord, d));
(h.x.is_finite() && h.y.is_finite()).then_some(h)
}
/// `(a, b)` when every coordinate is finite.
fn finite(a: Point, b: Point) -> Option<(Point, Point)> {
(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()).then_some((a, b))
}
/// 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 freehand fitting (Pencil,
/// Paintbrush); the Curvature tool has its own rule, [`curvature_spline`].
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)
}
/// A spline through `points` by the Curvature tool's rule (not a uniform Catmull-Rom: see
/// [`catmull_rom`], which the freehand fitting keeps): each stretch of three points follows the
/// circle through them. Every smooth point takes [`smooth_handles`] from its neighbours (a corner,
/// or one with no direction, gets none); with three or more points the ends of an open path get
/// [`end_handle`] into the curve unless flagged `true`, and a closed path has no ends. Points flagged `true` are corners; two points are a straight line. The
/// drawing, its preview and the engine's `path.curvature` all build the path with this.
pub fn curvature_spline(points: &[(Point, bool)], closed: bool) -> SubPath {
let n = points.len();
let mut anchors: Vec<Anchor> = Vec::with_capacity(n);
for (i, &(p, corner)) in points.iter().enumerate() {
let open_end = !closed && (i == 0 || i + 1 == n);
let neighbours = (n >= 3 && !corner && !open_end)
.then(|| (points.get((i + n - 1) % n), points.get((i + 1) % n)))
.and_then(|(prev, next)| Some((prev?.0, next?.0)));
anchors.push(match neighbours.and_then(|(prev, next)| smooth_handles(prev, p, next)) {
Some((h_in, h_out)) => Anchor { p, h_in, h_out, kind: AnchorKind::Smooth },
None => Anchor::corner(p),
});
}
if n >= 3 && !closed {
// The end handles mirror the neighbours' tangents, which are in place by now.
let (first, last) = (points.first().is_some_and(|f| !f.1), points.last().is_some_and(|l| !l.1));
let head = match (anchors.first(), anchors.get(1)) {
(Some(a), Some(b)) if first => end_handle(a.p, b, true),
_ => None,
};
let tail = match (anchors.last(), n.checked_sub(2).and_then(|j| anchors.get(j))) {
(Some(a), Some(b)) if last => end_handle(a.p, b, false),
_ => None,
};
if let (Some(h), Some(a)) = (head, anchors.first_mut()) {
a.h_out = h;
}
if let (Some(h), Some(a)) = (tail, anchors.last_mut()) {
a.h_in = h;
}
}
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
}
/// Visit `n` and everything inside it, depth first in paint order, but a Shaper Group's original
/// shapes (its first child), which are not shown.
pub(crate) fn walk_shown<'a>(n: &'a vectorcraft_doc::Node, f: &mut impl FnMut(&'a vectorcraft_doc::Node)) {
f(n);
for c in n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())) {
walk_shown(c, f);
}
}
/// Visit the nodes the anchor tools and the Lasso may pick, bottom to top ([`walk_shown`]). In
/// isolation mode that is the isolated object and its contents, so in Construction mode, where a
/// Shaper Group's original shapes are isolated, it visits them.
pub(crate) fn walk_pickable<'a>(cx: &ToolContext<'a>, mut f: impl FnMut(&'a vectorcraft_doc::Node)) {
if let Some(id) = cx.isolation
&& let Some(n) = cx.doc.node(id)
{
walk_shown(n, &mut f);
} else if cx.isolation.is_none() {
for l in &cx.doc.layers {
walk_shown(l, &mut f);
}
}
}
/// Editable path nodes, selected ones first, then the rest top-most first ([`walk_pickable`]).
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![];
walk_pickable(cx, |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::*;
use vectorcraft_geom::ParamCurve;
#[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());
}
/// The freehand fitting (Pencil, Paintbrush) keeps its rule: a symmetric handle of a sixth of
/// the chord between the neighbours, corners and open ends without handles.
#[test]
fn catmull_rom_keeps_the_freehand_rule() {
let pts = [(pt(0.0, 0.0), false), (pt(10.0, 0.0), false), (pt(200.0, 100.0), false), (pt(150.0, 160.0), true), (pt(0.0, 200.0), false)];
let sp = catmull_rom(&pts, false);
let t = (pts[2].0 - pts[0].0) / 6.0;
assert_eq!((sp.anchors[1].h_in, sp.anchors[1].h_out), (pts[1].0 - t, pts[1].0 + t));
let t = (pts[3].0 - pts[1].0) / 6.0;
assert_eq!((sp.anchors[2].h_in, sp.anchors[2].h_out), (pts[2].0 - t, pts[2].0 + t));
for i in [0, 3, 4] {
assert_eq!(sp.anchors[i], Anchor::corner(pts[i].0), "anchor {i}");
}
// Closed: every smooth point wraps round.
let t = (pts[1].0 - pts[4].0) / 6.0;
let closed = catmull_rom(&pts, true);
assert_eq!((closed.anchors[0].h_in, closed.anchors[0].h_out), (pts[0].0 - t, pts[0].0 + t));
}
#[test]
fn three_points_curve_through_all_of_them() {
let pts = [(pt(0.0, 0.0), false), (pt(50.0, 50.0), false), (pt(100.0, 0.0), false)];
let sp = curvature_spline(&pts, false);
assert_eq!(sp.anchors.len(), 3);
assert_eq!((sp.anchors[1].p, sp.anchors[1].kind), (pt(50.0, 50.0), AnchorKind::Smooth));
// The middle tangent is horizontal.
assert!((sp.anchors[1].h_out.y - 50.0).abs() < 1e-9);
// Both ends get one handle into the curve, none on the outer side.
let (first, last) = (sp.anchors[0], sp.anchors[2]);
assert!(first.has_out() && !first.has_in() && last.has_in() && !last.has_out());
assert!(!sp.segment_is_line(0) && !sp.segment_is_line(1));
}
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn smooth(points: &[(f64, f64)]) -> Vec<(Point, bool)> {
points.iter().map(|&(x, y)| (pt(x, y), false)).collect()
}
fn finite(sp: &SubPath) -> bool {
sp.anchors.iter().all(|a| [a.p, a.h_in, a.h_out].iter().all(|q| q.x.is_finite() && q.y.is_finite()))
}
/// The point at `deg` degrees on the circle of radius `r` round `c`.
fn on_circle(c: Point, r: f64, deg: f64) -> Point {
let a = deg.to_radians();
Point::new(c.x + r * a.cos(), c.y + r * a.sin())
}
/// The largest relative error of the curve's distance from `c` against `r`, sampled.
fn off_circle(sp: &SubPath, c: Point, r: f64) -> f64 {
(0..sp.segment_count())
.flat_map(|i| (0..=50).map(move |k| (i, f64::from(k) / 50.0)))
.map(|(i, t)| (sp.segment(i).eval(t).distance(c) - r).abs() / r)
.fold(0.0, f64::max)
}
/// The angle between two directions, in degrees.
fn angle(a: Vec2, b: Vec2) -> f64 {
a.cross(b).abs().atan2(a.dot(b)).to_degrees()
}
/// Three points on a circle give a curve that stays on it, the ends included, however they
/// are spaced along it (a J or a U as much as an even arc).
#[test]
fn three_points_on_a_circle_follow_it() {
let c = pt(300.0, 300.0);
for degs in [[210.0, 270.0, 330.0], [180.0, 230.0, 340.0], [150.0, 270.0, 30.0], [100.0, 180.0, 280.0]] {
let pts: Vec<(Point, bool)> = degs.iter().map(|&d| (on_circle(c, 100.0, d), false)).collect();
let sp = curvature_spline(&pts, false);
let e = off_circle(&sp, c, 100.0);
assert!(e < 0.01, "{degs:?}: {:.3}% off the circle", e * 100.0);
}
}
/// Three points: both ends get a handle, leaving the chord at the angle the curve meets the
/// middle point at, measured from the same chord.
#[test]
fn the_ends_leave_their_chord_as_the_curve_meets_the_middle() {
let pts = smooth(&[(100.0, 100.0), (130.0, 400.0), (420.0, 430.0)]);
let sp = curvature_spline(&pts, false);
let (first, mid, last) = (sp.anchors[0], sp.anchors[1], sp.anchors[2]);
assert!(first.has_out() && last.has_in());
let t = mid.h_out - mid.p;
let c = mid.p - first.p;
assert!((angle(first.h_out - first.p, c) - angle(t, c)).abs() < 1e-9);
let c = last.p - mid.p;
assert!((angle(last.p - last.h_in, c) - angle(t, c)).abs() < 1e-9);
}
/// The middle tangent is the circle's: for points symmetric about a vertical axis it is
/// horizontal, and the handle on the shorter side is the shorter.
#[test]
fn the_tangent_is_the_circles() {
let sp = curvature_spline(&smooth(&[(0.0, 0.0), (50.0, 80.0), (100.0, 0.0)]), false);
let m = sp.anchors[1];
assert!((m.h_out.y - m.p.y).abs() < 1e-9 && (m.h_in.y - m.p.y).abs() < 1e-9);
assert!(m.h_out.x > m.p.x && m.h_in.x < m.p.x);
let (prev, p, next) = (pt(0.0, 0.0), pt(10.0, 0.0), pt(200.0, 100.0));
let (h_in, h_out) = smooth_handles(prev, p, next).unwrap();
assert!(p.distance(h_in) < p.distance(h_out));
assert!((h_in - p).cross(h_out - p).abs() < 1e-9 && (h_in - p).dot(h_out - p) < 0.0, "in line, on either side");
// A handle along its chord is a third of it; turned 90° from it, two thirds.
assert!((arc_handle(Vec2::new(30.0, 0.0), Vec2::new(1.0, 0.0)) - 10.0).abs() < 1e-12);
assert!((arc_handle(Vec2::new(30.0, 0.0), Vec2::new(0.0, 1.0)) - 20.0).abs() < 1e-12);
assert!((arc_handle(Vec2::new(30.0, 0.0), Vec2::new(-1.0, 0.1)) - 20.0).abs() < 1e-12, "capped at 90°");
}
/// A narrow V comes to a point at its foot instead of swinging round the circle through its
/// three points, ends included; a turn of 30° or more keeps the circle's handles.
#[test]
fn a_narrow_v_comes_to_a_point() {
let sp = curvature_spline(&smooth(&[(0.0, 0.0), (35.0, 800.0), (70.0, 10.0)]), false);
for seg in 0..2 {
for k in 0..=40 {
let x = sp.segment(seg).eval(f64::from(k) / 40.0).x;
assert!((-3.0..=73.0).contains(&x), "segment {seg} reaches x = {x}");
}
}
let m = sp.anchors[1];
assert!(m.p.distance(m.h_out) < 0.1 * m.p.distance(pt(70.0, 10.0)));
// A U whose chords meet at about 45° still follows its circle.
let c = pt(300.0, 300.0);
let pts: Vec<(Point, bool)> = [225.0, 90.0, 315.0].iter().map(|&d| (on_circle(c, 100.0, d), false)).collect();
assert!(off_circle(&curvature_spline(&pts, false), c, 100.0) < 0.01);
}
/// Two line segments `(a, b)` and `(c, d)` cross.
fn crosses(a: Point, b: Point, c: Point, d: Point) -> bool {
let side = |p: Point, q: Point, r: Point| (q - p).cross(r - p);
let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d));
d1 * d2 < 0.0 && d3 * d4 < 0.0
}
/// Very uneven spacing: no NaN, and the curve never crosses itself.
#[test]
fn very_uneven_spacing_stays_finite_and_simple() {
for pts in [
vec![(0.0, 0.0), (1.0, 0.0), (1000.0, 500.0), (1001.0, 520.0), (1500.0, 0.0)],
vec![(0.0, 0.0), (800.0, 10.0), (802.0, 400.0), (0.5, 410.0)],
vec![(0.0, 0.0), (0.001, 0.001), (300.0, 0.0), (300.0, 900.0), (299.0, 901.0)],
] {
let sp = curvature_spline(&smooth(&pts), false);
assert!(finite(&sp), "{pts:?}");
let poly: Vec<Point> =
(0..sp.segment_count()).flat_map(|i| (0..30).map(move |k| (i, f64::from(k) / 30.0))).map(|(i, t)| sp.segment(i).eval(t)).collect();
for i in 0..poly.len() - 1 {
for j in i + 2..poly.len() - 1 {
assert!(!crosses(poly[i], poly[i + 1], poly[j], poly[j + 1]), "{pts:?} crosses itself near {:?}", poly[i]);
}
}
}
}
#[test]
fn two_points_are_a_line_and_corners_have_no_handles() {
let sp = curvature_spline(&smooth(&[(0.0, 0.0), (80.0, 40.0)]), false);
assert!(sp.anchors.iter().all(|a| !a.has_in() && !a.has_out()) && sp.segment_is_line(0));
// An Alt-clicked middle point is a corner; its neighbours keep their end handle only where
// the neighbour has a handle on that segment, so both segments here stay straight.
let pts = [(pt(0.0, 0.0), false), (pt(50.0, 50.0), true), (pt(100.0, 0.0), false)];
let sp = curvature_spline(&pts, false);
assert!(!sp.anchors[1].has_in() && !sp.anchors[1].has_out());
assert!(sp.segment_is_line(0) && sp.segment_is_line(1));
// An Alt-clicked end stays a corner while the other end curves in.
let pts = [(pt(0.0, 0.0), true), (pt(50.0, 50.0), false), (pt(100.0, 0.0), false), (pt(150.0, 50.0), false)];
let sp = curvature_spline(&pts, false);
assert!(!sp.anchors[0].has_out() && sp.anchors[3].has_in());
}
#[test]
fn a_closed_path_is_smooth_all_round() {
let pts = smooth(&[(0.0, 0.0), (100.0, 10.0), (120.0, 90.0), (10.0, 100.0)]);
let sp = curvature_spline(&pts, true);
assert!(sp.closed);
for a in &sp.anchors {
assert_eq!(a.kind, AnchorKind::Smooth);
assert!((a.h_in - a.p).cross(a.h_out - a.p).abs() < 1e-9 * a.p.distance(a.h_out).max(1.0) * a.p.distance(a.h_in).max(1.0));
}
}
#[test]
fn degenerate_points_become_corners_without_nan() {
// A repeated point, and a fold straight back on itself.
let dup = curvature_spline(&smooth(&[(0.0, 0.0), (50.0, 0.0), (50.0, 0.0), (100.0, 40.0)]), false);
let fold = curvature_spline(&smooth(&[(0.0, 0.0), (100.0, 0.0), (0.0, 0.0)]), false);
let same = curvature_spline(&smooth(&[(5.0, 5.0), (5.0, 5.0), (5.0, 5.0)]), true);
for sp in [&dup, &fold, &same] {
assert!(finite(sp));
}
assert_eq!(fold.anchors[1].kind, AnchorKind::Corner);
assert!(!fold.anchors[1].has_in() && !fold.anchors[1].has_out());
assert!(smooth_handles(pt(0.0, 0.0), pt(0.0, 0.0), pt(1.0, 0.0)).is_none());
}
#[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());
}
}