From fc1810f5d9a69bea7cff8c04f98cd29f3e39ece5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:34:30 +0200 Subject: [PATCH] Array commands: ARRAY, and edit-array option loops for ARRAYRECT/ARRAYPOLAR (#230) (#232) * Array commands: ARRAY, and the edit-array option loops for ARRAYRECT/ARRAYPOLAR (#230) ARRAY (alias AR) selects objects, asks for the array type and continues as ARRAYRECT, ARRAYPATH or ARRAYPOLAR. ARRAYRECT no longer creates a fixed 4x3 grid right after selection: it previews the array and loops on ASsociative/Base point/COUnt/Spacing/COLumns/Rows/Levels/eXit. ARRAYPOLAR asks for the center (or Base point), then loops on Items/Angle between/ Fill angle/ROTate items/eXit. The copies are created on eXit or Enter. Associative arrays, levels and polar rows report "not available yet" and return to the loop without consuming the next input. The array commands move to cmd/array.rs. Arrays stay non-associative copies, and the JSON forms are unchanged. A full clockwise polar fill (-360) no longer puts the last item on top of the source. Co-Authored-By: Claude Opus 5.5 * docs/gaps.md: narrow F5 (array option loops, #230) Co-Authored-By: Claude Opus 5.5 * Arrays: refuse arrays over MAXARRAY instead of hanging or shrinking them (#230) `arrayrect {"rows":1000000,"cols":1000000}` was clamped to 1000x1000 and then created a million copies of every selected object, which hangs the app. ARRAYRECT, ARRAYPOLAR and ARRAYPATH (JSON and interactive, also via ARRAY) now count items x selected objects and refuse an array over the new MAXARRAY session variable (default 100000, settable 100..10000000) with a message naming the limit, instead of clamping the counts and silently creating fewer copies. The interactive count prompts accept at most what still fits under MAXARRAY. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/array.rs | 796 ++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/modify.rs | 86 +-- crates/engine/src/cmd/modify2.rs | 20 +- crates/engine/src/lib.rs | 3 + crates/engine/src/sysvars.rs | 3 + crates/engine/tests/array_commands.rs | 201 +++++++ docs/gaps.md | 2 +- 8 files changed, 1020 insertions(+), 93 deletions(-) create mode 100644 crates/engine/src/cmd/array.rs create mode 100644 crates/engine/tests/array_commands.rs diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs new file mode 100644 index 000000000..e6fbb5e9b --- /dev/null +++ b/crates/engine/src/cmd/array.rs @@ -0,0 +1,796 @@ +//! Array commands: ARRAY, ARRAYRECT and ARRAYPOLAR (ARRAYPATH lives in `modify2`). +//! +//! Arrays are created as separate copies of the source objects (non-associative). The interactive +//! forms select objects, then edit the array in an option loop with a live preview, and create +//! the copies on eXit (or Enter). + +use cadcraft_doc::{Drawing, EntityKind, Handle}; +use cadcraft_geom::{Bounds2, Mat3, TAU, Vec2}; +use serde_json::{Value, json}; + +use super::helpers::line; +use super::machines::{SelOutcome, SelectPhase, number}; +use super::modify::transform_entities; +use super::*; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +/// Most preview entities drawn while the array is edited. +const PREVIEW_LIMIT: usize = 2000; +/// Keyword-only prompts: each typed token is matched on its own (no free text). +const KEYWORDS: Accept = Accept { point: false, number: false, text: false, select: false, enter: true }; + +const NOT_ASSOCIATIVE: &str = "Associative arrays are not available yet; the array is created as separate objects."; +const NO_LEVELS: &str = "Levels (3D arrays) are not available yet."; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("array", "Array", run_array) + .alias(&["ar"]) + .params("{type?: \"rectangular\" (default) | \"polar\" | \"path\", ...the parameters of arrayrect, arraypolar or arraypath}") + .interactive(|_| Ok(Box::new(ArrayM::default()))), + CommandSpec::new("arrayrect", "Rectangular Array", run_arrayrect) + .menu(&["Modify", "Array", "Rectangular Array"]) + .params("{handles?, rows, cols, rowSpacing, colSpacing}") + .interactive(|_| Ok(Box::new(RectM::default()))), + CommandSpec::new("arraypolar", "Polar Array", run_arraypolar) + .menu(&["Modify", "Array", "Polar Array"]) + .params("{handles?, center, count, angle? (degrees, default 360), rotate?: bool}") + .interactive(|_| Ok(Box::new(PolarM::default()))), + ] +} + +// ---------------- geometry ---------------- + +/// Translations of the copies in a rectangular array (the source cell is skipped). +fn rect_mats(rows: u64, cols: u64, rs: f64, cs: f64) -> impl Iterator { + (0..rows) + .flat_map(move |r| (0..cols).map(move |c| (r, c))) + .filter(|rc| *rc != (0, 0)) + .map(move |(r, c)| Mat3::translate(Vec2::new(cs * c as f64, rs * r as f64))) +} + +/// Transforms of the copies 1..n of a polar array filling `fill` degrees (positive = ccw). +/// Without `rotate` each copy is moved so that `reference` travels around the circle. +fn polar_mats(center: Vec2, n: u64, fill: f64, rotate: bool, reference: Vec2) -> impl Iterator { + let total = fill.to_radians(); + let full = (total.abs() - TAU).abs() < 1e-9; + let step = if full { total / n.max(1) as f64 } else { total / n.saturating_sub(1).max(1) as f64 }; + (1..n).map(move |k| { + let a = step * k as f64; + if rotate { Mat3::rotate_about(center, a) } else { Mat3::translate(reference.rotate_about(center, a) - reference) } + }) +} + +fn bounds(d: &Drawing, hs: &[Handle]) -> Bounds2 { + hs.iter().filter_map(|h| d.entity(*h).map(|e| cadcraft_doc::entity_bounds(d, e, 0))).fold(Bounds2::EMPTY, |a, b| a.union(&b)) +} + +/// The centre of the first object (the point a non-rotated polar copy is moved by). +fn first_center(d: &Drawing, hs: &[Handle]) -> Option { + hs.first().and_then(|h| d.entity(*h)).map(|e| cadcraft_doc::entity_bounds(d, e, 0).center()).filter(|c| c.is_finite()) +} + +/// MAXARRAY: refuse an array of `items` copies of `objs` objects that would exceed the limit +/// (instead of creating fewer copies than asked for). +pub(crate) fn check_size(s: &Session, items: u64, objs: usize) -> Result<()> { + let max = s.settings.maxarray; + let total = items.saturating_mul(objs.max(1) as u64); + if total > max { + return Err(other(format!( + "The array would create {total} objects; the limit is {max} (MAXARRAY). Use fewer items or select fewer objects." + ))); + } + Ok(()) +} + +/// The most items a count prompt accepts when the rest of the array is `others` items of `objs` objects. +fn max_count(s: &Session, others: u64, objs: usize) -> u64 { + (s.settings.maxarray / others.max(1).saturating_mul(objs.max(1) as u64)).max(1) +} + +fn copy_all(s: &mut Session, hs: &[Handle], mats: impl Iterator) -> Result { + let mut n = 0; + for m in mats { + n += transform_entities(s, hs, &m, true)?.len(); + } + Ok(n) +} + +/// The objects transformed by each matrix, for the preview. +fn preview_copies(s: &Session, hs: &[Handle], mats: impl Iterator) -> Vec { + let Ok(d) = s.doc() else { return Vec::new() }; + let src: Vec = hs.iter().take(PREVIEW_LIMIT).filter_map(|h| d.entity(*h)).map(|e| e.kind.clone()).collect(); + if src.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + for m in mats { + for k in &src { + if out.len() >= PREVIEW_LIMIT { + return out; + } + let mut k = k.clone(); + k.transform(&m); + out.push(k); + } + } + out +} + +fn other(m: impl Into) -> EngineError { + EngineError::Other(m.into()) +} + +fn fmt(v: f64) -> String { + format!("{v:.4}") +} + +/// A count typed at a prompt (Enter keeps `cur`); `None` for inputs the prompt ignores. +fn count(i: &Input, cur: u64, max: u64) -> Result> { + match i { + Input::Enter => Ok(Some(cur)), + Input::Text(t) => t + .trim() + .parse::() + .ok() + .filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= max as f64) + .map(|v| Some(v as u64)) + .ok_or_else(|| other(format!("Requires an integer between 1 and {max} (MAXARRAY limits the size of the array)."))), + _ => Ok(None), + } +} + +/// An angle in degrees typed at a prompt (Enter keeps `cur`); `None` for inputs the prompt ignores. +fn angle(i: &Input, cur: f64) -> Result> { + match i { + Input::Enter => Ok(Some(cur)), + Input::Text(t) => crate::units::parse_angle(t).map(|a| Some(a.to_degrees())).ok_or_else(|| other("Requires a valid angle in degrees.")), + _ => Ok(None), + } +} + +fn yes_no(i: &Input, cur: bool) -> Option { + match i { + Input::Enter => Some(cur), + Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().chars().next() { + Some('y') => Some(true), + Some('n') => Some(false), + _ => None, + }, + _ => None, + } +} + +// ---------------- JSON ---------------- + +fn run_array(s: &mut Session, p: &Value) -> Result { + let id = match str_param(p, "type").unwrap_or("rectangular").to_ascii_lowercase().as_str() { + "rectangular" | "rect" | "r" => "arrayrect", + "polar" | "po" => "arraypolar", + "path" | "pa" => "arraypath", + _ => return Err(bad("array", "`type` must be \"rectangular\", \"polar\" or \"path\"")), + }; + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + (spec.run)(s, p) +} + +fn run_arrayrect(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(3).max(1); + let cols = p.get("cols").and_then(Value::as_u64).unwrap_or(4).max(1); + check_size(s, rows.saturating_mul(cols), hs.len())?; + let rs = f64_or(p, "rowSpacing", 1.0); + let cs = f64_or(p, "colSpacing", 1.0); + let n = copy_all(s, &hs, rect_mats(rows, cols, rs, cs))?; + Ok(json!({ "created": n })) +} + +fn run_arraypolar(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let c = point_req("arraypolar", p, "center")?; + let n = p.get("count").and_then(Value::as_u64).unwrap_or(6).max(1); + check_size(s, n, hs.len())?; + let fill = f64_or(p, "angle", 360.0); + let rotate = bool_or(p, "rotate", true); + let reference = first_center(s.doc()?, &hs).unwrap_or(c); + let n = copy_all(s, &hs, polar_mats(c, n, fill, rotate, reference))?; + Ok(json!({ "created": n })) +} + +// ---------------- ARRAY ---------------- + +/// ARRAY: select objects, choose the array type, then continue as ARRAYRECT, ARRAYPATH or +/// ARRAYPOLAR with those objects. +#[derive(Default)] +struct ArrayM { + sel: SelectPhase, + objs: Vec, + inner: Option>, +} + +impl Interactive for ArrayM { + fn name(&self) -> &'static str { + "ARRAY" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match &self.inner { + Some(m) => m.prompt(s), + None if !self.sel.done => self.sel.prompt(), + None => Prompt::new("Enter array type", KEYWORDS).kw(&["Rectangular", "PAth", "POlar"]).default("Rectangular"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(m) = &mut self.inner { + return m.input(s, i); + } + if !self.sel.done { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => { + self.objs = hs; + Ok(Step::Continue) + } + }; + } + let kind = match &i { + Input::Enter => "Rectangular", + Input::Keyword(k) => k.as_str(), + _ => return Err(other("Invalid option keyword.")), + }; + let objs = self.objs.clone(); + let (m, step): (Box, Step) = match kind { + "PAth" => (Box::new(super::modify2::PathArrayM::with_objects(objs)), Step::Continue), + "POlar" => { + let mut m = PolarM::with_objects(objs); + let step = m.ready(s); + (Box::new(m), step) + } + _ => { + let mut m = RectM::with_objects(objs); + let step = m.ready(s)?; + (Box::new(m), step) + } + }; + self.inner = Some(m); + Ok(step) + } + fn preview(&self, s: &Session, cursor: Vec2) -> Vec { + self.inner.as_ref().map(|m| m.preview(s, cursor)).unwrap_or_default() + } +} + +// ---------------- ARRAYRECT ---------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum RStage { + #[default] + Main, + Base, + /// COUnt: columns, then rows. + CountCols, + CountRows, + /// Spacing: between columns (or a unit cell), then between rows. + SpaceCols, + SpaceRows, + UnitCell, + /// COLumns: count, then spacing (or the total). + ColsCount, + ColsSpace, + ColsTotal, + /// Rows: count, then spacing (or the total), then the elevation increment. + RowsCount, + RowsSpace, + RowsTotal, + RowsElev, +} + +/// ARRAYRECT: select objects, then edit columns/rows/spacing in a loop; eXit creates the copies. +#[derive(Default)] +struct RectM { + sel: SelectPhase, + objs: Vec, + stage: RStage, + cols: u64, + rows: u64, + cs: f64, + rs: f64, + /// Array base point (distances picked with a point are measured from it). + base: Vec2, + centroid: Vec2, + /// Unit cell: the first corner, once picked. + corner: Option, +} + +impl RectM { + fn with_objects(objs: Vec) -> Self { + RectM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, ..RectM::default() } + } + + /// Objects are selected: set the defaults (4 columns × 3 rows, 1.5 × the selection size apart). + fn ready(&mut self, s: &mut Session) -> Result { + let b = bounds(s.doc()?, &self.objs); + let (w, h) = (b.width(), b.height()); + let size = |v: f64, o: f64| match (v.is_finite() && v > 1e-9, o.is_finite() && o > 1e-9) { + (true, _) => v * 1.5, + (false, true) => o * 1.5, + _ => 1.0, + }; + (self.cols, self.rows, self.cs, self.rs) = (4, 3, size(w, h), size(h, w)); + self.centroid = if b.center().is_finite() { b.center() } else { Vec2::ZERO }; + self.base = self.centroid; + self.stage = RStage::Main; + s.echo("Type = Rectangular Associative = No"); + Ok(Step::Continue) + } + + /// A distance typed or picked (from the base point); Enter keeps `cur`. + fn distance(&self, i: &Input, cur: f64) -> Result> { + let d = match i { + Input::Enter => return Ok(Some(cur)), + Input::Text(t) => number(t), + Input::Point(p) => Some(self.base.dist(*p)), + _ => return Ok(None), + }; + d.filter(|d| d.is_finite() && d.abs() > 1e-12).map(Some).ok_or_else(|| other("Requires a nonzero distance.")) + } + + fn finish(&mut self, s: &mut Session) -> Result { + check_size(s, self.rows.saturating_mul(self.cols), self.objs.len())?; + copy_all(s, &self.objs, rect_mats(self.rows, self.cols, self.rs, self.cs))?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + + fn main(&mut self, s: &mut Session, i: Input) -> Result { + let k = match i { + Input::Enter => return self.finish(s), + Input::Keyword(k) => k, + Input::Point(_) => return Err(other("Editing the array with grips is not available yet; choose an option.")), + _ => return Err(other("Invalid option keyword.")), + }; + self.stage = match k.as_str() { + "eXit" => return self.finish(s), + "Base point" => RStage::Base, + "COUnt" => RStage::CountCols, + "Spacing" => RStage::SpaceCols, + "COLumns" => RStage::ColsCount, + "Rows" => RStage::RowsCount, + "Levels" => { + s.echo(NO_LEVELS); + RStage::Main + } + _ => { + s.echo(NOT_ASSOCIATIVE); + RStage::Main + } + }; + Ok(Step::Continue) + } +} + +impl Interactive for RectM { + fn name(&self) -> &'static str { + "ARRAYRECT" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + return self.ready(s); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + use RStage::*; + if !self.sel.done { + return self.sel.prompt(); + } + let cols = || Prompt::new("Enter the number of columns", Accept::NUMBER).default(self.cols.to_string()); + let rows = || Prompt::new("Enter the number of rows", Accept::NUMBER).default(self.rows.to_string()); + let dist = |m: &str, d: f64| Prompt::new(m, Accept::POINT_OR_NUMBER).default(fmt(d)).base(self.base); + match self.stage { + Main => Prompt::new("Select grip to edit array", Accept::POINT) + .kw(&["ASsociative", "Base point", "COUnt", "Spacing", "COLumns", "Rows", "Levels", "eXit"]) + .default("eXit"), + Base => Prompt::new("Specify base point", Accept::POINT).kw(&["Key point"]).default("centroid"), + CountCols | ColsCount => cols(), + CountRows | RowsCount => rows(), + SpaceCols => dist("Specify the distance between columns", self.cs).kw(&["Unit cell"]), + SpaceRows => dist("Specify the distance between rows", self.rs), + UnitCell => match self.corner { + None => Prompt::new("Specify first corner of the unit cell", Accept::POINT).base(self.base), + Some(c) => Prompt::new("Specify other corner", Accept::POINT).base(c), + }, + ColsSpace => dist("Specify the distance between columns", self.cs).kw(&["Total"]), + ColsTotal => dist("Specify the distance between the first and last column", self.cs * self.cols.saturating_sub(1) as f64), + RowsSpace => dist("Specify the distance between rows", self.rs).kw(&["Total"]), + RowsTotal => dist("Specify the distance between the first and last row", self.rs * self.rows.saturating_sub(1) as f64), + RowsElev => Prompt::new("Specify the incrementing elevation between rows", Accept::NUMBER).default("0"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + use RStage::*; + if !self.sel.done { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => { + self.objs = hs; + self.ready(s) + } + }; + } + let stage = self.stage; + let next = match (stage, &i) { + (Main, _) => return self.main(s, i), + (Base, Input::Point(p)) => { + self.base = *p; + Main + } + (Base, Input::Enter) => { + self.base = self.centroid; + Main + } + (Base, Input::Keyword(_)) => { + s.echo("Key points are not available yet; specify a point."); + Base + } + (CountCols | ColsCount, _) => match count(&i, self.cols, max_count(s, self.rows, self.objs.len()))? { + Some(n) => { + self.cols = n; + if stage == CountCols { CountRows } else { ColsSpace } + } + None => stage, + }, + (CountRows | RowsCount, _) => match count(&i, self.rows, max_count(s, self.cols, self.objs.len()))? { + Some(n) => { + self.rows = n; + if stage == CountRows { Main } else { RowsSpace } + } + None => stage, + }, + (SpaceCols, Input::Keyword(_)) => { + self.corner = None; + UnitCell + } + (ColsSpace, Input::Keyword(_)) => ColsTotal, + (RowsSpace, Input::Keyword(_)) => RowsTotal, + (SpaceCols | ColsSpace, _) => match self.distance(&i, self.cs)? { + Some(d) => { + self.cs = d; + if stage == SpaceCols { SpaceRows } else { Main } + } + None => stage, + }, + (SpaceRows | RowsSpace, _) => match self.distance(&i, self.rs)? { + Some(d) => { + self.rs = d; + if stage == SpaceRows { Main } else { RowsElev } + } + None => stage, + }, + (ColsTotal, _) => match self.distance(&i, self.cs * self.cols.saturating_sub(1) as f64)? { + Some(t) => { + self.cs = t / self.cols.saturating_sub(1).max(1) as f64; + Main + } + None => stage, + }, + (RowsTotal, _) => match self.distance(&i, self.rs * self.rows.saturating_sub(1) as f64)? { + Some(t) => { + self.rs = t / self.rows.saturating_sub(1).max(1) as f64; + RowsElev + } + None => stage, + }, + (RowsElev, Input::Enter) => Main, + (RowsElev, Input::Text(t)) => match number(t) { + Some(z) if z.is_finite() => { + if z.abs() > 1e-12 { + s.echo("Row elevation increments (3D) are not available yet; the rows stay at the same elevation."); + } + Main + } + _ => return Err(other("Requires a number.")), + }, + (UnitCell, Input::Point(p)) => match self.corner { + None => { + self.corner = Some(*p); + UnitCell + } + Some(c) => { + let (dx, dy) = (p.x - c.x, p.y - c.y); + if dx.abs() <= 1e-12 || dy.abs() <= 1e-12 { + return Err(other("The unit cell must have a width and a height.")); + } + (self.cs, self.rs, self.corner) = (dx, dy, None); + Main + } + }, + (UnitCell, Input::Enter) => { + self.corner = None; + Main + } + _ => stage, + }; + self.stage = next; + Ok(Step::Continue) + } + fn preview(&self, s: &Session, cursor: Vec2) -> Vec { + if !self.sel.done { + return Vec::new(); + } + let mut out = preview_copies(s, &self.objs, rect_mats(self.rows, self.cols, self.rs, self.cs)); + match (self.stage, self.corner) { + (RStage::UnitCell, Some(c)) => out.push(line(c, cursor)), + (RStage::SpaceCols | RStage::SpaceRows | RStage::ColsSpace | RStage::ColsTotal | RStage::RowsSpace | RStage::RowsTotal, _) => { + out.push(line(self.base, cursor)) + } + _ => {} + } + out + } +} + +// ---------------- ARRAYPOLAR ---------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum PStage { + #[default] + Center, + /// Base point asked from the center prompt (returns there). + CenterBase, + Main, + Base, + Items, + Between, + Fill, + Rotate, +} + +/// ARRAYPOLAR: select objects, specify the center, then edit items/angles in a loop; eXit creates +/// the copies. +struct PolarM { + sel: SelectPhase, + objs: Vec, + stage: PStage, + center: Vec2, + items: u64, + /// Fill angle in degrees (positive = counterclockwise). + fill: f64, + rotate: bool, + /// Base point: the point non-rotated copies are moved by (default: the first object's centre). + base: Option, +} + +impl Default for PolarM { + fn default() -> Self { + PolarM { + sel: SelectPhase::default(), + objs: Vec::new(), + stage: PStage::Center, + center: Vec2::ZERO, + items: 6, + fill: 360.0, + rotate: true, + base: None, + } + } +} + +impl PolarM { + fn with_objects(objs: Vec) -> Self { + PolarM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, ..PolarM::default() } + } + + fn ready(&mut self, s: &mut Session) -> Step { + self.stage = PStage::Center; + s.echo("Type = Polar Associative = No"); + Step::Continue + } + + fn full(&self) -> bool { + (self.fill.abs() - 360.0).abs() < 1e-9 + } + + /// The angle between items, derived from the item count and the fill angle. + fn between(&self) -> f64 { + match self.items { + 0 | 1 => self.fill, + n if self.full() => self.fill / n as f64, + n => self.fill / (n - 1) as f64, + } + } + + fn mats(&self, s: &Session, center: Vec2) -> impl Iterator { + let reference = self.base.or_else(|| s.doc().ok().and_then(|d| first_center(d, &self.objs))).unwrap_or(center); + polar_mats(center, self.items, self.fill, self.rotate, reference) + } + + fn finish(&mut self, s: &mut Session) -> Result { + check_size(s, self.items, self.objs.len())?; + let mats: Vec = self.mats(s, self.center).collect(); + copy_all(s, &self.objs, mats.into_iter())?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + + fn main(&mut self, s: &mut Session, i: Input) -> Result { + let k = match i { + Input::Enter => return self.finish(s), + Input::Keyword(k) => k, + Input::Point(_) => return Err(other("Editing the array with grips is not available yet; choose an option.")), + _ => return Err(other("Invalid option keyword.")), + }; + self.stage = match k.as_str() { + "eXit" => return self.finish(s), + "Base point" => PStage::Base, + "Items" => PStage::Items, + "Angle between" => PStage::Between, + "Fill angle" => PStage::Fill, + "ROTate items" => PStage::Rotate, + "ROWs" => { + s.echo("Rows in polar arrays are not available yet."); + PStage::Main + } + "Levels" => { + s.echo(NO_LEVELS); + PStage::Main + } + _ => { + s.echo(NOT_ASSOCIATIVE); + PStage::Main + } + }; + Ok(Step::Continue) + } +} + +impl Interactive for PolarM { + fn name(&self) -> &'static str { + "ARRAYPOLAR" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + return Ok(self.ready(s)); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + use PStage::*; + if !self.sel.done { + return self.sel.prompt(); + } + match self.stage { + Center => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base point", "Axis of rotation"]), + CenterBase | Base => Prompt::new("Specify base point", Accept::POINT).kw(&["Key point"]).default("centroid"), + Main => Prompt::new("Select grip to edit array", Accept::POINT) + .kw(&["ASsociative", "Base point", "Items", "Angle between", "Fill angle", "ROWs", "Levels", "ROTate items", "eXit"]) + .default("eXit"), + Items => Prompt::new("Enter number of items in array", Accept::NUMBER).default(self.items.to_string()), + Between => Prompt::new("Specify the angle between items", Accept::NUMBER).default(fmt(self.between())), + Fill => Prompt::new("Specify the angle to fill (+=ccw, -=cw)", Accept::NUMBER).default(fmt(self.fill)), + Rotate => Prompt::new("Rotate arrayed items?", KEYWORDS).kw(&["Yes", "No"]).default(if self.rotate { "Yes" } else { "No" }), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + use PStage::*; + if !self.sel.done { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => { + self.objs = hs; + Ok(self.ready(s)) + } + }; + } + let stage = self.stage; + let next = match (stage, &i) { + (Main, _) => return self.main(s, i), + (Center, Input::Point(p)) => { + self.center = *p; + Main + } + (Center, Input::Keyword(k)) if k == "Base point" => CenterBase, + (Center, Input::Keyword(_)) => { + s.echo("A rotation axis (3D) is not available yet; specify the center point."); + Center + } + (Center, _) => return Err(other("Requires a point or an option keyword.")), + (CenterBase | Base, Input::Point(p)) => { + self.base = Some(*p); + if stage == CenterBase { Center } else { Main } + } + (CenterBase | Base, Input::Enter) => { + self.base = None; + if stage == CenterBase { Center } else { Main } + } + (CenterBase | Base, Input::Keyword(_)) => { + s.echo("Key points are not available yet; specify a point."); + stage + } + (Items, _) => match count(&i, self.items, max_count(s, 1, self.objs.len()))? { + Some(n) => { + self.items = n; + Main + } + None => stage, + }, + (Between, Input::Enter) => Main, + (Between, _) => match angle(&i, self.between())? { + Some(b) => { + if b.abs() < 1e-9 { + return Err(other("Requires a nonzero angle.")); + } + let n = self.items as f64; + let fill = if (b.abs() * n - 360.0).abs() < 1e-9 { 360.0 * b.signum() } else { b * (n - 1.0).max(1.0) }; + if fill.abs() > 360.0 + 1e-9 { + return Err(other("The items would span more than 360 degrees; enter a smaller angle or fewer items.")); + } + self.fill = fill; + Main + } + None => stage, + }, + (Fill, _) => match angle(&i, self.fill)? { + Some(f) => { + if f.abs() < 1e-9 || f.abs() > 360.0 + 1e-9 { + return Err(other("Requires a fill angle between -360 and 360 degrees (not 0).")); + } + self.fill = f.clamp(-360.0, 360.0); + Main + } + None => stage, + }, + (Rotate, _) => match yes_no(&i, self.rotate) { + Some(r) => { + self.rotate = r; + Main + } + None => return Err(other("Requires Yes or No.")), + }, + _ => stage, + }; + self.stage = next; + Ok(Step::Continue) + } + fn preview(&self, s: &Session, cursor: Vec2) -> Vec { + if !self.sel.done { + return Vec::new(); + } + let center = match self.stage { + PStage::Center | PStage::CenterBase => cursor, + _ => self.center, + }; + preview_copies(s, &self.objs, self.mats(s, center)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn polar_angles() { + // Full circle: 4 items 90° apart; partial: the last item sits at the fill angle. + let a: Vec = polar_mats(Vec2::ZERO, 4, 360.0, true, Vec2::X).map(|m| m.apply(Vec2::X)).collect(); + assert_eq!(a.len(), 3); + assert!(a[0].near(Vec2::new(0.0, 1.0), 1e-9) && a[2].near(Vec2::new(0.0, -1.0), 1e-9)); + let b: Vec = polar_mats(Vec2::ZERO, 3, -180.0, true, Vec2::X).map(|m| m.apply(Vec2::X)).collect(); + assert!(b[0].near(Vec2::new(0.0, -1.0), 1e-9) && b[1].near(Vec2::new(-1.0, 0.0), 1e-9)); + // A full clockwise circle does not put the last item on top of the source. + let c: Vec = polar_mats(Vec2::ZERO, 4, -360.0, true, Vec2::X).map(|m| m.apply(Vec2::X)).collect(); + assert!(c[2].near(Vec2::new(0.0, 1.0), 1e-9)); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ec136ce1f..7dad164dd 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ //! AutoCAD's menu bar so the catalog doubles as the parity metric. mod annotate; +mod array; mod blocks; pub mod constraints; mod draw; @@ -157,6 +158,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(hatch::specs()); v.extend(blocks::specs()); v.extend(modify::specs()); + v.extend(array::specs()); v.extend(modify2::specs()); v.extend(gripcmds::specs()); v.extend(layer::specs()); diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 32c11792f..f76d20744 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1,5 +1,5 @@ //! Modify commands: ERASE, MOVE, COPY, ROTATE, SCALE, MIRROR, OFFSET, TRIM, EXTEND, FILLET, -//! CHAMFER, EXPLODE, STRETCH, ARRAY, DRAWORDER, BREAK, JOIN. +//! CHAMFER, EXPLODE, STRETCH, DRAWORDER, BREAK, JOIN (arrays: `array`). use cadcraft_doc::{Entity, EntityKind, Handle, Prim}; use cadcraft_geom::{ @@ -79,14 +79,6 @@ pub fn specs() -> Vec { .alias(&["x"]) .params("{handles?}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Explode)))), - CommandSpec::new("arrayrect", "Rectangular Array", run_arrayrect) - .menu(&["Modify", "Array", "Rectangular Array"]) - .params("{handles?, rows, cols, rowSpacing, colSpacing}") - .interactive(|_| Ok(Box::new(SelectThen::new(Op::ArrayRect)))), - CommandSpec::new("arraypolar", "Polar Array", run_arraypolar) - .menu(&["Modify", "Array", "Polar Array"]) - .params("{handles?, center, count, angle? (degrees, default 360), rotate?: bool}") - .interactive(|_| Ok(Box::new(SelectThen::new(Op::ArrayPolar)))), CommandSpec::new("draworder.front", "Bring to Front", run_front) .menu(&["Tools", "Draw Order", "Bring to Front"]) .params("{handles?}") @@ -1015,47 +1007,6 @@ fn run_explode(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } -fn run_arrayrect(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(3).clamp(1, 1000); - let cols = p.get("cols").and_then(Value::as_u64).unwrap_or(4).clamp(1, 1000); - let rs = f64_or(p, "rowSpacing", 1.0); - let cs = f64_or(p, "colSpacing", 1.0); - let mut out = Vec::new(); - for r in 0..rows { - for c in 0..cols { - if r == 0 && c == 0 { - continue; - } - out.extend(transform_entities(s, &hs, &Mat3::translate(Vec2::new(cs * c as f64, rs * r as f64)), true)?); - } - } - Ok(json!({ "created": out.len() })) -} - -fn run_arraypolar(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let c = point_req("arraypolar", p, "center")?; - let n = p.get("count").and_then(Value::as_u64).unwrap_or(6).clamp(1, 10_000); - let total = f64_or(p, "angle", 360.0).to_radians(); - let rotate = bool_or(p, "rotate", true); - let step = if (total - TAU).abs() < 1e-9 { total / n as f64 } else { total / (n.saturating_sub(1).max(1)) as f64 }; - let mut out = Vec::new(); - for k in 1..n { - let a = step * k as f64; - let m = if rotate { - Mat3::rotate_about(c, a) - } else { - // Translate only: move the reference point around the circle. - let d = s.doc()?; - let r = hs.first().and_then(|h| d.entity(*h)).map(|e| cadcraft_doc::entity_bounds(d, e, 0).center()).unwrap_or(c); - Mat3::translate(r.rotate_about(c, a) - r) - }; - out.extend(transform_entities(s, &hs, &m, true)?); - } - Ok(json!({ "created": out.len() })) -} - fn run_front(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let space = s.space(); @@ -1298,8 +1249,6 @@ enum Op { Mirror, Stretch, Explode, - ArrayRect, - ArrayPolar, Join, } @@ -1364,22 +1313,6 @@ impl SelectThen { } Ok(Step::Done) } - Op::ArrayRect => { - let objs = self.objs.clone(); - let d = s.doc()?; - let b = objs - .iter() - .filter_map(|h| d.entity(*h).map(|e| cadcraft_doc::entity_bounds(d, e, 0))) - .fold(cadcraft_geom::Bounds2::EMPTY, |a, b| a.union(&b)); - let cs = b.width() * 1.5 + 1e-9; - let rs = b.height() * 1.5 + 1e-9; - run_arrayrect( - s, - &json!({ "handles": objs.iter().map(|h| h.hex()).collect::>(), "rows": 3, "cols": 4, "rowSpacing": rs, "colSpacing": cs }), - )?; - s.echo("Type = Rectangular Associative = No (3 rows × 4 columns)"); - Ok(Step::Done) - } _ => Ok(Step::Continue), } } @@ -1525,8 +1458,6 @@ impl Interactive for SelectThen { Op::Mirror => "MIRROR", Op::Stretch => "STRETCH", Op::Explode => "EXPLODE", - Op::ArrayRect => "ARRAYRECT", - Op::ArrayPolar => "ARRAYPOLAR", Op::Join => "JOIN", } } @@ -1580,8 +1511,6 @@ impl Interactive for SelectThen { (Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER).kw(&["Copy", "Reference"]).base_opt(base), (Op::Mirror, 0) => Prompt::new("Specify first point of mirror line", Accept::POINT), (Op::Mirror, _) => Prompt::new("Specify second point of mirror line", Accept::POINT).base_opt(base), - (Op::ArrayPolar, 0) => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base point", "Axis of rotation"]), - (Op::ArrayPolar, _) => Prompt::new("Enter number of items", Accept::NUMBER).default("6"), _ => Prompt::new("", Accept::POINT), } } @@ -1714,19 +1643,6 @@ impl Interactive for SelectThen { self.ask_erase = true; Ok(Step::Continue) } - (Op::ArrayPolar, 1, inp) => { - let n = match inp { - Input::Text(t) => { - t.trim().parse::().ok().filter(|n| *n >= 1).ok_or_else(|| EngineError::Other("Requires a positive integer.".into()))? - } - Input::Enter => 6, - _ => return Ok(Step::Continue), - }; - let objs: Vec = self.objs.iter().map(|h| h.hex()).collect(); - run_arraypolar(s, &json!({ "handles": objs, "center": [self.pts[0].x, self.pts[0].y], "count": n }))?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } (_, _, Input::Enter) => Ok(Step::Done), _ => Ok(Step::Continue), } diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 70f8f3fde..0ca531972 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -2126,13 +2126,14 @@ pub(crate) fn arraypath( return Err(other("Select objects to array.")); } let (n, step) = match (count, spacing) { - (_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize + 1).clamp(1, MAX_GEN), sp), + (_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize).saturating_add(1), sp), (Some(n), _) => { - let n = n.clamp(1, MAX_GEN); + let n = n.max(1); (n, if c.closed { l / n as f64 } else { l / (n.max(2) - 1) as f64 }) } _ => (6, if c.closed { l / 6.0 } else { l / 5.0 }), }; + super::array::check_size(s, n as u64, objs.len())?; let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) }; let mut out = Vec::new(); for i in 1..n { @@ -2149,19 +2150,26 @@ pub(crate) fn arraypath( fn run_arraypath(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let path = curves::handle_param(p, "path").ok_or_else(|| bad("arraypath", "`path` (handle) is required"))?; - let count = p.get("count").and_then(Value::as_u64).map(|n| n.min(MAX_GEN as u64) as usize); + let count = p.get("count").and_then(Value::as_u64).map(|n| usize::try_from(n).unwrap_or(usize::MAX)); let spacing = p.get("spacing").and_then(Value::as_f64); let r = arraypath(s, &hs, path, count, spacing, bool_or(p, "align", true))?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } #[derive(Default)] -struct PathArrayM { +pub(super) struct PathArrayM { sel: SelectPhase, objs: Vec, path: Option, } +impl PathArrayM { + /// Start at the path prompt with the objects already selected (ARRAY > PAth). + pub(super) fn with_objects(objs: Vec) -> Self { + PathArrayM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, path: None } + } +} + impl Interactive for PathArrayM { fn name(&self) -> &'static str { "ARRAYPATH" @@ -2202,9 +2210,7 @@ impl Interactive for PathArrayM { } (Some(path), inp @ (Input::Text(_) | Input::Enter)) => { let n = match &inp { - Input::Text(t) => { - number(t).filter(|n| *n >= 1.0 && *n <= MAX_GEN as f64).ok_or_else(|| other("Requires a count of 1 or more."))? as usize - } + Input::Text(t) => number(t).filter(|n| *n >= 1.0).ok_or_else(|| other("Requires a count of 1 or more."))? as usize, _ => 6, }; arraypath(s, &self.objs, path, Some(n), None, true)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0172c2661..456e2b79c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -239,6 +239,8 @@ pub struct Settings { pub pickadd: bool, pub gripsize: f64, pub cursorsize: f64, + /// MAXARRAY: the most objects one array command creates (items × selected objects). + pub maxarray: u64, pub isodraft: bool, pub annoallvisible: bool, pub annoautoscale: bool, @@ -271,6 +273,7 @@ impl Default for Settings { pickadd: true, gripsize: 5.0, cursorsize: 5.0, + maxarray: 100_000, isodraft: false, annoallvisible: true, annoautoscale: false, diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 70fdc9169..8e1048a32 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -25,6 +25,7 @@ const SESSION_VARS: &[&str] = &[ "PICKADD", "GRIPSIZE", "CURSORSIZE", + "MAXARRAY", "LASTPOINT", "FONTALT", "FONTFALLBACK", @@ -54,6 +55,7 @@ pub fn get(s: &Session, name: &str) -> Option { "PICKADD" => json!(i32::from(st.pickadd)), "GRIPSIZE" => json!(st.gripsize), "CURSORSIZE" => json!(st.cursorsize), + "MAXARRAY" => json!(st.maxarray), "LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]), // Process-wide font substitution (profile settings, not saved in the drawing). "FONTALT" => json!(cadcraft_fonts::ttf::font_alt()), @@ -145,6 +147,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?, "GRIPSIZE" => st.gripsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 255.0), "CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0), + "MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64, "FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?), "FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?), _ => { diff --git a/crates/engine/tests/array_commands.rs b/crates/engine/tests/array_commands.rs new file mode 100644 index 000000000..9c84acea1 --- /dev/null +++ b/crates/engine/tests/array_commands.rs @@ -0,0 +1,201 @@ +//! ARRAY, ARRAYRECT and ARRAYPOLAR at the command line: the type prompt and the edit-array +//! option loops. Each input is its own `cmdline` call (one typed line each). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +fn typed(s: &mut Session, inputs: &[&str]) { + for t in inputs { + s.cmdline(t).unwrap(); + } +} + +fn circle_centers(s: &Session) -> Vec { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Circle(c) => Some(c.center.xy()), + _ => None, + }) + .collect() +} + +fn has(pts: &[Vec2], x: f64, y: f64) -> bool { + pts.iter().any(|p| p.near(Vec2::new(x, y), 1e-6)) +} + +fn prompt(s: &Session) -> String { + s.prompt_text() +} + +#[test] +fn array_asks_for_the_type_and_continues_as_that_array() { + // ARRAY ▸ POlar. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap(); + typed(&mut s, &["array", "l", ""]); + assert!(prompt(&s).contains("Enter array type or [Rectangular/PAth/POlar] "), "{}", prompt(&s)); + typed(&mut s, &["po", "0,0", "i", "4", "x"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let c = circle_centers(&s); + assert_eq!(c.len(), 4); + assert!(has(&c, 0.0, 10.0) && has(&c, -10.0, 0.0) && has(&c, 0.0, -10.0)); + + // AR (alias), Enter takes the default Rectangular. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + typed(&mut s, &["ar", "l", "", ""]); + assert!(prompt(&s).contains("COUnt"), "{}", prompt(&s)); + typed(&mut s, &["cou", "2", "1", "s", "5", "5", ""]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let c = circle_centers(&s); + assert_eq!(c.len(), 2); + assert!(has(&c, 5.0, 0.0)); + + // ARRAY ▸ PAth continues at the path prompt with the selected objects. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 1], "radius": 0.25})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + typed(&mut s, &["array", "0.25,1", "", "pa", "5,0", "3"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + assert_eq!(circle_centers(&s).len(), 3); + + // The JSON form dispatches on `type`. + let mut s = Session::new(); + let r = s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap(); + let h = r["handle"].clone(); + let r = s.execute("array", &json!({"type": "polar", "handles": [h], "center": [0, 0], "count": 3})).unwrap(); + assert_eq!(r["created"], 2); + assert!(s.execute("array", &json!({"type": "spiral", "handles": [h]})).is_err()); +} + +#[test] +fn arrayrect_edits_the_array_in_a_loop_until_exit() { + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap(); + typed(&mut s, &["arrayrect", "l", ""]); + let p = prompt(&s); + assert!(p.contains("Select grip to edit array or [ASsociative/Base point/COUnt/Spacing/COLumns/Rows/Levels/eXit] "), "{p}"); + // Nothing is created before eXit. + assert_eq!(circle_centers(&s).len(), 1); + typed(&mut s, &["cou", "2", "3", "s", "10", "20"]); + // Unavailable options report and return to the loop without taking the next input. + typed(&mut s, &["as", "l", "cou", "2", "3"]); + assert!(prompt(&s).contains("Select grip to edit array"), "{}", prompt(&s)); + typed(&mut s, &["x"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let c = circle_centers(&s); + assert_eq!(c.len(), 6, "{c:?}"); + for (x, y) in [(10.0, 0.0), (0.0, 20.0), (10.0, 40.0)] { + assert!(has(&c, x, y), "{c:?}"); + } + + // COLumns with a Total distance, Rows with spacing and elevation. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap(); + typed(&mut s, &["arrayrect", "l", "", "col", "3", "t", "20", "r", "2", "5", "0", "x"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let c = circle_centers(&s); + assert_eq!(c.len(), 6, "{c:?}"); + assert!(has(&c, 20.0, 0.0) && has(&c, 10.0, 5.0), "{c:?}"); + + // Invalid counts re-prompt. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap(); + typed(&mut s, &["arrayrect", "l", "", "cou", "0"]); + assert!(prompt(&s).contains("number of columns"), "{}", prompt(&s)); + typed(&mut s, &["2", "1", "x"]); + assert_eq!(circle_centers(&s).len(), 2); + + // The JSON form is unchanged. + let mut s = Session::new(); + let r = s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap(); + let r = s.execute("arrayrect", &json!({"handles": [r["handle"]], "rows": 2, "cols": 3, "rowSpacing": 4, "colSpacing": 2})).unwrap(); + assert_eq!(r["created"], 5); + assert!(has(&circle_centers(&s), 4.0, 4.0)); +} + +#[test] +fn arraypolar_asks_for_the_center_then_edits_items_and_angles() { + let mut s = Session::new(); + s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap(); + typed(&mut s, &["arraypolar", "l", ""]); + assert!(prompt(&s).contains("Specify center point of array or [Base point/Axis of rotation]"), "{}", prompt(&s)); + typed(&mut s, &["0,0"]); + let p = prompt(&s); + assert!( + p.contains("Select grip to edit array or [ASsociative/Base point/Items/Angle between/Fill angle/ROWs/Levels/ROTate items/eXit] "), + "{p}" + ); + typed(&mut s, &["i", "4", "x"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + // X ended ARRAYPOLAR; it did not start EXPLODE. + assert!(!s.log.iter().any(|l| l.contains("EXPLODE"))); + let c = circle_centers(&s); + assert_eq!(c.len(), 4); + assert!(has(&c, 0.0, 10.0) && has(&c, -10.0, 0.0) && has(&c, 0.0, -10.0)); + + // Angle between with 4 items: 30° apart, filling 90°. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap(); + s.script("ARRAYPOLAR L\n\n0,0 I 4 A 30 X\n").unwrap(); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let c = circle_centers(&s); + assert_eq!(c.len(), 4); + assert!(has(&c, 0.0, 10.0) && !has(&c, -10.0, 0.0), "{c:?}"); + + // Fill angle 180 with 3 items, items not rotated: the copies only move. + let mut s = Session::new(); + s.execute("line", &json!({"points": [[10, 0], [12, 0]]})).unwrap(); + typed(&mut s, &["arraypolar", "l", "", "0,0", "i", "3", "f", "180", "rot", "n", "x"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let lines: Vec<(Vec2, Vec2)> = s + .doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 3); + // The middle copy: the line's centre (11,0) moved to (0,11), still horizontal. + assert!(lines.iter().any(|(a, b)| a.near(Vec2::new(-1.0, 11.0), 1e-6) && b.near(Vec2::new(1.0, 11.0), 1e-6)), "{lines:?}"); +} + +#[test] +fn arrays_over_maxarray_are_refused_not_shrunk() { + // JSON: a million × a million rows/cols (or items) is refused up front, nothing is created. + let mut s = Session::new(); + let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + let h = c["handle"].clone(); + let before = s.doc().unwrap().model.iter().count(); + let e = s.execute("arrayrect", &json!({"handles": [h], "rows": 1_000_000, "cols": 1_000_000})).unwrap_err(); + assert!(e.to_string().contains("MAXARRAY"), "{e}"); + assert!(s.execute("arraypolar", &json!({"handles": [h], "center": [5, 5], "count": 200_000})).unwrap_err().to_string().contains("MAXARRAY")); + let path = s.execute("line", &json!({"points": [[0, 5], [100, 5]]})).unwrap()["handles"][0].clone(); + let e = s.execute("arraypath", &json!({"handles": [h], "path": path, "spacing": 1e-6})).unwrap_err(); + assert!(e.to_string().contains("MAXARRAY"), "{e}"); + assert_eq!(s.doc().unwrap().model.iter().count(), before + 1); + + // The limit counts every selected object, and MAXARRAY is a settable system variable. + s.execute("setvar", &json!({"name": "MAXARRAY", "value": 100})).unwrap(); + let h2 = s.execute("circle", &json!({"center": [3, 0], "radius": 1})).unwrap()["handle"].clone(); + assert!(s.execute("arrayrect", &json!({"handles": [h, h2], "rows": 10, "cols": 6})).is_err()); + assert_eq!(s.execute("arrayrect", &json!({"handles": [h, h2], "rows": 10, "cols": 5})).unwrap()["created"], 98); + + // Interactive: a count over the limit is rejected at its prompt and the loop keeps going. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + typed(&mut s, &["arrayrect", "l", "", "cou", "1000", "1000"]); + assert!(s.log.iter().any(|l| l.contains("MAXARRAY")), "{:?}", s.log); + assert!(prompt(&s).contains("rows"), "{}", prompt(&s)); + typed(&mut s, &["2", ""]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + assert_eq!(circle_centers(&s).len(), 2000); +} diff --git a/docs/gaps.md b/docs/gaps.md index c65b0c687..4af8bc880 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -32,7 +32,7 @@ do them first. | F2 | **[alpha blocker]** No xrefs (XATTACH, XREF palette, REFEDIT, BIND, XCLIP, overlay, paths); DXF xref blocks read with an empty path | `dxf_read.rs:923` | High: multi-file projects | 30–45 | | F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 | | F4 | No autosave (SAVETIME), crash recovery files, AUDIT, RECOVER, drawing recovery manager | no SAVETIME anywhere | High: lost work | 10–15 | -| F5 | Arrays are not associative: no ARRAYEDIT; the interactive rectangular array always makes 3×4 at 1.5× bounds | `modify.rs:1356-1360` | Medium-high | 10–15 | +| F5 | Arrays are not associative: no ARRAYEDIT; ARRAY/ARRAYRECT/ARRAYPOLAR edit the array in an option loop, then create plain copies (ASsociative, Levels, polar ROWs and grip editing print "not available yet") | `cmd/array.rs` | Medium-high | 8–12 | | F6 | FILLET/CHAMFER/JOIN/LENGTHEN/EXTEND don't handle ellipses and splines; FILLET Trim/Multiple and polyline segment pairs; CHAMFER Angle/Method | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--45) | Medium | 10–15 | | F7 | Dimensions: no DIMSTYLE manager dialog; 43 of ~80 DIMSTYLE variables (DIMTOFL, DIMATFIT, DIMTMOVE, DIMLTYPE, DIMLWD, DIMFXL, DIMTOLJ, DIMALTU …); no smart DIM; no DIMJOGGED, DIMJOGLINE, DIMBREAK, oblique DIMEDIT, inspection; QDIM continuous only (SETVAR DIM* now become style overrides, #129) | `doc/src/tables.rs:223-266`, `annotate.rs:64,364` | High for detailers | 25–35 | | F8 | No TOLERANCE (GD&T feature control frames) | menu "Dimension > Tolerance..." uncovered | Medium (mechanical) | 4–6 |