INSERT inserts exploded (*NAME) and drawing files (fixes #340) (#341)

At the block name prompt, *NAME inserts the block's objects: after the
insertion point INSERT asks one scale factor and the rotation, and no
attribute values. A path (C:\parts\bolt.dxf), NAME=path or NAME= reads
the drawing through the io hooks and defines a block named after the
file (or NAME) from its model space, with the file's $INSBASE as the
base point, adding the layers, linetypes, styles and blocks it needs and
keeping this drawing's definitions. A plain path whose block already
exists asks whether to redefine it; NAME=path redefines without asking.
A file that can't be read is reported at the name prompt. The JSON form
takes `file` (optional `name`) and "*NAME".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 23:32:29 +02:00 committed by GitHub
parent dacd8ece49
commit 4010b94919
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 491 additions and 21 deletions

View File

@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert", "Block...", run_insert)
.menu(&["Insert", "Block..."])
.alias(&["i", "-insert", "ddinsert"])
.params("{name, at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}")
.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("attdef", "Define Attributes...", run_attdef)
.menu(&["Draw", "Block", "Define Attributes..."])
@ -186,6 +186,7 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
}
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
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")?;
// `scale`: one factor, or [x, y] for different X and Y scale factors.
@ -414,11 +415,15 @@ impl BlockM {
}
}
mod insert_file;
/// Where the command-line INSERT is in its prompt sequence.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum InsStage {
#[default]
Name,
/// A file whose block already exists: redefine it from the file?
Redefine,
Point,
/// Basepoint: a new base point, picked on the block shown at its definition position.
BasePoint,
@ -444,13 +449,17 @@ struct InsertM {
rotation: Option<f64>,
attdefs: Vec<(String, String, String)>,
values: serde_json::Map<String, Value>,
/// `*name`: insert the block's objects (one uniform scale factor, no attribute values).
explode: bool,
/// The file waiting for the Redefine answer.
file: Option<insert_file::FileRef>,
}
impl InsertM {
/// After the insertion point or a scale/rotation answer: the next prompt still needed.
fn advance(&mut self, s: &mut Session) -> Result<Step> {
self.stage = if self.scale.is_none() {
InsStage::XScale
if self.explode { InsStage::PresetScale } else { InsStage::XScale }
} else if self.rotation.is_none() {
InsStage::Rotation
} else {
@ -460,12 +469,52 @@ 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)
{
insert_with(s, &n, a, sc, r, self.base, &self.values)?;
let h = insert_with(s, &n, a, sc, r, self.base, &self.values)?;
if self.explode {
insert_file::explode_insert(s, h)?;
}
return Ok(Step::Done);
}
Ok(Step::Continue)
}
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point.
fn choose(&mut self, s: &mut Session, name: &str) -> Result<()> {
let Some(b) = s.doc()?.block(name).cloned() else {
s.echo(format!("Block \"{name}\" not found."));
return Ok(());
};
self.attdefs = if self.explode {
Vec::new()
} else {
b.entities
.iter()
.filter_map(|e| {
if let EntityKind::AttDef(a) = &e.kind {
(!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone()))
} else {
None
}
})
.collect()
};
s.doc_mut()?.header.set_str("INSNAME", &b.name);
self.name = Some(b.name.clone());
self.stage = InsStage::Point;
Ok(())
}
/// Define the block from a file, then choose it; a file that can't be read is reported.
fn choose_file(&mut self, s: &mut Session, f: &insert_file::FileRef) -> Result<()> {
match f.define(s) {
Ok(name) => self.choose(s, &name),
Err(e) => {
s.echo(e.to_string());
Ok(())
}
}
}
/// Scale factors from an opposite corner: the X and Y distances from the insertion point.
fn corner(a: Vec2, p: Vec2) -> Result<Vec2> {
let d = p - a;
@ -492,6 +541,10 @@ impl Interactive for InsertM {
let p = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]);
if last.is_empty() { p } else { p.default(last) }
}
InsStage::Redefine => {
let name = self.file.as_ref().map(|f| f.name.clone()).unwrap_or_default();
Prompt::new(format!("Block \"{name}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N")
}
InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]),
InsStage::BasePoint => Prompt::new("Specify base point", Accept::POINT),
InsStage::PresetScale => Prompt::new("Specify scale factor for XYZ axes", Accept::NUMBER).default("1"),
@ -521,24 +574,27 @@ impl Interactive for InsertM {
s.echo(format!("Defined blocks: {}", names.join(", ")));
return Ok(Step::Continue);
}
let Some(b) = s.doc()?.block(&n).cloned() else {
s.echo(format!("Block \"{n}\" not found."));
return Ok(Step::Continue);
};
self.attdefs = b
.entities
.iter()
.filter_map(|e| {
if let EntityKind::AttDef(a) = &e.kind {
(!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone()))
} else {
None
}
})
.collect();
s.doc_mut()?.header.set_str("INSNAME", &b.name);
self.name = Some(b.name.clone());
self.stage = InsStage::Point;
// `*name`: insert exploded.
self.explode = n.starts_with('*');
let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string();
match insert_file::file_ref(s, &n) {
Some(f) if f.ask_redefine(s) => {
self.file = Some(f);
self.stage = InsStage::Redefine;
}
Some(f) => self.choose_file(s, &f)?,
None => self.choose(s, &n)?,
}
}
InsStage::Redefine => {
// Yes defines the block from the file; No (the default) inserts the existing block.
let Some(f) = self.file.take() else { return Ok(Step::Continue) };
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_lowercase().starts_with('y'));
if yes {
self.choose_file(s, &f)?;
} else {
self.choose(s, &f.name)?;
}
}
InsStage::Point => match i {
Input::Point(p) => {
@ -563,6 +619,10 @@ impl Interactive for InsertM {
_ => return Ok(Step::Continue),
};
self.scale = Some(Vec2::new(f, f));
// Exploded insertion asks its one scale factor after the insertion point.
if self.at.is_some() {
return self.advance(s);
}
self.stage = InsStage::Point;
}
InsStage::PresetRotation => {

View File

@ -0,0 +1,258 @@
//! INSERT of a drawing file (`-INSERT C:\parts\bolt.dxf`, `bolt=C:\parts\bolt.dwg`, JSON `file`)
//! and exploded insertion (`*name`).
//!
//! A file becomes a block named after it (or after the part before `=`): its model space, with
//! the file's `$INSBASE` as the base point, plus the layers, linetypes, styles and blocks it needs
//! (names this drawing already has keep this drawing's definitions). The file is read through the
//! host's io hooks like OPEN, so the engine stays I/O-agnostic.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, Handle};
use serde_json::{Value, json};
use super::super::file::io;
use super::super::modify::explode_kind;
use super::{references_block, valid_block_name};
use crate::cmd::{bad, str_param};
use crate::{EngineError, Result, Session};
/// A drawing file named at the block name prompt.
#[derive(Clone, Debug)]
pub(super) struct FileRef {
/// The block to define.
pub name: String,
pub path: String,
/// `name=path`: redefine the block without asking.
explicit: bool,
}
impl FileRef {
/// Whether to ask before redefining: a plain path whose block already exists.
pub fn ask_redefine(&self, s: &Session) -> bool {
!self.explicit && s.doc().is_ok_and(|d| d.block(&self.name).is_some())
}
/// Read the file and (re)define its block. Returns the block's name.
pub fn define(&self, s: &mut Session) -> Result<String> {
let src = read_drawing(&self.path)?;
define_from(s, &src, &self.name)
}
}
/// The file `t` names at the block name prompt: `name=path` (`name=` alone: the file `name`), or a
/// path (it has a folder separator or a .dwg/.dxf extension) that isn't the name of a block.
pub(super) fn file_ref(s: &Session, t: &str) -> Option<FileRef> {
let t = t.trim();
if let Some((name, path)) = t.split_once('=') {
let name = name.trim();
let path = if path.trim().is_empty() { name } else { path.trim() };
return (!name.is_empty()).then(|| FileRef { name: name.to_string(), path: path.to_string(), explicit: true });
}
let lower = t.to_ascii_lowercase();
let pathlike = t.contains(['/', '\\']) || lower.ends_with(".dwg") || lower.ends_with(".dxf");
if !pathlike || s.doc().is_ok_and(|d| d.block(t).is_some()) {
return None;
}
Some(FileRef { name: stem(t), path: t.to_string(), explicit: false })
}
/// The file name without folders and extension (either separator, so Windows paths work anywhere).
fn stem(path: &str) -> String {
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
match file.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
_ => file.to_string(),
}
}
/// Read a drawing file through the io hooks; a path without extension tries .dwg, then .dxf.
fn read_drawing(path: &str) -> Result<Drawing> {
let hooks = io().ok_or_else(|| bad("insert", "file formats are not available in this build"))?;
let has_ext = stem(path) != path.rsplit(['/', '\\']).next().unwrap_or(path);
let candidates = if has_ext { vec![path.to_string()] } else { vec![path.to_string(), format!("{path}.dwg"), format!("{path}.dxf")] };
let mut last = None;
for p in candidates {
match read_file(&p) {
Ok(bytes) => return (hooks.read)(&bytes, &p).map_err(|e| bad("insert", format!("{p}: {e}"))),
Err(e) => last = Some(e),
}
}
Err(last.unwrap_or_else(|| bad("insert", format!("{path}: file not found"))))
}
fn read_file(_path: &str) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
Err(bad("insert", "paths are not available on the web"))
}
/// (Re)define block `name` from `src`'s model space with base point `$INSBASE`, adding the
/// layers, linetypes, styles and blocks of `src` this drawing doesn't have. Nothing changes when
/// the definition fails (an invalid name, or a block that would contain itself).
fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
if !valid_block_name(name) {
return Err(bad("insert", format!("\"{name}\" is not a valid block name")));
}
let mut d = s.doc()?.clone();
// Redefining keeps the existing definition's name (block names are case-insensitive).
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
merge_tables(&mut d, src);
let renames = block_names(&d, src, &name);
for (from, to) in &renames {
let Some(b) = src.blocks.get(from) else { continue };
let mut nb = (**b).clone();
nb.name = to.clone();
nb.entities = Default::default();
for e in b.entities.iter() {
let e = adopt(&mut d, e, &renames);
nb.entities.push(e);
}
d.blocks.insert(to.clone(), Arc::new(nb));
}
let mut b = Block::new(&name);
b.base = src.header.point("INSBASE").filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()).unwrap_or_default();
let mut ents = Vec::new();
for e in src.model.iter() {
let e = adopt(&mut d, e, &renames);
d.ensure_layer(&e.common.layer);
ents.push(e);
}
if references_block(&d, &ents, &name) {
return Err(bad("insert", format!("Block \"{name}\" references itself.")));
}
for e in ents {
b.entities.push(e);
}
d.blocks.insert(name.clone(), Arc::new(b));
*s.doc_mut()? = d;
Ok(name)
}
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
/// exists keeps this drawing's definition).
fn merge_tables(d: &mut Drawing, src: &Drawing) {
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
for t in theirs {
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
ours.push(t.clone());
}
}
}
merge(&mut d.layers, &src.layers, |l| &l.name);
merge(&mut d.linetypes, &src.linetypes, |l| &l.name);
merge(&mut d.text_styles, &src.text_styles, |t| &t.name);
merge(&mut d.dim_styles, &src.dim_styles, |t| &t.name);
merge(&mut d.mleader_styles, &src.mleader_styles, |t| &t.name);
merge(&mut d.table_styles, &src.table_styles, |t| &t.name);
}
/// Which source blocks to copy, and under which name: new names keep theirs, generated
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
/// already has (or the block being defined) keeps this drawing's definition (not copied).
fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
taken.insert(defining.to_ascii_uppercase());
for (name, b) in &src.blocks {
let upper = name.to_ascii_uppercase();
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
continue;
}
let to = if !taken.contains(&upper) {
name.clone()
} else if b.anonymous || name.starts_with('*') {
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
Some(n) => n,
None => continue,
}
} else {
continue;
};
taken.insert(to.to_ascii_uppercase());
out.insert(name.clone(), to);
}
out
}
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
/// links to other source objects (associative dimension points) dropped.
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
let mut e = e.clone();
e.handle = d.new_handle();
match &mut e.kind {
EntityKind::Insert(i) => {
if let Some(n) = renames.get(&i.block) {
i.block = n.clone();
}
}
EntityKind::Dimension(dm) => {
dm.assoc.clear();
if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) {
dm.block = Some(n.clone());
}
}
_ => {}
}
e
}
/// Replace block reference `h` by the objects of its block (an exploded insertion).
pub(super) fn explode_insert(s: &mut Session, h: Handle) -> Result<Vec<Handle>> {
let space = s.space();
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
let parts = explode_kind(s.doc()?, &e).unwrap_or_default();
let d = s.doc_mut()?;
d.remove_entity(h);
let mut out = Vec::new();
for mut p in parts {
p.handle = d.new_handle();
out.push(p.handle);
if let Some(st) = d.space_mut(&space) {
st.push(p);
}
}
Ok(out)
}
/// The JSON INSERT parameters with `file` defined as a block and `*name` as `explode: true`.
pub(super) fn json_params(s: &mut Session, p: &Value) -> Result<Value> {
let mut p = p.clone();
let mut name = str_param(&p, "name").map(str::trim).map(str::to_string);
if let Some(n) = name.as_deref().and_then(|n| n.strip_prefix('*')) {
name = Some(n.trim().to_string());
p["explode"] = json!(true);
}
if let Some(path) = str_param(&p, "file").map(str::to_string) {
let n = name.clone().filter(|n| !n.is_empty()).unwrap_or_else(|| stem(&path));
name = Some(FileRef { name: n, path, explicit: true }.define(s)?);
}
if let Some(n) = name {
p["name"] = json!(n);
}
Ok(p)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_names_become_block_names() {
assert_eq!(stem("C:\\parts\\bolt.dxf"), "bolt");
assert_eq!(stem("/parts/nut.v2.DWG"), "nut.v2");
assert_eq!(stem("washer"), "washer");
let s = Session::new();
let f = file_ref(&s, "M8=parts/bolt.dwg").unwrap();
assert_eq!((f.name.as_str(), f.path.as_str(), f.explicit), ("M8", "parts/bolt.dwg", true));
let f = file_ref(&s, "bolt=").unwrap();
assert_eq!((f.name.as_str(), f.path.as_str()), ("bolt", "bolt"));
assert_eq!(file_ref(&s, "C:\\parts\\bolt.dxf").map(|f| f.name), Some("bolt".into()));
assert!(file_ref(&s, "BOLT").is_none());
}
}

View File

@ -0,0 +1,152 @@
//! INSERT of exploded blocks (`*NAME`) and of drawing files (`-INSERT path`, `NAME=path`, JSON
//! `file`), read through the io hooks (#340).
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::color::Color;
use cadcraft_engine::doc::geom::Vec3;
use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, HVal, Handle, Insert, Layer, Line};
use serde_json::json;
/// A drawing file in a test format: its bytes are "x,y", the $INSBASE. Model space: a line from
/// 10,10 to 20,10 on layer PARTS (colour 1), and a reference to block NUT (a circle) at 10,10.
fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
let text = String::from_utf8_lossy(bytes);
let (x, y) = text.trim().split_once(',').ok_or("bad test file")?;
let (x, y): (f64, f64) = (x.parse().map_err(|_| "x")?, y.parse().map_err(|_| "y")?);
let mut d = Drawing::new_imperial();
d.header.set("INSBASE", HVal::Point(Vec3::new(x, y, 0.0)));
let mut parts = Layer::new("PARTS");
parts.color = Color::Index(1);
d.layers.push(parts);
let mut nut = Block::new("NUT");
nut.entities.push(Entity::new(Handle(0x50), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 1.0 })));
d.blocks.insert("NUT".into(), std::sync::Arc::new(nut));
let mut line = Entity::new(Handle(0x60), EntityKind::Line(Line { a: Vec3::new(10.0, 10.0, 0.0), b: Vec3::new(20.0, 10.0, 0.0) }));
line.common.layer = "PARTS".into();
d.model.push(line);
let ins = Insert {
block: "NUT".into(),
insert: Vec3::new(10.0, 10.0, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
d.model.push(Entity::new(Handle(0x61), EntityKind::Insert(ins)));
Ok(d)
}
fn hooks() {
set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });
}
/// Write a test drawing file with $INSBASE `base`; returns its path.
fn file(name: &str, base: &str) -> String {
let dir = std::env::temp_dir().join(format!("cadcraft-insert-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join(name);
std::fs::write(&p, base).unwrap();
p.to_string_lossy().into_owned()
}
/// Lines in model space as (ax, ay, bx, by), rounded.
fn lines(s: &Session) -> Vec<(f64, f64, f64, f64)> {
let r = |v: f64| (v * 1e6).round() / 1e6;
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Line(l) => Some((r(l.a.x), r(l.a.y), r(l.b.x), r(l.b.y))),
_ => None,
})
.collect()
}
/// Block references in model space as (block, x, y).
fn inserts(s: &Session) -> Vec<(String, f64, f64)> {
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Insert(i) => Some((i.block.clone(), i.insert.x, i.insert.y)),
_ => None,
})
.collect()
}
#[test]
fn star_name_inserts_the_blocks_objects_with_one_scale_factor() {
let mut s = Session::new();
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
s.execute("block", &json!({ "name": "B1", "base": [0, 0], "keep": "delete", "handles": ["100"] })).unwrap();
// Insertion point, then one scale factor and the rotation; no block reference is left.
s.script("-INSERT *B1 5,5 2 90\n").unwrap();
assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text());
assert!(inserts(&s).is_empty());
assert_eq!(lines(&s), vec![(5.0, 5.0, 5.0, 25.0)]);
// A plain name after an exploded one inserts a block reference again.
s.script("-INSERT B1 0,0 1 1 0\n").unwrap();
assert_eq!(inserts(&s).len(), 1);
// JSON: "*NAME" explodes too.
s.execute("insert", &json!({ "name": "*B1", "at": [0, 50] })).unwrap();
assert_eq!(inserts(&s).len(), 1);
assert_eq!(lines(&s).last(), Some(&(0.0, 50.0, 10.0, 50.0)));
}
#[test]
fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() {
hooks();
let bolt = file("bolt.dxf", "10,10");
let mut s = Session::new();
// This drawing's own layer PARTS keeps its colour.
s.execute("layer.new", &json!({ "name": "PARTS", "color": 3 })).unwrap();
s.script(&format!("-INSERT {bolt} 100,100 1 1 0\n")).unwrap();
assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text());
let d = s.doc().unwrap();
let b = d.block("bolt").expect("block named after the file");
assert_eq!(b.base, Vec3::new(10.0, 10.0, 0.0), "$INSBASE is the base point");
assert_eq!(b.entities.len(), 2);
assert!(d.block("NUT").is_some(), "nested blocks come along");
assert_eq!(d.layer("PARTS").map(|l| l.color), Some(Color::Index(3)), "an existing layer keeps its definition");
assert_eq!(inserts(&s), vec![("bolt".into(), 100.0, 100.0)]);
// The file again: its block exists, so INSERT asks; No (the default) keeps the definition.
s.cmdline(&format!("-INSERT {bolt}")).unwrap();
assert!(s.prompt_text().contains("Redefine"), "{}", s.prompt_text());
s.script("N 0,0 1 1 0\n").unwrap();
assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text());
assert_eq!(inserts(&s).len(), 2);
// NAME=path defines the block under that name without asking; *path inserts the file's objects.
let pin = file("pin.dxf", "10,10");
s.script(&format!("-INSERT M8={bolt} 0,0 1 1 0\n-INSERT *{pin} 50,0 1 0\n")).unwrap();
assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text());
assert!(s.doc().unwrap().block("M8").is_some());
assert_eq!(inserts(&s).iter().filter(|i| i.0 == "M8").count(), 1);
// The exploded line, moved from the base point 10,10 to 50,0; the nested NUT stays a reference.
assert_eq!(lines(&s), vec![(50.0, 0.0, 60.0, 0.0)]);
assert!(inserts(&s).iter().any(|i| i.0 == "NUT" && i.1 == 50.0 && i.2 == 0.0));
// JSON: `file` with an optional block name, never a dialog.
let r = s.execute("insert", &json!({ "file": file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap();
assert!(r["handle"].is_string(), "{r}");
assert!(inserts(&s).iter().any(|i| i.0 == "washer"));
// A file that can't be read is reported; nothing is inserted.
let n = inserts(&s).len();
assert!(s.execute("insert", &json!({ "file": "/no/such/part.dxf", "at": [0, 0] })).is_err());
s.cmdline("-INSERT /no/such/part.dxf").unwrap();
assert!(s.log.iter().any(|m| m.contains("/no/such/part.dxf")), "{:?}", s.log.last());
assert!(s.prompt_text().contains("block name"), "{}", s.prompt_text());
assert_eq!(inserts(&s).len(), n);
}