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 |