diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 6b968f8c..1d47d74d 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -84,7 +84,7 @@ pub fn specs() -> Vec { .collect() } -fn valid_block_name(n: &str) -> bool { +pub(crate) fn valid_block_name(n: &str) -> bool { !n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) } @@ -94,7 +94,7 @@ fn referenced_block(e: &Entity) -> Option { } /// Whether any of `ents` refers to block `name`, directly or through nested block references. -fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool { +pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool { let mut todo: Vec = ents.iter().filter_map(referenced_block).collect(); let mut seen = std::collections::HashSet::new(); while let Some(b) = todo.pop() { diff --git a/crates/engine/src/lisp/block_tests.rs b/crates/engine/src/lisp/block_tests.rs new file mode 100644 index 00000000..6e85a22e --- /dev/null +++ b/crates/engine/src/lisp/block_tests.rs @@ -0,0 +1,236 @@ +//! `entmake` defines blocks (#1662): a BLOCK list begins a definition, the entities made after it +//! go into the block instead of the drawing, and an ENDBLK list adds the block and returns its name. + +#![allow(clippy::unwrap_used)] + +use super::tests::session; +use super::value::{Value, to_text}; +use crate::Session; + +fn eval(s: &mut Session, code: &str) -> Result { + match s.lisp_eval(code, false, true, Vec::::new()) { + Ok(Some(Ok(v))) => Ok(to_text(&v, true)), + Ok(Some(Err(e))) => Err(e), + Ok(None) => Err("did not finish".into()), + Err(e) => Err(e.to_string()), + } +} + +fn ok(s: &mut Session, code: &str) -> String { + match eval(s, code) { + Ok(v) => v, + Err(e) => panic!("{code}: {e}"), + } +} + +fn model_len(s: &Session) -> usize { + s.doc().unwrap().model.len() +} + +const BLOCK_EB: &str = r#"(entmake '((0 . "BLOCK") (2 . "EB") (70 . 0) (10 5.0 5.0 0.0) (4 . "made by entmake")))"#; +const LINE: &str = r#"(entmake '((0 . "LINE") (8 . "0") (10 0.0 0.0 0.0) (11 10.0 0.0 0.0)))"#; +const CIRCLE: &str = r#"(entmake '((0 . "CIRCLE") (8 . "PARTS") (10 5.0 5.0 0.0) (40 . 2.5)))"#; +const ENDBLK: &str = r#"(entmake '((0 . "ENDBLK")))"#; + +/// The issue's routine: BLOCK, a LINE, ENDBLK, then tblsearch. +#[test] +fn the_issues_routine_defines_the_block() { + let mut s = session(); + let code = r#"(list (entmake (quote ((0 . "BLOCK") (2 . "EB") (70 . 0) (10 0.0 0.0 0.0)))) (entmake (quote ((0 . "LINE") (8 . "0") (10 0.0 0.0 0.0) (11 10.0 0.0 0.0)))) (entmake (quote ((0 . "ENDBLK")))) (cdr (assoc 2 (tblsearch "BLOCK" "EB"))))"#; + let v = ok(&mut s, code); + assert!(v.starts_with("(((0 . \"BLOCK\")"), "{v}"); + assert!(v.ends_with(" \"EB\" \"EB\")"), "ENDBLK returns the name and tblsearch finds it: {v}"); + assert_eq!(model_len(&s), 0, "the LINE went into the block, not model space"); + let d = s.doc().unwrap(); + assert_eq!(d.block("EB").unwrap().entities.len(), 1); +} + +#[test] +fn the_entities_base_point_and_description_are_kept() { + let mut s = session(); + assert!(ok(&mut s, BLOCK_EB).starts_with("((0 . \"BLOCK\")")); + assert!(ok(&mut s, LINE).starts_with("((0 . \"LINE\")"), "an entity in a definition returns its list"); + ok(&mut s, CIRCLE); + assert_eq!(model_len(&s), 0); + assert!(s.doc().unwrap().block("EB").is_none(), "nothing is added before ENDBLK"); + assert_eq!(ok(&mut s, ENDBLK), "\"EB\""); + let d = s.doc().unwrap(); + let b = d.block("EB").unwrap(); + assert_eq!((b.base.x, b.base.y, b.description.as_str()), (5.0, 5.0, "made by entmake")); + let hs: Vec<_> = b.entities.iter().map(|e| e.handle).collect(); + assert_eq!(hs.len(), 2); + assert!(hs[0].0 != 0 && hs[0] != hs[1], "each entity gets its own handle: {hs:?}"); + assert!(d.layers.iter().any(|l| l.name == "PARTS"), "a layer the entities name is created"); + // It inserts like any block, and entmake makes entities in the drawing again afterwards. + s.execute("insert", &serde_json::json!({"name": "EB", "at": [100, 0]})).unwrap(); + ok(&mut s, LINE); + assert_eq!(model_len(&s), 2); +} + +#[test] +fn anonymous_blocks_get_a_name() { + let mut s = session(); + ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "*U") (70 . 1) (10 0.0 0.0 0.0)))"#); + ok(&mut s, LINE); + let name = ok(&mut s, ENDBLK); + assert!(name.starts_with("\"*U") && name.len() > 4, "{name}"); + let d = s.doc().unwrap(); + let b = d.block(name.trim_matches('"')).unwrap(); + assert!(b.anonymous); + assert_eq!(b.entities.len(), 1); +} + +#[test] +fn a_block_of_the_same_name_is_redefined() { + let mut s = session(); + for (n, code) in [(1, LINE), (2, CIRCLE)] { + ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "R") (70 . 0) (10 0.0 0.0 0.0)))"#); + for _ in 0..n { + ok(&mut s, code); + } + assert_eq!(ok(&mut s, ENDBLK), "\"R\""); + } + assert_eq!(s.doc().unwrap().block("R").unwrap().entities.len(), 2, "the second definition"); + // Names are case-insensitive: the existing spelling is kept. + ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "r") (70 . 0) (10 0.0 0.0 0.0)))"#); + assert_eq!(ok(&mut s, ENDBLK), "\"R\""); +} + +#[test] +fn what_cant_be_defined_is_refused() { + let mut s = session(); + assert_eq!(ok(&mut s, ENDBLK), "nil", "ENDBLK without a BLOCK"); + for bad in [ + r#"(entmake '((0 . "BLOCK") (2 . "") (70 . 0) (10 0.0 0.0 0.0)))"#, + r#"(entmake '((0 . "BLOCK") (2 . "A prefix.len(), "{given} -> {name}"); + assert!(s.doc().unwrap().block(name).unwrap().anonymous); + } +} + +/// As BLOCK: a block being edited in place (REFEDIT) isn't redefined until REFCLOSE. +#[test] +fn a_block_being_edited_in_place_is_not_redefined() { + let mut s = session(); + for code in [r#"(entmake '((0 . "BLOCK") (2 . "R") (70 . 0) (10 0.0 0.0 0.0)))"#, LINE, ENDBLK] { + ok(&mut s, code); + } + let ins = s.execute("insert", &serde_json::json!({"name": "R", "at": [0, 0]})).unwrap(); + s.execute("refedit", &serde_json::json!({"handle": ins["handle"]})).unwrap(); + ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "r") (70 . 0) (10 0.0 0.0 0.0)))"#); + ok(&mut s, CIRCLE); + ok(&mut s, CIRCLE); + assert_eq!(ok(&mut s, ENDBLK), "nil"); + s.execute("refclose", &serde_json::json!({"option": "discard"})).unwrap(); + assert_eq!(s.doc().unwrap().block("R").unwrap().entities.len(), 1, "the definition is unchanged"); + // After REFCLOSE it can be redefined. + for code in [r#"(entmake '((0 . "BLOCK") (2 . "R") (70 . 0) (10 0.0 0.0 0.0)))"#, CIRCLE, CIRCLE] { + ok(&mut s, code); + } + assert_eq!(ok(&mut s, ENDBLK), "\"R\""); + assert_eq!(s.doc().unwrap().block("R").unwrap().entities.len(), 2); +} + +/// A routine that fails drops the definition it began, with or without an *error* handler: the +/// next routine's entities go into the drawing. One that ends normally keeps it open. +#[test] +fn a_failed_routine_drops_its_open_definition() { + let mut s = session(); + for handler in [None, Some("(defun *error* (msg) (princ msg))")] { + if let Some(h) = handler { + ok(&mut s, h); + } + let before = model_len(&s); + assert!(eval(&mut s, r#"(progn (entmake '((0 . "BLOCK") (2 . "FAIL") (70 . 0) (10 0.0 0.0 0.0))) (car 1))"#).is_err()); + ok(&mut s, LINE); + assert_eq!(model_len(&s), before + 1, "the line is in the drawing ({handler:?})"); + assert_eq!(ok(&mut s, ENDBLK), "nil", "nothing is open"); + assert!(s.doc().unwrap().block("FAIL").is_none()); + } + // A routine that ends normally leaves its definition open for the next one. + ok(&mut s, BLOCK_EB); + ok(&mut s, LINE); + assert_eq!(ok(&mut s, ENDBLK), "\"EB\""); +} + +/// An anonymous block that inserts the name it is about to get (`*U1` in an empty drawing) is a +/// block inserting itself too: refused like a named one. +#[test] +fn an_anonymous_block_that_inserts_itself_is_refused() { + let mut s = session(); + assert!(s.doc().unwrap().block("*U1").is_none()); + ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "*U") (70 . 1) (10 0.0 0.0 0.0)))"#); + ok(&mut s, r#"(entmake '((0 . "INSERT") (2 . "*U1") (10 0.0 0.0 0.0)))"#); + assert_eq!(ok(&mut s, ENDBLK), "nil"); + assert!(s.doc().unwrap().block("*U1").is_none()); + // The next anonymous block still gets a name and is added. + ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "*U") (70 . 1) (10 0.0 0.0 0.0)))"#); + ok(&mut s, LINE); + assert_eq!(ok(&mut s, ENDBLK), "\"*U1\""); +} + +/// The layers a definition's entities name are created only when the block is added: a refused +/// definition, or one a failed routine drops, leaves no layer behind. +#[test] +fn a_definition_that_isnt_added_leaves_no_layer() { + let mut s = session(); + let has = |s: &Session, n: &str| s.doc().unwrap().layers.iter().any(|l| l.name.eq_ignore_ascii_case(n)); + // Refused at ENDBLK: it inserts itself. + ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "SELF") (70 . 0) (10 0.0 0.0 0.0)))"#); + ok(&mut s, r#"(entmake '((0 . "LINE") (8 . "ORPHAN1") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0)))"#); + ok(&mut s, r#"(entmake '((0 . "INSERT") (8 . "ORPHAN1") (2 . "SELF") (10 0.0 0.0 0.0)))"#); + assert!(!has(&s, "ORPHAN1"), "nothing before ENDBLK"); + assert_eq!(ok(&mut s, ENDBLK), "nil"); + assert!(!has(&s, "ORPHAN1"), "a refused block leaves no layer"); + // Dropped: the routine fails. + assert!(eval(&mut s, r#"(progn (entmake '((0 . "BLOCK") (2 . "GONE") (70 . 0) (10 0.0 0.0 0.0))) (entmake '((0 . "LINE") (8 . "ORPHAN2") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0))) (car 1))"#).is_err()); + assert!(!has(&s, "ORPHAN2"), "a dropped definition leaves no layer"); + // Added: its layers are created. + ok(&mut s, BLOCK_EB); + ok(&mut s, CIRCLE); + assert!(!has(&s, "PARTS")); + assert_eq!(ok(&mut s, ENDBLK), "\"EB\""); + assert!(has(&s, "PARTS"), "the block's layer exists once it is added"); +} diff --git a/crates/engine/src/lisp/drawing.rs b/crates/engine/src/lisp/drawing.rs index 53c7af86..7e85dd67 100644 --- a/crates/engine/src/lisp/drawing.rs +++ b/crates/engine/src/lisp/drawing.rs @@ -357,10 +357,25 @@ fn entity_from_list(ctx: &Ctx<'_>, list: &Value) -> Result<(String, cadcraft_doc pub fn entmake(ctx: &mut Ctx<'_>, a: Vec) -> Result { arity(&a, 0, 1)?; let Some(list) = a.first().filter(|v| !v.is_nil()) else { return Ok(Flow::Value(Value::Nil)) }; + // A block definition is a BLOCK list, its entities, then an ENDBLK list (#1662). + match list_to_groups(list) { + Ok((k, tags)) if k == "BLOCK" => return Ok(Flow::Value(begin_block(ctx, list, &tags))), + Ok((k, _)) if k == "ENDBLK" => return Ok(Flow::Value(end_block(ctx))), + _ => {} + } let (_, common, kind) = match entity_from_list(ctx, list) { Ok(x) => x, Err(_) => return Ok(Flow::Value(Value::Nil)), }; + if let Some((_, ents)) = ctx.env.pending_block.as_mut() { + // Into the block being defined; it is added to the drawing, with handles and the layers + // its entities name, at ENDBLK (a refused or dropped definition leaves nothing behind). + if ents.len() >= MAX_PENDING_BLOCK_ENTITIES { + return Ok(Flow::Value(Value::Nil)); + } + ents.push(Entity { handle: Handle(0), common, kind }); + return Ok(Flow::Value(list.clone())); + } let space = ctx.s.space(); let Ok(d) = ctx.s.doc_mut() else { return Ok(Flow::Value(Value::Nil)) }; // A layer entmake names that doesn't exist yet is created. @@ -374,6 +389,75 @@ pub fn entmake(ctx: &mut Ctx<'_>, a: Vec) -> Result { } } +/// `entmake` of a BLOCK list (2 name, 70 flags, 10 base point, 4 description): begin a block +/// definition. Flag 1 makes an anonymous block (`*U…`, named at ENDBLK); xrefs (flag 4) can't be +/// made this way. nil, and the definition already begun kept, when one is open. +fn begin_block(ctx: &mut Ctx<'_>, list: &Value, tags: &[Tag]) -> Value { + if ctx.env.pending_block.is_some() { + return Value::Nil; + } + let group = |code: i32| tags.iter().find(|t| t.code == code); + let flags = group(70).map(Tag::i64).unwrap_or(0); + let name = group(2).map(|t| t.str().trim().to_string()).unwrap_or_default(); + let anonymous = flags & 1 != 0; + if flags & 4 != 0 || !(anonymous && name.starts_with('*') || crate::cmd::blocks::valid_block_name(&name)) { + return Value::Nil; + } + let mut b = cadcraft_doc::Block::new(&name); + b.anonymous = anonymous; + b.base = cadcraft_geom::Vec3::new( + group(10).map(Tag::f64).unwrap_or(0.0), + group(20).map(Tag::f64).unwrap_or(0.0), + group(30).map(Tag::f64).unwrap_or(0.0), + ); + if !b.base.is_finite() { + return Value::Nil; + } + b.description = group(4).map(Tag::str).unwrap_or_default(); + ctx.env.pending_block = Some((b, Vec::new())); + list.clone() +} + +/// The most entities one `entmake` block definition takes (a runaway loop can't fill memory). +const MAX_PENDING_BLOCK_ENTITIES: usize = 1_000_000; + +/// `entmake` of an ENDBLK list: add the block begun to the drawing (redefining one of the same +/// name) and return its name, generated for an anonymous block. nil without one begun, or when +/// its entities reference the block itself. +fn end_block(ctx: &mut Ctx<'_>) -> Value { + let Some((mut b, ents)) = ctx.env.pending_block.take() else { return Value::Nil }; + let Ok(d) = ctx.s.doc_mut() else { return Value::Nil }; + if b.anonymous { + // Its kind letter (`*U`, `*X`…) is kept; a name that isn't one gets `*U`. + let prefix: String = b.name.chars().take(2).collect(); + let prefix = if prefix.chars().count() == 2 && prefix.starts_with('*') { prefix.as_str() } else { "*U" }; + b.name = d.anonymous_block_name(prefix); + } else { + // Block names are case-insensitive: a redefinition keeps the existing name. + if let Some(existing) = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&b.name)) { + b.name = existing.clone(); + } + // As BLOCK: a block being edited in place isn't redefined until REFCLOSE. + if d.refedit.as_ref().is_some_and(|r| r.block.eq_ignore_ascii_case(&b.name)) { + return Value::Nil; + } + } + // Named or anonymous (a routine can know the name an anonymous block will get), a block + // that inserts itself isn't added. + if crate::cmd::blocks::references_block(d, &ents, &b.name) { + return Value::Nil; + } + for mut e in ents { + d.ensure_layer(&e.common.layer); + e.handle = d.new_handle(); + b.entities.push(e); + } + let name = b.name.clone(); + d.blocks.insert(name.clone(), Arc::new(b)); + ctx.s.touch(); + Value::str(&name) +} + pub fn entmod(ctx: &mut Ctx<'_>, a: Vec) -> Result { arity(&a, 1, 1)?; let list = &a[0]; diff --git a/crates/engine/src/lisp/mod.rs b/crates/engine/src/lisp/mod.rs index 47dea46d..95bc556a 100644 --- a/crates/engine/src/lisp/mod.rs +++ b/crates/engine/src/lisp/mod.rs @@ -58,6 +58,9 @@ pub struct Env { pub order: Option<(usize, Arc>)>, /// `tblnext`'s place in each table. pub tblnext: HashMap, + /// A block definition `entmake` has begun (a BLOCK list) and not yet ended (ENDBLK): the + /// entities made meanwhile go into it, numbered when it ends. + pub pending_block: Option<(cadcraft_doc::Block, Vec)>, /// The command history's length after LISP last printed a line: when something else wrote since /// (a prompt, a command's echo), a leading newline only ends that line. pub printed_at: Option, @@ -75,6 +78,7 @@ impl Default for Env { deleted: HashMap::new(), order: None, tblnext: HashMap::new(), + pending_block: None, printed_at: None, }; builtins::install(&mut e); @@ -440,6 +444,12 @@ impl Session { /// A LISP error: run `*error*` when the routine defined one, else print it. fn lisp_error(&mut self, e: LispError) { let Some(mut run) = self.lisp.run.take() else { return }; + // A block definition the failed routine began is dropped: a later routine's entities + // would otherwise go into it instead of the drawing (#1662). A definition still spans + // runs that end normally. + if let Some(env) = self.lisp.envs.get_mut(&run.uid) { + env.pending_block = None; + } // The command a failed routine left running is cancelled. if self.running.is_some() { self.cancel(); @@ -614,3 +624,6 @@ fn file_key(name: &str) -> String { #[cfg(test)] mod tests; + +#[cfg(test)] +mod block_tests; diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index d6bd1a70..9b3b082c 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -4,7 +4,7 @@ use super::value::{Value, to_text}; use crate::Session; use crate::cmd::file::{EntityCodec, set_entity_codec}; -fn session() -> Session { +pub(super) fn session() -> Session { set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); Session::new() } diff --git a/docs/autolisp.md b/docs/autolisp.md index 910fc278..960fbf61 100644 --- a/docs/autolisp.md +++ b/docs/autolisp.md @@ -82,6 +82,14 @@ DXF file holds for the object, plus `-1` (its name) and `410` (its layout). Angl are in radians, as in AutoCAD, where the file holds degrees; `entmake`, `entmod` and `ssget` filters take radians too. +`entmake` defines blocks: a BLOCK list (2 name, 70 flags, 10 base point, 4 description) begins a +definition, the entities made after it go into the block instead of the drawing, and an ENDBLK +list adds the block (redefining one of the same name) and returns its name. Flag 1 with a `*U` +name makes an anonymous block, named when it ends. A BLOCK while a definition is open, an +xref (flag 4), an invalid name, a block that inserts itself and a block being edited in place +(REFEDIT) return nil. A definition stays open across routines until ENDBLK, but a routine that +fails drops the one it began. + ## Testing against the corpus The AutoLISP conformance corpus (#648) lives in its own repository. Point `CADCRAFT_LISP_CORPUS`