mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
M0: workspace skeleton — geom, color, doc, render, text, pathops, svg, tools, engine
Engine-first Cargo workspace for DrawCraft (clean-room Illustrator): - geom: anchor-based path model, shapes, hit testing, snapping - color: colours, paints, gradients, swatches, blend modes, harmonies - doc: persistent Arc document tree, appearance stacks, text model, selection - render: vello_cpu renderer (appearance, gradients, clip groups, blend modes) - text: font DB, harfrust shaping, point/area/path layout - pathops: curve booleans (linesweeper), Pathfinder, offset, simplify - svg: import (usvg) and export - tools: selection/direct/pen/shape tools emitting commands - engine: Session, history with interactions, ~110 commands Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fa1689bfd8
commit
bfee80a22c
@@ -0,0 +1,513 @@
|
||||
//! Curve-preserving boolean operations built on `linesweeper`'s robust sweep-line topology.
|
||||
//!
|
||||
//! Inputs are converted to closed [`BezPath`]s (open subpaths are implicitly closed, as a filled
|
||||
//! open path is painted in Illustrator). The sweep splits curves at intersections and y-extrema;
|
||||
//! [`tidy_segments`] then re-joins pieces that came from the same smooth curve by refitting, so
|
||||
//! results have roughly as few anchors as the inputs.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use drawcraft_geom::{Anchor, FillRule, PathData, SubPath};
|
||||
use kurbo::{BezPath, CubicBez, PathSeg, Point, Shape as _};
|
||||
use linesweeper::topology::{ContourIdx, Contours, Topology, WindingNumber};
|
||||
|
||||
use crate::PathOpsError;
|
||||
use crate::fit::{end_tangent, fit_single, is_straight, sample, start_tangent};
|
||||
|
||||
/// Default curve-refit tolerance (document points) used to merge split curve pieces back together.
|
||||
/// Similar in spirit to Illustrator's Pathfinder "Precision" option (0.028 pt).
|
||||
pub const DEFAULT_PRECISION: f64 = 0.01;
|
||||
|
||||
/// Binary boolean operation.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum BoolOp {
|
||||
/// A ∪ B
|
||||
Union,
|
||||
/// A ∩ B
|
||||
Intersect,
|
||||
/// A − B
|
||||
Difference,
|
||||
/// A ⊕ B (symmetric difference)
|
||||
Xor,
|
||||
}
|
||||
|
||||
impl BoolOp {
|
||||
/// Evaluate the operation on "inside A" / "inside B" flags.
|
||||
pub fn apply(self, a: bool, b: bool) -> bool {
|
||||
match self {
|
||||
BoolOp::Union => a || b,
|
||||
BoolOp::Intersect => a && b,
|
||||
BoolOp::Difference => a && !b,
|
||||
BoolOp::Xor => a != b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn inside(rule: FillRule, w: i32) -> bool {
|
||||
match rule {
|
||||
FillRule::NonZero => w != 0,
|
||||
FillRule::EvenOdd => w % 2 != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Winding numbers for an arbitrary number of input sets (tag = input index).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Multi(pub(crate) Vec<i32>);
|
||||
|
||||
impl Multi {
|
||||
fn get(&self, i: usize) -> i32 {
|
||||
self.0.get(i).copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Multi {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
let n = self.0.len().max(other.0.len());
|
||||
(0..n).all(|i| self.get(i) == other.get(i))
|
||||
}
|
||||
}
|
||||
impl Eq for Multi {}
|
||||
|
||||
impl std::ops::Add for Multi {
|
||||
type Output = Multi;
|
||||
fn add(mut self, rhs: Self) -> Multi {
|
||||
self += rhs;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::AddAssign for Multi {
|
||||
fn add_assign(&mut self, rhs: Self) {
|
||||
if self.0.len() < rhs.0.len() {
|
||||
self.0.resize(rhs.0.len(), 0);
|
||||
}
|
||||
for (a, b) in self.0.iter_mut().zip(rhs.0) {
|
||||
*a += b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WindingNumber for Multi {
|
||||
type Tag = usize;
|
||||
fn single(tag: usize, positive: bool) -> Self {
|
||||
let mut v = vec![0; tag + 1];
|
||||
v[tag] = if positive { 1 } else { -1 };
|
||||
Multi(v)
|
||||
}
|
||||
fn of_tag(&self, tag: usize) -> Self {
|
||||
let mut v = vec![0; tag + 1];
|
||||
v[tag] = self.get(tag);
|
||||
Multi(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a (filled) path to a closed BezPath: every subpath with ≥ 2 anchors is closed.
|
||||
pub(crate) fn fill_bezpath(p: &PathData) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
for sp in &p.subpaths {
|
||||
if sp.anchors.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if sp.closed {
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
} else {
|
||||
let mut c = sp.clone();
|
||||
c.closed = true;
|
||||
c.to_bezpath_into(&mut bp);
|
||||
}
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
/// Sweep tolerance for a set of paths (same scaling rule as linesweeper's own `binary_op`).
|
||||
pub(crate) fn eps_for<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> Result<f64, PathOpsError> {
|
||||
let mut m: f64 = 0.0;
|
||||
for p in paths {
|
||||
for el in p.elements() {
|
||||
for pt in el_points(el) {
|
||||
if !pt.x.is_finite() || !pt.y.is_finite() {
|
||||
return Err(PathOpsError::NonFinite);
|
||||
}
|
||||
m = m.max(pt.x.abs()).max(pt.y.abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((m * f64::EPSILON * 64.0).max(1e-6))
|
||||
}
|
||||
|
||||
fn el_points(el: &kurbo::PathEl) -> impl Iterator<Item = Point> {
|
||||
use kurbo::PathEl::*;
|
||||
let v: [Option<Point>; 3] = match *el {
|
||||
MoveTo(p) | LineTo(p) => [Some(p), None, None],
|
||||
QuadTo(a, b) => [Some(a), Some(b), None],
|
||||
CurveTo(a, b, c) => [Some(a), Some(b), Some(c)],
|
||||
ClosePath => [None, None, None],
|
||||
};
|
||||
v.into_iter().flatten()
|
||||
}
|
||||
|
||||
/// An arrangement (planar subdivision) of N filled paths.
|
||||
pub(crate) struct Arrangement {
|
||||
pub(crate) top: Topology<Multi>,
|
||||
pub(crate) rules: Vec<FillRule>,
|
||||
/// Output cleanup that keeps the inputs' anchors.
|
||||
pub(crate) tidy: Tidy,
|
||||
}
|
||||
|
||||
/// Output cleanup settings: refit `precision`, and anchor positions that must survive (the
|
||||
/// inputs' own anchors — only joints the sweep introduced are merged away).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Tidy {
|
||||
pub(crate) precision: f64,
|
||||
keep: Vec<Point>,
|
||||
/// Input curves (with inflated bounds) used to rebuild split pieces exactly.
|
||||
curves: Vec<(kurbo::Rect, CubicBez)>,
|
||||
}
|
||||
|
||||
impl Tidy {
|
||||
/// Merge any smooth joint.
|
||||
pub(crate) fn free(precision: f64) -> Self {
|
||||
Self { precision, keep: Vec::new(), curves: Vec::new() }
|
||||
}
|
||||
/// Keep every on-curve point of `paths`.
|
||||
pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator<Item = &'a BezPath>) -> Self {
|
||||
let mut keep = Vec::new();
|
||||
let mut curves = Vec::new();
|
||||
for p in paths {
|
||||
for s in p.segments() {
|
||||
let seg = to_seg(s);
|
||||
keep.push(seg.c.p0);
|
||||
if !seg.line {
|
||||
let r = seg.c.bounding_box().inflate(1e-6, 1e-6);
|
||||
curves.push((r, seg.c));
|
||||
}
|
||||
}
|
||||
}
|
||||
keep.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
Self { precision, keep, curves }
|
||||
}
|
||||
fn is_kept(&self, p: Point) -> bool {
|
||||
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
|
||||
let lo = self.keep.partition_point(|q| q.x < p.x - tol);
|
||||
self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol)
|
||||
}
|
||||
}
|
||||
|
||||
impl Arrangement {
|
||||
pub(crate) fn new(paths: &[(BezPath, FillRule)]) -> Result<Self, PathOpsError> {
|
||||
let eps = eps_for(paths.iter().map(|p| &p.0))?;
|
||||
let top = Topology::<Multi>::from_paths(paths.iter().enumerate().map(|(i, (p, _))| (p, i)), eps).map_err(|_| PathOpsError::OpenPath)?;
|
||||
let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|p| &p.0));
|
||||
Ok(Self { top, rules: paths.iter().map(|p| p.1).collect(), tidy })
|
||||
}
|
||||
|
||||
pub(crate) fn mask(&self, w: &Multi) -> Vec<bool> {
|
||||
self.rules.iter().enumerate().map(|(i, &r)| inside(r, w.get(i))).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours {
|
||||
self.top.contours(|w| pred(&self.mask(w)))
|
||||
}
|
||||
|
||||
/// All distinct non-empty coverage masks that occur next to some edge, sorted.
|
||||
pub(crate) fn distinct_masks(&self) -> Vec<Vec<bool>> {
|
||||
let mut set = BTreeSet::new();
|
||||
for s in self.top.segment_indices() {
|
||||
let h = s.first_half();
|
||||
for w in [self.top.winding_clockwise(h), self.top.winding_counter_clockwise(h)] {
|
||||
let m = self.mask(w);
|
||||
if m.iter().any(|&b| b) {
|
||||
set.insert(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
set.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Vertices where three or more edges meet (true intersections / touch points).
|
||||
pub(crate) fn junctions(&self) -> Vec<Point> {
|
||||
let mut count: std::collections::HashMap<(u64, u64), (usize, Point)> = std::collections::HashMap::new();
|
||||
for s in self.top.segment_indices() {
|
||||
for h in [s.first_half(), s.second_half()] {
|
||||
let p = self.top.point(h).to_kurbo();
|
||||
count.entry((p.x.to_bits(), p.y.to_bits())).or_insert((0, p)).0 += 1;
|
||||
}
|
||||
}
|
||||
let mut v: Vec<Point> = count.into_values().filter(|(c, _)| *c >= 3).map(|(_, p)| p).collect();
|
||||
v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert selected contours into a tidy compound path.
|
||||
pub(crate) fn contours_to_path(c: &Contours, idx: impl IntoIterator<Item = ContourIdx>, tidy: &Tidy) -> PathData {
|
||||
let mut subpaths = Vec::new();
|
||||
for i in idx {
|
||||
if is_sliver(&c[i].path, tidy.precision) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sp) = tidy_bezpath(&c[i].path, tidy) {
|
||||
subpaths.push(sp);
|
||||
}
|
||||
}
|
||||
PathData::new(subpaths)
|
||||
}
|
||||
|
||||
pub(crate) fn all_contours_to_path(c: &Contours, tidy: &Tidy) -> PathData {
|
||||
contours_to_path(c, (0..c.contours().count()).map(ContourIdx), tidy)
|
||||
}
|
||||
|
||||
/// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris
|
||||
/// (e.g. the lens left between two coincident curves) and are dropped, like Illustrator's
|
||||
/// Pathfinder precision setting does.
|
||||
fn is_sliver(bp: &BezPath, precision: f64) -> bool {
|
||||
let a = bp.area().abs();
|
||||
let per = bp.perimeter(1e-6);
|
||||
per <= 0.0 || 2.0 * a / per < precision
|
||||
}
|
||||
|
||||
/// One segment of a path: a cubic plus whether it is a straight line.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Seg {
|
||||
pub(crate) c: CubicBez,
|
||||
pub(crate) line: bool,
|
||||
}
|
||||
|
||||
impl Seg {
|
||||
pub(crate) fn line(a: Point, b: Point) -> Self {
|
||||
Self { c: CubicBez::new(a, a, b, b), line: true }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_seg(s: PathSeg) -> Seg {
|
||||
match s {
|
||||
PathSeg::Line(l) => Seg::line(l.p0, l.p1),
|
||||
PathSeg::Quad(q) => {
|
||||
let c = q.raise();
|
||||
Seg { c, line: is_straight(&c, 1e-9) }
|
||||
}
|
||||
PathSeg::Cubic(c) => Seg { c, line: is_straight(&c, 1e-9 * (1.0 + c.p0.distance(c.p3))) },
|
||||
}
|
||||
}
|
||||
|
||||
fn tidy_bezpath(bp: &BezPath, tidy: &Tidy) -> Option<SubPath> {
|
||||
let segs: Vec<Seg> = bp.segments().map(to_seg).collect();
|
||||
let segs = tidy_segments(segs, true, tidy);
|
||||
segs_to_subpath(&segs, true)
|
||||
}
|
||||
|
||||
/// Build a subpath from a chain of segments.
|
||||
pub(crate) fn segs_to_subpath(segs: &[Seg], closed: bool) -> Option<SubPath> {
|
||||
if segs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let n = segs.len();
|
||||
let mut anchors = Vec::with_capacity(n + 1);
|
||||
for i in 0..n {
|
||||
let s = segs[i];
|
||||
let p = s.c.p0;
|
||||
let h_out = if s.line { p } else { s.c.p1 };
|
||||
let h_in = if i == 0 && !closed {
|
||||
p
|
||||
} else {
|
||||
let prev = segs[(i + n - 1) % n];
|
||||
if prev.line { p } else { prev.c.p2 }
|
||||
};
|
||||
anchors.push(Anchor::with_handles(p, h_in, h_out));
|
||||
}
|
||||
if !closed {
|
||||
let last = segs[n - 1];
|
||||
let p = last.c.p3;
|
||||
let h_in = if last.line { p } else { last.c.p2 };
|
||||
anchors.push(Anchor::with_handles(p, h_in, p));
|
||||
}
|
||||
if closed && anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(SubPath::new(anchors, closed))
|
||||
}
|
||||
|
||||
/// Merge segments that were split from one curve (or collinear lines) back together, dropping
|
||||
/// degenerate pieces. `closed` segments form a loop.
|
||||
pub(crate) fn tidy_segments(segs: Vec<Seg>, closed: bool, tidy: &Tidy) -> Vec<Seg> {
|
||||
let scale = segs.iter().map(|s| s.c.p0.distance(s.c.p3)).fold(0.0, f64::max);
|
||||
let tiny = (scale * 1e-9).max(1e-9);
|
||||
let mut segs: Vec<Seg> = segs
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
let r = kurbo::Rect::from_points(s.c.p0, s.c.p3).union_pt(s.c.p1).union_pt(s.c.p2);
|
||||
r.width() + r.height() > tiny
|
||||
})
|
||||
.collect();
|
||||
let n = segs.len();
|
||||
if n < 2 {
|
||||
return segs;
|
||||
}
|
||||
// Joint i sits at the start of segment i.
|
||||
let mergeable = |a: &Seg, b: &Seg| -> bool {
|
||||
if a.line != b.line || tidy.is_kept(b.c.p0) {
|
||||
return false;
|
||||
}
|
||||
let ta = end_tangent(&a.c);
|
||||
let tb = start_tangent(&b.c);
|
||||
ta.dot(tb) > 0.9998 && ta.cross(tb).abs() < 0.02
|
||||
};
|
||||
if closed {
|
||||
// Rotate so we start at a real corner (if there is one).
|
||||
if let Some(k) = (0..n).find(|&i| !mergeable(&segs[(i + n - 1) % n], &segs[i])) {
|
||||
segs.rotate_left(k);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Seg> = Vec::with_capacity(n);
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let mut cur = segs[i];
|
||||
let mut j = i + 1;
|
||||
while j < n && mergeable(&segs[j - 1], &segs[j]) {
|
||||
match try_merge(&segs[i..=j], tidy) {
|
||||
Some(m) => {
|
||||
cur = m;
|
||||
j += 1;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn try_merge(run: &[Seg], tidy: &Tidy) -> Option<Seg> {
|
||||
let precision = tidy.precision;
|
||||
let first = run[0].c;
|
||||
let last = run[run.len() - 1].c;
|
||||
if run[0].line {
|
||||
let chord = last.p3 - first.p0;
|
||||
let len = chord.hypot();
|
||||
if len < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let ok = run.iter().all(|s| ((s.c.p3 - first.p0).cross(chord) / len).abs() <= precision * 0.5);
|
||||
return ok.then(|| Seg::line(first.p0, last.p3));
|
||||
}
|
||||
if let Some(c) = exact_merge(run, tidy) {
|
||||
return Some(Seg { c, line: false });
|
||||
}
|
||||
let mut pts = Vec::with_capacity(run.len() * 12 + 1);
|
||||
for (k, s) in run.iter().enumerate() {
|
||||
sample(&s.c, 12, &mut pts, k == 0);
|
||||
}
|
||||
let (c, err, _) = fit_single(&pts, start_tangent(&first), end_tangent(&last));
|
||||
if err > precision {
|
||||
return None;
|
||||
}
|
||||
Some(Seg { c, line: false })
|
||||
}
|
||||
|
||||
/// If every piece of `run` lies on one input cubic, return the exact sub-curve spanning the run.
|
||||
fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option<CubicBez> {
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
let a = run[0].c.p0;
|
||||
let b = run[run.len() - 1].c.p3;
|
||||
let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs());
|
||||
let tol = 1e-6 * scale;
|
||||
for (r, c) in &tidy.curves {
|
||||
if !r.contains(a) || !r.contains(b) {
|
||||
continue;
|
||||
}
|
||||
let na = c.nearest(a, 1e-12);
|
||||
let nb = c.nearest(b, 1e-12);
|
||||
if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol {
|
||||
continue;
|
||||
}
|
||||
let on_curve = run.iter().all(|s| c.nearest(s.c.eval(0.5), 1e-12).distance_sq.sqrt() <= tol * 10.0);
|
||||
if !on_curve || (na.t - nb.t).abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let sub = if na.t < nb.t {
|
||||
c.subsegment(na.t..nb.t)
|
||||
} else {
|
||||
let r = c.subsegment(nb.t..na.t);
|
||||
CubicBez::new(r.p3, r.p2, r.p1, r.p0)
|
||||
};
|
||||
return Some(CubicBez::new(a, sub.p1, sub.p2, b));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve self-intersections / overlaps of a single filled path into simple, consistently
|
||||
/// oriented contours (outer contours and holes).
|
||||
pub fn normalize(path: &PathData, rule: FillRule) -> PathData {
|
||||
try_normalize(path, rule).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fallible [`normalize`].
|
||||
pub fn try_normalize(path: &PathData, rule: FillRule) -> Result<PathData, PathOpsError> {
|
||||
let bp = fill_bezpath(path);
|
||||
let c = normalize_bez(&bp, rule)?;
|
||||
Ok(all_contours_to_path(&c, &Tidy::keeping(DEFAULT_PRECISION, [&bp])))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_bez(bp: &BezPath, rule: FillRule) -> Result<Contours, PathOpsError> {
|
||||
let eps = eps_for([bp])?;
|
||||
let top = Topology::<i32>::from_path(bp, eps).map_err(|_| PathOpsError::OpenPath)?;
|
||||
Ok(top.contours(|w| inside(rule, *w)))
|
||||
}
|
||||
|
||||
/// Boolean operation between two filled paths. Returns an empty path on invalid (non-finite) input.
|
||||
pub fn boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp) -> PathData {
|
||||
try_boolean(a, a_rule, b, b_rule, op, DEFAULT_PRECISION).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Boolean operation with an explicit refit `precision`; errors on non-finite input.
|
||||
pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp, precision: f64) -> Result<PathData, PathOpsError> {
|
||||
let arr = Arrangement::new(&[(fill_bezpath(a), a_rule), (fill_bezpath(b), b_rule)])?;
|
||||
let c = arr.contours(|m| op.apply(m[0], m[1]));
|
||||
let tidy = Tidy { precision, ..arr.tidy };
|
||||
Ok(all_contours_to_path(&c, &tidy))
|
||||
}
|
||||
|
||||
/// N-ary boolean: the area where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a
|
||||
/// point is inside `paths[i]` under its fill rule.
|
||||
pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> PathData {
|
||||
let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect();
|
||||
match Arrangement::new(&bps) {
|
||||
Ok(arr) => all_contours_to_path(&arr.contours(pred), &arr.tidy),
|
||||
Err(_) => PathData::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Union of many filled paths. Each input is first normalised on its own, then all are swept
|
||||
/// together with a single integer winding number, which keeps this fast for thousands of shapes.
|
||||
pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData {
|
||||
let mut all = BezPath::new();
|
||||
for (p, r) in paths {
|
||||
let bp = fill_bezpath(p);
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(c) = normalize_bez(&bp, *r) else { continue };
|
||||
for ct in c.contours() {
|
||||
all.extend(ct.path.iter());
|
||||
}
|
||||
}
|
||||
if all.elements().is_empty() {
|
||||
return PathData::default();
|
||||
}
|
||||
let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
|
||||
match normalize_bez(&all, FillRule::NonZero) {
|
||||
Ok(c) => all_contours_to_path(&c, &tidy),
|
||||
Err(_) => PathData::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsigned filled area of a path under a fill rule (normalises first, so it is exact for
|
||||
/// self-overlapping input).
|
||||
pub fn area(path: &PathData, rule: FillRule) -> f64 {
|
||||
let bp = fill_bezpath(path);
|
||||
match normalize_bez(&bp, rule) {
|
||||
Ok(c) => c.contours().map(|ct| ct.path.area()).sum::<f64>().abs(),
|
||||
Err(_) => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average,
|
||||
//! Join, Split Into Grid.
|
||||
|
||||
use drawcraft_geom::{Anchor, PathData, SubPath};
|
||||
use kurbo::{ParamCurveArclen, Point, Rect, Vec2};
|
||||
|
||||
use crate::boolean::{Seg, segs_to_subpath};
|
||||
use crate::fit::{end_tangent, fit_cubics, fit_single, is_straight, sample, start_tangent};
|
||||
|
||||
/// Options for [`simplify_with`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SimplifyOptions {
|
||||
/// Maximum distance (document points) between the original and simplified path.
|
||||
pub tolerance: f64,
|
||||
/// Anchors where the path turns by more than this many degrees stay corners.
|
||||
pub corner_angle_deg: f64,
|
||||
/// Produce only straight segments (Illustrator's "Convert to Straight Lines").
|
||||
pub straight_lines: bool,
|
||||
}
|
||||
|
||||
impl Default for SimplifyOptions {
|
||||
fn default() -> Self {
|
||||
Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Simplify with the default corner threshold (30°).
|
||||
pub fn simplify(path: &PathData, tolerance: f64) -> PathData {
|
||||
simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() })
|
||||
}
|
||||
|
||||
fn subpath_segs(sp: &SubPath) -> Vec<Seg> {
|
||||
(0..sp.segment_count()).map(|i| Seg { c: sp.segment(i), line: sp.segment_is_line(i) }).collect()
|
||||
}
|
||||
|
||||
fn turn_deg(a: &Seg, b: &Seg) -> f64 {
|
||||
let d = end_tangent(&a.c).dot(start_tangent(&b.c)).clamp(-1.0, 1.0);
|
||||
d.acos().to_degrees()
|
||||
}
|
||||
|
||||
/// Least-squares Bézier refit of every subpath, keeping corners sharper than the threshold.
|
||||
pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData {
|
||||
let tol = opts.tolerance.max(1e-6);
|
||||
let mut out = Vec::new();
|
||||
for sp in &path.subpaths {
|
||||
let mut segs = subpath_segs(sp);
|
||||
let n = segs.len();
|
||||
if n == 0 {
|
||||
out.push(sp.clone());
|
||||
continue;
|
||||
}
|
||||
let corner_at = |segs: &[Seg], i: usize| -> bool {
|
||||
if !sp.closed && (i == 0 || i == n) {
|
||||
return true;
|
||||
}
|
||||
turn_deg(&segs[(i + n - 1) % n], &segs[i % n]) > opts.corner_angle_deg
|
||||
};
|
||||
let mut corners: Vec<usize> = (0..n).filter(|&i| corner_at(&segs, i)).collect();
|
||||
if sp.closed {
|
||||
if let Some(&k) = corners.first() {
|
||||
segs.rotate_left(k);
|
||||
corners = corners.iter().map(|&c| c - k).collect();
|
||||
} else {
|
||||
corners = vec![0];
|
||||
}
|
||||
corners.push(n);
|
||||
} else if corners.last() != Some(&n) {
|
||||
corners.push(n);
|
||||
}
|
||||
let mut result: Vec<Seg> = Vec::new();
|
||||
for w in corners.windows(2) {
|
||||
let run = &segs[w[0]..w[1]];
|
||||
if run.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
for (k, s) in run.iter().enumerate() {
|
||||
let len = s.c.arclen(1e-6);
|
||||
let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256);
|
||||
sample(&s.c, m, &mut pts, k == 0);
|
||||
}
|
||||
if opts.straight_lines {
|
||||
let keep = rdp(&pts, tol);
|
||||
result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1])));
|
||||
continue;
|
||||
}
|
||||
let t0 = start_tangent(&run[0].c);
|
||||
let t1 = end_tangent(&run[run.len() - 1].c);
|
||||
for c in fit_cubics(&pts, t0, t1, tol) {
|
||||
let line = is_straight(&c, tol * 0.02);
|
||||
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
|
||||
}
|
||||
}
|
||||
if let Some(s) = segs_to_subpath(&result, sp.closed) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
PathData::new(out)
|
||||
}
|
||||
|
||||
/// Ramer–Douglas–Peucker polyline reduction.
|
||||
fn rdp(pts: &[Point], tol: f64) -> Vec<Point> {
|
||||
if pts.len() < 3 {
|
||||
return pts.to_vec();
|
||||
}
|
||||
let mut keep = vec![false; pts.len()];
|
||||
keep[0] = true;
|
||||
keep[pts.len() - 1] = true;
|
||||
let mut stack = vec![(0, pts.len() - 1)];
|
||||
while let Some((a, b)) = stack.pop() {
|
||||
let (pa, pb) = (pts[a], pts[b]);
|
||||
let chord = pb - pa;
|
||||
let len = chord.hypot();
|
||||
let mut best = (0.0, a);
|
||||
for (i, &p) in pts.iter().enumerate().take(b).skip(a + 1) {
|
||||
let d = if len < 1e-12 { p.distance(pa) } else { (p - pa).cross(chord).abs() / len };
|
||||
if d > best.0 {
|
||||
best = (d, i);
|
||||
}
|
||||
}
|
||||
if best.0 > tol {
|
||||
keep[best.1] = true;
|
||||
stack.push((a, best.1));
|
||||
stack.push((best.1, b));
|
||||
}
|
||||
}
|
||||
pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect()
|
||||
}
|
||||
|
||||
/// Object → Path → Smooth: pull handles towards Catmull-Rom-like tangents. `amount` ∈ [0, 1]
|
||||
/// (0 = unchanged, 1 = fully smooth). Endpoints of open paths keep their handles.
|
||||
pub fn smooth(path: &PathData, amount: f64) -> PathData {
|
||||
let t = amount.clamp(0.0, 1.0);
|
||||
let mut out = path.clone();
|
||||
for sp in &mut out.subpaths {
|
||||
let n = sp.anchors.len();
|
||||
if n < 3 {
|
||||
continue;
|
||||
}
|
||||
let orig = sp.anchors.clone();
|
||||
for i in 0..n {
|
||||
if !sp.closed && (i == 0 || i == n - 1) {
|
||||
continue;
|
||||
}
|
||||
let prev = orig[(i + n - 1) % n].p;
|
||||
let next = orig[(i + 1) % n].p;
|
||||
let p = orig[i].p;
|
||||
let Some(dir) = crate::fit::unit(next - prev) else { continue };
|
||||
let tin = p - dir * (p.distance(prev) / 3.0);
|
||||
let tout = p + dir * (p.distance(next) / 3.0);
|
||||
let a = &orig[i];
|
||||
let h_in = a.h_in.lerp(tin, t);
|
||||
let h_out = a.h_out.lerp(tout, t);
|
||||
sp.anchors[i] = Anchor::with_handles(p, h_in, h_out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Remove anchors that can be deleted without changing the shape by more than `tolerance`
|
||||
/// (collinear corner points, duplicate points, and curve joints that one cubic can replace).
|
||||
pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData {
|
||||
let tol = tolerance.max(1e-9);
|
||||
let mut out = Vec::new();
|
||||
for sp in &path.subpaths {
|
||||
let mut segs = subpath_segs(sp);
|
||||
if segs.is_empty() {
|
||||
out.push(sp.clone());
|
||||
continue;
|
||||
}
|
||||
// Drop zero-length segments.
|
||||
segs.retain(|s| !(s.c.p0.distance(s.c.p3) < 1e-9 && s.c.p1.distance(s.c.p0) < 1e-9 && s.c.p2.distance(s.c.p0) < 1e-9));
|
||||
let min_segs = if sp.closed { 2 } else { 1 };
|
||||
let mut changed = true;
|
||||
while changed && segs.len() > min_segs {
|
||||
changed = false;
|
||||
let n = segs.len();
|
||||
let joints = if sp.closed { n } else { n - 1 };
|
||||
for j in 0..joints {
|
||||
let (i0, i1) = if sp.closed { ((j + n - 1) % n, j) } else { (j, j + 1) };
|
||||
if let Some(m) = merge_pair(&segs[i0], &segs[i1], tol) {
|
||||
segs[i0] = m;
|
||||
segs.remove(i1);
|
||||
if sp.closed && i1 == 0 {
|
||||
// The merged segment now starts the loop.
|
||||
let last = segs.pop().expect("non-empty");
|
||||
segs.insert(0, last);
|
||||
}
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = segs_to_subpath(&segs, sp.closed) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
PathData::new(out)
|
||||
}
|
||||
|
||||
fn merge_pair(a: &Seg, b: &Seg, tol: f64) -> Option<Seg> {
|
||||
if a.line && b.line {
|
||||
let chord = b.c.p3 - a.c.p0;
|
||||
let len = chord.hypot();
|
||||
if len < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let mid = a.c.p3 - a.c.p0;
|
||||
let along = mid.dot(chord) / len;
|
||||
return ((mid.cross(chord) / len).abs() <= tol && along >= 0.0 && along <= len).then(|| Seg::line(a.c.p0, b.c.p3));
|
||||
}
|
||||
// Only merge across (near-)smooth joints.
|
||||
if end_tangent(&a.c).dot(start_tangent(&b.c)) < 0.99 {
|
||||
return None;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
sample(&a.c, 16, &mut pts, true);
|
||||
sample(&b.c, 16, &mut pts, false);
|
||||
let (c, err, _) = fit_single(&pts, start_tangent(&a.c), end_tangent(&b.c));
|
||||
(err <= tol).then_some(Seg { c, line: false })
|
||||
}
|
||||
|
||||
/// Object → Path → Add Anchor Points: one new anchor at the middle (t = 0.5) of every segment.
|
||||
pub fn add_anchor_points(path: &PathData) -> PathData {
|
||||
let mut out = path.clone();
|
||||
for sp in &mut out.subpaths {
|
||||
for seg in (0..sp.segment_count()).rev() {
|
||||
sp.insert_anchor(seg, 0.5);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Axis for [`average`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum AverageAxis {
|
||||
/// Align on a common horizontal line (same y).
|
||||
Horizontal,
|
||||
/// Align on a common vertical line (same x).
|
||||
Vertical,
|
||||
/// Collapse to the common centroid.
|
||||
Both,
|
||||
}
|
||||
|
||||
/// Object → Path → Average: move the selected anchors `(subpath, anchor)` (with their handles)
|
||||
/// to their average position along `axis`.
|
||||
pub fn average(path: &PathData, selection: &[(usize, usize)], axis: AverageAxis) -> PathData {
|
||||
let mut out = path.clone();
|
||||
let pts: Vec<Point> = selection.iter().filter_map(|&(s, a)| path.subpaths.get(s).and_then(|sp| sp.anchors.get(a)).map(|a| a.p)).collect();
|
||||
if pts.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let c = pts.iter().fold(Vec2::ZERO, |acc, p| acc + p.to_vec2()) / pts.len() as f64;
|
||||
for &(s, a) in selection {
|
||||
if let Some(an) = out.anchor_mut(s, a) {
|
||||
let d = match axis {
|
||||
AverageAxis::Horizontal => Vec2::new(0.0, c.y - an.p.y),
|
||||
AverageAxis::Vertical => Vec2::new(c.x - an.p.x, 0.0),
|
||||
AverageAxis::Both => c - an.p.to_vec2(),
|
||||
};
|
||||
an.translate(d);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Object → Path → Join. Open subpaths of all inputs are joined nearest-endpoint-first into one
|
||||
/// open path (endpoints closer than `tolerance` are merged into a single anchor, otherwise a
|
||||
/// straight segment connects them). A single open subpath is closed instead. Closed subpaths
|
||||
/// pass through unchanged.
|
||||
pub fn join(paths: &[PathData], tolerance: f64) -> PathData {
|
||||
let mut closed: Vec<SubPath> = Vec::new();
|
||||
let mut open: Vec<SubPath> = Vec::new();
|
||||
for p in paths {
|
||||
for sp in &p.subpaths {
|
||||
if sp.closed || sp.anchors.len() < 2 {
|
||||
if !sp.anchors.is_empty() {
|
||||
closed.push(sp.clone());
|
||||
}
|
||||
} else {
|
||||
open.push(sp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if open.len() == 1 {
|
||||
let mut sp = open.pop().expect("one");
|
||||
let n = sp.anchors.len();
|
||||
if n > 2 && sp.anchors[0].p.distance(sp.anchors[n - 1].p) <= tolerance {
|
||||
let last = sp.anchors.pop().expect("n > 2");
|
||||
let f = &mut sp.anchors[0];
|
||||
*f = Anchor::with_handles(f.p, last.h_in, f.h_out);
|
||||
} else {
|
||||
let f = &mut sp.anchors[0];
|
||||
f.h_in = f.p;
|
||||
let l = sp.anchors.last_mut().expect("n >= 2");
|
||||
l.h_out = l.p;
|
||||
}
|
||||
sp.closed = true;
|
||||
closed.push(sp);
|
||||
return PathData::new(closed);
|
||||
}
|
||||
while open.len() > 1 {
|
||||
// Find the closest pair of endpoints on different subpaths.
|
||||
let mut best = (f64::INFINITY, 0, 0, false, false);
|
||||
for i in 0..open.len() {
|
||||
for j in (i + 1)..open.len() {
|
||||
for ei in [false, true] {
|
||||
for ej in [false, true] {
|
||||
let pi = if ei { open[i].anchors.last() } else { open[i].anchors.first() }.expect("len>=2").p;
|
||||
let pj = if ej { open[j].anchors.last() } else { open[j].anchors.first() }.expect("len>=2").p;
|
||||
let d = pi.distance(pj);
|
||||
if d < best.0 {
|
||||
best = (d, i, j, ei, ej);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (d, i, j, ei, ej) = best;
|
||||
let mut b = open.remove(j);
|
||||
let a = &mut open[i];
|
||||
if !ei {
|
||||
a.reverse();
|
||||
}
|
||||
if ej {
|
||||
b.reverse();
|
||||
}
|
||||
if d <= tolerance {
|
||||
let first = b.anchors.remove(0);
|
||||
let l = a.anchors.last_mut().expect("len>=2");
|
||||
*l = Anchor::with_handles(l.p, l.h_in, first.h_out);
|
||||
} else {
|
||||
let l = a.anchors.last_mut().expect("len>=2");
|
||||
l.h_out = l.p;
|
||||
b.anchors[0].h_in = b.anchors[0].p;
|
||||
}
|
||||
a.anchors.extend(b.anchors);
|
||||
}
|
||||
closed.extend(open);
|
||||
PathData::new(closed)
|
||||
}
|
||||
|
||||
/// Object → Path → Split Into Grid: `rows × cols` rectangles filling `rect` with `gutter` spacing
|
||||
/// (row-major, top-left first).
|
||||
pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec<PathData> {
|
||||
if rows == 0 || cols == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let w = (rect.width() - gutter * (cols as f64 - 1.0)) / cols as f64;
|
||||
let h = (rect.height() - gutter * (rows as f64 - 1.0)) / rows as f64;
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::with_capacity(rows * cols);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
let x = rect.x0 + c as f64 * (w + gutter);
|
||||
let y = rect.y0 + r as f64 * (h + gutter);
|
||||
out.push(PathData::single(SubPath::polyline(
|
||||
&[Point::new(x, y), Point::new(x + w, y), Point::new(x + w, y + h), Point::new(x, y + h)],
|
||||
true,
|
||||
)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end
|
||||
//! tangents, then reparameterise with Newton steps, then split at the worst point" approach from
|
||||
//! Graphics Gems, written from the published description).
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Point, Vec2};
|
||||
|
||||
/// Maximum Newton reparameterisation rounds per fit.
|
||||
const MAX_REPARAM: usize = 6;
|
||||
|
||||
/// Unit vector or `None` if (nearly) zero.
|
||||
pub(crate) fn unit(v: Vec2) -> Option<Vec2> {
|
||||
let l = v.hypot();
|
||||
if l > 1e-12 && l.is_finite() { Some(v / l) } else { None }
|
||||
}
|
||||
|
||||
/// Direction the curve leaves `p0` (robust to retracted handles).
|
||||
pub(crate) fn start_tangent(c: &CubicBez) -> Vec2 {
|
||||
unit(c.p1 - c.p0).or_else(|| unit(c.p2 - c.p0)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0))
|
||||
}
|
||||
|
||||
/// Direction the curve arrives at `p3`, i.e. pointing *forward* along the travel direction.
|
||||
pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 {
|
||||
unit(c.p3 - c.p2).or_else(|| unit(c.p3 - c.p1)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0))
|
||||
}
|
||||
|
||||
/// Fit a chain of cubics through `pts` with maximum (parametric) error `tol`.
|
||||
///
|
||||
/// `t0` is the unit tangent leaving `pts[0]`; `t1` is the unit tangent arriving at the last point
|
||||
/// (forward direction). Consecutive output cubics are G1 continuous.
|
||||
pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<CubicBez> {
|
||||
let mut out = Vec::new();
|
||||
let pts = dedup(pts);
|
||||
if pts.len() < 2 {
|
||||
return out;
|
||||
}
|
||||
fit_rec(&pts, t0, t1, tol.max(1e-9), &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
fn dedup(pts: &[Point]) -> Vec<Point> {
|
||||
let mut v: Vec<Point> = Vec::with_capacity(pts.len());
|
||||
for &p in pts {
|
||||
if v.last().is_none_or(|q: &Point| q.distance(p) > 1e-12) {
|
||||
v.push(p);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn fit_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<CubicBez>, depth: usize) {
|
||||
let n = pts.len();
|
||||
if n == 2 {
|
||||
let d = pts[0].distance(pts[1]) / 3.0;
|
||||
out.push(CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1]));
|
||||
return;
|
||||
}
|
||||
let (c, err, split) = fit_single(pts, t0, t1);
|
||||
if err <= tol || depth > 40 {
|
||||
out.push(c);
|
||||
return;
|
||||
}
|
||||
// Split at the worst point with a centre tangent estimated from its neighbours.
|
||||
let split = split.clamp(1, n - 2);
|
||||
let tc = unit(pts[split + 1] - pts[split - 1]).unwrap_or(t0);
|
||||
fit_rec(&pts[..=split], t0, tc, tol, out, depth + 1);
|
||||
fit_rec(&pts[split..], tc, t1, tol, out, depth + 1);
|
||||
}
|
||||
|
||||
/// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of
|
||||
/// the worst point).
|
||||
pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
|
||||
let mut u = chord_params(pts);
|
||||
let mut best = generate(pts, &u, t0, t1);
|
||||
let (mut best_err, mut best_split) = max_error(pts, &best, &u);
|
||||
for _ in 0..MAX_REPARAM {
|
||||
for (i, ui) in u.iter_mut().enumerate() {
|
||||
*ui = newton(&best, pts[i], *ui);
|
||||
}
|
||||
let c = generate(pts, &u, t0, t1);
|
||||
let (e, s) = max_error(pts, &c, &u);
|
||||
if e < best_err {
|
||||
best = c;
|
||||
best_err = e;
|
||||
best_split = s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(best, best_err, best_split)
|
||||
}
|
||||
|
||||
fn chord_params(pts: &[Point]) -> Vec<f64> {
|
||||
let mut u = Vec::with_capacity(pts.len());
|
||||
let mut acc = 0.0;
|
||||
u.push(0.0);
|
||||
for w in pts.windows(2) {
|
||||
acc += w[0].distance(w[1]);
|
||||
u.push(acc);
|
||||
}
|
||||
if acc > 0.0 {
|
||||
for x in &mut u {
|
||||
*x /= acc;
|
||||
}
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
fn bern(t: f64) -> [f64; 4] {
|
||||
let s = 1.0 - t;
|
||||
[s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t]
|
||||
}
|
||||
|
||||
/// Solve the 2x2 normal equations for the handle lengths along the fixed tangents.
|
||||
fn generate(pts: &[Point], u: &[f64], t0: Vec2, t1: Vec2) -> CubicBez {
|
||||
let p0 = pts[0];
|
||||
let p3 = *pts.last().unwrap();
|
||||
let dir_in = -t1; // handle at the end points backwards
|
||||
let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for (p, &t) in pts.iter().zip(u) {
|
||||
let b = bern(t);
|
||||
let a0 = t0 * b[1];
|
||||
let a1 = dir_in * b[2];
|
||||
c00 += a0.dot(a0);
|
||||
c01 += a0.dot(a1);
|
||||
c11 += a1.dot(a1);
|
||||
let tmp = *p - (p0.to_vec2() * (b[0] + b[1]) + p3.to_vec2() * (b[2] + b[3])).to_point();
|
||||
x0 += a0.dot(tmp);
|
||||
x1 += a1.dot(tmp);
|
||||
}
|
||||
let det = c00 * c11 - c01 * c01;
|
||||
let seg = p0.distance(p3);
|
||||
let eps = 1e-6 * seg;
|
||||
let (mut al, mut ar) = if det.abs() > 1e-12 { ((x0 * c11 - x1 * c01) / det, (c00 * x1 - c01 * x0) / det) } else { (seg / 3.0, seg / 3.0) };
|
||||
if !al.is_finite() || !ar.is_finite() || al < eps || ar < eps {
|
||||
al = seg / 3.0;
|
||||
ar = seg / 3.0;
|
||||
}
|
||||
// Guard against wild handles on nearly-degenerate data.
|
||||
let cap = seg * 4.0 + 1e-9;
|
||||
al = al.min(cap);
|
||||
ar = ar.min(cap);
|
||||
CubicBez::new(p0, p0 + t0 * al, p3 + dir_in * ar, p3)
|
||||
}
|
||||
|
||||
fn max_error(pts: &[Point], c: &CubicBez, u: &[f64]) -> (f64, usize) {
|
||||
let mut e = 0.0;
|
||||
let mut idx = pts.len() / 2;
|
||||
for (i, (p, &t)) in pts.iter().zip(u).enumerate() {
|
||||
let d = c.eval(t).distance(*p);
|
||||
if d > e {
|
||||
e = d;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
(e, idx)
|
||||
}
|
||||
|
||||
fn newton(c: &CubicBez, p: Point, t: f64) -> f64 {
|
||||
let d1 = c.deriv();
|
||||
let d2 = d1.deriv();
|
||||
let q = c.eval(t) - p;
|
||||
let q1 = d1.eval(t).to_vec2();
|
||||
let q2 = d2.eval(t).to_vec2();
|
||||
let num = q.dot(q1);
|
||||
let den = q1.dot(q1) + q.dot(q2);
|
||||
if den.abs() < 1e-12 {
|
||||
return t;
|
||||
}
|
||||
let nt = t - num / den;
|
||||
if nt.is_finite() { nt.clamp(0.0, 1.0) } else { t }
|
||||
}
|
||||
|
||||
/// Is the cubic (within `tol`) a straight line from p0 to p3 with handles inside the chord?
|
||||
pub(crate) fn is_straight(c: &CubicBez, tol: f64) -> bool {
|
||||
let chord = c.p3 - c.p0;
|
||||
let len = chord.hypot();
|
||||
if len < 1e-12 {
|
||||
return c.p1.distance(c.p0) <= tol && c.p2.distance(c.p0) <= tol;
|
||||
}
|
||||
let dir = chord / len;
|
||||
[c.p1, c.p2].iter().all(|&h| {
|
||||
let v = h - c.p0;
|
||||
let along = v.dot(dir);
|
||||
v.cross(dir).abs() <= tol && along >= -tol && along <= len + tol
|
||||
})
|
||||
}
|
||||
|
||||
/// Sample a cubic at `n+1` evenly spaced parameters (including both ends).
|
||||
pub(crate) fn sample(c: &CubicBez, n: usize, out: &mut Vec<Point>, include_start: bool) {
|
||||
let start = if include_start { 0 } else { 1 };
|
||||
for i in start..=n {
|
||||
out.push(c.eval(i as f64 / n as f64));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fits_a_cubic_exactly() {
|
||||
let c = CubicBez::new((0.0, 0.0), (30.0, 60.0), (70.0, 60.0), (100.0, 0.0));
|
||||
let mut pts = Vec::new();
|
||||
sample(&c, 40, &mut pts, true);
|
||||
let out = fit_cubics(&pts, start_tangent(&c), end_tangent(&c), 0.05);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(out[0].p1.distance(c.p1) < 1.0, "{:?}", out[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_when_needed() {
|
||||
// A zig-zag cannot be fitted with one cubic.
|
||||
let pts: Vec<Point> = (0..=40).map(|i| Point::new(i as f64, ((i as f64) * 0.5).sin() * 10.0)).collect();
|
||||
let out = fit_cubics(&pts, Vec2::new(1.0, 0.0), Vec2::new(1.0, 0.0), 0.1);
|
||||
assert!(out.len() > 1);
|
||||
for w in out.windows(2) {
|
||||
assert!(w[0].p3.distance(w[1].p0) < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_detection() {
|
||||
let c = CubicBez::new((0.0, 0.0), (3.0, 0.0), (6.0, 0.0), (9.0, 0.0));
|
||||
assert!(is_straight(&c, 1e-6));
|
||||
let c = CubicBez::new((0.0, 0.0), (3.0, 1.0), (6.0, 0.0), (9.0, 0.0));
|
||||
assert!(!is_straight(&c, 1e-6));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! DrawCraft path operations: booleans (Pathfinder), shape builder regions, offset, outline stroke,
|
||||
//! simplify and the other Object → Path commands.
|
||||
//!
|
||||
//! Booleans are curve-preserving: `linesweeper`'s robust sweep-line works directly on cubic
|
||||
//! Béziers, and the pieces it splits curves into are refitted afterwards so results carry few
|
||||
//! anchors. See the crate README for the API overview.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod boolean;
|
||||
mod edit;
|
||||
mod fit;
|
||||
mod offset;
|
||||
mod pathfinder;
|
||||
|
||||
pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all};
|
||||
pub use edit::{
|
||||
AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid,
|
||||
};
|
||||
pub use offset::{Cap, Join, offset_path, outline_stroke};
|
||||
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
|
||||
|
||||
/// Errors from fallible operations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum PathOpsError {
|
||||
#[error("input contains NaN or infinite coordinates")]
|
||||
NonFinite,
|
||||
#[error("input path could not be closed")]
|
||||
OpenPath,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Offset Path and Outline Stroke.
|
||||
|
||||
use drawcraft_geom::{FillRule, PathData, SubPath};
|
||||
use kurbo::{BezPath, PathEl, Stroke, StrokeOpts};
|
||||
|
||||
pub use kurbo::{Cap, Join};
|
||||
|
||||
use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, all_contours_to_path, fill_bezpath, normalize_bez};
|
||||
|
||||
/// Flattening/fit tolerance handed to kurbo's stroker.
|
||||
const STROKE_TOL: f64 = 1e-3;
|
||||
|
||||
fn stroke_bez(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath {
|
||||
let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0));
|
||||
close_all(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL))
|
||||
}
|
||||
|
||||
/// kurbo's stroker may leave the outline of an open path without a final `ClosePath`.
|
||||
fn close_all(bp: &BezPath) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
let mut open = false;
|
||||
for el in bp.iter() {
|
||||
match el {
|
||||
PathEl::MoveTo(_) => {
|
||||
if open {
|
||||
out.close_path();
|
||||
}
|
||||
open = true;
|
||||
}
|
||||
PathEl::ClosePath => open = false,
|
||||
_ => {}
|
||||
}
|
||||
out.push(el);
|
||||
}
|
||||
if open {
|
||||
out.close_path();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Object → Path → Outline Stroke: the filled area painted by stroking `path` with `width`.
|
||||
/// Overlaps produced by the stroker are removed, so the result is a clean compound path.
|
||||
pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_limit: f64) -> PathData {
|
||||
if width <= 0.0 || !width.is_finite() || path.is_empty() {
|
||||
return PathData::default();
|
||||
}
|
||||
let s = stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit);
|
||||
if s.elements().is_empty() {
|
||||
return PathData::default();
|
||||
}
|
||||
normalize_bez(&s, FillRule::NonZero).map(|c| all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it.
|
||||
/// Closed subpaths are treated as a non-zero filled region; open subpaths are outlined with a
|
||||
/// stroke of width `2|delta|` (butt caps).
|
||||
pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> PathData {
|
||||
if !delta.is_finite() || path.is_empty() {
|
||||
return PathData::default();
|
||||
}
|
||||
let closed = PathData::new(path.subpaths.iter().filter(|s| s.closed && s.anchors.len() > 1).cloned().collect());
|
||||
let open: Vec<SubPath> = path.subpaths.iter().filter(|s| !s.closed && s.anchors.len() > 1).cloned().collect();
|
||||
let d = delta.abs();
|
||||
let fill = fill_bezpath(&closed);
|
||||
let ring = if d > 0.0 { stroke_bez(&closed.to_bezpath(), 2.0 * d, Cap::Butt, join, miter_limit) } else { BezPath::new() };
|
||||
let open_bp = if d > 0.0 && !open.is_empty() {
|
||||
stroke_bez(&PathData::new(open).to_bezpath(), 2.0 * d, Cap::Butt, join, miter_limit)
|
||||
} else {
|
||||
BezPath::new()
|
||||
};
|
||||
let Ok(arr) = Arrangement::new(&[(fill, FillRule::NonZero), (ring, FillRule::NonZero), (open_bp, FillRule::NonZero)]) else {
|
||||
return PathData::default();
|
||||
};
|
||||
let c = if delta >= 0.0 { arr.contours(|m| m[0] || m[1] || m[2]) } else { arr.contours(|m| m[0] && !m[1]) };
|
||||
all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION))
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Illustrator's Pathfinder panel and Shape Builder regions, over an ordered stack of shapes
|
||||
//! (index 0 = back-most, last = front-most).
|
||||
|
||||
use drawcraft_geom::{FillRule, PathData};
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, Point, Shape as _};
|
||||
|
||||
use crate::boolean::{Arrangement, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all};
|
||||
|
||||
/// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour);
|
||||
/// results carry the key of the object whose paint they keep.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Shape {
|
||||
pub path: PathData,
|
||||
pub rule: FillRule,
|
||||
pub key: u64,
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
pub fn new(path: PathData, rule: FillRule, key: u64) -> Self {
|
||||
Self { path, rule, key }
|
||||
}
|
||||
}
|
||||
|
||||
/// The ten Pathfinder panel operations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum PathfinderOp {
|
||||
/// Shape mode: union of everything; result keeps the front-most key.
|
||||
Unite,
|
||||
/// Shape mode: back-most minus everything in front; keeps the back-most key.
|
||||
MinusFront,
|
||||
/// Shape mode: area covered by *all* shapes; keeps the front-most key.
|
||||
Intersect,
|
||||
/// Shape mode: area covered by an odd number of shapes; keeps the front-most key.
|
||||
Exclude,
|
||||
/// Split into every non-overlapping face; each face keeps the key of the front-most shape
|
||||
/// covering it.
|
||||
Divide,
|
||||
/// Remove hidden parts; one result per shape that remains visible (no merging).
|
||||
Trim,
|
||||
/// Trim, then merge touching/overlapping visible parts with the same key.
|
||||
Merge,
|
||||
/// Keep the visible parts of lower shapes that lie inside the front-most shape (which is removed).
|
||||
Crop,
|
||||
/// Split every edge at intersections; returns *open* paths keyed by the shape they bound.
|
||||
Outline,
|
||||
/// Front-most minus everything behind; keeps the front-most key.
|
||||
MinusBack,
|
||||
}
|
||||
|
||||
/// One face of the planar arrangement of a set of shapes (Shape Builder / Live Paint face).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Region {
|
||||
/// Face outline (outer contour plus holes, consistently oriented).
|
||||
pub path: PathData,
|
||||
/// Indices of the input shapes covering this face, ascending.
|
||||
pub sources: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// Front-most covering shape.
|
||||
pub fn top(&self) -> usize {
|
||||
*self.sources.last().expect("regions are never uncovered")
|
||||
}
|
||||
/// Does the face contain `p`?
|
||||
pub fn contains(&self, p: Point) -> bool {
|
||||
self.path.to_bezpath().winding(p) != 0
|
||||
}
|
||||
}
|
||||
|
||||
fn arrangement(shapes: &[Shape]) -> Option<Arrangement> {
|
||||
let bps: Vec<(BezPath, FillRule)> = shapes.iter().map(|s| (fill_bezpath(&s.path), s.rule)).collect();
|
||||
Arrangement::new(&bps).ok()
|
||||
}
|
||||
|
||||
fn topmost(m: &[bool]) -> Option<usize> {
|
||||
m.iter().rposition(|&b| b)
|
||||
}
|
||||
|
||||
fn one(path: PathData, key: u64) -> Vec<Shape> {
|
||||
if path.is_empty() { Vec::new() } else { vec![Shape::new(path, FillRule::NonZero, key)] }
|
||||
}
|
||||
|
||||
/// Run a Pathfinder operation over `shapes` (back → front).
|
||||
pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
|
||||
let n = shapes.len();
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let front_key = shapes[n - 1].key;
|
||||
if op == PathfinderOp::Unite {
|
||||
let v: Vec<(&PathData, FillRule)> = shapes.iter().map(|s| (&s.path, s.rule)).collect();
|
||||
return one(unite_all(&v), front_key);
|
||||
}
|
||||
let Some(arr) = arrangement(shapes) else { return Vec::new() };
|
||||
let p = &arr.tidy;
|
||||
match op {
|
||||
PathfinderOp::Unite => unreachable!(),
|
||||
PathfinderOp::MinusFront => one(all_contours_to_path(&arr.contours(|m| m[0] && !m[1..].iter().any(|&b| b)), p), shapes[0].key),
|
||||
PathfinderOp::MinusBack => one(all_contours_to_path(&arr.contours(|m| m[n - 1] && !m[..n - 1].iter().any(|&b| b)), p), front_key),
|
||||
PathfinderOp::Intersect => one(all_contours_to_path(&arr.contours(|m| m.iter().all(|&b| b)), p), front_key),
|
||||
PathfinderOp::Exclude => one(all_contours_to_path(&arr.contours(|m| m.iter().filter(|&&b| b).count() % 2 == 1), p), front_key),
|
||||
PathfinderOp::Divide => regions_of(&arr)
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let key = shapes[r.top()].key;
|
||||
Shape::new(r.path, FillRule::NonZero, key)
|
||||
})
|
||||
.collect(),
|
||||
PathfinderOp::Trim => (0..n).flat_map(|i| one(all_contours_to_path(&arr.contours(|m| topmost(m) == Some(i)), p), shapes[i].key)).collect(),
|
||||
PathfinderOp::Merge => {
|
||||
let mut keys: Vec<u64> = Vec::new();
|
||||
for s in shapes {
|
||||
if !keys.contains(&s.key) {
|
||||
keys.push(s.key);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for k in keys {
|
||||
let c = arr.contours(|m| topmost(m).is_some_and(|t| shapes[t].key == k));
|
||||
for g in c.grouped() {
|
||||
out.extend(one(contours_to_path(&c, g, p), k));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
PathfinderOp::Crop => {
|
||||
if n < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
(0..n - 1)
|
||||
.flat_map(|i| {
|
||||
let c = arr.contours(|m| m[n - 1] && topmost(&m[..n - 1]) == Some(i));
|
||||
one(all_contours_to_path(&c, p), shapes[i].key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
PathfinderOp::Outline => outline_edges(&arr, shapes),
|
||||
}
|
||||
}
|
||||
|
||||
fn regions_of(arr: &Arrangement) -> Vec<Region> {
|
||||
let mut out = Vec::new();
|
||||
for mask in arr.distinct_masks() {
|
||||
let c = arr.contours(|m| m == mask.as_slice());
|
||||
let sources: Vec<usize> = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect();
|
||||
for g in c.grouped() {
|
||||
let path = contours_to_path(&c, g, &arr.tidy);
|
||||
if !path.is_empty() {
|
||||
out.push(Region { path, sources: sources.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// All faces of the planar arrangement of `shapes` (every area covered by at least one shape,
|
||||
/// split wherever coverage changes).
|
||||
pub fn regions(shapes: &[Shape]) -> Vec<Region> {
|
||||
arrangement(shapes).map(|a| regions_of(&a)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The face under `point`, if any (Shape Builder hover/click).
|
||||
pub fn region_at(shapes: &[Shape], point: Point) -> Option<Region> {
|
||||
regions(shapes).into_iter().find(|r| r.contains(point))
|
||||
}
|
||||
|
||||
/// Merge Shape Builder regions into one path (their union).
|
||||
pub fn merge_regions(regions: &[&Region]) -> PathData {
|
||||
let v: Vec<(&PathData, FillRule)> = regions.iter().map(|r| (&r.path, FillRule::NonZero)).collect();
|
||||
unite_all(&v)
|
||||
}
|
||||
|
||||
/// Pathfinder Outline: split every shape's boundary at junctions of the arrangement.
|
||||
fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec<Shape> {
|
||||
let junctions = arr.junctions();
|
||||
let scale = shapes.iter().filter_map(|s| s.path.control_bounds()).map(|r| r.width().max(r.height())).fold(1.0, f64::max);
|
||||
let tol = scale * 1e-7;
|
||||
// (chain, key, source index)
|
||||
let mut chains: Vec<(Vec<Seg>, u64)> = Vec::new();
|
||||
for s in shapes {
|
||||
for sp in &s.path.subpaths {
|
||||
if sp.anchors.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let mut sp = sp.clone();
|
||||
sp.closed = true;
|
||||
// Split each segment at junctions; record which piece starts are breaks.
|
||||
let mut pieces: Vec<(Seg, bool)> = Vec::new();
|
||||
for i in 0..sp.segment_count() {
|
||||
let c = sp.segment(i);
|
||||
let line = sp.segment_is_line(i);
|
||||
let mut ts: Vec<f64> = junctions
|
||||
.iter()
|
||||
.filter_map(|&j| {
|
||||
let nr = c.nearest(j, 1e-9);
|
||||
(nr.distance_sq.sqrt() <= tol.max(1e-6)).then_some(nr.t)
|
||||
})
|
||||
.collect();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
let start_is_junction = ts.first().is_some_and(|&t| t < 1e-9);
|
||||
let mut cuts: Vec<f64> = vec![0.0];
|
||||
cuts.extend(ts.into_iter().filter(|&t| t > 1e-9 && t < 1.0 - 1e-9));
|
||||
cuts.push(1.0);
|
||||
cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
for (k, w) in cuts.windows(2).enumerate() {
|
||||
let sub = c.subsegment(w[0]..w[1]);
|
||||
let seg = if line { Seg::line(sub.p0, sub.p3) } else { Seg { c: sub, line: false } };
|
||||
pieces.push((seg, if k == 0 { start_is_junction } else { true }));
|
||||
}
|
||||
}
|
||||
// Chain pieces between breaks.
|
||||
let first_break = pieces.iter().position(|(_, b)| *b);
|
||||
match first_break {
|
||||
None => {
|
||||
let segs: Vec<Seg> = pieces.into_iter().map(|(s, _)| s).collect();
|
||||
chains.push((segs, s.key));
|
||||
}
|
||||
Some(k) => {
|
||||
pieces.rotate_left(k);
|
||||
let mut cur: Vec<Seg> = Vec::new();
|
||||
for (seg, brk) in pieces {
|
||||
if brk && !cur.is_empty() {
|
||||
chains.push((std::mem::take(&mut cur), s.key));
|
||||
}
|
||||
cur.push(seg);
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
chains.push((cur, s.key));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// De-duplicate coincident chains, keeping the front-most key (later shapes overwrite).
|
||||
let mut out: Vec<(Vec<Seg>, u64)> = Vec::new();
|
||||
let sig = |c: &[Seg]| -> (Point, Point, Point) {
|
||||
let a = c[0].c.p0;
|
||||
let b = c[c.len() - 1].c.p3;
|
||||
let mid = mid_point(c);
|
||||
(a, b, mid)
|
||||
};
|
||||
for (chain, key) in chains {
|
||||
let (a, b, m) = sig(&chain);
|
||||
let near = |p: Point, q: Point| p.distance(q) <= tol.max(1e-6) * 10.0;
|
||||
if let Some(e) = out.iter_mut().find(|(c, _)| {
|
||||
let (a2, b2, m2) = sig(c);
|
||||
near(m, m2) && ((near(a, a2) && near(b, b2)) || (near(a, b2) && near(b, a2)))
|
||||
}) {
|
||||
e.1 = key;
|
||||
} else {
|
||||
out.push((chain, key));
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
.filter_map(|(chain, key)| {
|
||||
// Chain joints are the shapes' own anchors, so there is nothing to re-merge.
|
||||
let sp = segs_to_subpath(&chain, false)?;
|
||||
Some(Shape::new(PathData::single(sp), FillRule::NonZero, key))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mid_point(c: &[Seg]) -> Point {
|
||||
let lens: Vec<f64> = c.iter().map(|s| kurbo::ParamCurveArclen::arclen(&s.c, 1e-6)).collect();
|
||||
let total: f64 = lens.iter().sum();
|
||||
let mut acc = 0.0;
|
||||
for (s, l) in c.iter().zip(&lens) {
|
||||
if acc + l >= total / 2.0 && *l > 0.0 {
|
||||
let t = kurbo::ParamCurveArclen::inv_arclen(&s.c, total / 2.0 - acc, 1e-6);
|
||||
return s.c.eval(t);
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
c[0].c.p0
|
||||
}
|
||||
Reference in New Issue
Block a user