diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index a516137fa..535506806 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -362,9 +362,13 @@ impl EntityKind { i.scale.y *= s; i.scale.z *= s; i.rotation = cadcraft_geom::norm_angle(i.rotation + rot); + // Mirrored as AutoCAD 2024 mirrors a block reference: the X scale factor + // negated, the rotation 180 degrees plus twice the mirror line's angle less the + // old one, and a MINSERT grid's columns running the other way. if mirror { - i.scale.y = -i.scale.y; - i.rotation = cadcraft_geom::norm_angle(rot - (i.rotation - rot)); + i.scale.x = -i.scale.x; + i.rotation = cadcraft_geom::norm_angle(std::f64::consts::PI + rot - (i.rotation - rot)); + i.col_spacing = -i.col_spacing; } for a in &mut i.attribs { transform_text(&mut a.text, m, s, rot, false); diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 45ab8ae15..8189e37c1 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -43,6 +43,9 @@ pub fn specs() -> Vec { .alias(&["i", "-insert", "ddinsert"]) .params("{name (\"*NAME\": exploded), file?: DXF/DWG path (defines or redefines block `name`, default the file name, from its model space; base point $INSBASE), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}") .interactive(|_| Ok(Box::new(InsertM::default()))), + CommandSpec::new("minsert", "Multiple Insert", run_minsert) + .params("{name, file?: DXF/DWG path (as INSERT), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y], attribs?: {TAG: value}, rows?: 1..32767 (default 1), columns?: 1..32767 (default 1), rowSpacing?, columnSpacing? (along the reference's own axes, not scaled with it)}") + .interactive(|_| Ok(Box::new(InsertM { minsert: true, ..InsertM::default() }))), CommandSpec::new("attdef", "Define Attributes...", run_attdef) .menu(&["Draw", "Block", "Define Attributes..."]) .alias(&["att", "-attdef"]) @@ -233,6 +236,33 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option ins } +/// MINSERT's rectangular array of a block reference: rows and columns, and the distances between +/// them along the reference's rotated axes (not scaled with it). One row and one column is a plain +/// reference. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Grid { + rows: u32, + cols: u32, + row_spacing: f64, + col_spacing: f64, +} + +impl Grid { + const ONE: Grid = Grid { rows: 1, cols: 1, row_spacing: 0.0, col_spacing: 0.0 }; + /// The most rows or columns MINSERT takes, as AutoCAD 2024 asks for them. + const MAX: u32 = 32767; + + /// A distance is kept only along an axis with more than one copy: AutoCAD 2024 asks for no + /// other and stores 0 for it (one row of three columns: 44 = 20, 45 = 0). + fn new(rows: u32, cols: u32, row_spacing: f64, col_spacing: f64) -> Grid { + Grid { rows, cols, row_spacing: if rows > 1 { row_spacing } else { 0.0 }, col_spacing: if cols > 1 { col_spacing } else { 0.0 } } + } + + fn apply(self, ins: &mut Insert) { + (ins.rows, ins.cols, ins.row_spacing, ins.col_spacing) = (self.rows, self.cols, self.row_spacing, self.col_spacing); + } +} + /// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see /// [`insert_entity`]). pub(crate) fn insert_with( @@ -244,10 +274,12 @@ pub(crate) fn insert_with( base: Option, values: &serde_json::Map, ) -> Result { - insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values) + insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values, Grid::ONE) } -/// [`insert_with`] with a Z scale factor of its own. +/// [`insert_with`] with a Z scale factor of its own, and MINSERT's grid. A grid has the one set +/// of attributes (AutoCAD 2024 asks for their values once). +#[allow(clippy::too_many_arguments)] fn insert_scaled( s: &mut Session, name: &str, @@ -256,9 +288,11 @@ fn insert_scaled( rotation: f64, base: Option, values: &serde_json::Map, + grid: Grid, ) -> Result { let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; let mut ins = insert_entity(&blk, at, scale, rotation, base); + grid.apply(&mut ins); let m = ins.transform(blk.base.xy()); for e in blk.entities.iter() { if let EntityKind::AttDef(ad) = &e.kind { @@ -310,24 +344,52 @@ fn run_block(s: &mut Session, p: &Value) -> Result { } fn run_insert(s: &mut Session, p: &Value) -> Result { + insert_json(s, p, "insert", Grid::ONE) +} + +/// MINSERT's JSON form: INSERT's parameters and the grid. +fn run_minsert(s: &mut Session, p: &Value) -> Result { + let count = |key: &str| -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(1), + Some(v) => v + .as_f64() + .filter(|n| n.fract() == 0.0 && *n >= 1.0 && *n <= f64::from(Grid::MAX)) + .map(|n| n as u32) + .ok_or_else(|| bad("minsert", format!("`{key}` must be an integer between 1 and {}", Grid::MAX))), + } + }; + // A distance given must be a number: `"15"` is refused, not taken as 0. + let spacing = |key: &str| -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(0.0), + Some(v) => v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("minsert", format!("`{key}` must be a number"))), + } + }; + let grid = Grid::new(count("rows")?, count("columns")?, spacing("rowSpacing")?, spacing("columnSpacing")?); + insert_json(s, p, "minsert", grid) +} + +/// INSERT's and MINSERT's JSON form. +fn insert_json(s: &mut Session, p: &Value, cmd: &str, grid: Grid) -> Result { let p = &insert_file::json_params(s, p)?; - let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string(); - let at = point_req("insert", p, "at")?; + let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?.to_string(); + let at = point_req(cmd, p, "at")?; // `scale`: one factor, or [x, y] for different X and Y scale factors. let scale = match p.get("scale") { - Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?, + Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad(cmd, "`scale` must be a number or [x, y]"))?, _ => { let f = f64_or(p, "scale", 1.0); Vec2::new(f, f) } }; if scale.x == 0.0 || scale.y == 0.0 { - return Err(bad("insert", "scale cannot be 0")); + return Err(bad(cmd, "scale cannot be 0")); } let rot = f64_or(p, "rotation", 0.0).to_radians(); let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default(); - let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?; - if bool_or(p, "explode", false) { + let h = insert_scaled(s, &name, at, Vec3::new(scale.x, scale.y, scale.x), rot, point_param(p, "basePoint"), &vals, grid)?; + if cmd == "insert" && bool_or(p, "explode", false) { // The EXPLODE command body, not `execute`: INSERT records the one undo step. let explode = find_command("explode").ok_or_else(|| EngineError::UnknownCommand("explode".into()))?; return (explode.run)(s, &json!({ "handles": [h.hex()] })); @@ -560,6 +622,15 @@ enum InsStage { /// XYZ: the Z scale factor, after X and Y. ZScale, Rotation, + /// MINSERT: the number of rows, then of columns. + Rows, + Cols, + /// MINSERT: the distance between rows, or a unit cell's first corner. + RowSpacing, + /// MINSERT: the unit cell's opposite corner (its width between columns, its height between + /// rows). + Cell(Vec2), + ColSpacing, Attribs, /// A Verify attribute asked again. Verify, @@ -592,6 +663,12 @@ struct InsertM { explode: bool, /// The file waiting for the Redefine answer. file: Option, + /// MINSERT: after the rotation, the grid's rows and columns and the distances between them. + minsert: bool, + rows: Option, + cols: Option, + row_spacing: Option, + col_spacing: Option, } impl InsertM { @@ -601,6 +678,8 @@ impl InsertM { if self.explode { InsStage::PresetScale } else { InsStage::XScale } } else if self.rotation.is_none() { InsStage::Rotation + } else if let Some(g) = self.grid_stage() { + g } else { InsStage::Attribs }; @@ -619,7 +698,7 @@ impl InsertM { && self.values.len() >= self.attdefs.len() && let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation) { - let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?; + let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values, self.grid())?; if self.explode { insert_file::explode_insert(s, h)?; } @@ -628,6 +707,30 @@ impl InsertM { Ok(Step::Continue) } + /// MINSERT's next grid prompt, if any is left (as AutoCAD 2024 asks them): the rows, the + /// columns, then the distance between rows when there is more than one row and between + /// columns when there is more than one column. A unit cell gives both distances. + fn grid_stage(&self) -> Option { + if !self.minsert { + return None; + } + let (Some(rows), Some(cols)) = (self.rows, self.cols) else { + return Some(if self.rows.is_none() { InsStage::Rows } else { InsStage::Cols }); + }; + if rows > 1 && self.row_spacing.is_none() { + Some(InsStage::RowSpacing) + } else if cols > 1 && self.col_spacing.is_none() { + Some(InsStage::ColSpacing) + } else { + None + } + } + + /// The grid answered so far (one copy for INSERT). + fn grid(&self) -> Grid { + Grid::new(self.rows.unwrap_or(1), self.cols.unwrap_or(1), self.row_spacing.unwrap_or(0.0), self.col_spacing.unwrap_or(0.0)) + } + /// The Verify attributes asked for. fn verifying(&self) -> Vec<&(String, String, String, bool)> { self.attdefs.iter().filter(|a| a.3).collect() @@ -698,13 +801,18 @@ impl InsertM { } } +/// A distance typed at MINSERT's grid prompts: any finite number, negative or 0 too. +fn distance(t: &str) -> Result { + number(t).filter(|v| v.is_finite()).ok_or_else(|| EngineError::Other("Requires a distance.".into())) +} + fn nonzero(t: &str) -> Result { number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into())) } impl Interactive for InsertM { fn name(&self) -> &'static str { - "INSERT" + if self.minsert { "MINSERT" } else { "INSERT" } } fn prompt(&self, s: &Session) -> Prompt { let at = self.at.unwrap_or_default(); @@ -731,9 +839,16 @@ impl Interactive for InsertM { InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"), InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"), InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at), + InsStage::Rows => Prompt::new("Enter number of rows (---)", Accept::NUMBER).default("1"), + InsStage::Cols => Prompt::new("Enter number of columns (|||)", Accept::NUMBER).default("1"), + InsStage::RowSpacing => Prompt::new("Enter distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER), + InsStage::Cell(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a), + InsStage::ColSpacing => Prompt::new("Specify distance between columns (|||)", Accept::NUMBER), InsStage::Attribs => { let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); - Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default) + let p = Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT); + // No default shown when there is none ("Value:", AutoCAD 2024), not "<>". + if default.is_empty() { p } else { p.default(default) } } InsStage::Verify => { let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default(); @@ -755,9 +870,9 @@ impl Interactive for InsertM { s.echo(format!("Defined blocks: {}", names.join(", "))); return Ok(Step::Continue); } - // `*name`: insert exploded. - self.explode = n.starts_with('*'); - let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string(); + // `*name`: insert exploded (INSERT only: a grid has no exploded form). + self.explode = !self.minsert && n.starts_with('*'); + let n = if self.explode { n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string() } else { n }; match insert_file::file_ref(s, &n) { Some(f) if f.ask_redefine(s) => { self.file = Some(f); @@ -897,6 +1012,45 @@ impl Interactive for InsertM { }); return self.advance(s); } + InsStage::Rows | InsStage::Cols => { + let n = match i { + Input::Text(t) => t + .trim() + .parse::() + .ok() + .filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= f64::from(Grid::MAX)) + .map(|v| v as u32) + .ok_or_else(|| EngineError::Other(format!("Requires an integer between 1 and {}.", Grid::MAX)))?, + Input::Enter => 1, + _ => return Ok(Step::Continue), + }; + if self.stage == InsStage::Rows { + self.rows = Some(n); + } else { + self.cols = Some(n); + } + return self.advance(s); + } + InsStage::RowSpacing => match i { + Input::Point(a) => self.stage = InsStage::Cell(a), + Input::Text(t) => { + self.row_spacing = Some(distance(&t)?); + return self.advance(s); + } + _ => {} + }, + InsStage::Cell(a) => { + if let Input::Point(b) = i { + (self.col_spacing, self.row_spacing) = (Some(b.x - a.x), Some(b.y - a.y)); + return self.advance(s); + } + } + InsStage::ColSpacing => { + if let Input::Text(t) = i { + self.col_spacing = Some(distance(&t)?); + return self.advance(s); + } + } InsStage::Attribs => { let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); let v = match i { @@ -937,7 +1091,9 @@ impl Interactive for InsertM { _ => Vec2::new(1.0, 1.0), }); let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 }); - vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))] + let mut ins = insert_entity(b, at, self.scale3(scale), rot, self.base); + self.grid().apply(&mut ins); + vec![EntityKind::Insert(ins)] } } diff --git a/crates/engine/src/cmd/modify/explode.rs b/crates/engine/src/cmd/modify/explode.rs index b4edc2e80..21051fffc 100644 --- a/crates/engine/src/cmd/modify/explode.rs +++ b/crates/engine/src/cmd/modify/explode.rs @@ -55,6 +55,10 @@ fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc:: if let Some(items) = crate::cmd::array::explode_array(d, e) { return Some(items); } + // A MINSERT grid stays whole, as in AutoCAD 2024 (see `explode`). + if minserted(ins) { + return None; + } let blk = d.block(&ins.block)?; let m = ins.transform(blk.base.xy()); let stretched = m.similarity_scale().is_none(); @@ -223,9 +227,24 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option Result> { + // AutoCAD 2024 refuses a MINSERT grid: "The object is minserted." (Locked ones are left + // without a word, as `explode_with` leaves them.) + let d = s.doc()?; + let grids = hs + .iter() + .filter(|h| !crate::cmd::curves::is_locked(s, **h) && d.entity(**h).is_some_and(|e| matches!(&e.kind, EntityKind::Insert(i) if minserted(i)))) + .count(); + for _ in 0..grids { + s.echo("The object is minserted."); + } explode_with(s, hs, explode_kind) } +/// A block reference with more than one row or column (MINSERT). +fn minserted(i: &cadcraft_doc::Insert) -> bool { + i.rows > 1 || i.cols > 1 +} + /// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives /// none for are left alone. EXPLODE, and BURST with its own pieces for block references. pub(crate) fn explode_with( diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 9e014d4b1..957513621 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -81,6 +81,7 @@ mod malformed_handles; mod matchprop; mod measure_distance_snapping; mod metric_linetypes; +mod minsert; mod mirror_degenerate_line; mod mleader_content; mod mleader_remember; diff --git a/crates/engine/tests/it/minsert.rs b/crates/engine/tests/it/minsert.rs new file mode 100644 index 000000000..e123876df --- /dev/null +++ b/crates/engine/tests/it/minsert.rs @@ -0,0 +1,218 @@ +//! MINSERT, one block reference repeated in rows and columns, as AutoCAD 2024 makes and edits it +//! (accoreconsole: the prompts, and `entget` of the reference after MINSERT, EXPLODE, MIRROR, +//! SCALE and ROTATE). + +use std::f64::consts::PI; + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Insert}; +use serde_json::{Value, json}; + +fn handle(v: &Value) -> String { + v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap().to_owned() +} + +/// A session with block B (a line from (0, 0) to (10, 0) and a circle of radius 3 round (5, 5), +/// base point (0, 0)), after the AutoLISP `code`. +fn run(code: &str) -> Session { + let mut s = Session::new(); + let a = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + let c = handle(&s.execute("circle", &json!({"center": [5, 5], "radius": 3})).unwrap()); + s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [a, c]})).unwrap(); + s.cmdline(code).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + s +} + +fn inserts(s: &Session) -> Vec { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.clone()), + _ => None, + }) + .collect() +} + +/// The grid as `entget` gives it: 70 columns, 71 rows, 44 between columns, 45 between rows (a +/// plain reference, one row and one column, as 1/1/0/0). +fn grid(i: &Insert) -> (u32, u32, f64, f64) { + (i.cols, i.rows, i.col_spacing, i.row_spacing) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +#[test] +fn the_grid_is_stored_as_autocad_2024_stores_it() { + // (MINSERT's answers after the block name and insertion point, the grid AutoCAD stored.) + let cases = [ + // One row: no distance between rows asked, and 0 stored for it. + ("1 \"\" 0 1 3 20.0", (3, 1, 20.0, 0.0)), + ("1 \"\" 0 3 1 15.0", (1, 3, 0.0, 15.0)), + // One of each: a plain reference. + ("1 \"\" 0 1 1", (1, 1, 0.0, 0.0)), + // A unit cell: its width between columns, its height between rows, signed. + ("1 \"\" 0 2 3 '(0.0 0.0) '(25.0 12.0)", (3, 2, 25.0, 12.0)), + ("1 \"\" 0 2 3 '(25.0 12.0) '(0.0 0.0)", (3, 2, -25.0, -12.0)), + // Its sides along the drawing's axes, the reference rotated or not. + ("1 \"\" 30 2 3 '(0.0 0.0) '(25.0 12.0)", (3, 2, 25.0, 12.0)), + // Typed distances are kept as typed, negative or 0 too. + ("1 \"\" 0 2 2 -15.0 -20.0", (2, 2, -20.0, -15.0)), + ("1 \"\" 0 2 2 0.0 20.0", (2, 2, 20.0, 0.0)), + // The distances are not scaled with the reference. + ("2 \"\" 30 2 2 15.0 20.0", (2, 2, 20.0, 15.0)), + ]; + for (answers, want) in cases { + let s = run(&format!("(command \"_.MINSERT\" \"B\" '(20.0 10.0) {answers})")); + let [i] = inserts(&s).try_into().unwrap(); + let got = grid(&i); + assert!(got.0 == want.0 && got.1 == want.1 && near(got.2, want.2) && near(got.3, want.3), "{answers}: {got:?}"); + assert!(near(i.insert.x, 20.0) && near(i.insert.y, 10.0)); + } + let s = run("(command \"_.MINSERT\" \"B\" '(20.0 10.0) 2 \"\" 30 2 2 15.0 20.0)"); + let [i] = inserts(&s).try_into().unwrap(); + assert!(near(i.scale.x, 2.0) && near(i.scale.y, 2.0) && near(i.rotation, PI / 6.0)); +} + +#[test] +fn the_prompts_follow_autocad_2024() { + let mut s = Session::new(); + let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap(); + // Each prompt, and what was written to the command line on the way to it. + let mut asked = Vec::new(); + for input in ["minsert", "B", "20,10", "1", "", "0", "0", "2", "3", "x", "15", "20"] { + let logged = s.log.len(); + s.cmdline(input).unwrap(); + asked.extend(s.log.iter().skip(logged).filter(|l| l.starts_with("Requires")).cloned()); + if let Some(p) = s.current_prompt() { + asked.push(p.display()); + } + } + // The grid's prompts and messages are AutoCAD 2024's; the ones before are INSERT's. + assert_eq!( + asked, + [ + "Enter block name or [?]:", + "Specify insertion point or [Basepoint/Scale/X/Y/Z/Rotate]:", + "Enter X scale factor, specify opposite corner or [Corner/XYZ] <1>:", + "Enter Y scale factor :", + "Specify rotation angle <0>:", + "Enter number of rows (---) <1>:", + "Requires an integer between 1 and 32767.", + "Enter number of rows (---) <1>:", + "Enter number of columns (|||) <1>:", + "Enter distance between rows or specify unit cell (---):", + "Requires a distance.", + "Enter distance between rows or specify unit cell (---):", + "Specify distance between columns (|||):", + ] + ); + assert!(s.running.is_none()); + let [i] = inserts(&s).try_into().unwrap(); + assert_eq!(grid(&i), (3, 2, 20.0, 15.0)); +} + +#[test] +fn counts_run_from_1_to_32767() { + // AutoCAD 2024: "Requires an integer between 1 and 32767." + let mut s = Session::new(); + let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap(); + for input in ["minsert", "B", "0,0", "1", "", "0", "32768", "32767", "1", "2"] { + s.cmdline(input).unwrap(); + } + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(s.log.iter().filter(|l| l.starts_with("Requires an integer between 1 and 32767.")).count(), 1, "{:?}", s.log); + let [i] = inserts(&s).try_into().unwrap(); + assert_eq!(grid(&i), (1, 32767, 0.0, 2.0)); +} + +#[test] +fn attribute_values_are_asked_once_after_the_grid() { + let mut s = Session::new(); + let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + let a = handle(&s.execute("attdef", &json!({"tag": "TAG", "prompt": "Value", "at": [0, 2], "height": 1})).unwrap()); + s.execute("-block", &json!({"name": "BA", "base": [0, 0], "handles": [l, a]})).unwrap(); + for input in ["minsert", "BA", "0,700", "1", "", "0", "5", "4", "15", "20"] { + s.cmdline(input).unwrap(); + } + // After the grid, with no "<>" for an empty default (AutoCAD 2024: "Value: hello"). + assert_eq!(s.current_prompt().unwrap().display(), "Value:"); + s.cmdline("hello").unwrap(); + assert!(s.running.is_none()); + let [i] = inserts(&s).try_into().unwrap(); + assert_eq!(grid(&i), (4, 5, 20.0, 15.0)); + assert_eq!(i.attribs.iter().map(|a| a.text.value.as_str()).collect::>(), ["hello"]); +} + +#[test] +fn explode_leaves_a_grid_whole() { + let s = run("(progn (command \"_.MINSERT\" \"B\" '(20.0 10.0) 1 \"\" 0 2 2 15.0 20.0) (command \"_.EXPLODE\" (entlast) \"\"))"); + let [i] = inserts(&s).try_into().unwrap(); + assert_eq!(grid(&i), (2, 2, 20.0, 15.0)); + assert_eq!(s.doc().unwrap().model.iter().count(), 1); + assert!(s.log.iter().any(|l| l.contains("The object is minserted.")), "{:?}", s.log); +} + +#[test] +fn edits_carry_the_grid_as_in_autocad_2024() { + let made = "(command \"_.MINSERT\" \"B\" '(20.0 10.0) 1 \"\" 0 2 2 15.0 20.0)"; + // (the edit, the copy it leaves last: insertion point, X and Y scale, rotation, grid) + let cases = [ + ("(command \"_.SCALE\" (entlast) \"\" '(20.0 10.0) 2)", ([20.0, 10.0], [2.0, 2.0], 0.0, (2, 2, 40.0, 30.0))), + ("(command \"_.ROTATE\" (entlast) \"\" '(20.0 10.0) 30)", ([20.0, 10.0], [1.0, 1.0], PI / 6.0, (2, 2, 20.0, 15.0))), + ("(command \"_.MOVE\" (entlast) \"\" '(20.0 10.0) '(25.0 13.0))", ([25.0, 13.0], [1.0, 1.0], 0.0, (2, 2, 20.0, 15.0))), + // Mirrored about the vertical through the insertion point, and about the horizontal: X + // scale -1, and the columns run the other way. + ("(command \"_.MIRROR\" (entlast) \"\" '(20.0 10.0) '(20.0 30.0) \"_N\")", ([20.0, 10.0], [-1.0, 1.0], 0.0, (2, 2, -20.0, 15.0))), + ("(command \"_.MIRROR\" (entlast) \"\" '(20.0 10.0) '(40.0 10.0) \"_N\")", ([20.0, 10.0], [-1.0, 1.0], PI, (2, 2, -20.0, 15.0))), + ]; + for (edit, (at, scale, rot, want)) in cases { + let s = run(&format!("(progn {made} {edit})")); + let i = inserts(&s).pop().unwrap(); + let got = grid(&i); + assert!( + near(i.insert.x, at[0]) + && near(i.insert.y, at[1]) + && near(i.scale.x, scale[0]) + && near(i.scale.y, scale[1]) + && near(i.rotation, rot) + && got.0 == want.0 + && got.1 == want.1 + && near(got.2, want.2) + && near(got.3, want.3), + "{edit}: {i:?}" + ); + } +} + +#[test] +fn the_json_form_makes_the_same_grid() { + let mut s = Session::new(); + let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap(); + s.execute("minsert", &json!({"name": "B", "at": [20, 10], "rows": 2, "columns": 3, "rowSpacing": 15, "columnSpacing": 20})).unwrap(); + // One row: its distance is not kept. + s.execute("minsert", &json!({"name": "B", "at": [20, 100], "rows": 1, "columns": 3, "rowSpacing": 15, "columnSpacing": 20})).unwrap(); + let got: Vec<_> = inserts(&s).iter().map(grid).collect(); + assert_eq!(got, [(3, 2, 20.0, 15.0), (3, 1, 20.0, 0.0)]); + for bad in [ + json!({"name": "B", "at": [0, 0], "rows": 0}), + json!({"name": "B", "at": [0, 0], "columns": 32768}), + json!({"name": "B", "at": [0, 0], "rows": 2.5}), + // A distance that is not a number is refused, not taken as 0. + json!({"name": "B", "at": [0, 0], "rows": 2, "rowSpacing": "15"}), + json!({"name": "B", "at": [0, 0], "columns": 2, "columnSpacing": true}), + ] { + assert!(s.execute("minsert", &bad).is_err(), "{bad}"); + } + // A whole number written as 2.0 is a count; `explode` is INSERT's and leaves the grid made. + let r = s.execute("minsert", &json!({"name": "B", "at": [0, 200], "rows": 2.0, "rowSpacing": 15, "explode": true})).unwrap(); + assert!(r["handle"].is_string(), "{r}"); + assert_eq!(inserts(&s).last().map(grid), Some((1, 2, 0.0, 15.0))); +} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 34eb084eb..91ed70cf7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -533,6 +533,7 @@ Multiline Text Chữ nhiều dòng Multiline Text... Chữ nhiều dòng... Multiple Nhiều + Multiple Insert Chèn nhiều Multiple Point Nhiều điểm My Của tôi My properties: not available yet Thuộc tính của tôi: chưa khả dụng diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index a8d979efa..eae92cc14 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -448,6 +448,7 @@ Multiline Text 多行文字 Multiline Text... 多行文字... Multiple 多个 + Multiple Insert 多重插入 Multiple Point 多点 My 我的 My properties: not available yet 我的属性:暂不可用