Associative arrays: ASsociative, Levels and polar ROWs (part of #448) (#455)

Squash of PR #455 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 11:28:48 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e02d1ebd08
commit bd2a7597c5
12 changed files with 1239 additions and 112 deletions
+321
View File
@@ -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<ReplacedItem>,
}
/// 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<Vec<ArrayItem>> {
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<Vec<Entity>> {
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<Item = &'a Entity>) -> 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<ArrayParams> {
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());
}
}
+2
View File
@@ -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::*;
+4
View File
@@ -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<crate::ArrayParams>,
}
impl Block {
@@ -515,6 +518,7 @@ impl Block {
xref_path: None,
explodable: true,
units: 0,
array: None,
}
}
}
+485 -95
View File
@@ -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<CommandSpec> {
vec![
@@ -30,35 +35,177 @@ pub fn specs() -> Vec<CommandSpec> {
.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<Item = Mat3> {
(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<Item = Mat3> {
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<Mat3> {
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<String, usize>, 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<Handle> {
let d = s.doc()?;
let objs: Vec<Handle> = objs.iter().copied().filter(|h| !super::curves::is_locked(s, *h)).collect();
let ents: Vec<Entity> = 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<Value> {
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<Vec<Entity>> {
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<Value> {
(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<Value> {
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<Value> {
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<Handle>,
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<Option<f64>> {
let d = match i {
@@ -344,8 +545,12 @@ impl RectM {
}
fn finish(&mut self, s: &mut Session) -> Result<Step> {
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<Step> {
@@ -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<Handle>,
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<Vec2>,
}
@@ -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<Item = Mat3> {
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<Step> {
check_size(s, self.items, self.objs.len())?;
let mats: Vec<Mat3> = 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<Option<f64>> {
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<Step> {
@@ -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<Vec2> = 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<Vec2> { 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<Vec2> = 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<Vec2> = 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));
}
}
+3
View File
@@ -1147,6 +1147,9 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
)
}
EntityKind::Insert(ins) => {
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(
+1
View File
@@ -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 })));
+29 -16
View File
@@ -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<Vec2> {
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<Vec2>) {
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] <Yes>"), "{}", 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()
+215
View File
@@ -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<Entity> {
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<Vec2> = 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<ArrayParams> = 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);
}
+37 -1
View File
@@ -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<String> {
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<Tag> {
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<ArrayParams> {
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 {
+8
View File
@@ -848,6 +848,8 @@ struct Rx {
xref_paths: HashMap<String, String>,
/// Plot style name placeholder handle (upper case) → plot style name (`ACAD_PLOTSTYLENAME`).
pstyles: HashMap<String, String>,
/// Upper-case block name → associative array parameters from its BLOCK_RECORD.
block_arrays: HashMap<String, cadcraft_doc::ArrayParams>,
}
/// 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<Drawing> {
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);
}
+4
View File
@@ -1701,6 +1701,10 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
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);
+130
View File
@@ -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(&params("*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(&params("*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::<Vec<_>>()
.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);
}