From a948ab288460fba7ddbcfc26731624a864e9e990 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 22:13:49 +0200 Subject: [PATCH] Pathfinder panel laid out as InDesign's, with Join Path and Convert Point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel was a row of text buttons (Add, Subtract, …, Make Compound, Release). InDesign's has four rows of icon buttons: Paths (Join, Open, Close, Reverse), Pathfinder, Convert Shape and Convert Point. The icons are new drawings in icons.rs. Two engine commands back the rows that had none: path.join (close an open path, or join two open paths at their nearest ends) and path.convertPoints (plain, corner, smooth, symmetrical, on the selected points or whole paths). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/anchors.rs | 201 +++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 8 + crates/ui-egui/src/i18n/ar.rs | 8 + crates/ui-egui/src/i18n/it.rs | 8 + crates/ui-egui/src/i18n/ja.rs | 7 + crates/ui-egui/src/i18n/pt_br.rs | 10 ++ crates/ui-egui/src/i18n/uk.rs | 8 + crates/ui-egui/src/icons.rs | 120 ++++++++++++++ crates/ui-egui/src/panels/properties.rs | 81 ++++++++-- docs/target-app-parity.md | 1 + 11 files changed, 437 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e5..9a770db0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Pathfinder panel is laid out as InDesign's: Paths (Join, Open, Close, Reverse), Pathfinder, Convert Shape and Convert Point, each a row of icon buttons. Join Path closes an open path or joins two at their nearest ends (`path.join`), and Convert Point makes the selected points plain, corner, smooth or symmetrical (`path.convertPoints`). No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index c5015c7c..0b72c472 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -1,11 +1,12 @@ //! Anchor-point editing (the Pen tool group): Add Anchor Point, Delete Anchor Point and Convert -//! Direction Point. Positions are in spread coordinates; anchors are `(subpath, index)`. +//! Direction Point, and the Pathfinder panel's Join Path and Convert Point. Positions are in spread +//! coordinates; anchors are `(subpath, index)`. use designcraft_doc::{Document, ItemId, Shape}; use designcraft_geom::{Affine, Anchor, AnchorKind, Point}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, point_param}; +use super::{CommandSpec, bad, cmd, has_doc, has_selection, point_param}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -55,9 +56,147 @@ pub fn specs() -> Vec { has_doc, |s, p| erase_or_smooth(s, p, true) ), + cmd!( + "path.join", + "Join", + ["Object", "Paths"], + None, + "{ids?} — one open path: closes it; two open paths: joins them where their ends are nearest (coincident ends become one point) into the first, and removes the second → {id}", + has_selection, + join_paths + ), + cmd!( + "path.convertPoints", + "Convert Point", + [], + None, + "{kind: plain|corner|smooth|symmetrical, ids?} — the Direct Selection tool's selected points, or every point of the selected paths: plain (no direction handles), corner (independent handles), smooth (collinear handles), symmetrical (collinear, equal length) → {converted}", + has_selection, + convert_points + ), ] } +/// Join Path: close one open path, or join two open paths at their nearest ends. +fn join_paths(s: &mut Session, p: &Value) -> Result { + let ids = super::targets(s, p)?; + s.edit(|d, sel| { + let ids: Vec = + ids.iter().copied().filter(|id| d.item(*id).is_some_and(|it| !it.locked && !it.is_group() && it.text_frame().is_none())).collect(); + let open = |d: &Document, id: ItemId| d.item(id).and_then(|it| it.path.subpaths.iter().position(|sp| !sp.closed && sp.anchors.len() > 1)); + match ids.as_slice() { + [id] => { + let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?; + let mut closed = 0; + for sp in it.path.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() > 1) { + sp.closed = true; + closed += 1; + } + if closed == 0 { + return Err(bad("path.join", "the path is already closed")); + } + as_path(&mut it.shape); + Ok(json!({"id": id.0, "closed": closed})) + } + [a, b] => { + let (Some(sa), Some(sb)) = (open(d, *a), open(d, *b)) else { + return Err(bad("path.join", "select two open paths")); + }; + // b's path in a's space. + let inv_a = to_inner(d, *a).ok_or(designcraft_doc::DocError::NoItem(*a))?; + let xf_b = to_inner(d, *b).ok_or(designcraft_doc::DocError::NoItem(*b))?.inverse(); + let m = inv_a * xf_b; + let mut pb = d.item(*b).and_then(|it| it.path.subpaths.get(sb).cloned()).ok_or(designcraft_doc::DocError::NoItem(*b))?; + for an in &mut pb.anchors { + *an = Anchor { p: m * an.p, h_in: m * an.h_in, h_out: m * an.h_out, kind: an.kind }; + } + let ita = d.item_mut(*a).ok_or(designcraft_doc::DocError::NoItem(*a))?; + let pa = ita.path.subpaths.get_mut(sa).ok_or_else(|| bad("path.join", "no such subpath"))?; + // The pair of ends nearest each other; then a's end meets b's start. + let ends = |sp: &designcraft_geom::SubPath| (sp.anchors.first().map(|x| x.p), sp.anchors.last().map(|x| x.p)); + let ((Some(a0), Some(a1)), (Some(b0), Some(b1))) = (ends(pa), ends(&pb)) else { + return Err(bad("path.join", "select two open paths")); + }; + let pairs = [(a1, b0, false, false), (a1, b1, false, true), (a0, b0, true, false), (a0, b1, true, true)]; + let Some(&(_, _, rev_a, rev_b)) = pairs.iter().min_by(|x, y| (x.0 - x.1).hypot().total_cmp(&(y.0 - y.1).hypot())) else { + return Err(bad("path.join", "select two open paths")); + }; + if rev_a { + pa.reverse(); + } + if rev_b { + pb.reverse(); + } + let mut rest = pb.anchors.into_iter(); + if let Some(first) = rest.next() { + match pa.anchors.last_mut() { + Some(last) if (last.p - first.p).hypot() < 0.01 => *last = Anchor::with_handles(last.p, last.h_in, first.h_out), + _ => pa.anchors.push(first), + } + } + pa.anchors.extend(rest); + as_path(&mut ita.shape); + d.remove_item(*b)?; + *sel = designcraft_doc::Selection::items(vec![*a]); + Ok(json!({"id": a.0})) + } + _ => Err(bad("path.join", "select one or two open paths")), + } + }) +} + +/// Convert Point: the selected points (or every point of the selected paths) to a point type. +fn convert_points(s: &mut Session, p: &Value) -> Result { + let kind = super::str_param(p, "kind").unwrap_or("plain").to_string(); + if !matches!(kind.as_str(), "plain" | "corner" | "smooth" | "symmetrical") { + return Err(bad("path.convertPoints", format!("unknown kind `{kind}`"))); + } + let mut points: Vec<(ItemId, usize, usize)> = if p.get("ids").is_none() { s.doc()?.selection.anchors.clone() } else { Vec::new() }; + if points.is_empty() { + for id in super::targets(s, p)? { + if let Some(it) = s.doc()?.doc.item(id) { + points.extend(it.path.anchors().map(|(si, ai, _)| (id, si, ai))); + } + } + } + s.edit(|d, _| { + let mut converted = 0; + for (id, si, ai) in &points { + let Some(it) = d.item_mut(*id) else { continue }; + if it.locked || it.is_group() { + continue; + } + let Some(sp) = it.path.subpaths.get_mut(*si) else { continue }; + let Some(a) = sp.anchors.get(*ai).copied() else { continue }; + let auto = smooth_auto(&sp.anchors, *ai, sp.closed); + // The handles: the point's own, or ones worked out from its neighbours. + let (h_in, h_out) = if a.has_in() || a.has_out() { (a.h_in, a.h_out) } else { (auto.h_in, auto.h_out) }; + let new = match kind.as_str() { + "plain" => Anchor::corner(a.p), + "corner" => Anchor { p: a.p, h_in, h_out, kind: AnchorKind::Corner }, + _ => { + let dir = if (h_out - h_in).hypot() > 1e-9 { h_out - h_in } else { auto.h_out - auto.h_in }; + let len = dir.hypot(); + if len < 1e-9 { + a + } else { + let u = dir / len; + let (li, lo) = ((a.p - h_in).hypot(), (h_out - a.p).hypot()); + let (li, lo) = if kind == "symmetrical" { ((li + lo) / 2.0, (li + lo) / 2.0) } else { (li, lo) }; + Anchor { p: a.p, h_in: a.p - u * li, h_out: a.p + u * lo, kind: AnchorKind::Smooth } + } + } + }; + if let Some(slot) = sp.anchors.get_mut(*ai) { + *slot = new; + converted += 1; + } + as_path(&mut it.shape); + } + Ok(json!({"converted": converted})) + }) +} + /// Erase / Smooth: the stretch of one subpath touched by the drag. fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { let cmd = if smooth { "path.smooth" } else { "path.erase" }; @@ -253,6 +392,64 @@ pub fn anchor_near(d: &Document, id: ItemId, at: Point, tol: f64) -> Option<(usi mod tests { use super::*; + fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> u64 { + s.execute("line.create", &json!({"a": a, "b": b})).unwrap()["id"].as_u64().unwrap() + } + + /// Join Path closes one open path, and joins two open paths at their nearest ends. + #[test] + fn join_path_closes_one_path_and_joins_two() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = line(&mut s, [100.0, 100.0], [200.0, 100.0]); + // b runs back towards a: its end meets a's end at (200, 100). + let b = line(&mut s, [200.0, 200.0], [200.0, 100.0]); + let r = s.execute("path.join", &json!({"ids": [a, b]})).unwrap(); + assert_eq!(r["id"], a); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(b)).is_none()); + let sp = &d.item(ItemId(a)).unwrap().path.subpaths[0]; + assert_eq!(sp.anchors.len(), 3, "the coincident ends become one point"); + assert!(!sp.closed); + let bb = d.item(ItemId(a)).unwrap().bounds(); + assert!((bb.width() - 100.0).abs() < 0.5 && (bb.height() - 100.0).abs() < 0.5, "{bb:?}"); + // Joining it again closes it. + s.execute("path.join", &json!({"ids": [a]})).unwrap(); + assert!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].closed); + assert!(s.execute("path.join", &json!({"ids": [a]})).is_err(), "nothing left to close"); + // Ends apart are joined with a straight segment. + let c = line(&mut s, [300.0, 300.0], [400.0, 300.0]); + let e = line(&mut s, [300.0, 400.0], [400.0, 400.0]); + s.execute("path.join", &json!({"ids": [c, e]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(c)).unwrap().path.subpaths[0].anchors.len(), 4); + } + + /// Convert Point turns the selected points (or a whole path's) plain, corner, smooth or symmetrical. + #[test] + fn convert_point_kinds() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + let anchor = |s: &Session, i: usize| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors[i]; + s.execute("path.convertPoints", &json!({"kind": "smooth", "ids": [id]})).unwrap(); + let a = anchor(&s, 0); + assert_eq!(a.kind, AnchorKind::Smooth); + assert!(a.has_in() && a.has_out()); + let (di, dout) = (a.p - a.h_in, a.h_out - a.p); + assert!((di.x * dout.y - di.y * dout.x).abs() < 1e-6, "collinear handles"); + s.execute("path.convertPoints", &json!({"kind": "symmetrical", "ids": [id]})).unwrap(); + let a = anchor(&s, 1); + assert!(((a.p - a.h_in).hypot() - (a.h_out - a.p).hypot()).abs() < 1e-6, "equal handles"); + s.execute("path.convertPoints", &json!({"kind": "plain", "ids": [id]})).unwrap(); + let a = anchor(&s, 2); + assert!(!a.has_in() && !a.has_out() && a.kind == AnchorKind::Corner); + s.execute("path.convertPoints", &json!({"kind": "corner", "ids": [id]})).unwrap(); + let a = anchor(&s, 2); + assert!(a.has_in() && a.has_out() && a.kind == AnchorKind::Corner, "a corner point gets independent handles"); + assert!(s.execute("path.convertPoints", &json!({"kind": "nope", "ids": [id]})).is_err()); + assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().shape, Shape::Path); + } + #[test] fn erase_and_smooth_along_a_drag() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 04ee153c..ea6c2d59 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Paths:", ["Pfade:", "Tracés :", "Trazados:", "パス:", "路径:"]), + ("Pathfinder:", ["Pathfinder:", "Pathfinder :", "Buscatrazos:", "パスファインダー:", "路径查找器:"]), + ("Convert Shape:", ["Form konvertieren:", "Convertir la forme :", "Convertir forma:", "シェイプを変換:", "转换形状:"]), + ("Convert Point:", ["Punkt konvertieren:", "Convertir le point :", "Convertir punto:", "ポイントを変換:", "转换点:"]), + ("Join Path", ["Pfad zusammenfügen", "Joindre le tracé", "Unir trazado", "パスを連結", "连接路径"]), + ("Open Path", ["Pfad öffnen", "Ouvrir le tracé", "Abrir trazado", "パスをオープン", "开放路径"]), + ("Plain", ["Einfach", "Simple", "Normal", "プレーン", "普通"]), + ("Symmetrical", ["Symmetrisch", "Symétrique", "Simétrico", "対称", "对称"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index f68e7587..264ca414 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Paths:", "المسارات:"), + ("Pathfinder:", "مستكشف المسار:"), + ("Convert Shape:", "تحويل الشكل:"), + ("Convert Point:", "تحويل النقطة:"), + ("Join Path", "ربط المسار"), + ("Open Path", "فتح المسار"), + ("Plain", "عادي"), + ("Symmetrical", "متماثل"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index e91307f1..2bcdf8dc 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Paths:", "Tracciati:"), + ("Pathfinder:", "Elaborazione tracciati:"), + ("Convert Shape:", "Converti forma:"), + ("Convert Point:", "Converti punto:"), + ("Join Path", "Unisci tracciato"), + ("Open Path", "Apri tracciato"), + ("Plain", "Semplice"), + ("Symmetrical", "Simmetrico"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index f45ee094..3c935625 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Paths:", "パス:"), + ("Pathfinder:", "パスファインダー:"), + ("Convert Shape:", "シェイプを変換:"), + ("Convert Point:", "ポイントを変換:"), + ("Join Path", "パスを連結"), + ("Plain", "プレーン"), + ("Symmetrical", "対称"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 09e639e5..1669426f 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Paths:", "Demarcadores:"), + ("Pathfinder:", "Pathfinder:"), + ("Convert Shape:", "Converter forma:"), + ("Convert Point:", "Converter ponto:"), + ("Join Path", "Unir demarcador"), + ("Open Path", "Abrir demarcador"), + ("Close Path", "Fechar demarcador"), + ("Plain", "Simples"), + ("Smooth", "Suave"), + ("Symmetrical", "Simétrico"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index eab4159d..1552c27d 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Paths:", "Контури:"), + ("Pathfinder:", "Обробка контурів:"), + ("Convert Shape:", "Перетворити фігуру:"), + ("Convert Point:", "Перетворити точку:"), + ("Join Path", "Об’єднати контур"), + ("Open Path", "Розімкнути контур"), + ("Plain", "Простий"), + ("Symmetrical", "Симетричний"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c9346437..9da72e6e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -550,6 +550,94 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { } } } + // Pathfinder panel › Paths: a join point, a path opened and one closed, two opposed arrows. + "path-join" => { + pen.line(&[(3.0, 16.0), (5.0, 12.0), (10.0, 10.0)]); + pen.line(&[(10.0, 10.0), (15.0, 8.0), (17.0, 4.0)]); + pen.fcircle(10.0, 10.0, 2.2); + } + "path-open" | "path-close" => { + let gap = if name == "path-open" { 0.9 } else { 0.0 }; + let n = 28; + let pts: Vec<(f32, f32)> = (0..=n) + .map(|i| { + let a = -std::f32::consts::FRAC_PI_2 + gap / 2.0 + (std::f32::consts::TAU - gap) * i as f32 / n as f32; + (10.0 + 6.5 * a.cos(), 10.5 + 6.5 * a.sin()) + }) + .collect(); + pen.line(&pts); + if name == "path-open" { + pen.frect(11.0, 2.5, 14.0, 5.5); + pen.frect(6.0, 2.5, 9.0, 5.5); + } else { + pen.fcircle(10.0, 4.0, 1.8); + } + } + "path-reverse" => { + pen.line(&[(3.0, 7.0), (16.0, 7.0)]); + pen.line(&[(13.0, 4.0), (16.5, 7.0), (13.0, 10.0)]); + pen.line(&[(17.0, 13.0), (4.0, 13.0)]); + pen.line(&[(7.0, 10.0), (3.5, 13.0), (7.0, 16.0)]); + } + // Pathfinder panel › Convert Shape: the shape outlined. + "shape-rect" => pen.rect(3.0, 5.0, 17.0, 15.0), + "shape-rounded" | "shape-beveled" | "shape-inverse" => { + let (x0, y0, x1, y1, r): (f32, f32, f32, f32, f32) = (3.0, 5.0, 17.0, 15.0, 3.5); + // Clockwise from the top left: each corner's two ends, and the centre of its arc + // (inside for a rounded corner, the corner itself for an inverse rounded one). + let corners = [ + ((x0, y0), (x0, y0 + r), (x0 + r, y0), (x0 + r, y0 + r)), + ((x1, y0), (x1 - r, y0), (x1, y0 + r), (x1 - r, y0 + r)), + ((x1, y1), (x1, y1 - r), (x1 - r, y1), (x1 - r, y1 - r)), + ((x0, y1), (x0 + r, y1), (x0, y1 - r), (x0 + r, y1 - r)), + ]; + let mut pts = Vec::new(); + for (corner, a, b, inner) in corners { + if name == "shape-beveled" { + pts.extend([a, b]); + continue; + } + let c = if name == "shape-inverse" { corner } else { inner }; + let a0 = (a.1 - c.1).atan2(a.0 - c.0); + let mut d = (b.1 - c.1).atan2(b.0 - c.0) - a0; + if d > std::f32::consts::PI { + d -= std::f32::consts::TAU; + } else if d < -std::f32::consts::PI { + d += std::f32::consts::TAU; + } + pts.extend((0..=6).map(|k| { + let t = a0 + d * k as f32 / 6.0; + (c.0 + r * t.cos(), c.1 + r * t.sin()) + })); + } + pen.closed(&pts); + } + "shape-ellipse" => pen.ellipse(10.0, 10.0, 7.5, 5.0), + "shape-triangle" => pen.closed(&[(10.0, 3.5), (17.0, 16.0), (3.0, 16.0)]), + "shape-polygon" => pen.closed(&[(6.5, 4.0), (13.5, 4.0), (17.0, 10.0), (13.5, 16.0), (6.5, 16.0), (3.0, 10.0)]), + "shape-line" => pen.line(&[(4.0, 16.0), (16.0, 4.0)]), + "shape-orthogonal" => { + pen.line(&[(10.0, 3.0), (10.0, 17.0)]); + pen.line(&[(3.0, 10.0), (17.0, 10.0)]); + } + // Pathfinder panel › Convert Point: a point on a path, with its direction handles. + "point-plain" | "point-corner" | "point-smooth" | "point-symmetrical" => { + match name { + "point-plain" | "point-corner" => pen.line(&[(3.0, 16.0), (10.0, 8.0), (17.0, 16.0)]), + _ => pen.line(&[(3.0, 16.0), (5.5, 11.5), (10.0, 8.0), (14.5, 11.5), (17.0, 16.0)]), + } + let handles: &[(f32, f32)] = match name { + "point-corner" => &[(4.5, 4.0), (16.0, 5.0)], + "point-smooth" => &[(5.0, 8.0), (17.5, 8.0)], + "point-symmetrical" => &[(4.0, 8.0), (16.0, 8.0)], + _ => &[], + }; + for &(hx, hy) in handles { + pen.line(&[(10.0, 8.0), (hx, hy)]); + pen.fcircle(hx, hy, 1.4); + } + pen.frect(8.5, 6.5, 11.5, 9.5); + } // ---- Properties panel glyphs (original drawings) "orient-portrait" => { pen.rect(5.5, 3.0, 14.5, 17.0); @@ -885,6 +973,38 @@ mod tests { format!("{:?}", out.shapes.iter().map(|s| &s.shape).collect::>()) } + /// The Pathfinder panel's icons are all drawn, and all differ. + #[test] + fn pathfinder_panel_icons_are_drawn() { + let names = [ + "path-join", + "path-open", + "path-close", + "path-reverse", + "shape-rect", + "shape-rounded", + "shape-beveled", + "shape-inverse", + "shape-ellipse", + "shape-triangle", + "shape-polygon", + "shape-line", + "shape-orthogonal", + "point-plain", + "point-corner", + "point-smooth", + "point-symmetrical", + ]; + let unknown = drawing("no-such-icon"); + let drawn: Vec = names.iter().map(|n| drawing(n)).collect(); + for (i, (n, d)) in names.iter().zip(&drawn).enumerate() { + assert_ne!(*d, unknown, "{n} falls back to the placeholder"); + for (m, e) in names.iter().zip(&drawn).skip(i + 1) { + assert_ne!(d, e, "{n} draws like {m}"); + } + } + } + #[test] fn transform_tools_have_their_own_icons() { let names = ["tool-free-transform", "tool-scale", "tool-shear"]; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 358ec76d..b84e1dd2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1841,26 +1841,79 @@ fn pathfinder_row(app: &mut DesignApp, ui: &mut Ui) { }); } -/// Pathfinder panel: combine the selected shapes; make or release compound paths. +/// Pathfinder panel › Paths: (icon, command, params, tooltip). +const PATHS: [(&str, &str, &str, &str); 4] = [ + ("path-join", "path.join", "{}", "Join Path"), + ("path-open", "object.convertShape", r#"{"to": "openPath"}"#, "Open Path"), + ("path-close", "object.convertShape", r#"{"to": "closedPath"}"#, "Close Path"), + ("path-reverse", "path.reverse", "{}", "Reverse Path"), +]; + +/// Pathfinder panel › Convert Shape: (icon, shape, tooltip). +const CONVERT_SHAPE: [(&str, &str, &str); 9] = [ + ("shape-rect", "rectangle", "Rectangle"), + ("shape-rounded", "roundedRectangle", "Rounded Rectangle"), + ("shape-beveled", "beveledRectangle", "Beveled Rectangle"), + ("shape-inverse", "inverseRoundedRectangle", "Inverse Rounded Rectangle"), + ("shape-ellipse", "ellipse", "Ellipse"), + ("shape-triangle", "triangle", "Triangle"), + ("shape-polygon", "polygon", "Polygon"), + ("shape-line", "line", "Line"), + ("shape-orthogonal", "orthogonalLine", "Orthogonal Line"), +]; + +/// Pathfinder panel › Convert Point: (icon, kind, tooltip). +const CONVERT_POINT: [(&str, &str, &str); 4] = [ + ("point-plain", "plain", "Plain"), + ("point-corner", "corner", "Corner"), + ("point-smooth", "smooth", "Smooth"), + ("point-symmetrical", "symmetrical", "Symmetrical"), +]; + +/// Pathfinder panel, as InDesign's: Paths, Pathfinder, Convert Shape and Convert Point, each a row of +/// icon buttons acting on the selection. pub fn pathfinder_panel(app: &mut DesignApp, ui: &mut egui::Ui) { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "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(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + let section = |ui: &mut egui::Ui, app: &DesignApp, title: &str| { + ui.add_space(4.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, title)); + ui.add_space(2.0); + }; + let row = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui)| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(12.0, 8.0); + add(ui); + }); + }; + section(ui, app, "Paths:"); + row(ui, &mut |ui| { + for (icon, cmd, params, tip) in PATHS { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run(cmd, serde_json::from_str(params).unwrap_or_default()); + } + } + }); + section(ui, app, "Pathfinder:"); + row(ui, &mut |ui| { + for (icon, op, tip) in PATHFINDER { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { let _ = app.run("object.pathfinder", json!({"op": op})); } } }); - ui.add_space(4.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paths")).strong()); - ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Make Compound"))).clicked() { - let _ = app.run("object.makeCompoundPath", json!({})); + section(ui, app, "Convert Shape:"); + row(ui, &mut |ui| { + for (icon, to, tip) in CONVERT_SHAPE { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("object.convertShape", json!({"to": to})); + } } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Release"))).clicked() { - let _ = app.run("object.releaseCompoundPath", json!({})); + }); + section(ui, app, "Convert Point:"); + row(ui, &mut |ui| { + for (icon, kind, tip) in CONVERT_POINT { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("path.convertPoints", json!({"kind": kind})); + } } }); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a97fad..b2ea5756 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -417,6 +417,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read. - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. +- Pathfinder panel as InDesign's: Paths (Join Path `path.join`, Open/Close Path, Reverse Path), Pathfinder, Convert Shape, Convert Point (`path.convertPoints`: plain, corner, smooth, symmetrical). - 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`.