Files
cadcraft/crates/engine/tests/array_commands.rs
T
Valentin KleinandClaude Opus 5.5 fc1810f5d9 Array commands: ARRAY, and edit-array option loops for ARRAYRECT/ARRAYPOLAR (#230) (#232)
* Array commands: ARRAY, and the edit-array option loops for ARRAYRECT/ARRAYPOLAR (#230)

ARRAY (alias AR) selects objects, asks for the array type and continues as
ARRAYRECT, ARRAYPATH or ARRAYPOLAR. ARRAYRECT no longer creates a fixed 4x3
grid right after selection: it previews the array and loops on
ASsociative/Base point/COUnt/Spacing/COLumns/Rows/Levels/eXit. ARRAYPOLAR
asks for the center (or Base point), then loops on Items/Angle between/
Fill angle/ROTate items/eXit. The copies are created on eXit or Enter.
Associative arrays, levels and polar rows report "not available yet" and
return to the loop without consuming the next input.

The array commands move to cmd/array.rs. Arrays stay non-associative
copies, and the JSON forms are unchanged. A full clockwise polar fill
(-360) no longer puts the last item on top of the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs/gaps.md: narrow F5 (array option loops, #230)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Arrays: refuse arrays over MAXARRAY instead of hanging or shrinking them (#230)

`arrayrect {"rows":1000000,"cols":1000000}` was clamped to 1000x1000 and
then created a million copies of every selected object, which hangs the
app. ARRAYRECT, ARRAYPOLAR and ARRAYPATH (JSON and interactive, also via
ARRAY) now count items x selected objects and refuse an array over the
new MAXARRAY session variable (default 100000, settable 100..10000000)
with a message naming the limit, instead of clamping the counts and
silently creating fewer copies. The interactive count prompts accept at
most what still fits under MAXARRAY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:34:30 -04:00

202 lines
9.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! ARRAY, ARRAYRECT and ARRAYPOLAR at the command line: the type prompt and the edit-array
//! option loops. Each input is its own `cmdline` call (one typed line each).
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use cadcraft_engine::geom::Vec2;
use serde_json::json;
fn typed(s: &mut Session, inputs: &[&str]) {
for t in inputs {
s.cmdline(t).unwrap();
}
}
fn circle_centers(s: &Session) -> Vec<Vec2> {
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Circle(c) => Some(c.center.xy()),
_ => None,
})
.collect()
}
fn has(pts: &[Vec2], x: f64, y: f64) -> bool {
pts.iter().any(|p| p.near(Vec2::new(x, y), 1e-6))
}
fn prompt(s: &Session) -> String {
s.prompt_text()
}
#[test]
fn array_asks_for_the_type_and_continues_as_that_array() {
// ARRAY ▸ POlar.
let mut s = Session::new();
s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap();
typed(&mut s, &["array", "l", ""]);
assert!(prompt(&s).contains("Enter array type or [Rectangular/PAth/POlar] <Rectangular>"), "{}", prompt(&s));
typed(&mut s, &["po", "0,0", "i", "4", "x"]);
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
let c = circle_centers(&s);
assert_eq!(c.len(), 4);
assert!(has(&c, 0.0, 10.0) && has(&c, -10.0, 0.0) && has(&c, 0.0, -10.0));
// AR (alias), Enter takes the default Rectangular.
let mut s = Session::new();
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
typed(&mut s, &["ar", "l", "", ""]);
assert!(prompt(&s).contains("COUnt"), "{}", prompt(&s));
typed(&mut s, &["cou", "2", "1", "s", "5", "5", ""]);
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
let c = circle_centers(&s);
assert_eq!(c.len(), 2);
assert!(has(&c, 5.0, 0.0));
// ARRAY ▸ PAth continues at the path prompt with the selected objects.
let mut s = Session::new();
s.execute("circle", &json!({"center": [0, 1], "radius": 0.25})).unwrap();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
typed(&mut s, &["array", "0.25,1", "", "pa", "5,0", "3"]);
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
assert_eq!(circle_centers(&s).len(), 3);
// The JSON form dispatches on `type`.
let mut s = Session::new();
let r = s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap();
let h = r["handle"].clone();
let r = s.execute("array", &json!({"type": "polar", "handles": [h], "center": [0, 0], "count": 3})).unwrap();
assert_eq!(r["created"], 2);
assert!(s.execute("array", &json!({"type": "spiral", "handles": [h]})).is_err());
}
#[test]
fn arrayrect_edits_the_array_in_a_loop_until_exit() {
let mut s = Session::new();
s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap();
typed(&mut s, &["arrayrect", "l", ""]);
let p = prompt(&s);
assert!(p.contains("Select grip to edit array or [ASsociative/Base point/COUnt/Spacing/COLumns/Rows/Levels/eXit] <eXit>"), "{p}");
// Nothing is created before eXit.
assert_eq!(circle_centers(&s).len(), 1);
typed(&mut s, &["cou", "2", "3", "s", "10", "20"]);
// Unavailable options report and return to the loop without taking the next input.
typed(&mut s, &["as", "l", "cou", "2", "3"]);
assert!(prompt(&s).contains("Select grip to edit array"), "{}", prompt(&s));
typed(&mut s, &["x"]);
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
let c = circle_centers(&s);
assert_eq!(c.len(), 6, "{c:?}");
for (x, y) in [(10.0, 0.0), (0.0, 20.0), (10.0, 40.0)] {
assert!(has(&c, x, y), "{c:?}");
}
// COLumns with a Total distance, Rows with spacing and elevation.
let mut s = Session::new();
s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap();
typed(&mut s, &["arrayrect", "l", "", "col", "3", "t", "20", "r", "2", "5", "0", "x"]);
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
let c = circle_centers(&s);
assert_eq!(c.len(), 6, "{c:?}");
assert!(has(&c, 20.0, 0.0) && has(&c, 10.0, 5.0), "{c:?}");
// Invalid counts re-prompt.
let mut s = Session::new();
s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap();
typed(&mut s, &["arrayrect", "l", "", "cou", "0"]);
assert!(prompt(&s).contains("number of columns"), "{}", prompt(&s));
typed(&mut s, &["2", "1", "x"]);
assert_eq!(circle_centers(&s).len(), 2);
// The JSON form is unchanged.
let mut s = Session::new();
let r = s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap();
let r = s.execute("arrayrect", &json!({"handles": [r["handle"]], "rows": 2, "cols": 3, "rowSpacing": 4, "colSpacing": 2})).unwrap();
assert_eq!(r["created"], 5);
assert!(has(&circle_centers(&s), 4.0, 4.0));
}
#[test]
fn arraypolar_asks_for_the_center_then_edits_items_and_angles() {
let mut s = Session::new();
s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap();
typed(&mut s, &["arraypolar", "l", ""]);
assert!(prompt(&s).contains("Specify center point of array or [Base point/Axis of rotation]"), "{}", prompt(&s));
typed(&mut s, &["0,0"]);
let p = prompt(&s);
assert!(
p.contains("Select grip to edit array or [ASsociative/Base point/Items/Angle between/Fill angle/ROWs/Levels/ROTate items/eXit] <eXit>"),
"{p}"
);
typed(&mut s, &["i", "4", "x"]);
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
// X ended ARRAYPOLAR; it did not start EXPLODE.
assert!(!s.log.iter().any(|l| l.contains("EXPLODE")));
let c = circle_centers(&s);
assert_eq!(c.len(), 4);
assert!(has(&c, 0.0, 10.0) && has(&c, -10.0, 0.0) && has(&c, 0.0, -10.0));
// Angle between with 4 items: 30° apart, filling 90°.
let mut s = Session::new();
s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap();
s.script("ARRAYPOLAR L\n\n0,0 I 4 A 30 X\n").unwrap();
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
let c = circle_centers(&s);
assert_eq!(c.len(), 4);
assert!(has(&c, 0.0, 10.0) && !has(&c, -10.0, 0.0), "{c:?}");
// Fill angle 180 with 3 items, items not rotated: the copies only move.
let mut s = Session::new();
s.execute("line", &json!({"points": [[10, 0], [12, 0]]})).unwrap();
typed(&mut s, &["arraypolar", "l", "", "0,0", "i", "3", "f", "180", "rot", "n", "x"]);
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
let lines: Vec<(Vec2, Vec2)> = s
.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())),
_ => None,
})
.collect();
assert_eq!(lines.len(), 3);
// The middle copy: the line's centre (11,0) moved to (0,11), still horizontal.
assert!(lines.iter().any(|(a, b)| a.near(Vec2::new(-1.0, 11.0), 1e-6) && b.near(Vec2::new(1.0, 11.0), 1e-6)), "{lines:?}");
}
#[test]
fn arrays_over_maxarray_are_refused_not_shrunk() {
// JSON: a million × a million rows/cols (or items) is refused up front, nothing is created.
let mut s = Session::new();
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
let h = c["handle"].clone();
let before = s.doc().unwrap().model.iter().count();
let e = s.execute("arrayrect", &json!({"handles": [h], "rows": 1_000_000, "cols": 1_000_000})).unwrap_err();
assert!(e.to_string().contains("MAXARRAY"), "{e}");
assert!(s.execute("arraypolar", &json!({"handles": [h], "center": [5, 5], "count": 200_000})).unwrap_err().to_string().contains("MAXARRAY"));
let path = s.execute("line", &json!({"points": [[0, 5], [100, 5]]})).unwrap()["handles"][0].clone();
let e = s.execute("arraypath", &json!({"handles": [h], "path": path, "spacing": 1e-6})).unwrap_err();
assert!(e.to_string().contains("MAXARRAY"), "{e}");
assert_eq!(s.doc().unwrap().model.iter().count(), before + 1);
// The limit counts every selected object, and MAXARRAY is a settable system variable.
s.execute("setvar", &json!({"name": "MAXARRAY", "value": 100})).unwrap();
let h2 = s.execute("circle", &json!({"center": [3, 0], "radius": 1})).unwrap()["handle"].clone();
assert!(s.execute("arrayrect", &json!({"handles": [h, h2], "rows": 10, "cols": 6})).is_err());
assert_eq!(s.execute("arrayrect", &json!({"handles": [h, h2], "rows": 10, "cols": 5})).unwrap()["created"], 98);
// Interactive: a count over the limit is rejected at its prompt and the loop keeps going.
let mut s = Session::new();
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
typed(&mut s, &["arrayrect", "l", "", "cou", "1000", "1000"]);
assert!(s.log.iter().any(|l| l.contains("MAXARRAY")), "{:?}", s.log);
assert!(prompt(&s).contains("rows"), "{}", prompt(&s));
typed(&mut s, &["2", ""]);
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
assert_eq!(circle_centers(&s).len(), 2000);
}