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:
parent
dacd8ece49
commit
4010b94919
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue