mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
Squash of PR #455 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e02d1ebd08
commit
bd2a7597c5
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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::<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);
|
||||
}
|
||||
Reference in New Issue
Block a user