From cb9d93b647c08948d796d3973ec3037cb46dfe09 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 01:59:13 +0700 Subject: [PATCH] Fix #1462: Split Into Grid has its row and column sizes, gutters, totals, Add Guides and Preview Object > Path > Split Into Grid took a number of rows and columns and one gutter, and split each selected path on its own. The dialog now has the documented options, the rows beside the columns: number, height or width, gutter and total, with Add Guides and Preview. Several selected paths become one grid over them all, painted as the topmost one, as documented. object.path.splitIntoGrid takes rowGutter and columnGutter (gutter still sets both), totalHeight and totalWidth, height and width (the size of each row and column, which sets the total) and addGuides, which adds ruler guides along the row and column edges on the grid's layer. Behaviour reference: https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html --- crates/engine/src/cmd/pathops.rs | 71 ++++++++++++++++-------- crates/engine/src/tests_pathops.rs | 54 +++++++++++++++++++ crates/pathops/src/edit.rs | 18 ++++--- crates/pathops/tests/ops.rs | 7 +-- crates/pathops/tests/prop_pathops.rs | 2 +- crates/ui-egui/src/dialogs/form.rs | 6 +-- crates/ui-egui/src/dialogs/mod.rs | 2 +- crates/ui-egui/src/dialogs/path_ops.rs | 74 +++++++++++++++++++++++--- crates/ui-egui/src/dialogs/tests.rs | 36 +++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/menus.rs | 7 ++- docs/control-protocol.md | 2 +- 23 files changed, 255 insertions(+), 48 deletions(-) diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index c91fced2..c7937a11 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -149,7 +149,7 @@ pub fn specs() -> Vec { "Split Into Grid…", ["Object", "Path"], None, - "{rows?: 2, columns?: 2, gutter?: pt (12)} → {ids}", + "{rows?: 2, columns?: 2, gutter?: pt (12; rowGutter?, columnGutter?: one of the two), totalHeight?, totalWidth?: pt (the grid's size; the selected paths' when left out), height?, width?: pt (each row's and column's size, which then sets the total), addGuides?: false (ruler guides along the row and column edges)} one grid of rectangles over the selected paths replaces them, painted as the topmost one → {ids, guides: count}", has_selection, split_into_grid ), @@ -717,40 +717,65 @@ fn add_anchor_points(s: &mut Session, _: &Value) -> Result { // ---------- Split Into Grid ---------- +/// One direction of the grid: the (gutter, total size) of `n` rows or columns. A `size` for each +/// makes the total; otherwise it is `total`, or `extent` (the selection's) when neither is given. +fn grid_axis(p: &Value, n: f64, [size, gutter, total]: [&str; 3], extent: f64) -> (f64, f64) { + let gutter = len_param(p, gutter).or_else(|| len_param(p, "gutter")).unwrap_or(12.0).max(0.0); + let total = match len_param(p, size) { + Some(size) => n * size + (n - 1.0) * gutter, + None => len_param(p, total).unwrap_or(extent), + }; + (gutter, total) +} + fn split_into_grid(s: &mut Session, p: &Value) -> Result { let rows = num_param(p, "rows").unwrap_or(2.0).round(); let cols = num_param(p, "columns").unwrap_or(2.0).round(); if !(1.0..=500.0).contains(&rows) || !(1.0..=500.0).contains(&cols) { return Err(bad("object.path.splitIntoGrid", "rows and columns must be 1..500")); } - let gutter = len_param(p, "gutter").unwrap_or(12.0).max(0.0); + let too_small = || EngineError::Other("Split Into Grid: select paths large enough for the grid".into()); + // The selected paths, back to front: one grid over them all replaces them, where the topmost + // one was and painted as it is. + let d = &s.doc()?.doc; let roots = selected_roots(s)?; + let paths: Vec<&Node> = + roots.iter().filter_map(|id| d.node(*id)).filter(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })).collect(); + let Some((top, below)) = paths.split_last().map(|(top, below)| (Node::clone(top), below.iter().map(|n| n.id).collect::>())) else { + return Err(too_small()); + }; + let Some(b) = paths.iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)) else { return Err(too_small()) }; + let (row_gutter, height) = grid_axis(p, rows, ["height", "rowGutter", "totalHeight"], b.height()); + let (col_gutter, width) = grid_axis(p, cols, ["width", "columnGutter", "totalWidth"], b.width()); + let (rows, cols) = (rows as usize, cols as usize); + let cells = po::split_into_grid(vectorcraft_geom::Rect::new(b.x0, b.y0, b.x0 + width, b.y0 + height), rows, cols, (row_gutter, col_gutter)); + if cells.is_empty() { + return Err(too_small()); + } + let mut edges: Vec<(bool, f64)> = Vec::new(); + if bool_or(p, "addGuides", false) { + // Row-major cells: the first row has the column edges, each row's first cell the row edges. + let bounds = cells.iter().filter_map(PathData::bounds); + edges.extend(bounds.clone().take(cols).flat_map(|c| [(true, c.x0), (true, c.x1)])); + edges.extend(bounds.step_by(cols).flat_map(|c| [(false, c.y0), (false, c.y1)])); + // Without a gutter, neighbours share an edge. + edges.dedup_by(|a, b| a.0 == b.0 && (a.1 - b.1).abs() < 1e-9); + } let ids = s.edit("Split Into Grid", |d, sel| { - let mut out = vec![]; - for id in &roots { - let Some(n) = d.node(*id).cloned() else { continue }; - if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { - continue; - } - let Some(b) = n.geometric_bounds() else { continue }; - let cells = po::split_into_grid(b, rows as usize, cols as usize, gutter); - if cells.is_empty() { - continue; - } - let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; - d.remove(*id)?; - for (k, c) in cells.into_iter().enumerate() { - let node = shape_node(d, c, Some(&n)); - out.push(d.insert(par, idx + k, node)?); - } - } - if out.is_empty() { - return Err(EngineError::Other("Split Into Grid: select paths large enough for the grid".into())); + d.remove_many(&below)?; + let (par, idx, _) = d.position(top.id).ok_or(EngineError::NoNode(top.id))?; + let layer = d.layer_containing(top.id); + d.remove(top.id)?; + let mut out = Vec::with_capacity(cells.len()); + for (k, c) in cells.into_iter().enumerate() { + let node = shape_node(d, c, Some(&top)); + out.push(d.insert(par, idx + k, node)?); } + d.guides.extend(edges.iter().map(|&(vertical, pos)| vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) })); sel.set(out.iter().copied()); Ok(out) })?; - Ok(ids_json(&ids)) + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "guides": edges.len() })) } // ---------- Clean Up ---------- diff --git a/crates/engine/src/tests_pathops.rs b/crates/engine/src/tests_pathops.rs index c8081cb2..05036dd0 100644 --- a/crates/engine/src/tests_pathops.rs +++ b/crates/engine/src/tests_pathops.rs @@ -634,6 +634,60 @@ fn split_into_grid_cells() { assert!(s.execute("object.path.splitIntoGrid", &json!({"rows": 0})).is_err()); } +/// Split Into Grid's documented options: a gutter for the rows and one for the columns, the size +/// of each row and column or of the whole grid, and guides along the row and column edges. Several +/// selected paths give one grid over them all, painted as the topmost one, where it was. +#[test] +fn split_into_grid_options() { + let grid = |p: Value| { + let mut s = session(); + rect(&mut s, 10.0, 20.0, 110.0, 50.0); + let r = s.execute("object.path.splitIntoGrid", &p).unwrap(); + let cells: Vec = ids(&r).iter().map(|id| bounds(&s, *id)).collect(); + let guides: Vec<(bool, f64)> = s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect(); + assert_eq!(r["guides"], guides.len()); + (cells, guides) + }; + // A gutter each way; the grid keeps the path's size. + let (cells, guides) = grid(json!({"rows": 2, "columns": 3, "rowGutter": 10, "columnGutter": 4})); + assert!(close(cells[0], Rect::new(10.0, 20.0, 44.0, 40.0), 1e-9) && close(cells[5], Rect::new(86.0, 50.0, 120.0, 70.0), 1e-9), "{cells:?}"); + assert!(guides.is_empty()); + // The whole grid's size, from the path's top left corner. + let (cells, _) = grid(json!({"rows": 1, "columns": 2, "gutter": 0, "totalWidth": 60, "totalHeight": "1 in"})); + assert!(close(cells[1], Rect::new(40.0, 20.0, 70.0, 92.0), 1e-9), "{cells:?}"); + // The size of each row and column makes the total, whatever total is given. + let (cells, guides) = grid(json!({"rows": 2, "columns": 2, "gutter": 5, "width": 20, "height": 10, "totalWidth": 500, "addGuides": true})); + assert!(close(cells[3], Rect::new(35.0, 35.0, 55.0, 45.0), 1e-9), "{cells:?}"); + let edges = |vertical, at: [f64; 4]| at.map(|pos| (vertical, pos)); + assert_eq!(guides, [edges(true, [10.0, 30.0, 35.0, 55.0]), edges(false, [20.0, 30.0, 35.0, 45.0])].concat()); + // Without a gutter the rows share their edges: one guide for each. + let (_, guides) = grid(json!({"rows": 2, "columns": 1, "gutter": 0, "addGuides": true})); + assert_eq!(guides, [(true, 10.0), (true, 120.0), (false, 20.0), (false, 45.0), (false, 70.0)]); + + let mut s = session(); + let back = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + let kept = rect(&mut s, 300.0, 300.0, 10.0, 10.0); + let front = rect(&mut s, 60.0, 20.0, 40.0, 40.0); + set_fill(&mut s, front, Color::rgb(0.1, 0.6, 0.3)); + s.execute("select.set", &json!({"ids": [front.0, back.0]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = ids(&s.execute("object.path.splitIntoGrid", &json!({"rows": 1, "columns": 2, "gutter": 20, "addGuides": true})).unwrap()); + assert!(close(bounds(&s, r[0]), Rect::new(0.0, 0.0, 40.0, 60.0), 1e-9) && close(bounds(&s, r[1]), Rect::new(60.0, 0.0, 100.0, 60.0), 1e-9)); + let st = s.doc().unwrap(); + assert!(r.iter().all(|id| node(&s, *id).appearance == node(&s, r[0]).appearance) && st.doc.node(back).is_none() && st.doc.node(front).is_none()); + assert_eq!(st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::>(), [kept, r[0], r[1]], "above the path left alone"); + assert_eq!(st.selection.objects, r); + assert!(st.doc.guides.iter().all(|g| g.layer == Some(st.doc.layers[0].id))); + assert_eq!(st.history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.node(back).is_some() && st.doc.node(front).is_some() && st.doc.guides.is_empty()); + // A grid with no room for its gutters is refused and changes nothing. + assert!(s.execute("object.path.splitIntoGrid", &json!({"columns": 4, "gutter": 50})).is_err()); + assert!(s.execute("object.path.splitIntoGrid", &json!({"width": -5})).is_err()); + assert!(s.doc().unwrap().doc.node(back).is_some()); +} + #[test] fn clean_up_removes_junk() { let mut s = session(); diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 7e3451dd..5cb30bcd 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -422,22 +422,24 @@ pub fn join(paths: &[PathData], tolerance: f64) -> PathData { 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 { +/// Object → Path → Split Into Grid: `rows × cols` rectangles filling `rect` with a gutter between +/// the rows and one between the columns (row-major, top-left first). No cells when they would have +/// no size. +pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, (row_gutter, col_gutter): (f64, f64)) -> Vec { 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 { + let w = (rect.width() - col_gutter * (cols as f64 - 1.0)) / cols as f64; + let h = (rect.height() - row_gutter * (rows as f64 - 1.0)) / rows as f64; + // Written so that a size that is not a number gives no cells either. + if !(w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()) { 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); + let x = rect.x0 + c as f64 * (w + col_gutter); + let y = rect.y0 + r as f64 * (h + row_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, diff --git a/crates/pathops/tests/ops.rs b/crates/pathops/tests/ops.rs index 40bdfc2d..d65fb5b8 100644 --- a/crates/pathops/tests/ops.rs +++ b/crates/pathops/tests/ops.rs @@ -412,13 +412,14 @@ fn join_single_path_closes() { #[test] fn grid_split() { - let g = split_into_grid(Rect::new(0.0, 0.0, 100.0, 50.0), 2, 3, 5.0); + let g = split_into_grid(Rect::new(0.0, 0.0, 100.0, 50.0), 2, 3, (7.0, 5.0)); assert_eq!(g.len(), 6); let w = (100.0 - 10.0) / 3.0; - let h = (50.0 - 5.0) / 2.0; + let h = (50.0 - 7.0) / 2.0; assert!(g.iter().all(|c| close(ar(c), w * h, 1e-9))); assert_eq!(g[5].bounds().unwrap().x1, 100.0); - assert!(split_into_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, 3, 1.0).is_empty()); + assert!(split_into_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, 3, (1.0, 1.0)).is_empty()); + assert!(split_into_grid(Rect::new(0.0, 0.0, 10.0, f64::NAN), 2, 3, (1.0, 1.0)).is_empty()); } #[test] diff --git a/crates/pathops/tests/prop_pathops.rs b/crates/pathops/tests/prop_pathops.rs index 262e85e4..82feae9f 100644 --- a/crates/pathops/tests/prop_pathops.rs +++ b/crates/pathops/tests/prop_pathops.rs @@ -188,7 +188,7 @@ proptest! { let gutter_w = g * (cols - 1) as f64; let gutter_h = g * (rows - 1) as f64; prop_assume!(r.width() > gutter_w + 1.0 && r.height() > gutter_h + 1.0); - let cells = split_into_grid(r, rows, cols, g); + let cells = split_into_grid(r, rows, cols, (g, g)); prop_assert_eq!(cells.len(), rows * cols); let total: f64 = cells.iter().map(|c| area(c, NZ)).sum(); let want = (r.width() - gutter_w) * (r.height() - gutter_h); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 3c5f7198..acc70cba 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -23,7 +23,7 @@ pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, } /// The label cell of a grid row. -fn row_label(ui: &mut egui::Ui, label: &str) { +pub(super) fn row_label(ui: &mut egui::Ui, label: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)); } @@ -56,7 +56,6 @@ fn lengths(kind: &str) -> &'static [&'static str] { "lineSegment" => &["length"], "offsetPath" => &["offset"], "simplify" => &["tolerance"], - "splitIntoGrid" => &["gutter"], _ => &[], } } @@ -76,7 +75,6 @@ fn suffix(kind: &str, key: &str) -> Option<&'static str> { fn order(kind: &str) -> &'static [&'static str] { match kind { "offsetPath" => &["offset", "joins", "miterLimit"], - "splitIntoGrid" => &["rows", "columns", "gutter"], "rectangularGrid" => &["width", "height", "rows", "rowSkew", "columns", "columnSkew"], "polarGrid" => &["width", "height", "concentric", "concentricSkew", "radial", "radialSkew"], "artboardOptions" => &["name", "width", "height"], @@ -431,7 +429,7 @@ pub(super) fn humanize(k: &str) -> String { } /// [`humanize`] translated where it is painted: the ":" stays outside the translated text. -fn humanized(k: &str) -> String { +pub(super) fn humanized(k: &str) -> String { let h = humanize(k); match h.strip_suffix(':') { Some(base) => format!("{}:", tl!(base)), diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index cd8b38e8..101a29f2 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -52,7 +52,7 @@ mod new_document; pub mod new_swatch; pub mod office_export; pub mod package; -mod path_ops; +pub(crate) mod path_ops; pub mod pdf_presets; pub mod perspective_grid; pub mod perspective_options; diff --git a/crates/ui-egui/src/dialogs/path_ops.rs b/crates/ui-egui/src/dialogs/path_ops.rs index bc47850d..c2c35af3 100644 --- a/crates/ui-egui/src/dialogs/path_ops.rs +++ b/crates/ui-egui/src/dialogs/path_ops.rs @@ -1,6 +1,7 @@ -//! Path dialogs: Average, Offset Path and Simplify (with Preview), and Split Into Grid. +//! Path dialogs: Average, and Offset Path, Simplify and Split Into Grid with Preview. use serde_json::{Value, json}; +use vectorcraft_doc::Unit; use super::{DialogSpec, form, run_and_close}; use crate::VectorcraftApp; @@ -25,18 +26,79 @@ fn command(d: &Dialog) -> (&'static str, Value) { ("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)})) } "simplify" => ("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})), - _ => ("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})), + _ => ("object.path.splitIntoGrid", form::params(d)), } } +/// The undo label of the dialogs that preview on the canvas (all but Average). +fn previewed(kind: &str) -> Option<&'static str> { + match kind { + "offsetPath" => Some("Offset Path"), + "simplify" => Some("Simplify"), + "splitIntoGrid" => Some("Split Into Grid"), + _ => None, + } +} + +/// The fields Split Into Grid opens with for a selection `width` × `height` points. +pub(crate) fn split_fields(width: f64, height: f64) -> Value { + json!({"rows": 2, "columns": 2, "rowGutter": 12.0, "columnGutter": 12.0, "totalHeight": height, "totalWidth": width, "addGuides": false}) +} + +/// Split Into Grid's fields: the rows on the left and the columns on the right, each with its +/// number, size, gutter and total, and Add Guides. The size of a row or column is what the total +/// leaves each of them after the gutters, so it follows the other three; typing it sets the total. +fn split_grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + const WIDTH: f32 = 84.0; + let axes = [ + ("rows", tl!("Rows:"), tl!("Height:"), "rowGutter", "totalHeight"), + ("columns", tl!("Columns:"), tl!("Width:"), "columnGutter", "totalWidth"), + ]; + let gutter_label = form::humanized("gutter"); + egui::Grid::new("dlg").num_columns(4).spacing([10.0, 8.0]).show(ui, |ui| { + for (number, label, ..) in axes { + form::row_label(ui, label); + if let Some(v) = crate::widgets::plain_field(ui, ("dlg-num", number), d.f64(number, 2.0), "", 0, WIDTH) { + d.fields.insert(number.into(), json!(v.round().clamp(1.0, 500.0))); + } + } + ui.end_row(); + for (number, _, label, gutter, total) in axes { + form::row_label(ui, label); + let n = d.f64(number, 2.0).max(1.0); + let gaps = d.f64(gutter, 0.0) * (n - 1.0); + if let Some(v) = crate::widgets::num_field(ui, ("dlg-size", number), Some((d.f64(total, 0.0) - gaps) / n), unit, WIDTH) { + d.fields.insert(total.into(), json!(n * v + gaps)); + } + } + ui.end_row(); + for (.., gutter, _) in axes { + form::row_label(ui, &gutter_label); + form::length(ui, d, gutter, unit, WIDTH); + } + ui.end_row(); + for (.., total) in axes { + form::row_label(ui, tl!("Total:")); + form::length(ui, d, total, unit, WIDTH); + } + ui.end_row(); + }); + ui.add_space(8.0); + form::check(ui, d, "addGuides", tl!("Add Guides")); +} + fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - form::grid(ui, d, app.session.general_unit()); - if matches!(d.kind.as_str(), "offsetPath" | "simplify") { + let unit = app.session.general_unit(); + if d.kind == "splitIntoGrid" { + split_grid(ui, d, unit); + } else { + form::grid(ui, d, unit); + } + if let Some(label) = previewed(&d.kind) { let (id, params) = command(d); if d.fields.get(form::PREVIEWED) != Some(¶ms) || !d.bool("preview") { d.fields.remove("__counts"); } - let label = if d.kind == "simplify" { "Simplify" } else { "Offset Path" }; if let Some(r) = form::preview_result(app, ui, d, label, id, params) && d.kind == "simplify" { @@ -55,7 +117,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (id, params) = command(d); - if !matches!(d.kind.as_str(), "offsetPath" | "simplify") { + if previewed(&d.kind).is_none() { return run_and_close(app, id, params); } let r = form::commit_preview(app, id, params)?; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index bfee917f..e3f6e072 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -379,6 +379,42 @@ fn a_form_dialog_is_as_wide_as_its_fields_not_the_window() { assert!(narrow.width() <= 360.0, "{:.0} wide in a 360-point window", narrow.width()); } +/// Object › Path › Split Into Grid opens on a grid the size of the selection, shows its rows and +/// columns side by side (number, size, gutter, total), previews on the canvas and, on OK, splits +/// the path and adds the guides as one undo step. +#[test] +fn split_into_grid_opens_at_the_selections_size_and_previews() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 112, "height": 62})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + crate::menus::invoke(&mut app, "object.path.splitIntoGrid", json!({})); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.f64("totalWidth", 0.0), d.f64("totalHeight", 0.0), d.bool("addGuides")), (112.0, 62.0, false)); + d.fields.extend([ + ("columns".to_string(), json!(3)), + ("columnGutter".to_string(), json!(8)), + ("addGuides".into(), json!(true)), + ("preview".into(), json!(true)), + ]); + frame(&mut app, Default::default()); + // The preview: 2 × 3 cells and the guides along their edges, not yet an undo step. + assert_eq!((objects(&app), app.session.doc().unwrap().doc.guides.len()), (6, 10)); + assert!(app.session.in_interaction()); + confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!((objects(&app), st.doc.guides.len(), st.history.undo.len()), (6, 10, undo + 1)); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + // Cancel rolls a preview back. + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.path.splitIntoGrid", json!({})); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); + frame(&mut app, Default::default()); + assert_eq!(objects(&app), 4, "one grid over the six cells"); + cancel(&mut app); + assert_eq!(objects(&app), 6); +} + #[test] fn menu_parameter_dialogs_are_compact() { // #292: Object › Path › Simplify (and its neighbours) spanned the whole window. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9f8d8acb..94798738 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2974,6 +2974,8 @@ theme Appearance Motiv Offset Path Odsadit cestu Simplify Zjednodušit Split Into Grid Rozdělit do mřížky + Total: Celkem: + Add Guides Přidat vodítka Unite Sjednotit Delete Pattern Odstranit vzorek Done Hotovo diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d00caa9f..949f4b84 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2691,6 +2691,8 @@ effect Points Punkte Spiral Tool Spirale-Werkzeug Split Freeform Line Freiformlinie teilen Split Into Grid In Raster teilen + Total: Gesamt: + Add Guides Hilfslinien hinzufügen Split Into Grid… In Raster teilen… Split Path Pfad teilen spot Vollton diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7467cec5..ae6ac751 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2741,6 +2741,8 @@ Spiral Tool Herramienta Espiral Split Freeform Line Dividir línea libre Split Into Grid Dividir en cuadrícula + Total: Total: + Add Guides Agregar guías Split Into Grid… Dividir en cuadrícula… Split Path Dividir trazado spot plana diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d70e57be..85242a40 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2738,6 +2738,8 @@ Spiral Tool Outil Spirale Split Freeform Line Scinder la ligne libre Split Into Grid Diviser en grille + Total: Total : + Add Guides Ajouter des repères Split Into Grid… Diviser en grille… Split Path Scinder le tracé spot ton direct diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 42a892de..3662f906 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3343,6 +3343,8 @@ Split Complementary Felosztott kiegészítő Split Freeform Line Szabad formájú vonal kettévágása Split Into Grid Felosztás rácsra + Total: Összesen: + Add Guides Segédvonalak hozzáadása Split Into Grid… Felosztás rácsra… Split Path Útvonal kettévágása spot direkt diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cabf9da7..f1c529c7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2741,6 +2741,8 @@ Spiral Tool Strumento Spirale Split Freeform Line Dividi linea libera Split Into Grid Dividi in griglia + Total: Totale: + Add Guides Aggiungi guide Split Into Grid… Dividi in griglia… Split Path Dividi tracciato spot spot diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2500e047..3fdeeb5c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2522,6 +2522,8 @@ Spiral Tool スパイラルツール Split Freeform Line フリーフォームラインを分割 Split Into Grid グリッドに分割 + Total: 合計: + Add Guides ガイドを追加 Split Path パスを分割 Spot Color 特色 Spot Colors 特色 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d11ec669..cb1dad70 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2516,6 +2516,8 @@ Spiral Tool 나선 도구 Split Freeform Line 자유형 선 분할 Split Into Grid 격자로 분할 + Total: 합계: + Add Guides 안내선 추가 Split Path 패스 분할 Spot Color 별색 Spot Colors 별색 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4111e53e..b22407a6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2737,6 +2737,8 @@ Spiral Tool Инструмент «Спираль» Split Freeform Line Разделить произвольную линию Split Into Grid Разделить на сетку + Total: Всего: + Add Guides Добавить направляющие Split Into Grid… Разделить на сетку… Split Path Разделить контур spot плашечный diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2bd62953..293f79fa 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2734,6 +2734,8 @@ effect Points Точки Spiral Tool Інструмент «Спіраль» Split Freeform Line Розділити довільну лінію Split Into Grid Розділити на сітку + Total: Усього: + Add Guides Додати напрямні Split Into Grid… Розділити на сітку… Split Path Розділити контур spot плашковий diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eaec2a43..95dd4b20 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2628,6 +2628,8 @@ Spiral Tool 螺旋工具 Split Freeform Line 分割自由曲线 Split Into Grid 分割为网格线 + Total: 总计: + Add Guides 添加参考线 Split Into Grid… 分割为网格… Split Path 分割路径 spot 专色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2c75bd15..84d609b0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2719,6 +2719,8 @@ Spiral Tool 螺旋工具 Split Freeform Line 分割任意形狀線條 Split Into Grid 分割為格線 + Total: 總計: + Add Guides 增加參考線 Split Into Grid… 分割為格線… Split Path 分割路徑 spot 特別色 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d78b21..47130d6a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3024,7 +3024,12 @@ fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> "object.path.offsetPath" if app.ui.offset_path.is_object() => ("offsetPath", app.ui.offset_path.clone()), "object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})), "object.path.simplify" => ("simplify", json!({"tolerance": "1 pt", "preview": true})), - "object.path.splitIntoGrid" => ("splitIntoGrid", json!({"rows": 2, "columns": 2, "gutter": "12 pt"})), + "object.path.splitIntoGrid" => { + // The grid starts at the size of the selection. + let size = + app.session.active().and_then(|st| st.doc.bounds_of(&st.selection.objects, false)).map_or((0.0, 0.0), |b| (b.width(), b.height())); + ("splitIntoGrid", crate::dialogs::path_ops::split_fields(size.0, size.1)) + } "object.vectorHalftone" => (crate::dialogs::halftone::KIND, crate::dialogs::halftone::fields()), "artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::fields()), _ => return None, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 078dd52d..cb036d42 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -390,7 +390,7 @@ Perspective plane options: double-clicking a plane widget of the perspective gri opens the `perspectivePlane` dialog (fields `plane`: left, right or ground; `location`: points along the plane's normal; `objects`: none, move or copy). `ui.dialog.confirm` runs `perspective.plane.move` with them. -Units: dialog distance fields (Move's `dx`/`dy`, shape sizes, Offset Path's `offset`, Split Into Grid's `gutter`, +Units: dialog distance fields (Move's `dx`/`dy`, shape sizes, Offset Path's `offset`, Split Into Grid's gutters and totals, Artboard Options' sizes, effects' distances, `transformEach`'s `moveH`/`moveV`, New Document's `width`/`height`, the length preferences in `preferences`) show in the General unit (the active document's units, or `unitsGeneral` without a document). Through `ui.dialog.set` a number is in points and a string may carry its unit (`"10 mm"`; a bare number in a