diff --git a/Cargo.lock b/Cargo.lock index 8dec524e9..ab258324c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 42381d8ad..1d93121a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/ROADMAP.md b/ROADMAP.md index bd8bde621..fdcf85943 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`. diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index bab319527..dff98de7d 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -39,6 +39,15 @@ pub fn specs() -> Vec { 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 { }) } +fn pathfinder(s: &mut Session, p: &Value) -> Result { + 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 = 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 = + 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 { 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(); diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml index 33a2a54fd..9ed510bff 100644 --- a/crates/geom/Cargo.toml +++ b/crates/geom/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] kurbo = { workspace = true } +flo_curves = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 9bdcc6fd9..4d987c02e 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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; diff --git a/crates/geom/src/pathfinder.rs b/crates/geom/src/pathfinder.rs new file mode 100644 index 000000000..69514893f --- /dev/null +++ b/crates/geom/src/pathfinder.rs @@ -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 { + 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 { + 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 { + let flo: Vec> = shapes.iter().map(to_flo).filter(|p| !p.is_empty()).collect(); + let (first, rest) = flo.split_first()?; + let union = |v: &[Vec]| -> Vec { + let mut acc: Vec = Vec::new(); + for p in v { + acc = if acc.is_empty() { p.clone() } else { path_add(&acc, p, ACCURACY) }; + } + acc + }; + let out: Vec = 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 = path_sub(&acc, p, ACCURACY); + let b: Vec = 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::().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:?}"); + } +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d26998377..896e21747 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8f83dd63f..b3574b62c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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", diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 46e628234..e110cceab 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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 [ diff --git a/docs/parity.md b/docs/parity.md index 6b6a44eb5..efddf8425 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |