ARRAYEDIT and ARRAYCLOSE for associative arrays (fixes #448) (#460)

Squash of PR #460 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 11:36:02 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 61a8a5f984
commit d42313cde2
6 changed files with 1122 additions and 1 deletions
+19
View File
@@ -37,6 +37,23 @@ pub struct ArrayParams {
/// Items that refer to another block than the source (ARRAYEDIT REPlace).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replaced: Vec<ReplacedItem>,
/// The source objects are out for editing (ARRAYEDIT Source, until ARRAYCLOSE). Not saved:
/// a drawing saved meanwhile keeps the objects as separate objects.
#[serde(skip)]
pub editing: Option<SourceEdit>,
}
/// ARRAYEDIT Source in progress: the array's source objects placed in the drawing for editing.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SourceEdit {
/// The array (block reference) being edited.
pub array: crate::Handle,
/// The item the objects were placed at: (i, j, level).
pub item: (u32, u32, u32),
/// The objects placed for editing.
pub objects: Vec<crate::Handle>,
/// Objects with this handle or above were drawn while editing; they join the source too.
pub first_new: u64,
}
/// Rectangular or polar placement.
@@ -272,6 +289,7 @@ mod tests {
levels: 2,
level_spacing: 5.0,
replaced: Vec::new(),
editing: None,
}
}
@@ -299,6 +317,7 @@ mod tests {
levels: 1,
level_spacing: 0.0,
replaced: Vec::new(),
editing: None,
};
let items = p.items().unwrap_or_default();
assert_eq!(items.len(), 8);
+6 -1
View File
@@ -20,6 +20,8 @@ use super::modify::transform_entities;
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod edit;
/// Most preview entities drawn while the array is edited.
const PREVIEW_LIMIT: usize = 2000;
/// Keyword-only prompts: each typed token is matched on its own (no free text).
@@ -46,13 +48,16 @@ pub fn specs() -> Vec<CommandSpec> {
)
.interactive(|_| Ok(Box::new(PolarM::default()))),
]
.into_iter()
.chain(edit::specs())
.collect()
}
// ---------------- geometry ----------------
/// 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() }
ArrayParams { source: "*".into(), kind, levels: u32::try_from(levels).unwrap_or(u32::MAX), level_spacing, replaced: Vec::new(), editing: None }
}
fn count32(n: u64) -> u32 {
+930
View File
@@ -0,0 +1,930 @@
//! ARRAYEDIT and ARRAYCLOSE: change an associative array's parameters, move its base point,
//! replace items with other objects (REPlace) and undo that (RESet), and edit the source objects
//! (Source places them in the drawing; ARRAYCLOSE saves or discards the changes).
//!
//! Every change builds a new array block for the edited block reference, so copies of the array
//! that shared the old block keep it; blocks nothing refers to any more are removed.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use cadcraft_doc::{
ArrayKind, ArrayParams, Block, Drawing, Entity, EntityKind, Handle, Insert, MAX_BLOCK_DEPTH, ReplacedItem, SourceEdit, entity_bounds,
};
use cadcraft_geom::{Bounds2, Mat3, Vec2};
use serde_json::{Value, json};
use super::super::helpers::v3;
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::*;
use super::{KEYWORDS, angle, check_size, count, fmt, nesting, other, yes, yes_no};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("arrayedit", "Edit Array", run_arrayedit)
.menu(&["Modify", "Object", "Array"])
.params(
"{handle, cols?, rows?, colSpacing?, rowSpacing?, axisAngle? (degrees), rowElevation?, levels?, levelSpacing?, items?, angle? (fill, degrees), angleBetween?, rotate?: bool, center? ([x, y]), base? ([x, y], the new base point), reset?: bool, replace?: {handles, base? ([x, y], default the objects' centre), items: [[i, j, level]…] | \"source\"}, source?: {item? ([i, j, level], default [0, 0, 0])} (places the source objects for editing; ARRAYCLOSE ends)}",
)
.interactive(|_| Ok(Box::new(EditM::default()))),
CommandSpec::new("arrayclose", "Close Array Editing", run_arrayclose)
.params("{save?: bool (default true)}")
.interactive(|_| Ok(Box::new(CloseM))),
]
}
// ---------------- the array ----------------
/// The array block reference `h`, its array block and parameters (with the block names its items
/// use).
fn array_of(d: &Drawing, h: Handle) -> Result<(Insert, Arc<Block>, ArrayParams)> {
let e = d.entity(h).ok_or_else(|| other("No such object."))?;
let p = d.array_params(e).ok_or_else(|| other("The object is not an associative array."))?;
let EntityKind::Insert(ins) = &e.kind else { return Err(other("The object is not an associative array.")) };
let blk = d.block(&ins.block).cloned().ok_or_else(|| other("The array's block is missing."))?;
Ok((ins.clone(), blk, p))
}
/// The open ARRAYEDIT Source, if any: the array block holding it and the state.
fn open_edit(d: &Drawing) -> Option<(String, SourceEdit)> {
d.blocks.iter().find_map(|(n, b)| b.array.as_ref().and_then(|a| a.editing.clone()).map(|e| (n.clone(), e)))
}
fn refuse_while_editing(s: &Session) -> Result<()> {
if open_edit(s.doc()?).is_some() {
return Err(other("An array's source objects are being edited; enter ARRAYCLOSE first."));
}
Ok(())
}
/// Where item (i, j, level) goes in array block coordinates: the image of the block's origin
/// and the item's rotation (a block's own base point is applied on top).
fn item_place(p: &ArrayParams, item: (u32, u32, u32)) -> Result<Mat3> {
let it =
p.items().unwrap_or_default().into_iter().find(|it| (it.i, it.j, it.level) == item).ok_or_else(|| other("The array has no such item."))?;
Ok(Mat3::translate(it.insert.xy()).then_before(Mat3::rotate(it.rotation)))
}
/// Whether `e` refers to one of `names`, through nested block references.
fn refers_to(d: &Drawing, e: &Entity, names: &[&str]) -> bool {
let mut todo: Vec<(String, usize)> = match &e.kind {
EntityKind::Insert(i) => vec![(i.block.clone(), 0)],
_ => return false,
};
let mut seen = HashSet::new();
while let Some((b, depth)) = todo.pop() {
if names.iter().any(|n| n.eq_ignore_ascii_case(&b)) {
return true;
}
if depth > MAX_BLOCK_DEPTH || !seen.insert(b.to_ascii_uppercase()) {
continue;
}
if let Some(blk) = d.block(&b) {
todo.extend(blk.entities.iter().filter_map(|e| match &e.kind {
EntityKind::Insert(i) => Some((i.block.clone(), depth + 1)),
_ => None,
}));
}
}
false
}
/// Remove the anonymous blocks among `names` that nothing refers to any more (in order, so a
/// source block goes after the array block that used it).
fn drop_unused(d: &mut Drawing, names: &[String]) {
for n in names {
if !n.starts_with('*') || d.block(n).is_none() {
continue;
}
let used = |e: &Arc<Entity>| match &e.kind {
EntityKind::Insert(i) => i.block.eq_ignore_ascii_case(n),
EntityKind::MLeader(m) => m.block.as_ref().is_some_and(|b| b.block.eq_ignore_ascii_case(n)),
_ => false,
};
let in_spaces = d.model.iter().any(used) || d.layouts.iter().any(|l| l.entities.iter().any(used));
let in_blocks = d.blocks.values().any(|b| b.entities.iter().any(used) || b.array.as_ref().is_some_and(|a| a.source.eq_ignore_ascii_case(n)));
if !in_spaces && !in_blocks {
d.blocks.remove(n);
}
}
}
/// Rebuild array `h` with parameters `p` (and its block reference moved to `insert` when
/// given): a new array block for this reference, old blocks removed when unused.
fn rebuild(s: &mut Session, h: Handle, p: ArrayParams, insert: Option<Vec2>) -> Result<()> {
if !p.is_valid() {
return Err(other("The array parameters are not valid."));
}
let d = s.doc()?;
let (old_ins, _, old) = array_of(d, h)?;
let objs = d.block(&p.source).map(|b| b.entities.len()).ok_or_else(|| other("The array's source block is missing."))?;
check_size(s, p.item_count(), objs)?;
let d = s.doc_mut()?;
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));
d.modify_entity(h, |e| {
if let EntityKind::Insert(i) = &mut e.kind {
i.block.clone_from(&name);
if let Some(at) = insert {
i.insert = at.to3(i.insert.z);
}
}
})
.map_err(|e| other(e.to_string()))?;
let mut gone = vec![old_ins.block, old.source];
gone.extend(old.replaced.into_iter().map(|r| r.block));
drop_unused(d, &gone);
Ok(())
}
/// A new anonymous block from objects `hs` in array `h`'s block coordinates, with its base point
/// at world point `base`. The objects stay in the drawing.
fn block_from(s: &mut Session, h: Handle, hs: &[Handle], base: Vec2) -> Result<String> {
let d = s.doc()?;
let (ins, blk, p) = array_of(d, h)?;
let inv = ins.transform(blk.base.xy()).inverse().ok_or_else(|| other("The array's scale is zero."))?;
let ents: Vec<Entity> = hs.iter().filter(|x| **x != h).filter_map(|x| d.entity(*x).map(|e| (**e).clone())).collect();
if ents.is_empty() {
return Err(other("Select objects other than the array."));
}
if ents.iter().any(|e| refers_to(d, e, &[&ins.block, &p.source])) {
return Err(other("An array's items can't contain the array itself."));
}
let refs: Vec<&Entity> = ents.iter().collect();
if nesting(d, &refs, &mut Default::default(), 0) + 2 > MAX_BLOCK_DEPTH {
return Err(other(format!("The objects nest block references too deeply (more than {MAX_BLOCK_DEPTH} levels).")));
}
let d = s.doc_mut()?;
let name = d.anonymous_block_name("*U");
let mut b = Block::new(&name);
b.base = v3(inv.apply(base));
for mut e in ents {
e.handle = d.new_handle();
e.kind.transform(&inv);
b.entities.push(e);
}
d.blocks.insert(name.clone(), Arc::new(b));
Ok(name)
}
/// The centre of objects `hs` (world).
fn centroid(d: &Drawing, hs: &[Handle]) -> Vec2 {
let b = hs.iter().filter_map(|h| d.entity(*h)).fold(Bounds2::EMPTY, |a, e| a.union(&entity_bounds(d, e, 0)));
Some(b.center()).filter(|c| c.is_finite()).unwrap_or_default()
}
/// The item of array `h` under world point `at` (the lowest level when levels overlap).
fn item_at(d: &Drawing, h: Handle, at: Vec2, tol: f64) -> Option<(u32, u32, u32)> {
let (ins, blk, p) = array_of(d, h).ok()?;
let w = ins.transform(blk.base.xy());
let mut best: Option<((u32, u32, u32), f64)> = None;
// Each block's own extents, measured once.
let mut extents: HashMap<String, (Vec2, Bounds2)> = HashMap::new();
for it in p.items()? {
let name = p.block_for(it.i, it.j, it.level);
let (base, local) = match extents.get(name) {
Some(x) => *x,
None => {
let b = d.block(name)?;
let x = (b.base.xy(), b.entities.iter().fold(Bounds2::EMPTY, |a, e| a.union(&entity_bounds(d, e, 1))));
extents.insert(name.to_string(), x);
x
}
};
let m = w.then_before(Mat3::translate(it.insert.xy())).then_before(Mat3::rotate(it.rotation)).then_before(Mat3::translate(-base));
if local.is_empty() {
continue;
}
let corners = [local.min, Vec2::new(local.max.x, local.min.y), local.max, Vec2::new(local.min.x, local.max.y)];
let wb = Bounds2::from_points(corners.iter().map(|c| m.apply(*c)));
let inside = at.x >= wb.min.x - tol && at.x <= wb.max.x + tol && at.y >= wb.min.y - tol && at.y <= wb.max.y + tol;
let dist = wb.center().dist(at);
if inside && best.is_none_or(|(_, bd)| dist < bd - 1e-12) {
best = Some(((it.i, it.j, it.level), dist));
}
}
best.map(|b| b.0)
}
/// Move the base point of array `h` to world point `base` without moving the items.
fn rebase(s: &mut Session, h: Handle, base: Vec2) -> Result<()> {
let d = s.doc()?;
let (ins, blk, mut p) = array_of(d, h)?;
let w = ins.transform(blk.base.xy());
let b = w.inverse().ok_or_else(|| other("The array's scale is zero."))?.apply(base);
if !b.is_finite() {
return Err(other("Requires a valid base point."));
}
// The source block keeps its objects; its base point moves with the array's.
let src = d.block(&p.source).ok_or_else(|| other("The array's source block is missing."))?;
let mut ns = (**src).clone();
ns.base = (ns.base.xy() + b).to3(ns.base.z);
let d = s.doc_mut()?;
ns.name = d.anonymous_block_name("*U");
p.source.clone_from(&ns.name);
d.blocks.insert(ns.name.clone(), Arc::new(ns));
if let ArrayKind::Polar(k) = &mut p.kind {
k.center -= b;
}
rebuild(s, h, p, Some(w.apply(b)))
}
/// ARRAYEDIT Source: place the source objects of array `h` at `item` for editing. Returns them.
fn open_source(s: &mut Session, h: Handle, item: (u32, u32, u32)) -> Result<Vec<Handle>> {
refuse_while_editing(s)?;
let d = s.doc()?;
let (ins, blk, p) = array_of(d, h)?;
let src = d.block(&p.source).cloned().ok_or_else(|| other("The array's source block is missing."))?;
let m = ins.transform(blk.base.xy()).then_before(item_place(&p, item)?).then_before(Mat3::translate(-src.base.xy()));
let space = d.space_of(h).unwrap_or_default();
let d = s.doc_mut()?;
let mut out = Vec::new();
for e in src.entities.iter() {
let mut ne = (**e).clone();
ne.handle = d.new_handle();
ne.kind.transform(&m);
out.push(ne.handle);
d.ensure_layer(&ne.common.layer);
if let Some(st) = d.space_mut(&space) {
st.push(ne);
}
}
let first_new = d.handseed;
let mut b = (*blk).clone();
if let Some(a) = &mut b.array {
a.editing = Some(SourceEdit { array: h, item, objects: out.clone(), first_new });
}
d.blocks.insert(b.name.clone(), Arc::new(b));
s.set_selection(out.clone());
s.echo("Edit the source objects, then enter ARRAYCLOSE to save or discard the changes.");
Ok(out)
}
/// ARRAYCLOSE: end the open Source editing, saving the objects as the array's new source or
/// discarding them.
fn close_source(s: &mut Session, save: bool) -> Result<Value> {
let d = s.doc()?;
let (blk_name, ed) = open_edit(d).ok_or_else(|| other("No array is being edited."))?;
let space = d.space_of(ed.array).unwrap_or_default();
let names: Vec<String> = {
let b = d.block(&blk_name);
[Some(blk_name.clone()), b.and_then(|b| b.array.as_ref()).map(|a| a.source.clone())].into_iter().flatten().collect()
};
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
let mut objs: Vec<Handle> = ed.objects.iter().copied().filter(|x| d.entity(*x).is_some()).collect();
if let Some(st) = d.space(&space) {
let fresh: Vec<Handle> = st
.iter()
.filter(|e| e.handle.0 >= ed.first_new && e.handle != ed.array && !objs.contains(&e.handle) && !refers_to(d, e, &refs))
.map(|e| e.handle)
.collect();
objs.extend(fresh);
}
// The array still refers to the block being edited (else it was erased or changed).
let live = d.entity(ed.array).is_some_and(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(&blk_name)));
if save && live && objs.is_empty() {
return Err(other("The source would have no objects; enter ARRAYCLOSE No to discard the changes, or erase the array."));
}
let d = s.doc_mut()?;
if let Some(b) = d.blocks.get_mut(&blk_name) {
let b = Arc::make_mut(b);
if let Some(a) = &mut b.array {
a.editing = None;
}
}
if !save {
for h in &objs {
d.remove_entity(*h);
}
s.set_selection(Vec::new());
s.echo("The changes to the array were discarded.");
return Ok(json!({ "saved": false }));
}
if !live {
s.echo("The array no longer exists; the edited objects stay in the drawing.");
return Ok(json!({ "saved": false }));
}
// The new source block (base point at its origin) holds the objects as seen from the item
// they were placed at.
let d = s.doc()?;
let (ins, blk, mut p) = array_of(d, ed.array)?;
let inv = ins.transform(blk.base.xy()).then_before(item_place(&p, ed.item)?).inverse().ok_or_else(|| other("The array's scale is zero."))?;
let ents: Vec<Entity> = objs.iter().filter_map(|x| d.entity(*x).map(|e| (**e).clone())).collect();
let er: Vec<&Entity> = ents.iter().collect();
if nesting(d, &er, &mut Default::default(), 0) + 2 > MAX_BLOCK_DEPTH {
return Err(other(format!("The objects nest block references too deeply (more than {MAX_BLOCK_DEPTH} levels).")));
}
let d = s.doc_mut()?;
let name = d.anonymous_block_name("*U");
let mut nb = Block::new(&name);
for mut e in ents {
e.handle = d.new_handle();
e.kind.transform(&inv);
nb.entities.push(e);
}
d.blocks.insert(name.clone(), Arc::new(nb));
for h in &objs {
d.remove_entity(*h);
}
p.source = name;
rebuild(s, ed.array, p, None)?;
s.set_selection(Vec::new());
Ok(json!({ "saved": true }))
}
// ---------------- JSON ----------------
fn item_param(v: &Value) -> Option<(u32, u32, u32)> {
let a = v.as_array()?;
let n = |k: usize| a.get(k).and_then(Value::as_u64).and_then(|x| u32::try_from(x).ok());
Some((n(0)?, n(1).unwrap_or(0), n(2).unwrap_or(0)))
}
fn count_param(p: &Value, key: &str) -> Result<Option<u32>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.filter(|n| *n >= 1)
.and_then(|n| u32::try_from(n).ok())
.map(Some)
.ok_or_else(|| bad("arrayedit", format!("`{key}` must be a whole number of at least 1"))),
}
}
fn num_param(p: &Value, key: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(Some).ok_or_else(|| bad("arrayedit", format!("`{key}` must be a number"))),
}
}
/// The parameter changes of the JSON form applied to `a`.
fn apply_params(a: &mut ArrayParams, p: &Value, to_local: &Mat3) -> Result<()> {
if let Some(n) = count_param(p, "levels")? {
a.levels = n;
}
if let Some(x) = num_param(p, "levelSpacing")? {
a.level_spacing = x;
}
match &mut a.kind {
ArrayKind::Rect(r) => {
if let Some(n) = count_param(p, "cols")? {
r.cols = n;
}
if let Some(n) = count_param(p, "rows")? {
r.rows = n;
}
if let Some(x) = num_param(p, "colSpacing")? {
r.col_spacing = x;
}
if let Some(x) = num_param(p, "rowSpacing")? {
r.row_spacing = x;
}
if let Some(x) = num_param(p, "axisAngle")? {
r.axis_angle = x.to_radians();
}
if let Some(x) = num_param(p, "rowElevation")? {
r.row_elevation = x;
}
}
ArrayKind::Polar(k) => {
if let Some(n) = count_param(p, "items")? {
k.items = n;
}
if let Some(n) = count_param(p, "rows")? {
k.rows = n;
}
if let Some(x) = num_param(p, "rowSpacing")? {
k.row_spacing = x;
}
if let Some(x) = num_param(p, "rowElevation")? {
k.row_elevation = x;
}
if let Some(f) = num_param(p, "angle")? {
k.fill = fill_angle(f)?;
}
if let Some(b) = num_param(p, "angleBetween")? {
k.fill = fill_from_between(b, k.items)?;
}
if let Some(r) = p.get("rotate").and_then(Value::as_bool) {
k.rotate = r;
}
if let Some(c) = point_param(p, "center") {
k.center = to_local.apply(c);
}
}
}
Ok(())
}
fn fill_angle(f: f64) -> Result<f64> {
if f.abs() < 1e-9 || f.abs() > 360.0 + 1e-9 {
return Err(other("Requires a fill angle between -360 and 360 degrees (not 0)."));
}
Ok(f.clamp(-360.0, 360.0))
}
fn fill_from_between(b: f64, items: u32) -> Result<f64> {
if b.abs() < 1e-9 {
return Err(other("Requires a nonzero angle."));
}
let n = f64::from(items);
let fill = if (b.abs() * n - 360.0).abs() < 1e-9 { 360.0 * b.signum() } else { b * (n - 1.0).max(1.0) };
if fill.abs() > 360.0 + 1e-9 {
return Err(other("The items would span more than 360 degrees; enter a smaller angle or fewer items."));
}
Ok(fill)
}
fn run_arrayedit(s: &mut Session, p: &Value) -> Result<Value> {
let h = super::super::curves::handle_param(p, "handle").ok_or_else(|| bad("arrayedit", "`handle` (the array) is required"))?;
refuse_while_editing(s)?;
let (ins, blk, _) = array_of(s.doc()?, h)?;
let w = ins.transform(blk.base.xy());
let to_local = w.inverse().ok_or_else(|| other("The array's scale is zero."))?;
if bool_or(p, "reset", false) {
let (_, _, mut a) = array_of(s.doc()?, h)?;
a.replaced.clear();
rebuild(s, h, a, None)?;
}
if let Some(r) = p.get("replace").filter(|v| !v.is_null()) {
let hs = targets(s, r)?;
let base = point_param(r, "base").unwrap_or_else(|| s.doc().map(|d| centroid(d, &hs)).unwrap_or_default());
let name = block_from(s, h, &hs, base)?;
let (_, _, mut a) = array_of(s.doc()?, h)?;
match r.get("items") {
Some(Value::String(t)) if t.eq_ignore_ascii_case("source") => a.source = name,
Some(Value::Array(list)) => {
for v in list.iter().take(cadcraft_doc::MAX_ARRAY_ITEMS as usize) {
let (i, j, level) = item_param(v).ok_or_else(|| bad("arrayedit", "`replace.items` holds [i, j, level] item numbers"))?;
a.replaced.push(ReplacedItem { i, j, level, block: name.clone() });
}
}
_ => return Err(bad("arrayedit", "`replace.items` must be a list of [i, j, level] or \"source\"")),
}
rebuild(s, h, a, None)?;
}
if let Some(b) = point_param(p, "base") {
rebase(s, h, b)?;
}
let (_, _, mut a) = array_of(s.doc()?, h)?;
let before = a.clone();
apply_params(&mut a, p, &to_local)?;
if a != before {
rebuild(s, h, a, None)?;
}
if let Some(src) = p.get("source").filter(|v| !v.is_null()) {
let item = src
.get("item")
.map(|v| item_param(v).ok_or_else(|| bad("arrayedit", "`source.item` is [i, j, level]")))
.transpose()?
.unwrap_or((0, 0, 0));
let objs = open_source(s, h, item)?;
return Ok(json!({ "handle": h.hex(), "source": objs.iter().map(|x| x.hex()).collect::<Vec<_>>() }));
}
Ok(json!({ "handle": h.hex() }))
}
fn run_arrayclose(s: &mut Session, p: &Value) -> Result<Value> {
close_source(s, bool_or(p, "save", true))
}
// ---------------- interactive ----------------
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Stage {
#[default]
Select,
Main,
Base,
ColsCount,
ColsSpace,
ColsTotal,
RowsCount,
RowsSpace,
RowsTotal,
RowsElev,
LevelsCount,
LevelsSpace,
LevelsTotal,
Items,
Between,
Fill,
Rotate,
/// REPlace: objects, their base point, then the items to replace.
RepSelect,
RepBase,
RepItems,
/// Source: the item whose objects are placed for editing.
SourceItem,
}
/// ARRAYEDIT: select an array, then change it option by option until eXit.
#[derive(Default)]
struct EditM {
stage: Stage,
array: Option<Handle>,
sel: SelectPhase,
rep_objs: Vec<Handle>,
rep_block: Option<String>,
}
impl EditM {
/// The array's parameters as stored (cheap: the prompt asks for them on every redraw).
fn params(&self, s: &Session) -> Option<ArrayParams> {
let d = s.doc().ok()?;
match &d.entity(self.array?)?.kind {
EntityKind::Insert(i) => d.block(&i.block)?.array.clone(),
_ => None,
}
}
/// Apply `f` to the array's parameters and rebuild it.
fn change(&self, s: &mut Session, f: impl FnOnce(&mut ArrayParams) -> Result<()>) -> Result<()> {
let h = self.array.ok_or_else(|| other("No array selected."))?;
let (_, _, mut p) = array_of(s.doc()?, h)?;
f(&mut p)?;
rebuild(s, h, p, None)
}
fn objs(&self, s: &Session) -> usize {
self.params(s).and_then(|p| s.doc().ok().and_then(|d| d.block(&p.source).map(|b| b.entities.len()))).unwrap_or(1)
}
/// The largest count for one direction when the others hold `others` items.
fn max_for(&self, s: &Session, others: u64) -> u64 {
super::max_count(s, others, self.objs(s))
}
/// A distance in the array's own units: typed, or picked as the distance from the base point.
fn distance(&self, s: &Session, 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) => {
let h = self.array.ok_or_else(|| other("No array selected."))?;
let (ins, blk, _) = array_of(s.doc()?, h)?;
ins.transform(blk.base.xy()).inverse().map(|inv| inv.apply(*p).len())
}
_ => return Ok(None),
};
d.filter(|d| d.is_finite() && d.abs() > 1e-12).map(Some).ok_or_else(|| other("Requires a nonzero distance."))
}
fn main(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let k = match i {
Input::Enter => return Ok(Step::Done),
Input::Keyword(k) => k,
_ => return Err(other("Invalid option keyword.")),
};
self.stage = match k.as_str() {
"eXit" => return Ok(Step::Done),
"Source" => Stage::SourceItem,
"REPlace" => {
self.sel = SelectPhase::default();
Stage::RepSelect
}
"Base point" => Stage::Base,
"Rows" => Stage::RowsCount,
"Columns" => Stage::ColsCount,
"Levels" => Stage::LevelsCount,
"Items" => Stage::Items,
"Angle between" => Stage::Between,
"Fill angle" => Stage::Fill,
"ROTate items" => Stage::Rotate,
"RESet" => {
self.change(s, |p| {
p.replaced.clear();
Ok(())
})?;
s.echo("Replaced items were restored.");
Stage::Main
}
_ => return Err(other("Invalid option keyword.")),
};
Ok(Step::Continue)
}
}
impl Interactive for EditM {
fn name(&self) -> &'static str {
"ARRAYEDIT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
refuse_while_editing(s)?;
let pre = s.selection();
if let [h] = pre.as_slice()
&& s.doc().ok().and_then(|d| d.entity(*h).and_then(|e| d.array_params(e))).is_some()
{
self.array = Some(*h);
self.stage = Stage::Main;
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
use Stage::*;
let p = self.params(s);
let rect = p.as_ref().and_then(|p| match &p.kind {
ArrayKind::Rect(r) => Some(r.clone()),
ArrayKind::Polar(_) => None,
});
let polar = p.as_ref().and_then(|p| match &p.kind {
ArrayKind::Polar(k) => Some(k.clone()),
ArrayKind::Rect(_) => None,
});
let (levels, ls) = p.as_ref().map(|p| (p.levels, p.level_spacing)).unwrap_or((1, 0.0));
let (cols, rows, cs, rs, elev) = match (&rect, &polar) {
(Some(r), _) => (r.cols, r.rows, r.col_spacing, r.row_spacing, r.row_elevation),
(_, Some(k)) => (k.items, k.rows, 0.0, k.row_spacing, k.row_elevation),
_ => (1, 1, 0.0, 0.0, 0.0),
};
let dist = |m: &str, d: f64| Prompt::new(m, Accept::POINT_OR_NUMBER).default(fmt(d));
let total = |n: u32, d: f64| d * f64::from(n.saturating_sub(1));
match self.stage {
Select => Prompt::new("Select array", Accept::SELECT),
Main if polar.is_some() => Prompt::new("Enter an option", KEYWORDS)
.kw(&["Source", "REPlace", "Base point", "Items", "Angle between", "Fill angle", "Rows", "Levels", "ROTate items", "RESet", "eXit"])
.default("eXit"),
Main => Prompt::new("Enter an option", KEYWORDS)
.kw(&["Source", "REPlace", "Base point", "Rows", "Columns", "Levels", "RESet", "eXit"])
.default("eXit"),
Base => Prompt::new("Specify base point", Accept::POINT).kw(&["Key point"]).default("centroid"),
ColsCount => Prompt::new("Enter the number of columns", Accept::NUMBER).default(cols.to_string()),
ColsSpace => dist("Specify the distance between columns", cs).kw(&["Total"]),
ColsTotal => dist("Specify the distance between the first and last column", total(cols, cs)),
RowsCount => Prompt::new("Enter the number of rows", Accept::NUMBER).default(rows.to_string()),
RowsSpace => dist("Specify the distance between rows", rs).kw(&["Total"]),
RowsTotal => dist("Specify the distance between the first and last row", total(rows, rs)),
RowsElev => Prompt::new("Specify the incrementing elevation between rows", Accept::NUMBER).default(fmt(elev)),
LevelsCount => Prompt::new("Enter the number of levels", Accept::NUMBER).default(levels.to_string()),
LevelsSpace => dist("Specify the distance between levels", ls).kw(&["Total"]),
LevelsTotal => dist("Specify the distance between the first and last level", total(levels, ls)),
Items => Prompt::new("Enter number of items in array", Accept::NUMBER).default(cols.to_string()),
Between => {
let b = polar.as_ref().map_or(0.0, |k| {
let full = (k.fill.abs() - 360.0).abs() < 1e-9;
if k.items <= 1 {
k.fill
} else if full {
k.fill / f64::from(k.items)
} else {
k.fill / f64::from(k.items - 1)
}
});
Prompt::new("Specify the angle between items", Accept::NUMBER).default(fmt(b))
}
Fill => Prompt::new("Specify the angle to fill (+=ccw, -=cw)", Accept::NUMBER).default(fmt(polar.as_ref().map_or(360.0, |k| k.fill))),
Rotate => Prompt::new("Rotate arrayed items?", KEYWORDS).kw(&["Yes", "No"]).default(yes(polar.as_ref().is_none_or(|k| k.rotate))),
RepSelect => Prompt::new("Select replacement objects", Accept::SELECT),
RepBase => Prompt::new("Select base point of replacement objects", Accept::POINT).kw(&["Key point"]).default("centroid"),
RepItems => Prompt::new("Select an item in array to replace", Accept::POINT).kw(&["Source objects"]),
SourceItem => Prompt::new("Select item in array", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
use Stage::*;
let stage = self.stage;
if stage == Select {
return match i {
Input::Pick(hs) => {
let d = s.doc()?;
match hs.iter().copied().find(|h| d.entity(*h).and_then(|e| d.array_params(e)).is_some()) {
Some(h) => {
self.array = Some(h);
self.stage = Main;
Ok(Step::Continue)
}
None => Err(other("The object is not an associative array.")),
}
}
Input::Enter | Input::Cancel => Ok(Step::Done),
_ => Ok(Step::Continue),
};
}
if stage == RepSelect {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => {
self.stage = Main;
Ok(Step::Continue)
}
SelOutcome::Done(hs) => {
self.rep_objs = hs;
self.stage = RepBase;
s.set_selection(Vec::new());
Ok(Step::Continue)
}
};
}
let h = self.array.ok_or_else(|| other("No array selected."))?;
let (cols, rows, levels) = self.params(s).map(|p| (p.grid().0, p.grid().1, p.levels)).unwrap_or((1, 1, 1));
let (c64, r64, l64) = (u64::from(cols), u64::from(rows), u64::from(levels));
let next = match (stage, &i) {
(Main, _) => return self.main(s, i),
(Base, Input::Point(p)) => {
rebase(s, h, *p)?;
Main
}
(Base, Input::Enter) => {
let d = s.doc()?;
let (ins, blk, p) = array_of(d, h)?;
let src = d.block(&p.source).ok_or_else(|| other("The array's source block is missing."))?;
let local = src.entities.iter().fold(Bounds2::EMPTY, |a, e| a.union(&entity_bounds(d, e, 1)));
let at = ins.transform(blk.base.xy()).then_before(Mat3::translate(-src.base.xy())).apply(local.center());
if at.is_finite() {
rebase(s, h, at)?;
}
Main
}
(Base | RepBase, Input::Keyword(_)) => {
s.echo("Key points are not available yet; specify a point.");
stage
}
(ColsCount | Items, _) => match count(&i, c64, self.max_for(s, r64.saturating_mul(l64)))? {
Some(n) => {
let n = super::count32(n);
self.change(s, |p| {
match &mut p.kind {
ArrayKind::Rect(r) => r.cols = n,
ArrayKind::Polar(k) => k.items = n,
}
Ok(())
})?;
if stage == ColsCount { ColsSpace } else { Main }
}
None => stage,
},
(RowsCount, _) => match count(&i, r64, self.max_for(s, c64.saturating_mul(l64)))? {
Some(n) => {
let n = super::count32(n);
self.change(s, |p| {
match &mut p.kind {
ArrayKind::Rect(r) => r.rows = n,
ArrayKind::Polar(k) => k.rows = n,
}
Ok(())
})?;
RowsSpace
}
None => stage,
},
(LevelsCount, _) => match count(&i, l64, self.max_for(s, c64.saturating_mul(r64)))? {
Some(n) => {
let n = super::count32(n);
self.change(s, |p| {
p.levels = n;
Ok(())
})?;
LevelsSpace
}
None => stage,
},
(ColsSpace, Input::Keyword(_)) => ColsTotal,
(RowsSpace, Input::Keyword(_)) => RowsTotal,
(LevelsSpace, Input::Keyword(_)) => LevelsTotal,
(ColsSpace | ColsTotal | RowsSpace | RowsTotal | LevelsSpace | LevelsTotal, _) => {
let p = self.params(s).ok_or_else(|| other("No array selected."))?;
let (cur, n) = match (stage, &p.kind) {
(ColsSpace | ColsTotal, ArrayKind::Rect(r)) => (r.col_spacing, r.cols),
(RowsSpace | RowsTotal, ArrayKind::Rect(r)) => (r.row_spacing, r.rows),
(RowsSpace | RowsTotal, ArrayKind::Polar(k)) => (k.row_spacing, k.rows),
(LevelsSpace | LevelsTotal, _) => (p.level_spacing, p.levels),
_ => (0.0, 1),
};
let is_total = matches!(stage, ColsTotal | RowsTotal | LevelsTotal);
let gaps = f64::from(n.saturating_sub(1).max(1));
let shown = if is_total { cur * f64::from(n.saturating_sub(1)) } else { cur };
match self.distance(s, &i, shown)? {
Some(d) => {
let v = if is_total { d / gaps } else { d };
self.change(s, |p| {
match (stage, &mut p.kind) {
(ColsSpace | ColsTotal, ArrayKind::Rect(r)) => r.col_spacing = v,
(RowsSpace | RowsTotal, ArrayKind::Rect(r)) => r.row_spacing = v,
(RowsSpace | RowsTotal, ArrayKind::Polar(k)) => k.row_spacing = v,
_ => p.level_spacing = v,
}
Ok(())
})?;
if matches!(stage, RowsSpace | RowsTotal) { RowsElev } else { Main }
}
None => stage,
}
}
(RowsElev, Input::Enter) => Main,
(RowsElev, Input::Text(t)) => match number(t) {
Some(z) if z.is_finite() => {
self.change(s, |p| {
match &mut p.kind {
ArrayKind::Rect(r) => r.row_elevation = z,
ArrayKind::Polar(k) => k.row_elevation = z,
}
Ok(())
})?;
Main
}
_ => return Err(other("Requires a number.")),
},
(Between | Fill, Input::Enter) => Main,
(Between | Fill, _) => match angle(&i, 0.0, &s.angle_settings())? {
Some(a) => {
self.change(s, |p| {
if let ArrayKind::Polar(k) = &mut p.kind {
k.fill = if stage == Fill { fill_angle(a)? } else { fill_from_between(a, k.items)? };
}
Ok(())
})?;
Main
}
None => stage,
},
(Rotate, _) => {
let cur = self.params(s).is_none_or(|p| !matches!(p.kind, ArrayKind::Polar(ref k) if !k.rotate));
match yes_no(&i, cur) {
Some(r) => {
self.change(s, |p| {
if let ArrayKind::Polar(k) = &mut p.kind {
k.rotate = r;
}
Ok(())
})?;
Main
}
None => return Err(other("Requires Yes or No.")),
}
}
(RepBase, Input::Point(_) | Input::Enter) => {
let base = match &i {
Input::Point(p) => *p,
_ => centroid(s.doc()?, &self.rep_objs),
};
self.rep_block = Some(block_from(s, h, &self.rep_objs.clone(), base)?);
RepItems
}
(RepItems, Input::Point(at)) => {
let tol = s.pixel_size() * s.settings.pickbox;
let item = item_at(s.doc()?, h, *at, tol).ok_or_else(|| other("No array item there; select an item of the array."))?;
let name = self.rep_block.clone().ok_or_else(|| other("No replacement objects."))?;
self.change(s, |p| {
p.replaced.push(ReplacedItem { i: item.0, j: item.1, level: item.2, block: name });
Ok(())
})?;
RepItems
}
(RepItems, Input::Keyword(_)) => {
let name = self.rep_block.clone().ok_or_else(|| other("No replacement objects."))?;
self.change(s, |p| {
p.source = name;
Ok(())
})?;
Main
}
(RepItems, Input::Enter) => Main,
(SourceItem, Input::Point(at)) => {
let tol = s.pixel_size() * s.settings.pickbox;
let item = item_at(s.doc()?, h, *at, tol).ok_or_else(|| other("No array item there; select an item of the array."))?;
open_source(s, h, item)?;
return Ok(Step::Done);
}
(SourceItem, Input::Enter) => Main,
_ => stage,
};
self.stage = next;
Ok(Step::Continue)
}
}
/// ARRAYCLOSE: save or discard the source objects being edited.
struct CloseM;
impl Interactive for CloseM {
fn name(&self) -> &'static str {
"ARRAYCLOSE"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if open_edit(s.doc()?).is_none() {
return Err(other("No array is being edited."));
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
Prompt::new("Save changes to array?", KEYWORDS).kw(&["Yes", "No"]).default("Yes")
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match yes_no(&i, true) {
Some(save) => {
close_source(s, save)?;
Ok(Step::Done)
}
None => Err(other("Requires Yes or No.")),
}
}
}
+164
View File
@@ -201,6 +201,170 @@ fn pasted_arrays_get_their_own_blocks() {
assert!(params.iter().all(|p| d.block(&p.source).is_some()));
}
/// Circle centres drawn by the array `h`, sorted.
fn centres(s: &Session, h: &Value) -> Vec<(i64, i64)> {
let d = s.doc().unwrap();
let e = d.entity(Handle::parse_hex(h.as_str().unwrap()).unwrap()).unwrap().as_ref().clone();
let mut v: Vec<(i64, i64)> = circles(d, &e).iter().map(|(p, _)| ((p.x * 1000.0).round() as i64, (p.y * 1000.0).round() as i64)).collect();
v.sort();
v
}
fn lines_in(s: &Session, h: &Value) -> usize {
fn walk(d: &Drawing, e: &Entity, depth: usize) -> usize {
match &e.kind {
EntityKind::Line(_) => 1,
EntityKind::Insert(i) if depth < 8 => d.block(&i.block).unwrap().entities.iter().map(|b| walk(d, b, depth + 1)).sum(),
_ => 0,
}
}
let d = s.doc().unwrap();
walk(d, d.entity(Handle::parse_hex(h.as_str().unwrap()).unwrap()).unwrap(), 0)
}
fn rect_array(s: &mut Session) -> Value {
let h = circle(s, 0.0, 0.0);
s.execute("arrayrect", &json!({"handles": [h], "rows": 1, "cols": 3, "colSpacing": 2})).unwrap()["handle"].clone()
}
#[test]
fn arrayedit_changes_parameters_of_one_array() {
let mut s = Session::new();
let a = rect_array(&mut s);
let b = s.execute("copy", &json!({"handles": [a], "delta": [0, 100]})).unwrap();
let b = s.doc().unwrap().model.iter().map(|e| json!(e.handle.hex())).find(|x| *x != a).unwrap_or(b);
s.execute("arrayedit", &json!({"handle": a, "cols": 2, "rows": 2, "rowSpacing": 5, "colSpacing": 3})).unwrap();
assert_eq!(centres(&s, &a), vec![(0, 0), (0, 5000), (3000, 0), (3000, 5000)]);
// The copy that shared the array's block is unchanged.
assert_eq!(centres(&s, &b), vec![(0, 100_000), (2000, 100_000), (4000, 100_000)]);
// Blocks nothing uses are gone: two arrays, each with an array block and a shared source.
let anon = s.doc().unwrap().blocks.keys().filter(|k| k.starts_with('*')).count();
assert_eq!(anon, 3);
// Polar: items, fill, rotation, centre.
let mut s = Session::new();
let h = circle(&mut s, 10.0, 0.0);
let p = s.execute("arraypolar", &json!({"handles": [h], "center": [0, 0], "count": 4})).unwrap()["handle"].clone();
s.execute("arrayedit", &json!({"handle": p, "items": 3, "angle": 180})).unwrap();
assert_eq!(centres(&s, &p), vec![(-10000, 0), (0, 10000), (10000, 0)]);
s.execute("arrayedit", &json!({"handle": p, "angleBetween": 45, "center": [0, 10]})).unwrap();
assert_eq!(centres(&s, &p).len(), 3);
assert!(centres(&s, &p).contains(&(10000, 0)));
// Bad values are refused and change nothing.
let before = centres(&s, &p);
assert!(s.execute("arrayedit", &json!({"handle": p, "items": 0})).is_err());
assert!(s.execute("arrayedit", &json!({"handle": p, "items": 10_000_000})).unwrap_err().to_string().contains("MAXARRAY"));
assert!(s.execute("arrayedit", &json!({"handle": p, "angle": 400})).is_err());
assert_eq!(centres(&s, &p), before);
let c = circle(&mut s, 50.0, 50.0);
assert!(s.execute("arrayedit", &json!({"handle": c, "items": 3})).is_err());
}
#[test]
fn arrayedit_base_replace_and_reset() {
let mut s = Session::new();
let a = rect_array(&mut s);
// A new base point moves the reference, not the items.
s.execute("arrayedit", &json!({"handle": a, "base": [7, 7]})).unwrap();
assert_eq!(centres(&s, &a), vec![(0, 0), (2000, 0), (4000, 0)]);
let d = s.doc().unwrap();
let e = d.entity(Handle::parse_hex(a.as_str().unwrap()).unwrap()).unwrap();
assert!(matches!(&e.kind, EntityKind::Insert(i) if i.insert.xy().near(Vec2::new(7.0, 7.0), 1e-9)));
// Spacing still counts from the items.
s.execute("arrayedit", &json!({"handle": a, "colSpacing": 4})).unwrap();
assert_eq!(centres(&s, &a), vec![(0, 0), (4000, 0), (8000, 0)]);
// REPlace item 1 with a line; the line stays in the drawing.
let l = s.execute("line", &json!({"points": [[0, 20], [1, 20]]})).unwrap()["handles"][0].clone();
s.execute("arrayedit", &json!({"handle": a, "replace": {"handles": [l], "base": [0, 20], "items": [[1, 0, 0]]}})).unwrap();
assert_eq!(centres(&s, &a), vec![(0, 0), (8000, 0)]);
assert_eq!(lines_in(&s, &a), 1);
assert_eq!(s.doc().unwrap().model.len(), 2);
// Replacing with the array itself is refused.
assert!(s.execute("arrayedit", &json!({"handle": a, "replace": {"handles": [a], "items": [[0, 0, 0]]}})).is_err());
// RESet restores the item.
s.execute("arrayedit", &json!({"handle": a, "reset": true})).unwrap();
assert_eq!(centres(&s, &a).len(), 3);
assert_eq!(lines_in(&s, &a), 0);
// REPlace Source objects: every item becomes the line.
s.execute("arrayedit", &json!({"handle": a, "replace": {"handles": [l], "items": "source"}})).unwrap();
assert_eq!(centres(&s, &a).len(), 0);
assert_eq!(lines_in(&s, &a), 3);
// EXPLODE gives three lines (plus the original one).
s.execute("explode", &json!({"handles": [a]})).unwrap();
assert_eq!(s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count(), 4);
}
#[test]
fn arrayedit_source_then_arrayclose() {
let mut s = Session::new();
let a = rect_array(&mut s);
// Source places the circle of item 2 (at x = 4) in the drawing.
let r = s.execute("arrayedit", &json!({"handle": a, "source": {"item": [2, 0, 0]}})).unwrap();
let placed = r["source"].as_array().unwrap().clone();
assert_eq!(placed.len(), 1);
// Only one array at a time; other edits wait for ARRAYCLOSE.
assert!(s.execute("arrayedit", &json!({"handle": a, "cols": 5})).is_err());
// Move the circle up by 1 and draw a line next to it: both become the source.
s.execute("move", &json!({"handles": placed, "delta": [0, 1]})).unwrap();
s.execute("line", &json!({"points": [[4, 3], [5, 3]]})).unwrap();
s.execute("arrayclose", &json!({})).unwrap();
assert_eq!(centres(&s, &a), vec![(0, 1000), (2000, 1000), (4000, 1000)]);
assert_eq!(lines_in(&s, &a), 3);
assert_eq!(s.doc().unwrap().model.len(), 1);
// Discard: the placed objects go, the array stays as it was.
s.execute("arrayedit", &json!({"handle": a, "source": {}})).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 3);
s.execute("arrayclose", &json!({"save": false})).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 1);
assert_eq!(centres(&s, &a).len(), 3);
assert!(s.execute("arrayclose", &json!({})).is_err());
// Saving an empty source is refused; the edit stays open.
let r = s.execute("arrayedit", &json!({"handle": a, "source": {}})).unwrap();
s.execute("erase", &json!({"handles": r["source"]})).unwrap();
assert!(s.execute("arrayclose", &json!({})).is_err());
s.execute("arrayclose", &json!({"save": false})).unwrap();
assert_eq!(centres(&s, &a).len(), 3);
}
#[test]
fn typed_arrayedit() {
let mut s = Session::new();
let a = rect_array(&mut s);
typed(&mut s, &["arrayedit", "l"]);
let p = s.prompt_text();
assert!(p.contains("Enter an option or [Source/REPlace/Base point/Rows/Columns/Levels/RESet/eXit] <eXit>"), "{p}");
typed(&mut s, &["c", "4", "t", "9", "r", "2", "5", ""]);
assert!(s.prompt_text().contains("Enter an option"), "{}", s.prompt_text());
assert_eq!(centres(&s, &a).len(), 8);
assert!(centres(&s, &a).contains(&(9000, 5000)), "{:?}", centres(&s, &a));
typed(&mut s, &["x"]);
assert!(s.current_prompt().is_none(), "{}", s.prompt_text());
// One undo step takes the whole edit back.
s.execute("undo", &json!({})).unwrap();
assert_eq!(centres(&s, &a).len(), 3);
// REPlace with a typed pick of item 2, then Source on item 0 and ARRAYCLOSE.
s.execute("line", &json!({"points": [[0, 20], [1, 20]]})).unwrap();
s.set_selection(Vec::new());
typed(&mut s, &["arrayedit", "1,0"]);
typed(&mut s, &["rep", "l", "", ""]);
assert!(s.prompt_text().contains("Select an item in array to replace or [Source objects]"), "{}", s.prompt_text());
typed(&mut s, &["4,0", "", "x"]);
assert_eq!(centres(&s, &a).len(), 2);
assert_eq!(lines_in(&s, &a), 1);
typed(&mut s, &["arrayedit", "1,0", "s", "0,0"]);
assert!(s.current_prompt().is_none(), "{}", s.prompt_text());
typed(&mut s, &["arrayclose"]);
assert!(s.prompt_text().contains("Save changes to array? [Yes/No] <Yes>"), "{}", s.prompt_text());
typed(&mut s, &[""]);
assert!(s.current_prompt().is_none(), "{}", s.prompt_text());
assert_eq!(centres(&s, &a).len(), 2);
}
#[test]
fn json_arrays_follow_arrayassociativity() {
// With ARRAYASSOCIATIVITY 0 the JSON form makes copies unless `associative` asks otherwise.
+1
View File
@@ -23,6 +23,7 @@ fn params(source: &str) -> ArrayParams {
levels: 2,
level_spacing: 4.0,
replaced: vec![ReplacedItem { i: 1, j: 0, level: 0, block: "Größe".into() }],
editing: None,
}
}
+2
View File
@@ -1041,3 +1041,5 @@
Convert to Arc Перетворити на дугу
Convert to Line Перетворити на відрізок
Stretch Vertex Розтягнути вершину
Edit Array Редагувати масив
Close Array Editing Завершити редагування масиву
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
1041
1042
1043
1044
1045