From bd2a7597c5f7fb6142ab55932c7e1a50f13ed666 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:28:48 +0200 Subject: [PATCH] Associative arrays: ASsociative, Levels and polar ROWs (part of #448) (#455) Squash of PR #455 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/array.rs | 321 +++++++++++++ crates/doc/src/lib.rs | 2 + crates/doc/src/tables.rs | 4 + crates/engine/src/cmd/array.rs | 580 +++++++++++++++++++---- crates/engine/src/cmd/modify.rs | 3 + crates/engine/src/spatial.rs | 1 + crates/engine/tests/array_commands.rs | 45 +- crates/engine/tests/assoc_array.rs | 215 +++++++++ crates/io/src/dxf_ext.rs | 38 +- crates/io/src/dxf_read.rs | 8 + crates/io/src/dxf_write.rs | 4 + crates/io/tests/assoc_array_roundtrip.rs | 130 +++++ 12 files changed, 1239 insertions(+), 112 deletions(-) create mode 100644 crates/doc/src/array.rs create mode 100644 crates/engine/tests/assoc_array.rs create mode 100644 crates/io/tests/assoc_array_roundtrip.rs diff --git a/crates/doc/src/array.rs b/crates/doc/src/array.rs new file mode 100644 index 00000000..7ec0ba4f --- /dev/null +++ b/crates/doc/src/array.rs @@ -0,0 +1,321 @@ +//! Associative arrays: the parameters an array keeps, and where they place its items. +//! +//! An associative array is a block reference to an anonymous block (`*U…`, the *array block*) +//! that holds one block reference per item. Each item refers to the array's *source block* +//! (another anonymous block holding the source objects) or, for items replaced with ARRAYEDIT, +//! to another block. Other programs see an ordinary block reference; CADCraft keeps the +//! parameters below on the array block ([`Block::array`](crate::Block)) so the items can be +//! regenerated when the array is edited. +//! +//! All positions are in the array block's coordinates: its origin is the array's base point, +//! which is where the array's block reference is inserted. + +use cadcraft_geom::{Vec2, Vec3}; +use serde::{Deserialize, Serialize}; + +use crate::{Common, Drawing, Entity, EntityKind, Insert, Lineweight}; + +/// Most items one array may have (items × rows × levels), whatever MAXARRAY says. +pub const MAX_ARRAY_ITEMS: u64 = 10_000_000; + +fn one() -> u32 { + 1 +} + +/// The parameters of an associative array. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArrayParams { + /// The source block the items refer to. + pub source: String, + pub kind: ArrayKind, + /// Levels: copies of the whole array along Z. + #[serde(default = "one")] + pub levels: u32, + #[serde(default)] + pub level_spacing: f64, + /// Items that refer to another block than the source (ARRAYEDIT REPlace). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub replaced: Vec, +} + +/// Rectangular or polar placement. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ArrayKind { + Rect(RectArray), + Polar(PolarArray), +} + +/// A rectangular array: columns along the axis angle, rows across it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RectArray { + pub cols: u32, + pub rows: u32, + pub col_spacing: f64, + pub row_spacing: f64, + /// Direction of the rows (the column axis), radians. + #[serde(default)] + pub axis_angle: f64, + /// Elevation added per row. + #[serde(default)] + pub row_elevation: f64, +} + +/// A polar array: items around a centre, rows as rings further out. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolarArray { + pub center: Vec2, + pub items: u32, + /// Angle to fill, degrees (positive = counterclockwise, ±360 = a full circle). + pub fill: f64, + /// Items are rotated as they go around (else they keep their orientation). + pub rotate: bool, + #[serde(default = "one")] + pub rows: u32, + /// Distance between rings, outwards from the centre. + #[serde(default)] + pub row_spacing: f64, + #[serde(default)] + pub row_elevation: f64, +} + +/// An item that refers to another block. `i` is the column (rectangular) or the item around the +/// circle (polar), `j` the row. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplacedItem { + pub i: u32, + pub j: u32, + #[serde(default)] + pub level: u32, + pub block: String, +} + +/// Where one item goes: its block reference's insertion point (the image of the array's base +/// point) and rotation (radians). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ArrayItem { + pub i: u32, + pub j: u32, + pub level: u32, + pub insert: Vec3, + pub rotation: f64, +} + +impl ArrayParams { + /// Items along the first direction (columns or items around) and rows. + pub fn grid(&self) -> (u32, u32) { + match &self.kind { + ArrayKind::Rect(r) => (r.cols, r.rows), + ArrayKind::Polar(p) => (p.items, p.rows), + } + } + + /// The number of items, saturating. + pub fn item_count(&self) -> u64 { + let (a, b) = self.grid(); + u64::from(a).saturating_mul(u64::from(b)).saturating_mul(u64::from(self.levels)) + } + + /// Whether the parameters are usable: at least one item in every direction, at most + /// [`MAX_ARRAY_ITEMS`] in all, and finite numbers. Parameters read from files are checked + /// with this before they are kept. + pub fn is_valid(&self) -> bool { + let (a, b) = self.grid(); + let nums = match &self.kind { + ArrayKind::Rect(r) => [r.col_spacing, r.row_spacing, r.axis_angle, r.row_elevation, 0.0, 0.0], + ArrayKind::Polar(p) => [p.center.x, p.center.y, p.fill, p.row_spacing, p.row_elevation, 0.0], + }; + a >= 1 + && b >= 1 + && self.levels >= 1 + && self.item_count() <= MAX_ARRAY_ITEMS + && !self.source.trim().is_empty() + && nums.iter().chain(std::iter::once(&self.level_spacing)).all(|v| v.is_finite()) + && self.replaced.len() as u64 <= MAX_ARRAY_ITEMS + } + + /// Where every item goes, row by row and level by level; `None` when the parameters are not + /// [valid](Self::is_valid). + pub fn items(&self) -> Option> { + if !self.is_valid() { + return None; + } + let n = usize::try_from(self.item_count()).ok()?; + let mut out = Vec::with_capacity(n); + for level in 0..self.levels { + let lz = self.level_spacing * f64::from(level); + match &self.kind { + ArrayKind::Rect(r) => { + let (ux, uy) = (Vec2::from_angle(r.axis_angle), Vec2::from_angle(r.axis_angle).perp()); + for j in 0..r.rows { + for i in 0..r.cols { + let p = ux * (r.col_spacing * f64::from(i)) + uy * (r.row_spacing * f64::from(j)); + out.push(ArrayItem { i, j, level, insert: p.to3(lz + r.row_elevation * f64::from(j)), rotation: 0.0 }); + } + } + } + ArrayKind::Polar(p) => { + let total = p.fill.to_radians(); + let full = (total.abs() - std::f64::consts::TAU).abs() < 1e-9; + let step = if full { total / f64::from(p.items.max(1)) } else { total / f64::from(p.items.saturating_sub(1).max(1)) }; + // Rows move the items outwards: along the line from the centre to the base point. + let out_dir = { + let d = (Vec2::ZERO - p.center).normalized(); + if d == Vec2::ZERO { Vec2::X } else { d } + }; + for j in 0..p.rows { + let o = out_dir * (p.row_spacing * f64::from(j)); + for i in 0..p.items { + let a = step * f64::from(i); + let at = o.rotate_about(p.center, a); + let rotation = if p.rotate { a } else { 0.0 }; + out.push(ArrayItem { i, j, level, insert: at.to3(lz + p.row_elevation * f64::from(j)), rotation }); + } + } + } + } + } + Some(out) + } + + /// The block an item refers to: a replacement, else the source. + pub fn block_for(&self, i: u32, j: u32, level: u32) -> &str { + self.replaced.iter().rev().find(|r| (r.i, r.j, r.level) == (i, j, level)).map_or(self.source.as_str(), |r| r.block.as_str()) + } + + /// The block references that make up the array block: one per item, on layer 0 with ByBlock + /// properties so they take the array's. Handles are allocated from `d`. `None` when the + /// parameters are not [valid](Self::is_valid). + pub fn item_entities(&self, d: &mut Drawing) -> Option> { + let items = self.items()?; + let common = Common { + layer: "0".into(), + color: cadcraft_color::Color::ByBlock, + linetype: "ByBlock".into(), + lineweight: Lineweight::ByBlock, + ..Common::default() + }; + let mut out = Vec::with_capacity(items.len()); + for it in items { + let ins = Insert { + block: self.block_for(it.i, it.j, it.level).to_string(), + insert: it.insert, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: it.rotation, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + out.push(Entity { handle: d.new_handle(), common: common.clone(), kind: EntityKind::Insert(ins) }); + } + Some(out) + } + + /// Take the block names from the array block's items `ents` (in the order [`items`] + /// lists them): the source block is whatever the items that are not replaced refer to, so + /// the parameters follow blocks renamed on paste, on INSERT of a file or by other programs + /// (which may rename anonymous blocks). `false` when the items don't match the parameters + /// (another program changed the block), so the array should be treated as a plain block. + /// + /// [`items`]: Self::items + pub fn sync_names<'a>(&mut self, ents: impl Iterator) -> bool { + let n = self.item_count(); + let ents: Vec<&Entity> = ents.take(usize::try_from(n).unwrap_or(usize::MAX).saturating_add(1)).collect(); + if ents.len() as u64 != n { + return false; + } + let Some(items) = self.items() else { return false }; + let mut source: Option<&str> = None; + for (it, e) in items.iter().zip(&ents) { + let EntityKind::Insert(ins) = &e.kind else { return false }; + match self.replaced.iter_mut().rev().find(|r| (r.i, r.j, r.level) == (it.i, it.j, it.level)) { + Some(r) => r.block.clone_from(&ins.block), + None => match source { + None => source = Some(&ins.block), + Some(s) if s.eq_ignore_ascii_case(&ins.block) => {} + Some(_) => return false, + }, + } + } + if let Some(s) = source { + self.source = s.to_string(); + } + true + } +} + +impl Drawing { + /// The parameters of `e` when it is an associative array (a block reference to an array + /// block whose items match its parameters), with the block names its items use. + pub fn array_params(&self, e: &Entity) -> Option { + let EntityKind::Insert(i) = &e.kind else { return None }; + let b = self.block(&i.block)?; + let mut p = b.array.clone()?; + p.sync_names(b.entities.iter().map(|e| e.as_ref())).then_some(p) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rect(cols: u32, rows: u32) -> ArrayParams { + ArrayParams { + source: "*U1".into(), + kind: ArrayKind::Rect(RectArray { cols, rows, col_spacing: 2.0, row_spacing: 3.0, axis_angle: 0.0, row_elevation: 0.0 }), + levels: 2, + level_spacing: 5.0, + replaced: Vec::new(), + } + } + + #[test] + fn rect_items_go_column_row_level() { + let items = rect(3, 2).items().unwrap_or_default(); + assert_eq!(items.len(), 12); + let last = items.last().copied().map(|i| (i.i, i.j, i.level, i.insert)); + assert_eq!(last, Some((2, 1, 1, Vec3::new(4.0, 3.0, 5.0)))); + } + + #[test] + fn polar_rows_go_outwards() { + let p = ArrayParams { + source: "*U1".into(), + kind: ArrayKind::Polar(PolarArray { + center: Vec2::new(-10.0, 0.0), + items: 4, + fill: 360.0, + rotate: true, + rows: 2, + row_spacing: 1.0, + row_elevation: 0.0, + }), + levels: 1, + level_spacing: 0.0, + replaced: Vec::new(), + }; + let items = p.items().unwrap_or_default(); + assert_eq!(items.len(), 8); + // Item 1 of row 0 is a quarter turn round: base (0,0) → (-10,10). + assert!(items[1].insert.xy().near(Vec2::new(-10.0, 10.0), 1e-9)); + // Row 1 starts one unit further out. + assert!(items[4].insert.xy().near(Vec2::new(1.0, 0.0), 1e-9)); + } + + #[test] + fn hostile_params_are_invalid() { + let mut p = rect(u32::MAX, u32::MAX); + assert!(!p.is_valid() && p.items().is_none()); + p = rect(0, 1); + assert!(!p.is_valid()); + p = rect(2, 2); + p.level_spacing = f64::NAN; + assert!(!p.is_valid()); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 65300d06..2dfd1409 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod array; mod constraint; mod entity; mod extents; @@ -18,6 +19,7 @@ mod tables; use std::collections::{BTreeMap, BTreeSet}; +pub use array::*; pub use cadcraft_color as color; pub use cadcraft_geom as geom; pub use constraint::*; diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index d3b992de..c81b7f41 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -502,6 +502,9 @@ pub struct Block { pub explodable: bool, #[serde(default)] pub units: u8, + /// The parameters of an associative array when this is an array block. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub array: Option, } impl Block { @@ -515,6 +518,7 @@ impl Block { xref_path: None, explodable: true, units: 0, + array: None, } } } diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index e00624be..00cb51d7 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -1,14 +1,20 @@ //! 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). +//! Arrays are associative by default (ARRAYASSOCIATIVITY): the source objects move into an +//! anonymous source block and the array becomes one block reference to an anonymous array block +//! holding a reference per item, with the array parameters kept on that block +//! ([`cadcraft_doc::ArrayParams`]). With ASsociative = No the items are separate copies. The +//! interactive forms select objects, then edit the array in an option loop with a live preview, +//! and create the array on eXit (or Enter). -use cadcraft_doc::{Drawing, EntityKind, Handle}; -use cadcraft_geom::{Bounds2, Mat3, TAU, Vec2}; +use std::collections::HashMap; +use std::sync::Arc; + +use cadcraft_doc::{ArrayKind, ArrayParams, Block, Drawing, Entity, EntityKind, Handle, Insert, MAX_BLOCK_DEPTH, PolarArray, RectArray}; +use cadcraft_geom::{Bounds2, Mat3, Vec2, Vec3}; use serde_json::{Value, json}; -use super::helpers::line; +use super::helpers::{line, v3}; use super::machines::{SelOutcome, SelectPhase, number}; use super::modify::transform_entities; use super::*; @@ -19,8 +25,7 @@ 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."; +const FLAT_LEVELS: &str = "Levels need an associative array; the copies are made on one level."; pub fn specs() -> Vec { vec![ @@ -30,35 +35,177 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::new(ArrayM::default()))), CommandSpec::new("arrayrect", "Rectangular Array", run_arrayrect) .menu(&["Modify", "Array", "Rectangular Array"]) - .params("{handles?, rows, cols, rowSpacing, colSpacing}") + .params( + "{handles?, rows, cols, rowSpacing, colSpacing, levels? (1), levelSpacing?, rowElevation?, axisAngle? (degrees), base? ([x, y], default the objects' centre), associative?: bool (default ARRAYASSOCIATIVITY, on)} → {handle (the array, when associative), created (copies)}", + ) .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}") + .params( + "{handles?, center, count, angle? (degrees, default 360), rotate?: bool, rows? (1), rowSpacing?, rowElevation?, levels? (1), levelSpacing?, base? ([x, y], default the first object's centre), associative?: bool (default ARRAYASSOCIATIVITY, on)} → {handle (the array, when associative), created (copies)}", + ) .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))) +/// Array parameters around `kind` (the source block is named when the array is made). +fn params(kind: ArrayKind, levels: u64, level_spacing: f64) -> ArrayParams { + ArrayParams { source: "*".into(), kind, levels: u32::try_from(levels).unwrap_or(u32::MAX), level_spacing, replaced: Vec::new() } } -/// 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 count32(n: u64) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) +} + +/// World transforms of the items of `p` with its base point at `base`, except the first item +/// (the source objects themselves) and items above the first level (copies are flat). +fn item_mats(p: &ArrayParams, base: Vec2) -> Vec { + p.items() + .unwrap_or_default() + .into_iter() + .filter(|it| it.level == 0 && (it.i, it.j) != (0, 0)) + .map(|it| Mat3::translate(base + it.insert.xy()).then_before(Mat3::rotate(it.rotation)).then_before(Mat3::translate(-base))) + .collect() +} + +/// How deeply block references nest in `ents` (0 without any), at most `MAX_BLOCK_DEPTH + 1`. +fn nesting(d: &Drawing, ents: &[&Entity], memo: &mut HashMap, depth: usize) -> usize { + let mut most = 0; + for e in ents { + let EntityKind::Insert(i) = &e.kind else { continue }; + let key = i.block.to_ascii_uppercase(); + let n = match memo.get(&key) { + Some(n) => *n, + None if depth > MAX_BLOCK_DEPTH => MAX_BLOCK_DEPTH + 1, + None => { + // Cycles end here: a block being measured counts as too deep. + memo.insert(key.clone(), MAX_BLOCK_DEPTH + 1); + let inner: Vec<&Entity> = d.block(&i.block).map(|b| b.entities.iter().map(|e| e.as_ref()).collect()).unwrap_or_default(); + let n = (1 + nesting(d, &inner, memo, depth + 1)).min(MAX_BLOCK_DEPTH + 1); + memo.insert(key, n); + n + } + }; + most = most.max(n); + } + most +} + +/// ARRAYASSOCIATIVITY: whether new arrays start associative (on unless set to 0). +fn assoc_default(s: &Session) -> bool { + s.doc().map(|d| d.header.i64("ARRAYASSOCIATIVITY", 1) != 0).unwrap_or(true) +} + +/// Make `objs` an associative array with its base point at `base`: the objects move into a new +/// source block, the items into a new array block, and one block reference to the array block +/// takes their place. Returns that reference. +pub(crate) fn make_assoc(s: &mut Session, objs: &[Handle], base: Vec2, mut p: ArrayParams) -> Result { + let d = s.doc()?; + let objs: Vec = objs.iter().copied().filter(|h| !super::curves::is_locked(s, *h)).collect(); + let ents: Vec = objs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect(); + if ents.is_empty() { + return Err(other("No objects to array (objects on locked layers are left out).")); + } + // The array adds two levels of block references (array block, then source block). + let refs: Vec<&Entity> = ents.iter().collect(); + if nesting(d, &refs, &mut HashMap::new(), 0) + 2 > MAX_BLOCK_DEPTH { + return Err(other(format!("The objects nest block references too deeply to array (more than {MAX_BLOCK_DEPTH} levels)."))); + } + if !base.is_finite() { + return Err(other("The array base point is not a valid point.")); + } + let d = s.doc_mut()?; + let src_name = d.anonymous_block_name("*U"); + let mut src = Block::new(&src_name); + let to_local = Mat3::translate(-base); + for mut e in ents { + e.handle = d.new_handle(); + e.kind.transform(&to_local); + src.entities.push(e); + } + d.blocks.insert(src_name.clone(), Arc::new(src)); + p.source = src_name; + let items = p.item_entities(d).ok_or_else(|| other("The array parameters are not valid."))?; + let name = d.anonymous_block_name("*U"); + let mut blk = Block::new(&name); + for e in items { + blk.entities.push(e); + } + blk.array = Some(p); + d.blocks.insert(name.clone(), Arc::new(blk)); + for h in &objs { + d.remove_entity(*h); + } + s.add_entity(EntityKind::Insert(Insert { + block: name, + insert: v3(base), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + })) +} + +/// Create the array: associative (one block reference) or separate copies. Returns the JSON +/// result: the array's handle when associative, and the number of copies the array adds. +fn create(s: &mut Session, objs: &[Handle], base: Vec2, p: ArrayParams, assoc: bool) -> Result { + let copies = p.item_count().saturating_sub(1).saturating_mul(objs.len() as u64); + if assoc { + let h = make_assoc(s, objs, base, p)?; + return Ok(json!({ "handle": h.hex(), "created": copies, "associative": true })); + } + if p.levels > 1 { + s.echo(FLAT_LEVELS); + } + let n = copy_all(s, objs, item_mats(&p, base).into_iter())?; + Ok(json!({ "created": n })) +} + +/// EXPLODE of an associative array: every item becomes the objects of its block (the source +/// objects, or a replacement block's reference), in world coordinates. `None` for other objects. +pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option> { + let EntityKind::Insert(ins) = &e.kind else { return None }; + let p = d.array_params(e)?; + let blk = d.block(&ins.block)?; + let m = ins.transform(blk.base.xy()); + let mut out = Vec::new(); + for item in blk.entities.iter() { + let item = placed(e, item, &m); + let source = match &item.kind { + EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(&p.source) => d.block(&i.block).map(|b| (i.transform(b.base.xy()), b)), + _ => None, + }; + match source { + Some((im, b)) => out.extend(b.entities.iter().map(|se| placed(&item, se, &im))), + None => out.push(item), + } + } + Some(out) +} + +/// `child` of a block referenced by `parent`, moved by `m`, with layer 0 and ByBlock properties +/// taken from the reference (as EXPLODE does). +fn placed(parent: &Entity, child: &Entity, m: &Mat3) -> Entity { + let mut ne = child.clone(); + ne.kind.transform(m); + if ne.common.layer == "0" { + ne.common.layer = parent.common.layer.clone(); + } + if ne.common.color == cadcraft_color::Color::ByBlock { + ne.common.color = parent.common.color; + } + if ne.common.linetype.eq_ignore_ascii_case("ByBlock") { + ne.common.linetype = parent.common.linetype.clone(); + } + if ne.common.lineweight == cadcraft_doc::Lineweight::ByBlock { + ne.common.lineweight = parent.common.lineweight; + } + ne } fn bounds(d: &Drawing, hs: &[Handle]) -> Bounds2 { @@ -175,27 +322,56 @@ fn run_array(s: &mut Session, p: &Value) -> Result { (spec.run)(s, p) } +/// A count parameter (at least 1). +fn count_or(p: &Value, key: &str, default: u64) -> u64 { + p.get(key).and_then(Value::as_u64).unwrap_or(default).max(1) +} + 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 rows = count_or(p, "rows", 3); + let cols = count_or(p, "cols", 4); + let levels = count_or(p, "levels", 1); + check_size(s, rows.saturating_mul(cols).saturating_mul(levels), hs.len())?; + let kind = ArrayKind::Rect(RectArray { + cols: count32(cols), + rows: count32(rows), + col_spacing: f64_or(p, "colSpacing", 1.0), + row_spacing: f64_or(p, "rowSpacing", 1.0), + axis_angle: f64_or(p, "axisAngle", 0.0).to_radians(), + row_elevation: f64_or(p, "rowElevation", 0.0), + }); 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 })) + let base = match point_param(p, "base") { + Some(b) => b, + None => Some(bounds(s.doc()?, &hs).center()).filter(|c| c.is_finite()).unwrap_or_default(), + }; + let p2 = params(kind, levels, f64_or(p, "levelSpacing", rs)); + let associative = bool_or(p, "associative", assoc_default(s)); + create(s, &hs, base, p2, associative) } 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 })) + let n = count_or(p, "count", 6); + let rows = count_or(p, "rows", 1); + let levels = count_or(p, "levels", 1); + check_size(s, n.saturating_mul(rows).saturating_mul(levels), hs.len())?; + let base = point_param(p, "base").or_else(|| s.doc().ok().and_then(|d| first_center(d, &hs))).unwrap_or(c); + let row_spacing = f64_or(p, "rowSpacing", 1.0); + let kind = ArrayKind::Polar(PolarArray { + center: c - base, + items: count32(n), + fill: f64_or(p, "angle", 360.0), + rotate: bool_or(p, "rotate", true), + rows: count32(rows), + row_spacing, + row_elevation: f64_or(p, "rowElevation", 0.0), + }); + let p2 = params(kind, levels, f64_or(p, "levelSpacing", row_spacing)); + let associative = bool_or(p, "associative", assoc_default(s)); + create(s, &hs, base, p2, associative) } // ---------------- ARRAY ---------------- @@ -274,6 +450,7 @@ impl Interactive for ArrayM { enum RStage { #[default] Main, + Assoc, Base, /// COUnt: columns, then rows. CountCols, @@ -291,18 +468,28 @@ enum RStage { RowsSpace, RowsTotal, RowsElev, + /// Levels: count, then spacing (or the total). + LevelsCount, + LevelsSpace, + LevelsTotal, } -/// ARRAYRECT: select objects, then edit columns/rows/spacing in a loop; eXit creates the copies. +/// ARRAYRECT: select objects, then edit columns/rows/levels/spacing in a loop; eXit creates the +/// array. #[derive(Default)] struct RectM { sel: SelectPhase, objs: Vec, stage: RStage, + assoc: bool, cols: u64, rows: u64, + levels: u64, cs: f64, rs: f64, + /// Distance between levels and the elevation added per row. + ls: f64, + elev: f64, /// Array base point (distances picked with a point are measured from it). base: Vec2, centroid: Vec2, @@ -324,14 +511,28 @@ impl RectM { (false, true) => o * 1.5, _ => 1.0, }; - (self.cols, self.rows, self.cs, self.rs) = (4, 3, size(w, h), size(h, w)); + (self.cols, self.rows, self.levels, self.cs, self.rs) = (4, 3, 1, size(w, h), size(h, w)); + (self.ls, self.elev) = (self.rs, 0.0); + self.assoc = assoc_default(s); 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"); + s.echo(format!("Type = Rectangular Associative = {}", yes(self.assoc))); Ok(Step::Continue) } + fn params(&self) -> ArrayParams { + let kind = ArrayKind::Rect(RectArray { + cols: count32(self.cols), + rows: count32(self.rows), + col_spacing: self.cs, + row_spacing: self.rs, + axis_angle: 0.0, + row_elevation: self.elev, + }); + params(kind, if self.assoc { self.levels } else { 1 }, self.ls) + } + /// A distance typed or picked (from the base point); Enter keeps `cur`. fn distance(&self, i: &Input, cur: f64) -> Result> { let d = match i { @@ -344,8 +545,12 @@ impl RectM { } 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))?; + let levels = if self.assoc { self.levels } else { 1 }; + check_size(s, self.rows.saturating_mul(self.cols).saturating_mul(levels), self.objs.len())?; + if !self.assoc && self.levels > 1 { + s.echo(FLAT_LEVELS); + } + create(s, &self.objs.clone(), self.base, self.params(), self.assoc)?; s.set_selection(Vec::new()); Ok(Step::Done) } @@ -359,24 +564,28 @@ impl RectM { }; self.stage = match k.as_str() { "eXit" => return self.finish(s), + "ASsociative" => RStage::Assoc, "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 - } + "Levels" => RStage::LevelsCount, + _ => return Err(other("Invalid option keyword.")), }; Ok(Step::Continue) } } +fn yes(b: bool) -> &'static str { + if b { "Yes" } else { "No" } +} + +/// The ASsociative prompt. +fn assoc_prompt(cur: bool) -> Prompt { + Prompt::new("Create associative array", KEYWORDS).kw(&["Yes", "No"]).default(yes(cur)) +} + impl Interactive for RectM { fn name(&self) -> &'static str { "ARRAYRECT" @@ -401,6 +610,7 @@ impl Interactive for RectM { Main => Prompt::new("Select grip to edit array", Accept::POINT) .kw(&["ASsociative", "Base point", "COUnt", "Spacing", "COLumns", "Rows", "Levels", "eXit"]) .default("eXit"), + Assoc => assoc_prompt(self.assoc), Base => Prompt::new("Specify base point", Accept::POINT).kw(&["Key point"]).default("centroid"), CountCols | ColsCount => cols(), CountRows | RowsCount => rows(), @@ -414,7 +624,10 @@ impl Interactive for RectM { 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"), + RowsElev => Prompt::new("Specify the incrementing elevation between rows", Accept::NUMBER).default(fmt(self.elev)), + LevelsCount => Prompt::new("Enter the number of levels", Accept::NUMBER).default(self.levels.to_string()), + LevelsSpace => dist("Specify the distance between levels", self.ls).kw(&["Total"]), + LevelsTotal => dist("Specify the distance between the first and last level", self.ls * self.levels.saturating_sub(1) as f64), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { @@ -430,8 +643,16 @@ impl Interactive for RectM { }; } let stage = self.stage; + let n = self.objs.len(); let next = match (stage, &i) { (Main, _) => return self.main(s, i), + (Assoc, _) => match yes_no(&i, self.assoc) { + Some(a) => { + self.assoc = a; + Main + } + None => return Err(other("Requires Yes or No.")), + }, (Base, Input::Point(p)) => { self.base = *p; Main @@ -444,26 +665,34 @@ impl Interactive for RectM { 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; + (CountCols | ColsCount, _) => match count(&i, self.cols, max_count(s, self.rows.saturating_mul(self.levels), n))? { + Some(c) => { + self.cols = c; 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; + (CountRows | RowsCount, _) => match count(&i, self.rows, max_count(s, self.cols.saturating_mul(self.levels), n))? { + Some(r) => { + self.rows = r; if stage == CountRows { Main } else { RowsSpace } } None => stage, }, + (LevelsCount, _) => match count(&i, self.levels, max_count(s, self.cols.saturating_mul(self.rows), n))? { + Some(l) => { + self.levels = l; + LevelsSpace + } + None => stage, + }, (SpaceCols, Input::Keyword(_)) => { self.corner = None; UnitCell } (ColsSpace, Input::Keyword(_)) => ColsTotal, (RowsSpace, Input::Keyword(_)) => RowsTotal, + (LevelsSpace, Input::Keyword(_)) => LevelsTotal, (SpaceCols | ColsSpace, _) => match self.distance(&i, self.cs)? { Some(d) => { self.cs = d; @@ -478,6 +707,16 @@ impl Interactive for RectM { } None => stage, }, + (LevelsSpace, _) => match self.distance(&i, self.ls)? { + Some(d) => { + self.ls = d; + if !self.assoc && self.levels > 1 { + s.echo(FLAT_LEVELS); + } + Main + } + 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; @@ -492,11 +731,19 @@ impl Interactive for RectM { } None => stage, }, + (LevelsTotal, _) => match self.distance(&i, self.ls * self.levels.saturating_sub(1) as f64)? { + Some(t) => { + self.ls = t / self.levels.saturating_sub(1).max(1) as f64; + Main + } + 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."); + self.elev = z; + if !self.assoc && z.abs() > 1e-12 { + s.echo("Row elevations need an associative array; the copies stay at the same elevation."); } Main } @@ -529,12 +776,20 @@ impl Interactive for RectM { 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)); + let mut out = preview_copies(s, &self.objs, item_mats(&self.params(), self.base).into_iter()); 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)) - } + ( + RStage::SpaceCols + | RStage::SpaceRows + | RStage::ColsSpace + | RStage::ColsTotal + | RStage::RowsSpace + | RStage::RowsTotal + | RStage::LevelsSpace + | RStage::LevelsTotal, + _, + ) => out.push(line(self.base, cursor)), _ => {} } out @@ -550,24 +805,41 @@ enum PStage { /// Base point asked from the center prompt (returns there). CenterBase, Main, + Assoc, Base, Items, Between, Fill, Rotate, + /// ROWs: count, then spacing (or the total), then the elevation increment. + RowsCount, + RowsSpace, + RowsTotal, + RowsElev, + /// Levels: count, then spacing (or the total). + LevelsCount, + LevelsSpace, + LevelsTotal, } -/// ARRAYPOLAR: select objects, specify the center, then edit items/angles in a loop; eXit creates -/// the copies. +/// ARRAYPOLAR: select objects, specify the center, then edit items/angles/rows/levels in a loop; +/// eXit creates the array. struct PolarM { sel: SelectPhase, objs: Vec, stage: PStage, + assoc: bool, center: Vec2, items: u64, /// Fill angle in degrees (positive = counterclockwise). fill: f64, rotate: bool, + rows: u64, + /// Distance between rows (rings), the elevation added per row, and the distance between levels. + rs: f64, + elev: f64, + levels: u64, + ls: f64, /// Base point: the point non-rotated copies are moved by (default: the first object's centre). base: Option, } @@ -578,10 +850,16 @@ impl Default for PolarM { sel: SelectPhase::default(), objs: Vec::new(), stage: PStage::Center, + assoc: true, center: Vec2::ZERO, items: 6, fill: 360.0, rotate: true, + rows: 1, + rs: 1.0, + elev: 0.0, + levels: 1, + ls: 1.0, base: None, } } @@ -594,7 +872,16 @@ impl PolarM { fn ready(&mut self, s: &mut Session) -> Step { self.stage = PStage::Center; - s.echo("Type = Polar Associative = No"); + self.assoc = assoc_default(s); + // Rows and levels default to 1.5 × the selection's size apart. + if let Ok(d) = s.doc() { + let b = bounds(d, &self.objs); + let size = b.width().max(b.height()); + if size.is_finite() && size > 1e-9 { + (self.rs, self.ls) = (size * 1.5, size * 1.5); + } + } + s.echo(format!("Type = Polar Associative = {}", yes(self.assoc))); Step::Continue } @@ -611,15 +898,32 @@ impl PolarM { } } - 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) + /// The base point: picked, else the first object's centre, else the centre of the array. + fn base_point(&self, s: &Session, center: Vec2) -> Vec2 { + self.base.or_else(|| s.doc().ok().and_then(|d| first_center(d, &self.objs))).unwrap_or(center) + } + + fn params(&self, base: Vec2, center: Vec2) -> ArrayParams { + let kind = ArrayKind::Polar(PolarArray { + center: center - base, + items: count32(self.items), + fill: self.fill, + rotate: self.rotate, + rows: count32(self.rows), + row_spacing: self.rs, + row_elevation: self.elev, + }); + params(kind, if self.assoc { self.levels } else { 1 }, self.ls) } 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())?; + let levels = if self.assoc { self.levels } else { 1 }; + check_size(s, self.items.saturating_mul(self.rows).saturating_mul(levels), self.objs.len())?; + if !self.assoc && self.levels > 1 { + s.echo(FLAT_LEVELS); + } + let base = self.base_point(s, self.center); + create(s, &self.objs.clone(), base, self.params(base, self.center), self.assoc)?; s.set_selection(Vec::new()); Ok(Step::Done) } @@ -633,26 +937,29 @@ impl PolarM { }; self.stage = match k.as_str() { "eXit" => return self.finish(s), + "ASsociative" => PStage::Assoc, "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 - } + "ROWs" => PStage::RowsCount, + "Levels" => PStage::LevelsCount, + _ => return Err(other("Invalid option keyword.")), }; Ok(Step::Continue) } + + /// A distance typed or picked (from the centre); 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.center.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.")) + } } impl Interactive for PolarM { @@ -672,16 +979,25 @@ impl Interactive for PolarM { if !self.sel.done { return self.sel.prompt(); } + let dist = |m: &str, d: f64| Prompt::new(m, Accept::POINT_OR_NUMBER).default(fmt(d)).base(self.center); 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"), + Assoc => assoc_prompt(self.assoc), 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" }), + Rotate => Prompt::new("Rotate arrayed items?", KEYWORDS).kw(&["Yes", "No"]).default(yes(self.rotate)), + RowsCount => Prompt::new("Enter the number of rows", Accept::NUMBER).default(self.rows.to_string()), + 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(fmt(self.elev)), + LevelsCount => Prompt::new("Enter the number of levels", Accept::NUMBER).default(self.levels.to_string()), + LevelsSpace => dist("Specify the distance between levels", self.ls).kw(&["Total"]), + LevelsTotal => dist("Specify the distance between the first and last level", self.ls * self.levels.saturating_sub(1) as f64), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { @@ -697,8 +1013,16 @@ impl Interactive for PolarM { }; } let stage = self.stage; + let n = self.objs.len(); let next = match (stage, &i) { (Main, _) => return self.main(s, i), + (Assoc, _) => match yes_no(&i, self.assoc) { + Some(a) => { + self.assoc = a; + Main + } + None => return Err(other("Requires Yes or No.")), + }, (Center, Input::Point(p)) => { self.center = *p; Main @@ -721,9 +1045,67 @@ impl Interactive for PolarM { 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; + (Items, _) => match count(&i, self.items, max_count(s, self.rows.saturating_mul(self.levels), n))? { + Some(c) => { + self.items = c; + Main + } + None => stage, + }, + (RowsCount, _) => match count(&i, self.rows, max_count(s, self.items.saturating_mul(self.levels), n))? { + Some(c) => { + self.rows = c; + RowsSpace + } + None => stage, + }, + (LevelsCount, _) => match count(&i, self.levels, max_count(s, self.items.saturating_mul(self.rows), n))? { + Some(c) => { + self.levels = c; + LevelsSpace + } + None => stage, + }, + (RowsSpace, Input::Keyword(_)) => RowsTotal, + (LevelsSpace, Input::Keyword(_)) => LevelsTotal, + (RowsSpace, _) => match self.distance(&i, self.rs)? { + Some(d) => { + self.rs = d; + RowsElev + } + 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() => { + self.elev = z; + if !self.assoc && z.abs() > 1e-12 { + s.echo("Row elevations need an associative array; the copies stay at the same elevation."); + } + Main + } + _ => return Err(other("Requires a number.")), + }, + (LevelsSpace, _) => match self.distance(&i, self.ls)? { + Some(d) => { + self.ls = d; + if !self.assoc && self.levels > 1 { + s.echo(FLAT_LEVELS); + } + Main + } + None => stage, + }, + (LevelsTotal, _) => match self.distance(&i, self.ls * self.levels.saturating_sub(1) as f64)? { + Some(t) => { + self.ls = t / self.levels.saturating_sub(1).max(1) as f64; Main } None => stage, @@ -774,7 +1156,8 @@ impl Interactive for PolarM { PStage::Center | PStage::CenterBase => cursor, _ => self.center, }; - preview_copies(s, &self.objs, self.mats(s, center)) + let base = self.base_point(s, center); + preview_copies(s, &self.objs, item_mats(&self.params(base, center), base).into_iter()) } } @@ -782,16 +1165,23 @@ impl Interactive for PolarM { mod tests { use super::*; + fn polar(n: u32, fill: f64) -> ArrayParams { + let kind = ArrayKind::Polar(PolarArray { center: -Vec2::X, items: n, fill, rotate: true, rows: 1, row_spacing: 0.0, row_elevation: 0.0 }); + params(kind, 1, 0.0) + } + #[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(); + // Base point (1,0) around the origin. Full circle: 4 items 90° apart; partial: the last + // item sits at the fill angle. + let at = |p: &ArrayParams| -> Vec { item_mats(p, Vec2::X).iter().map(|m| m.apply(Vec2::X)).collect() }; + let a = at(&polar(4, 360.0)); 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(); + let b = at(&polar(3, -180.0)); 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(); + let c = at(&polar(4, -360.0)); assert!(c[2].near(Vec2::new(0.0, 1.0), 1e-9)); } } diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index fe2ceaee..2f5295c3 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1147,6 +1147,9 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option { + if let Some(items) = super::array::explode_array(d, e) { + return Some(items); + } let blk = d.block(&ins.block)?; let m = ins.transform(blk.base.xy()); Some( diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs index a8b50e91..684840a9 100644 --- a/crates/engine/src/spatial.rs +++ b/crates/engine/src/spatial.rs @@ -278,6 +278,7 @@ mod tests { xref_path: None, explodable: true, units: 0, + array: None, }; blk.entities .push(Entity::new(cadcraft_doc::Handle(1_000_000), EntityKind::Circle(cadcraft_doc::Circle { center: v3(0.0, 0.0), radius: 1.0 }))); diff --git a/crates/engine/tests/array_commands.rs b/crates/engine/tests/array_commands.rs index 9c84acea..d420c36d 100644 --- a/crates/engine/tests/array_commands.rs +++ b/crates/engine/tests/array_commands.rs @@ -2,8 +2,8 @@ //! 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 cadcraft_engine::doc::{Drawing, Entity, EntityKind}; +use cadcraft_engine::geom::{Mat3, Vec2}; use serde_json::json; fn typed(s: &mut Session, inputs: &[&str]) { @@ -12,16 +12,27 @@ fn typed(s: &mut Session, inputs: &[&str]) { } } +/// Circle centres in model space, inside (associative) arrays too. 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 walk(d: &Drawing, e: &Entity, m: Mat3, depth: usize, out: &mut Vec) { + match &e.kind { + EntityKind::Circle(c) => out.push(m.apply(c.center.xy())), + EntityKind::Insert(i) if depth < 8 => { + let b = d.block(&i.block).unwrap(); + let im = m.then_before(i.transform(b.base.xy())); + for be in b.entities.iter() { + walk(d, be, im, depth + 1, out); + } + } + _ => {} + } + } + let d = s.doc().unwrap(); + let mut out = Vec::new(); + for e in d.model.iter() { + walk(d, e, Mat3::IDENTITY, 0, &mut out); + } + out } fn has(pts: &[Vec2], x: f64, y: f64) -> bool { @@ -83,8 +94,10 @@ fn arrayrect_edits_the_array_in_a_loop_until_exit() { // 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"]); + // ASsociative asks Yes/No and returns to the loop. + typed(&mut s, &["as"]); + assert!(prompt(&s).contains("Create associative array or [Yes/No] "), "{}", prompt(&s)); + typed(&mut s, &["n"]); assert!(prompt(&s).contains("Select grip to edit array"), "{}", prompt(&s)); typed(&mut s, &["x"]); assert!(s.current_prompt().is_none(), "{}", prompt(&s)); @@ -111,7 +124,7 @@ fn arrayrect_edits_the_array_in_a_loop_until_exit() { typed(&mut s, &["2", "1", "x"]); assert_eq!(circle_centers(&s).len(), 2); - // The JSON form is unchanged. + // The JSON form (associative by default) places the same items. 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(); @@ -148,10 +161,10 @@ fn arraypolar_asks_for_the_center_then_edits_items_and_angles() { 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. + // Fill angle 180 with 3 items, items not rotated, not associative: 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"]); + typed(&mut s, &["arraypolar", "l", "", "0,0", "i", "3", "f", "180", "rot", "n", "as", "n", "x"]); assert!(s.current_prompt().is_none(), "{}", prompt(&s)); let lines: Vec<(Vec2, Vec2)> = s .doc() diff --git a/crates/engine/tests/assoc_array.rs b/crates/engine/tests/assoc_array.rs new file mode 100644 index 00000000..5c0a75fc --- /dev/null +++ b/crates/engine/tests/assoc_array.rs @@ -0,0 +1,215 @@ +//! Associative arrays: ARRAYRECT and ARRAYPOLAR make one block reference whose block keeps the +//! array parameters (ASsociative, Levels, polar ROWs); EXPLODE gives plain objects; MOVE and COPY +//! keep the array. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{ArrayKind, ArrayParams, Drawing, Entity, EntityKind, Handle}; +use cadcraft_engine::geom::{Mat3, Vec2}; +use serde_json::{Value, json}; + +fn typed(s: &mut Session, inputs: &[&str]) { + for t in inputs { + s.cmdline(t).unwrap(); + } +} + +fn circle(s: &mut Session, x: f64, y: f64) -> Value { + s.execute("circle", &json!({"center": [x, y], "radius": 1})).unwrap()["handle"].clone() +} + +fn model(s: &Session) -> Vec { + s.doc().unwrap().model.iter().map(|e| (**e).clone()).collect() +} + +/// The only object in model space, which must be an associative array, and its parameters. +fn the_array(s: &Session) -> (Entity, ArrayParams) { + let m = model(s); + assert_eq!(m.len(), 1, "{m:?}"); + let e = m[0].clone(); + let p = s.doc().unwrap().array_params(&e).expect("an associative array"); + (e, p) +} + +/// World positions of the circles drawn by `e` (through nested block references). +fn circles(d: &Drawing, e: &Entity) -> Vec<(Vec2, f64)> { + fn walk(d: &Drawing, e: &Entity, m: Mat3, z: f64, depth: usize, out: &mut Vec<(Vec2, f64)>) { + match &e.kind { + EntityKind::Circle(c) => out.push((m.apply(c.center.xy()), z + c.center.z)), + EntityKind::Insert(i) if depth < 8 => { + let b = d.block(&i.block).unwrap(); + let im = m.then_before(i.transform(b.base.xy())); + for be in b.entities.iter() { + walk(d, be, im, z + i.insert.z, depth + 1, out); + } + } + _ => {} + } + } + let mut out = Vec::new(); + walk(d, e, Mat3::IDENTITY, 0.0, 0, &mut out); + out +} + +fn has(pts: &[(Vec2, f64)], x: f64, y: f64, z: f64) -> bool { + pts.iter().any(|(p, pz)| p.near(Vec2::new(x, y), 1e-6) && (pz - z).abs() < 1e-6) +} + +#[test] +fn json_arrays_are_associative_by_default() { + let mut s = Session::new(); + let h = circle(&mut s, 0.0, 0.0); + let r = s + .execute( + "arrayrect", + &json!({"handles": [h], "rows": 2, "cols": 3, "rowSpacing": 4, "colSpacing": 2, "levels": 2, "levelSpacing": 10, "rowElevation": 1}), + ) + .unwrap(); + assert_eq!(r["associative"], true); + assert_eq!(r["created"], 11); + let (e, p) = the_array(&s); + assert_eq!(r["handle"], json!(e.handle.hex())); + assert_eq!(p.levels, 2); + assert!(matches!(&p.kind, ArrayKind::Rect(k) if k.cols == 3 && k.rows == 2 && k.row_elevation == 1.0)); + // The source circle moved into the array; the items are where the copies would be. + let c = circles(s.doc().unwrap(), &e); + assert_eq!(c.len(), 12); + assert!(has(&c, 0.0, 0.0, 0.0) && has(&c, 4.0, 4.0, 1.0) && has(&c, 4.0, 4.0, 11.0), "{c:?}"); + + // associative: false makes separate copies, as before. + let mut s = Session::new(); + let h = circle(&mut s, 0.0, 0.0); + let r = s.execute("arraypolar", &json!({"handles": [h], "center": [10, 0], "count": 4, "associative": false})).unwrap(); + assert_eq!(r["created"], 3); + assert!(r.get("handle").is_none()); + assert_eq!(model(&s).len(), 4); +} + +#[test] +fn typed_levels_and_polar_rows() { + // ARRAYRECT Levels: count, then spacing. + let mut s = Session::new(); + circle(&mut s, 0.0, 0.0); + typed(&mut s, &["arrayrect", "l", "", "cou", "2", "1", "s", "5", "5", "l"]); + assert!(s.prompt_text().contains("Enter the number of levels"), "{}", s.prompt_text()); + typed(&mut s, &["3", "7", "x"]); + assert!(s.current_prompt().is_none(), "{}", s.prompt_text()); + let (e, p) = the_array(&s); + assert_eq!((p.levels, p.level_spacing), (3, 7.0)); + let c = circles(s.doc().unwrap(), &e); + assert_eq!(c.len(), 6); + assert!(has(&c, 5.0, 0.0, 14.0), "{c:?}"); + + // ARRAYPOLAR ROWs: rings further out from the centre, with an elevation per row. + let mut s = Session::new(); + circle(&mut s, 10.0, 0.0); + typed(&mut s, &["arraypolar", "l", "", "0,0", "i", "4", "row"]); + assert!(s.prompt_text().contains("Enter the number of rows"), "{}", s.prompt_text()); + typed(&mut s, &["2", "3", "0.5", "x"]); + assert!(s.current_prompt().is_none(), "{}", s.prompt_text()); + let (e, p) = the_array(&s); + assert!(matches!(&p.kind, ArrayKind::Polar(k) if k.items == 4 && k.rows == 2 && k.row_spacing == 3.0)); + let c = circles(s.doc().unwrap(), &e); + assert_eq!(c.len(), 8); + assert!(has(&c, 0.0, 10.0, 0.0) && has(&c, 13.0, 0.0, 0.5) && has(&c, 0.0, -13.0, 0.5), "{c:?}"); + + // ASsociative No (or ARRAYASSOCIATIVITY 0) makes copies; ROWs work there too. + let mut s = Session::new(); + s.execute("setvar", &json!({"name": "ARRAYASSOCIATIVITY", "value": 0})).unwrap(); + circle(&mut s, 10.0, 0.0); + typed(&mut s, &["arraypolar", "l", "", "0,0"]); + assert!(s.log.iter().any(|l| l.contains("Associative = No")), "{:?}", s.log); + typed(&mut s, &["i", "4", "row", "2", "3", "", "x"]); + assert_eq!(model(&s).len(), 8); + assert!(model(&s).iter().all(|e| matches!(e.kind, EntityKind::Circle(_)))); +} + +#[test] +fn explode_gives_plain_objects_and_edits_move_the_array() { + let mut s = Session::new(); + let h = circle(&mut s, 10.0, 0.0); + let r = s.execute("arraypolar", &json!({"handles": [h], "center": [0, 0], "count": 4})).unwrap(); + let arr = r["handle"].clone(); + + // MOVE and COPY transform the block reference; the copy is an array too. + s.execute("move", &json!({"handles": [arr], "delta": [100, 0]})).unwrap(); + let r = s.execute("copy", &json!({"handles": [arr], "delta": [0, 100]})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.model.len(), 2, "{r}"); + for e in d.model.iter() { + assert!(d.array_params(e).is_some()); + } + let e = d.entity(Handle::parse_hex(arr.as_str().unwrap()).unwrap()).unwrap().as_ref().clone(); + let c = circles(d, &e); + assert!(has(&c, 100.0, 10.0, 0.0) && has(&c, 90.0, 0.0, 0.0), "{c:?}"); + + // EXPLODE: the items become circles, not block references. + let r = s.execute("explode", &json!({"handles": [arr]})).unwrap(); + assert_eq!(r["handles"].as_array().unwrap().len(), 4); + let d = s.doc().unwrap(); + let plain: Vec = d + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Circle(c) => Some(c.center.xy()), + _ => None, + }) + .collect(); + assert_eq!(plain.len(), 4); + assert!(plain.iter().any(|p| p.near(Vec2::new(100.0, -10.0), 1e-6)), "{plain:?}"); + + // UNDO brings the array back. + s.execute("undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().entity(Handle::parse_hex(arr.as_str().unwrap()).unwrap()).is_some()); +} + +#[test] +fn arrays_of_arrays_and_limits() { + // An array of an array: exploding the outer one gives the inner arrays. + let mut s = Session::new(); + let h = circle(&mut s, 0.0, 0.0); + let inner = s.execute("arrayrect", &json!({"handles": [h], "rows": 1, "cols": 3, "colSpacing": 2})).unwrap()["handle"].clone(); + let outer = s.execute("arrayrect", &json!({"handles": [inner], "rows": 2, "cols": 1, "rowSpacing": 5})).unwrap()["handle"].clone(); + let (e, _) = the_array(&s); + assert_eq!(circles(s.doc().unwrap(), &e).len(), 6); + let r = s.execute("explode", &json!({"handles": [outer]})).unwrap(); + assert_eq!(r["handles"].as_array().unwrap().len(), 2); + let d = s.doc().unwrap(); + assert!(d.model.iter().all(|e| d.array_params(e).is_some())); + + // Levels count toward MAXARRAY; nothing is created when refused. + let mut s = Session::new(); + let h = circle(&mut s, 0.0, 0.0); + let e = s.execute("arrayrect", &json!({"handles": [h], "rows": 1000, "cols": 1000, "levels": 1000})).unwrap_err(); + assert!(e.to_string().contains("MAXARRAY"), "{e}"); + let e = s.execute("arraypolar", &json!({"handles": [h], "center": [5, 5], "count": 1000, "rows": 1000})).unwrap_err(); + assert!(e.to_string().contains("MAXARRAY"), "{e}"); + assert_eq!(model(&s).len(), 1); +} + +#[test] +fn pasted_arrays_get_their_own_blocks() { + let mut s = Session::new(); + let h = circle(&mut s, 0.0, 0.0); + let arr = s.execute("arrayrect", &json!({"handles": [h], "rows": 1, "cols": 3, "colSpacing": 2})).unwrap()["handle"].clone(); + s.execute("copyclip", &json!({"handles": [arr]})).unwrap(); + s.execute("pasteclip", &json!({"at": [0, 50]})).unwrap(); + let d = s.doc().unwrap(); + let params: Vec = d.model.iter().filter_map(|e| d.array_params(e)).collect(); + assert_eq!(params.len(), 2); + // The pasted array's blocks were renamed (the names were taken); its parameters follow. + assert_ne!(params[0].source, params[1].source); + assert!(params.iter().all(|p| d.block(&p.source).is_some())); +} + +#[test] +fn json_arrays_follow_arrayassociativity() { + // With ARRAYASSOCIATIVITY 0 the JSON form makes copies unless `associative` asks otherwise. + let mut s = Session::new(); + s.execute("setvar", &json!({"name": "ARRAYASSOCIATIVITY", "value": 0})).unwrap(); + let h = circle(&mut s, 0.0, 0.0); + let r = s.execute("arrayrect", &json!({"handles": [h], "rows": 1, "cols": 3, "rowSpacing": 4, "colSpacing": 2})).unwrap(); + assert_ne!(r["associative"], true); + assert_eq!(model(&s).iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count(), 3); + let r = s.execute("arraypolar", &json!({"handles": [h], "center": [10, 0], "count": 4, "associative": true})).unwrap(); + assert_eq!(r["associative"], true); +} diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 661239a0..ad4d6a57 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use cadcraft_color::Color; -use cadcraft_doc::{AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric, PlotStyleTable}; +use cadcraft_doc::{ArrayParams, AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric, PlotStyleTable}; use cadcraft_dxf::Tag; use cadcraft_render::Arrowhead; use serde::{Deserialize, Serialize}; @@ -500,6 +500,42 @@ fn json_chunks(json: &str) -> Vec { chars.chunks(250).map(|c| c.iter().collect()).collect() } +/// An associative array's parameters as CADCraft xdata on its block record: `1000 ARRAY`, +/// `1002 {`, the JSON text in `1000` strings short enough for the 255-byte limit even when +/// characters are written as `\U+XXXX` escapes, `1002 }`. +pub(crate) fn array_xdata(p: &ArrayParams) -> Vec { + let Ok(json) = serde_json::to_string(p) else { return Vec::new() }; + let safe = json_chunks(&json).concat(); + let mut v = vec![Tag::s(1000, "ARRAY"), Tag::s(1002, "{")]; + let (mut cur, mut cost) = (String::new(), 0usize); + for c in safe.chars() { + let n = if c.is_ascii() { 1 } else { 7 * c.len_utf16() }; + if cost + n > 240 { + v.push(Tag::s(1000, std::mem::take(&mut cur))); + cost = 0; + } + cur.push(c); + cost += n; + } + if !cur.is_empty() { + v.push(Tag::s(1000, cur)); + } + v.push(Tag::s(1002, "}")); + v +} + +/// The array parameters written by [`array_xdata`]; `None` for other records and for +/// parameters that are not usable. +pub(crate) fn read_array(tags: &[Tag]) -> Option { + let list = xdata_list(xdata(tags, APP), "ARRAY"); + let mut text = String::new(); + for t in list.iter().take(MAX_XDATA_ITEMS).filter(|t| t.code == 1000) { + text.push_str(&t.str()); + } + let p: ArrayParams = serde_json::from_str(&text).ok()?; + p.is_valid().then_some(p) +} + /// The saved layer states stored in the `CADCRAFT_LAYERSTATES` XRECORD. #[derive(Serialize, Deserialize)] struct LayerStatesPayload { diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 8a6b1270..cbfbbe36 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -848,6 +848,8 @@ struct Rx { xref_paths: HashMap, /// Plot style name placeholder handle (upper case) → plot style name (`ACAD_PLOTSTYLENAME`). pstyles: HashMap, + /// Upper-case block name → associative array parameters from its BLOCK_RECORD. + block_arrays: HashMap, } /// The plot style names of a file: the entries of the `ACAD_PLOTSTYLENAME` dictionary of the @@ -1197,6 +1199,9 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { "BLOCK_RECORD" => { let units = t.i(70).and_then(|u| u8::try_from(u).ok()).unwrap_or(0); rx.block_props.insert(n.to_ascii_uppercase(), (units, t.i(280).unwrap_or(1) != 0)); + if let Some(a) = crate::dxf_ext::read_array(tg) { + rx.block_arrays.insert(n.to_ascii_uppercase(), a); + } rx.brs.insert(h.trim().to_ascii_uppercase(), n); } _ => {} @@ -1506,6 +1511,9 @@ pub fn read(bytes: &[u8]) -> Result { for (_, _, e) in ents { b.entities.push(e); } + // Array parameters, with the block names the items use (other programs may rename + // anonymous blocks); dropped when the items no longer match them. + b.array = rx.block_arrays.get(&up).cloned().and_then(|mut a| a.sync_names(b.entities.iter().map(|e| e.as_ref())).then_some(a)); if let Some(path) = rx.xref_paths.remove(&up) { b.xref_path = Some(path); } diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 8e826821..59f49957 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1701,6 +1701,10 @@ pub fn tags(d: &Drawing, version: Version) -> Vec { if let Some(b) = blk { w.i(70, i64::from(b.units)); w.i(280, i64::from(b.explodable)); + if let Some(a) = &b.array { + w.s(1001, dxf_ext::APP); + w.xdata(dxf_ext::array_xdata(a)); + } } }; br_rec(&mut w, &ms_br, "*Model_Space", Some(&model_layout), None); diff --git a/crates/io/tests/assoc_array_roundtrip.rs b/crates/io/tests/assoc_array_roundtrip.rs new file mode 100644 index 00000000..4bdf585b --- /dev/null +++ b/crates/io/tests/assoc_array_roundtrip.rs @@ -0,0 +1,130 @@ +//! An associative array (a block reference to an anonymous array block whose items refer to a +//! source block) keeps its parameters through a DXF save and reopen; other readers just see the +//! blocks. Damaged parameter data is ignored, never a crash. + +use std::sync::Arc; + +use cadcraft_doc::*; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_io::{read_dxf, write_dxf}; + +fn params(source: &str) -> ArrayParams { + ArrayParams { + source: source.into(), + kind: ArrayKind::Polar(PolarArray { + center: Vec2::new(-10.0, 0.0), + items: 5, + fill: 270.0, + rotate: false, + rows: 2, + row_spacing: 3.5, + row_elevation: 0.25, + }), + levels: 2, + level_spacing: 4.0, + replaced: vec![ReplacedItem { i: 1, j: 0, level: 0, block: "Größe".into() }], + } +} + +/// A circle as the source, an array of it, and a block "Größe" replacing one item. +fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + let mut src = Block::new("*U1"); + let h = d.new_handle(); + src.entities.push(Entity::new(h, EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 1.0 }))); + d.blocks.insert("*U1".into(), Arc::new(src)); + let mut other = Block::new("Größe"); + let h = d.new_handle(); + other.entities.push(Entity::new(h, EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }))); + d.blocks.insert("Größe".into(), Arc::new(other)); + let p = params("*U1"); + let items = p.item_entities(&mut d).unwrap(); + let mut arr = Block::new("*U2"); + for e in items { + arr.entities.push(e); + } + arr.array = Some(p); + d.blocks.insert("*U2".into(), Arc::new(arr)); + let ins = Insert { + block: "*U2".into(), + insert: Vec3::new(20.0, 5.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.3, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); + d +} + +#[test] +fn array_parameters_survive_a_dxf_round_trip() { + let d = drawing(); + let text = write_dxf(&d).replace("\r\n", "\n"); + // Kept as CADCraft xdata; the items are ordinary block references. + assert!(text.contains("\n1001\nCADCRAFT\n1000\nARRAY\n1002\n{\n"), "array xdata missing"); + let back = read_dxf(text.as_bytes()).unwrap(); + let arr = back.block("*U2").unwrap(); + assert_eq!(arr.array.as_ref(), Some(¶ms("*U1"))); + assert_eq!(arr.entities.len(), 20); + assert!(back.block("*U1").unwrap().array.is_none()); + let ins = back.model.iter().next().unwrap(); + assert_eq!(back.array_params(ins), Some(params("*U1"))); + // A second round trip keeps them too. + let again = read_dxf(write_dxf(&back).as_bytes()).unwrap(); + assert_eq!(again.block("*U2").unwrap().array.as_ref(), Some(¶ms("*U1"))); +} + +#[test] +fn damaged_array_xdata_is_ignored() { + let text = write_dxf(&drawing()).replace("\r\n", "\n"); + let start = text.find("\n1000\nARRAY\n1002\n{\n").unwrap() + "\n1000\nARRAY\n1002\n{\n".len(); + for bad in [ + "1000\n{\"source\":\"*U1\"\n", + "1000\nnot json at all\n", + "1000\n{\"source\":\"*U1\",\"kind\":{\"type\":\"rect\",\"cols\":4294967295,\"rows\":4294967295,\"colSpacing\":1,\"rowSpacing\":1}}\n", + "1000\n{\"source\":\"*U1\",\"kind\":{\"type\":\"rect\",\"cols\":0,\"rows\":1,\"colSpacing\":1,\"rowSpacing\":1}}\n", + "1000\n{\"source\":\"*U1\",\"kind\":{\"type\":\"rect\",\"cols\":2,\"rows\":1,\"colSpacing\":1e999,\"rowSpacing\":1}}\n", + ] { + let end = start + text[start..].find("1002\n}\n").unwrap(); + let hostile = format!("{}{}{}", &text[..start], bad, &text[end..]); + let back = read_dxf(hostile.as_bytes()).unwrap(); + let arr = back.block("*U2").unwrap(); + assert!(arr.array.is_none(), "{bad}"); + // The items are still there: the array reads as a plain block reference. + assert_eq!(arr.entities.len(), 20); + } +} + +#[test] +fn renamed_blocks_are_followed() { + // Another program renamed the anonymous source block and the replacement block: the + // parameters follow the names the items use. + let text = write_dxf(&drawing()).replace("\r\n", "\n"); + let renamed: String = text + .lines() + .map(|l| match l { + "*U1" => "*U9", + "Gr\\U+00F6\\U+00DFe" => "Other", + l => l, + }) + .collect::>() + .join("\n"); + let back = read_dxf(renamed.as_bytes()).unwrap(); + let p = back.block("*U2").unwrap().array.clone().unwrap(); + assert_eq!(p.source, "*U9"); + assert_eq!(p.replaced[0].block, "Other"); + + // Items another program added or removed: the block is kept as a plain block. + let mut d = drawing(); + let mut arr = (**d.block("*U2").unwrap()).clone(); + let first = arr.entities.iter().next().unwrap().handle; + arr.entities.remove(first); + d.blocks.insert("*U2".into(), Arc::new(arr)); + let back = read_dxf(write_dxf(&d).as_bytes()).unwrap(); + assert!(back.block("*U2").unwrap().array.is_none()); + assert_eq!(back.block("*U2").unwrap().entities.len(), 19); +}