D: Pathfinder — add, subtract, intersect, exclude overlap, minus back (curves kept)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 15:45:34 -04:00
co-authored by Claude Opus 5.5
parent ea1a0b16c7
commit 9dd44f63cf
11 changed files with 370 additions and 4 deletions
Generated
+84 -1
View File
@@ -143,6 +143,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "aliasable"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
@@ -976,6 +982,7 @@ dependencies = [
name = "designcraft-geom"
version = "0.1.0"
dependencies = [
"flo_curves",
"kurbo",
"proptest",
"serde",
@@ -1242,7 +1249,7 @@ dependencies = [
"bitflags 2.13.2",
"emath",
"epaint",
"itertools",
"itertools 0.15.0",
"log",
"nohash-hasher",
"profiling",
@@ -1496,6 +1503,18 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "flo_curves"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5937462c05a3c43fecd2b0402b995b92888a35cc7dd6ca39306094c9b60a32fd"
dependencies = [
"itertools 0.14.0",
"ouroboros",
"roots",
"smallvec",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
@@ -2021,6 +2040,12 @@ dependencies = [
"pdf-writer",
]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2201,6 +2226,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -3019,6 +3053,30 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "ouroboros"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59"
dependencies = [
"aliasable",
"ouroboros_macro",
"static_assertions",
]
[[package]]
name = "ouroboros_macro"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0"
dependencies = [
"heck",
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
"syn 2.0.119",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3292,6 +3350,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proc-macro2-diagnostics"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"version_check",
"yansi",
]
[[package]]
name = "profiling"
version = "1.0.18"
@@ -3623,6 +3694,12 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "roots"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "082f11ffa03bbef6c2c6ea6bea1acafaade2fd9050ae0234ab44a2153742b058"
[[package]]
name = "roxmltree"
version = "0.20.0"
@@ -5476,6 +5553,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yoke"
version = "0.8.3"
+2
View File
@@ -50,6 +50,8 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
resvg = { version = "0.47", default-features = false, features = ["text"] }
krilla-svg = "0.8"
psd = "0.3"
# Boolean operations on Bézier paths (Pathfinder).
flo_curves = "0.8"
krilla = { version = "0.8", features = ["pdf"] }
hayro = "0.7"
quick-xml = "0.38"
+1
View File
@@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`.
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character).
- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`.
+68
View File
@@ -39,6 +39,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
create_outlines
),
cmd!(
"object.pathfinder",
"Pathfinder",
["Object", "Pathfinder"],
None,
"{op: add|subtract|intersect|exclude|minusBack, ids?} — combine the selected shapes into one (the backmost keeps its look; the frontmost for minusBack) → {id}",
has_selection,
pathfinder
),
cmd!(
"path.split",
"Split Path",
@@ -104,6 +113,49 @@ fn make_compound(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn pathfinder(s: &mut Session, p: &Value) -> Result<Value> {
use designcraft_geom::pathfinder::{Op, combine};
let op_s = super::str_param(p, "op").unwrap_or("add");
let op = Op::parse(op_s).ok_or_else(|| bad("object.pathfinder", format!("unknown op `{op_s}`")))?;
let ids = targets(s, p)?;
if ids.len() < 2 {
return Err(bad("object.pathfinder", "select two or more shapes"));
}
s.edit(|d, sel| {
let (sr, _) = spread_xf(d, ids[0]).ok_or(designcraft_doc::DocError::NoItem(ids[0]))?;
let order: Vec<ItemId> = d
.spread(sr)
.map(|sp| {
sp.items
.iter()
.filter(|i| ids.contains(&i.id) && !matches!(i.content, Content::Group { .. } | Content::Text(_)))
.map(|i| i.id)
.collect()
})
.unwrap_or_default();
if order.len() < 2 {
return Err(bad("object.pathfinder", "select two or more shapes on one spread (not text frames or groups)"));
}
let keep = if op == Op::MinusBack { *order.last().expect("two") } else { order[0] };
let shapes: Vec<PathData> =
order.iter().filter_map(|id| spread_xf(d, *id).and_then(|(_, xf)| Some(d.item(*id)?.path.transformed(xf)))).collect();
let Some(result) = combine(op, &shapes) else { return Err(bad("object.pathfinder", "the shapes don't overlap: nothing is left")) };
let (_, kxf) = spread_xf(d, keep).ok_or(designcraft_doc::DocError::NoItem(keep))?;
for id in &order {
if *id != keep {
d.remove_item(*id)?;
}
}
let it = d.item_mut(keep).ok_or(designcraft_doc::DocError::NoItem(keep))?;
it.path = result.transformed(kxf.inverse());
orient_holes(&mut it.path);
it.shape = Shape::Path;
it.corners = Default::default();
*sel = Selection::items(vec![keep]);
Ok(json!({"id": keep.0}))
})
}
fn release_compound(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, sel| {
@@ -302,6 +354,22 @@ mod tests {
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn pathfinder_subtracts_front_from_back() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200, 100, 400, 300], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
let r = s.execute("object.pathfinder", &json!({"op": "subtract"})).unwrap();
assert_eq!(r["id"], a);
let d = s.doc().unwrap().doc.clone();
assert!(d.item(ItemId(b)).is_none());
let bb = d.item(ItemId(a)).unwrap().bounds();
assert!((bb.x1 - 300.0).abs() < 0.5 && (bb.x0 - 100.0).abs() < 0.5, "{bb:?}");
assert!(s.execute("object.pathfinder", &json!({"op": "nope"})).is_err());
}
#[test]
fn create_outlines_turns_text_into_paths() {
let mut s = Session::new();
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
kurbo = { workspace = true }
flo_curves = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
+1
View File
@@ -10,6 +10,7 @@
pub mod corners;
pub mod hit;
pub mod path;
pub mod pathfinder;
pub mod shapes;
pub mod snap;
pub mod units;
+177
View File
@@ -0,0 +1,177 @@
//! Pathfinder: Add, Subtract, Intersect, Exclude Overlap and Minus Back on closed paths, keeping
//! curves as curves (flo_curves' Bézier path arithmetic).
use flo_curves::bezier::path::{SimpleBezierPath, path_add, path_intersect, path_sub};
use flo_curves::geo::Coord2;
use crate::Point;
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Op {
/// Union of all shapes.
Add,
/// The front shapes cut out of the backmost one.
Subtract,
/// The area all shapes share.
Intersect,
/// Areas covered an odd number of times.
Exclude,
/// The back shapes cut out of the frontmost one.
MinusBack,
}
impl Op {
pub fn parse(s: &str) -> Option<Op> {
Some(match s {
"add" => Op::Add,
"subtract" => Op::Subtract,
"intersect" => Op::Intersect,
"exclude" => Op::Exclude,
"minusBack" => Op::MinusBack,
_ => return None,
})
}
}
const ACCURACY: f64 = 0.01;
fn c(p: Point) -> Coord2 {
Coord2(p.x, p.y)
}
/// Every subpath as a closed Bézier path (open ones are closed with a straight segment).
fn to_flo(path: &PathData) -> Vec<SimpleBezierPath> {
let mut out = Vec::new();
for sp in &path.subpaths {
if sp.anchors.len() < 2 {
continue;
}
let mut closed = sp.clone();
closed.closed = true;
let curves = (0..closed.segment_count()).map(|i| {
let s = closed.segment(i);
(c(s.p1), c(s.p2), c(s.p3))
});
out.push((c(closed.anchors[0].p), curves.collect()));
}
out
}
fn from_flo(paths: &[SimpleBezierPath]) -> PathData {
let p = |c: &Coord2| Point::new(c.0, c.1);
let mut subpaths = Vec::new();
for (start, curves) in paths {
if curves.is_empty() {
continue;
}
let mut anchors = vec![Anchor::corner(p(start))];
for (c1, c2, end) in curves {
anchors.last_mut().expect("non-empty").h_out = p(c1);
let mut a = Anchor::corner(p(end));
a.h_in = p(c2);
anchors.push(a);
}
// The last curve comes back to the start: fold its end into the first anchor.
if anchors.len() > 2 && anchors.last().expect("non-empty").p.distance(anchors[0].p) < 1e-6 {
let last = anchors.pop().expect("non-empty");
anchors[0].h_in = last.h_in;
}
for a in &mut anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
if !a.has_in() && !a.has_out() {
a.kind = AnchorKind::Corner;
}
}
subpaths.push(SubPath::new(anchors, true));
}
PathData::new(subpaths)
}
/// Combine `shapes` (back to front, all in one coordinate space). `None` when nothing is left.
pub fn combine(op: Op, shapes: &[PathData]) -> Option<PathData> {
let flo: Vec<Vec<SimpleBezierPath>> = shapes.iter().map(to_flo).filter(|p| !p.is_empty()).collect();
let (first, rest) = flo.split_first()?;
let union = |v: &[Vec<SimpleBezierPath>]| -> Vec<SimpleBezierPath> {
let mut acc: Vec<SimpleBezierPath> = Vec::new();
for p in v {
acc = if acc.is_empty() { p.clone() } else { path_add(&acc, p, ACCURACY) };
}
acc
};
let out: Vec<SimpleBezierPath> = match op {
Op::Add => union(&flo),
Op::Subtract => {
let cut = union(rest);
if cut.is_empty() { first.clone() } else { path_sub(first, &cut, ACCURACY) }
}
Op::MinusBack => {
let (front, backs) = flo.split_last()?;
let cut = union(backs);
if cut.is_empty() { front.clone() } else { path_sub(front, &cut, ACCURACY) }
}
Op::Intersect => {
let mut acc = first.clone();
for p in rest {
acc = path_intersect(&acc, p, ACCURACY);
if acc.is_empty() {
break;
}
}
acc
}
Op::Exclude => {
let mut acc = first.clone();
for p in rest {
let a: Vec<SimpleBezierPath> = path_sub(&acc, p, ACCURACY);
let b: Vec<SimpleBezierPath> = path_sub(p, &acc, ACCURACY);
acc = a.into_iter().chain(b).collect();
}
acc
}
};
let r = from_flo(&out);
if r.subpaths.is_empty() { None } else { Some(r) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Rect, shapes};
fn area(p: &PathData) -> f64 {
p.subpaths.iter().map(|s| s.area()).sum::<f64>().abs()
}
#[test]
fn boolean_ops_on_overlapping_squares() {
let a = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let b = shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0));
let near = |x: f64, y: f64| (x - y).abs() < 1.0;
let add = combine(Op::Add, &[a.clone(), b.clone()]).unwrap();
assert!(near(area(&add), 17500.0), "add {}", area(&add));
let sub = combine(Op::Subtract, &[a.clone(), b.clone()]).unwrap();
assert!(near(area(&sub), 7500.0), "subtract {}", area(&sub));
let int = combine(Op::Intersect, &[a.clone(), b.clone()]).unwrap();
assert!(near(area(&int), 2500.0), "intersect {}", area(&int));
assert_eq!(int.bounds().unwrap(), Rect::new(50.0, 50.0, 100.0, 100.0));
let mb = combine(Op::MinusBack, &[a.clone(), b.clone()]).unwrap();
assert!(near(area(&mb), 7500.0));
assert!(mb.bounds().unwrap().x1 > 149.0, "the front square remains");
let ex = combine(Op::Exclude, &[a.clone(), b]).unwrap();
assert_eq!(ex.subpaths.len(), 2);
let far = shapes::rectangle(Rect::new(500.0, 500.0, 510.0, 510.0));
assert!(combine(Op::Intersect, &[a, far]).is_none());
}
#[test]
fn curves_stay_curves() {
let a = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0));
let b = shapes::ellipse(Rect::new(60.0, 0.0, 160.0, 100.0));
let add = combine(Op::Add, &[a, b]).unwrap();
assert_eq!(add.subpaths.len(), 1);
assert!(add.anchors().any(|(_, _, a)| a.has_in() || a.has_out()), "curved anchors");
let bb = add.bounds().unwrap();
assert!((bb.x0 - 0.0).abs() < 0.5 && (bb.x1 - 160.0).abs() < 0.5, "{bb:?}");
}
}
+2
View File
@@ -24,6 +24,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("textWrap", "Text Wrap", "panel-wrap"),
("table", "Table", "panel-table"),
("align", "Align", "panel-align"),
("pathfinder", "Pathfinder", "tool-scissors"),
("links", "Links", "panel-links"),
("info", "Info", "panel-info"),
("preflight", "Preflight", "panel-preflight"),
@@ -154,6 +155,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
"paragraph" => panels::properties::paragraph_panel(app, ui),
"textWrap" => panels::properties::wrap_panel(app, ui),
"align" => panels::properties::align_panel(app, ui),
"pathfinder" => panels::properties::pathfinder_panel(app, ui),
"color" => panels::swatches::color_panel(app, ui),
"gradient" => panels::swatches::gradient_panel(app, ui),
"effects" => panels::properties::effects_panel(app, ui),
+7
View File
@@ -286,6 +286,13 @@ pub const MENUS: &[(&str, &[&str])] = &[
"cmd:object.makeCompoundPath",
"cmd:object.releaseCompoundPath",
"<",
">Pathfinder",
"cmd:object.pathfinder|Add|{\"op\": \"add\"}",
"cmd:object.pathfinder|Subtract|{\"op\": \"subtract\"}",
"cmd:object.pathfinder|Intersect|{\"op\": \"intersect\"}",
"cmd:object.pathfinder|Exclude Overlap|{\"op\": \"exclude\"}",
"cmd:object.pathfinder|Minus Back|{\"op\": \"minusBack\"}",
"<",
">Arrange",
"cmd:object.bringToFront",
"cmd:object.bringForward",
+24
View File
@@ -1445,6 +1445,30 @@ pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
/// Pathfinder panel: combine the selected shapes; make or release compound paths.
pub fn pathfinder_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.label(egui::RichText::new("Pathfinder").strong());
ui.horizontal_wrapped(|ui| {
for (label, op) in
[("Add", "add"), ("Subtract", "subtract"), ("Intersect", "intersect"), ("Exclude Overlap", "exclude"), ("Minus Back", "minusBack")]
{
if ui.button(label).clicked() {
let _ = app.run("object.pathfinder", json!({"op": op}));
}
}
});
ui.add_space(4.0);
ui.label(egui::RichText::new("Paths").strong());
ui.horizontal(|ui| {
if ui.button("Make Compound").clicked() {
let _ = app.run("object.makeCompoundPath", json!({}));
}
if ui.button("Release").clicked() {
let _ = app.run("object.releaseCompoundPath", json!({}));
}
});
}
pub fn align_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
for (edge, tip) in [
+3 -3
View File
@@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 42 | 23 | 32 | 55% |
| P1 | 97 | 43 | 23 | 31 | 56% |
| P2 | 76 | 8 | 7 | 61 | 15% |
| Area | Parity |
@@ -18,7 +18,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Application shell & workspace | 69% |
| Documents, pages, spreads | 71% |
| Layers | 95% |
| Frames, shapes & paths | 83% |
| Frames, shapes & paths | 85% |
| Transform | 93% |
| Fill, stroke, colour | 69% |
| Effects & transparency | 50% |
@@ -104,7 +104,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Frames, shapes & paths | Content type | P0 | D |
| Frames, shapes & paths | Convert Shape | P1 | P |
| Frames, shapes & paths | Corner Options | P0 | D |
| Frames, shapes & paths | Pathfinder | P1 | M |
| Frames, shapes & paths | Pathfinder | P1 | D |
| Frames, shapes & paths | Compound paths | P1 | D |
| Frames, shapes & paths | Text outlines | P1 | D |
| Frames, shapes & paths | Clipping paths | P2 | M |