diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index c25b7c913..81732a3f8 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -7,7 +7,7 @@ pub(in crate::cmd) mod purge; use std::sync::Arc; use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign}; -use cadcraft_geom::{Mat3, Vec2, Vec3}; +use cadcraft_geom::{Vec2, Vec3}; use serde_json::{Value, json}; use super::helpers::v3; @@ -24,6 +24,7 @@ mod description; mod dyngrip; mod extract; pub(crate) mod refedit; +mod wblock; pub fn specs() -> Vec { vec![ @@ -49,7 +50,8 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::new(attdef::AttdefM::default()))), attedit::attedit_spec(), attedit::dash_attedit_spec(), - CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"), + wblock::wblock_spec(), + wblock::dash_wblock_spec(), CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"), CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic) .params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)") @@ -362,61 +364,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handle": s.add_entity(k)?.hex() })) } -fn run_wblock(s: &mut Session, p: &Value) -> Result { - let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string(); - let src = s.doc()?; - let mut d = cadcraft_doc::Drawing::new_imperial(); - // The written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its - // units ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches - // and scaled on insert, and its defaults (text size, current styles, annotation scales). - d.header = src.header.clone(); - d.layers = src.layers.clone(); - d.linetypes = src.linetypes.clone(); - d.text_styles = src.text_styles.clone(); - d.dim_styles = src.dim_styles.clone(); - d.mleader_styles = src.mleader_styles.clone(); - d.table_styles = src.table_styles.clone(); - d.mline_styles = src.mline_styles.clone(); - d.scales = src.scales.clone(); - d.blocks = src.blocks.clone(); - // The base point becomes the written drawing's origin, its insertion base: by default 0,0 for - // objects and the block's base point for a block (`base` is then in block coordinates). - let base = point_param(p, "base"); - d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO)); - let m = Mat3::translate(-base.unwrap_or(Vec2::ZERO)); - let ents: Vec = if let Some(name) = str_param(p, "name") { - let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?; - let m = Mat3::translate(-base.unwrap_or(b.base.xy())); - b.entities - .iter() - .map(|e| { - let mut e = (**e).clone(); - e.kind.transform(&m); - e - }) - .collect() - } else { - targets(s, p)? - .iter() - .filter_map(|h| src.entity(*h)) - .map(|e| { - let mut e = (**e).clone(); - e.kind.transform(&m); - e - }) - .collect() - }; - for mut e in ents { - e.handle = d.new_handle(); - d.model.push(e); - } - let hooks = super::file::io().ok_or_else(|| bad("wblock", "file formats are not available in this build"))?; - let bytes = (hooks.write)(&d, &path).map_err(|e| bad("wblock", e))?; - #[cfg(not(target_arch = "wasm32"))] - std::fs::write(&path, &bytes).map_err(|e| bad("wblock", format!("{path}: {e}")))?; - Ok(json!({ "path": path, "entities": d.model.len(), "bytes": bytes.len() })) -} - fn run_base(s: &mut Session, p: &Value) -> Result { let at = point_req("base", p, "at")?; s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at))); diff --git a/crates/engine/src/cmd/blocks/insert_file.rs b/crates/engine/src/cmd/blocks/insert_file.rs index 4d0af93b3..1a90502b5 100644 --- a/crates/engine/src/cmd/blocks/insert_file.rs +++ b/crates/engine/src/cmd/blocks/insert_file.rs @@ -59,7 +59,7 @@ pub(super) fn file_ref(s: &Session, t: &str) -> Option { } /// The file name without folders and extension (either separator, so Windows paths work anywhere). -fn stem(path: &str) -> String { +pub(super) 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(), diff --git a/crates/engine/src/cmd/blocks/wblock.rs b/crates/engine/src/cmd/blocks/wblock.rs new file mode 100644 index 000000000..03356269e --- /dev/null +++ b/crates/engine/src/cmd/blocks/wblock.rs @@ -0,0 +1,323 @@ +//! WBLOCK (its JSON form) and -WBLOCK at the command line: objects, a block or the whole drawing +//! written to a drawing file. + +use cadcraft_doc::{Drawing, Entity, Handle}; +use cadcraft_geom::{Mat3, Vec2, Vec3}; +use serde_json::{Value, json}; + +use super::super::curves::KW; +use super::super::machines::{SelOutcome, SelectPhase}; +use super::super::{CommandSpec, bad, point_param, str_param, targets}; +use super::insert_file::stem; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +pub(super) fn wblock_spec() -> CommandSpec { + CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}") +} + +/// -WBLOCK erases the objects it writes, whatever its mOde option says: AutoCAD 2024 does, and +/// keeps "Object conversion=Delete" as the setting. +pub(super) fn dash_wblock_spec() -> CommandSpec { + CommandSpec::new("-wblock", "Write Block", run_dash_wblock) + .params("{path, name? | handles?, base?} (the objects written are erased)") + .interactive(|_| Ok(Box::new(DashWblockM::default()))) +} + +/// The web build has no file system: -WBLOCK would erase objects it can't save. +const NO_FILES: &str = "files can't be written in the web build"; + +/// What WBLOCK writes. +enum Source<'a> { + /// These objects, `base` becoming the written drawing's origin. + Objects(&'a [Handle], Vec2), + /// A block's objects, from its base point or `base` (in block coordinates). + Block(&'a str, Option), + /// The whole drawing. + Whole, +} + +fn run_wblock(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string(); + let base = point_param(p, "base"); + let (entities, bytes) = match str_param(p, "name") { + Some(name) => write(s, "wblock", &path, Source::Block(name, base))?, + None => write(s, "wblock", &path, Source::Objects(&targets(s, p)?, base.unwrap_or(Vec2::ZERO)))?, + }; + Ok(json!({ "path": path, "entities": entities, "bytes": bytes })) +} + +fn run_dash_wblock(s: &mut Session, p: &Value) -> Result { + if cfg!(target_arch = "wasm32") { + return Err(bad("-wblock", NO_FILES)); + } + let path = str_param(p, "path").ok_or_else(|| bad("-wblock", "`path` is required"))?.to_string(); + let base = point_param(p, "base"); + let (entities, bytes) = match str_param(p, "name") { + Some(name) => write(s, "-wblock", &path, Source::Block(name, base))?, + None => { + let hs = targets(s, p)?; + // As at the command line, nothing selected writes no file. + if !hs.iter().any(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())) { + return Err(bad("-wblock", "no objects selected")); + } + let written = write(s, "-wblock", &path, Source::Objects(&hs, base.unwrap_or(Vec2::ZERO)))?; + erase(s, &hs)?; + written + } + }; + Ok(json!({ "path": path, "entities": entities, "bytes": bytes })) +} + +/// Write `what` to `path` through the io hooks; the objects written and the file's size. The +/// written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its units +/// ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches and +/// scaled on insert, and its defaults (text size, current styles, annotation scales). +fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usize)> { + let src = s.doc()?; + // The base point becomes the written drawing's origin: by default 0,0 for objects and the + // block's base point for a block. + let d = match what { + Source::Whole => src.clone(), + Source::Block(name, base) => { + let b = src.block(name).ok_or_else(|| bad(cmd, format!("no block `{name}`")))?; + let m = Mat3::translate(-base.unwrap_or(b.base.xy())); + with_objects(src, b.entities.iter().map(|e| moved(e, &m))) + } + Source::Objects(hs, base) => { + let m = Mat3::translate(-base); + with_objects(src, hs.iter().filter_map(|h| src.entity(*h)).map(|e| moved(e, &m))) + } + }; + let hooks = super::super::file::io().ok_or_else(|| bad(cmd, "file formats are not available in this build"))?; + let bytes = (hooks.write)(&d, path).map_err(|e| bad(cmd, e))?; + // Through a temporary file, as SAVE writes, so a failed write keeps the file it replaces. + #[cfg(not(target_arch = "wasm32"))] + crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + Ok((d.model.len(), bytes.len())) +} + +/// A new drawing of `ents`, with `src`'s settings, styles and blocks; its insertion base 0,0. +fn with_objects(src: &Drawing, ents: impl Iterator) -> Drawing { + let mut d = Drawing::new_imperial(); + d.header = src.header.clone(); + d.layers = src.layers.clone(); + d.linetypes = src.linetypes.clone(); + d.text_styles = src.text_styles.clone(); + d.dim_styles = src.dim_styles.clone(); + d.mleader_styles = src.mleader_styles.clone(); + d.table_styles = src.table_styles.clone(); + d.mline_styles = src.mline_styles.clone(); + d.scales = src.scales.clone(); + d.blocks = src.blocks.clone(); + d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO)); + for mut e in ents { + e.handle = d.new_handle(); + d.model.push(e); + } + d +} + +fn moved(e: &Entity, m: &Mat3) -> Entity { + let mut e = e.clone(); + e.kind.transform(m); + e +} + +fn erase(s: &mut Session, hs: &[Handle]) -> Result<()> { + let doc = s.doc_mut()?; + for h in hs { + doc.remove_entity(*h); + } + s.set_selection(Vec::new()); + Ok(()) +} + +/// The file -WBLOCK writes for a typed name: as named when it ends in .dwg or .dxf, else with +/// .dwg added, as AutoCAD names a drawing. +fn output_path(typed: &str) -> String { + let t = typed.trim().trim_matches('"'); + let lower = t.to_ascii_lowercase(); + if lower.ends_with(".dwg") || lower.ends_with(".dxf") { t.to_string() } else { format!("{t}.dwg") } +} + +/// Whether a file is there to replace; the web has no file system. +fn exists(path: &str) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + std::path::Path::new(path).exists() + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + false + } +} + +/// Where -WBLOCK is in its prompts, as AutoCAD 2024 asks them. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Ask { + #[default] + File, + /// The file exists: "… already exists, do you want to replace it? [Yes/No] ". + Replace, + /// A DXF file's decimal places of accuracy. + Precision, + /// An existing block, `=` (the block named as the file), `*` (the whole drawing), or Enter + /// for objects. + Block, + Base, + /// mOde at the base point prompt: asked, but the objects are erased whatever it says. + Mode, + Objects, +} + +const SAME_NAME: &str = "= (block=output file)"; +const WHOLE: &str = "* (whole drawing)"; + +#[derive(Default)] +struct DashWblockM { + ask: Ask, + path: String, + base: Vec2, + sel: SelectPhase, +} + +impl Interactive for DashWblockM { + fn name(&self) -> &'static str { + "WBLOCK" + } + fn begin(&mut self, s: &mut Session) -> Result { + if cfg!(target_arch = "wasm32") { + s.echo(format!("-WBLOCK: {NO_FILES}.")); + return Ok(Step::Cancel); + } + s.echo("Current settings: Object conversion=Delete"); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.ask { + Ask::File => Prompt::new("Enter name of output file", Accept::TEXT), + Ask::Replace => Prompt::new(format!("{} already exists, do you want to replace it?", self.path), KW).kw(&["Yes", "No"]).default("N"), + Ask::Precision => Prompt::new("Enter decimal places of accuracy (0 to 16)", Accept::NUMBER).kw(&["Binary", "Preview"]).default("16"), + Ask::Block => Prompt::new("Enter name of existing block", Accept::TEXT).kw(&[SAME_NAME, WHOLE]).default("define new drawing"), + Ask::Base => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["mOde"]), + Ask::Mode => { + Prompt::new("Choose mode for selected object conversion", KW).kw(&["Convert to block", "Retain", "Delete"]).default("Delete") + } + Ask::Objects => self.sel.prompt(), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.ask { + Ask::File => { + if let Input::Text(t) = &i + && !t.trim().trim_matches('"').is_empty() + { + self.path = output_path(t); + self.ask = if exists(&self.path) { Ask::Replace } else { self.after_file(s) }; + } + Ok(Step::Continue) + } + Ask::Replace => match &i { + // Yes goes on; No, the default, ends the command; anything else is asked again. + Input::Keyword(k) if k == "Yes" => { + self.ask = self.after_file(s); + Ok(Step::Continue) + } + Input::Keyword(_) | Input::Enter => Ok(Step::Done), + _ => { + s.echo("Invalid option keyword."); + Ok(Step::Continue) + } + }, + Ask::Precision => { + match &i { + Input::Enter => self.ask = Ask::Block, + // The file is written at full precision whatever is asked. + Input::Text(t) if t.trim().parse::().is_ok_and(|n| n <= 16) => self.ask = Ask::Block, + Input::Keyword(k) if k == "Binary" => s.echo("Binary DXF output is not available."), + Input::Keyword(_) => {} + _ => s.echo("Requires an integer between 0 and 16."), + } + Ok(Step::Continue) + } + Ask::Block => { + let name = match &i { + Input::Enter => { + self.ask = Ask::Base; + return Ok(Step::Continue); + } + Input::Keyword(k) if k == WHOLE => return self.write(s, Source::Whole), + Input::Keyword(k) if k == SAME_NAME => stem(&self.path), + Input::Text(t) => t.trim().to_string(), + // A point (from a LISP list) is a name too: AutoCAD answers Block "0,0" not found. + Input::Point(p) => format!("{},{}", p.x, p.y), + _ => return Ok(Step::Continue), + }; + if s.doc()?.block(&name).is_none() { + s.echo(format!("Block \"{name}\" not found.")); + return Ok(Step::Continue); + } + self.write(s, Source::Block(&name, None)) + } + Ask::Base => { + match i { + Input::Keyword(_) => self.ask = Ask::Mode, + Input::Enter | Input::Text(_) => s.echo("Point or option keyword required."), + Input::Point(p) => { + self.base = p; + self.sel = SelectPhase::begin(s); + if self.sel.done { + let picked = self.sel.picked.clone(); + return self.write_objects(s, &picked); + } + self.ask = Ask::Objects; + } + _ => {} + } + Ok(Step::Continue) + } + Ask::Mode => { + // Any answer, Enter included, goes back to the base point. + self.ask = Ask::Base; + Ok(Step::Continue) + } + Ask::Objects => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + // Nothing selected: nothing is written. + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => self.write_objects(s, &hs), + }, + } + } +} + +impl DashWblockM { + /// The prompt after the file name: a DXF file's precision, else the block. + fn after_file(&self, s: &mut Session) -> Ask { + if self.path.to_ascii_lowercase().ends_with(".dxf") { + s.echo("Current DXF settings: Precision= 16 Format= ASCII Preview= No"); + Ask::Precision + } else { + Ask::Block + } + } + + fn write(&self, s: &mut Session, what: Source) -> Result { + if let Err(e) = write(s, "-wblock", &self.path, what) { + s.echo(e.to_string()); + } + Ok(Step::Done) + } + + fn write_objects(&self, s: &mut Session, hs: &[Handle]) -> Result { + if hs.is_empty() { + return Ok(Step::Done); + } + match write(s, "-wblock", &self.path, Source::Objects(hs, self.base)) { + Ok(_) => erase(s, hs)?, + Err(e) => s.echo(e.to_string()), + } + Ok(Step::Done) + } +} diff --git a/crates/engine/tests/dash_wblock.rs b/crates/engine/tests/dash_wblock.rs new file mode 100644 index 000000000..348cde45a --- /dev/null +++ b/crates/engine/tests/dash_wblock.rs @@ -0,0 +1,244 @@ +//! -WBLOCK at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts, the +//! drawing read back afterwards). It writes objects from a base point and erases them, whatever +//! its mOde option says; a block by name, `=` (the block named as the file) or `*` (the whole +//! drawing); asks before replacing a file; and writes nothing when nothing is selected. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::{Drawing, EntityKind, Handle}; +use serde_json::json; + +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +/// One line per object written: a line's start (where the base point puts it), else its type. +fn write(d: &Drawing, _path: &str) -> Result, String> { + let mut out = String::new(); + for e in d.model.iter() { + match &e.kind { + EntityKind::Line(l) => out.push_str(&format!("LINE {},{}\n", l.a.x, l.a.y)), + k => out.push_str(&format!("{}\n", k.dxf_name())), + } + } + Ok(out.into_bytes()) +} + +/// A session writing through [`write`], with the drawing the measured scripts start from: line A +/// (0,0)-(10,0), circle B at (5,5) radius 3, line C (0,20)-(10,20). +fn drawing() -> (Session, Handle, Handle, Handle) { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + let handle = |v: serde_json::Value| Handle::parse_hex(v["handles"][0].as_str().or(v["handle"].as_str()).unwrap()).unwrap(); + let a = handle(s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap()); + let b = handle(s.execute("circle", &json!({ "center": [5, 5], "radius": 3 })).unwrap()); + let c = handle(s.execute("line", &json!({ "points": [[0, 20], [10, 20]] })).unwrap()); + (s, a, b, c) +} + +/// A file in a test's own folder, with forward slashes for a LISP string. +fn file(dir: &TempDir, name: &str) -> String { + dir.path_str(name).replace('\\', "/") +} + +fn lisp(s: &mut Session, expr: &str) { + s.cmdline(expr).unwrap(); +} + +fn ent(h: Handle) -> String { + format!("(handent \"{}\")", h.hex()) +} + +fn alive(s: &Session, h: Handle) -> bool { + s.doc().unwrap().entity(h).is_some() +} + +fn read(path: &str) -> Vec { + std::fs::read_to_string(path).unwrap().lines().map(str::to_string).collect() +} + +#[test] +fn objects_are_written_from_the_base_point_and_erased() { + // WBLOCK-000: A and B from (5,5); AutoCAD erases both and keeps C. + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + let path = file(&dir, "WB00.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" '(5.0 5.0) {} {} \"\")", ent(a), ent(b))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]); + assert!(!alive(&s, a) && !alive(&s, b) && alive(&s, c)); + assert!(s.log.iter().any(|l| l == "Current settings: Object conversion=Delete"), "{:?}", s.log); +} + +#[test] +fn the_prompts_are_autocads() { + let (mut s, a, _, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.cmdline("-wblock").unwrap(); + assert!(s.prompt_text().ends_with("Enter name of output file:"), "{}", s.prompt_text()); + s.cmdline(&file(&dir, "P.dwg")).unwrap(); + assert!( + s.prompt_text().ends_with("Enter name of existing block or [= (block=output file)/* (whole drawing)] :"), + "{}", + s.prompt_text() + ); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("Specify insertion base point or [mOde]:"), "{}", s.prompt_text()); + // WBLOCK-008: anything but a point or mOde there. + s.cmdline("").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Point or option keyword required.")); + s.cmdline("_O").unwrap(); + assert!(s.prompt_text().ends_with("[Convert to block/Retain/Delete] :"), "{}", s.prompt_text()); + s.cmdline("_R").unwrap(); + assert!(s.prompt_text().ends_with("Specify insertion base point or [mOde]:"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + assert!(s.prompt_text().ends_with("Select objects:"), "{}", s.prompt_text()); + lisp(&mut s, &ent(a)); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); +} + +#[test] +fn mode_is_asked_but_the_objects_are_erased_whatever_it_says() { + // WBM-01, WBM-02: Retain and Convert to block are taken, yet A is erased (no block reference + // takes its place), and the next -WBLOCK still says "Object conversion=Delete". + for mode in ["_R", "_C"] { + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + let first = file(&dir, "M01a.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{first}\" \"\" \"_O\" \"{mode}\" '(0.0 0.0) {} \"\")", ent(a))); + assert!(s.running.is_none(), "{mode}: {}", s.prompt_text()); + assert_eq!(read(&first), ["LINE 0,0"]); + assert!(!alive(&s, a) && alive(&s, b) && alive(&s, c), "{mode}"); + assert_eq!(s.doc().unwrap().model.len(), 2, "{mode}: nothing takes A's place"); + let before = s.log.len(); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(0.0 0.0) {} \"\")", file(&dir, "M01b.dwg"), ent(b))); + assert!(s.log[before..].iter().any(|l| l == "Current settings: Object conversion=Delete"), "{mode}: {:?}", s.log); + assert!(!alive(&s, b) && alive(&s, c), "{mode}"); + } +} + +#[test] +fn nothing_selected_writes_nothing() { + // WBM-05: the command ends; no file, nothing erased. + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + let path = file(&dir, "M05.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" '(0.0 0.0) \"\")")); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(!std::path::Path::new(&path).exists()); + assert!(alive(&s, a) && alive(&s, b) && alive(&s, c)); +} + +#[test] +fn an_existing_file_is_replaced_only_on_yes() { + // WBM-06: No (the default) ends the command; WBM-07: Yes goes on to the block prompt. + let (mut s, a, b, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + let path = file(&dir, "M06.dwg"); + std::fs::write(&path, "old").unwrap(); + s.cmdline("-wblock").unwrap(); + s.cmdline(&path).unwrap(); + assert!(s.prompt_text().ends_with(&format!("{path} already exists, do you want to replace it? [Yes/No] :")), "{}", s.prompt_text()); + // Only Yes replaces it: other text is asked again. + s.cmdline("yodel").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword.")); + assert!(s.prompt_text().ends_with("[Yes/No] :"), "{}", s.prompt_text()); + s.cmdline("_N").unwrap(); + assert!(s.running.is_none()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old"); + // Enter is No too. + s.cmdline("-wblock").unwrap(); + s.cmdline(&path).unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"_Y\" \"\" '(0.0 0.0) {} \"\")", ent(b))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["CIRCLE"]); + assert!(alive(&s, a) && !alive(&s, b)); +} + +#[test] +fn a_block_is_written_by_name_by_equals_or_the_whole_drawing_by_star() { + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.execute("block", &json!({ "name": "WB01K", "base": [5, 5], "handles": [a.hex(), b.hex()], "keep": "delete" })).unwrap(); + // WBLOCK-001: a block by name, from its base point; nothing is erased. + let path = file(&dir, "WB01.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"WB01K\")")); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]); + assert!(alive(&s, c)); + // WBLOCK-002: "=" is the block named as the file. + let path = file(&dir, "WB01K.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"=\")")); + assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]); + // WBLOCK-003: "*" is the whole drawing. + let path = file(&dir, "WB03.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"*\")")); + assert_eq!(read(&path), ["LINE 0,20"]); + assert!(alive(&s, c)); + // WBLOCK-005: a point given there is a name too. + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" '(0.0 0.0))", file(&dir, "WB05.dwg"))); + assert!(s.log.iter().any(|l| l == "Block \"0,0\" not found."), "{:?}", s.log); + s.cancel(); + // WBLOCK-008, WBLOCK-015: a name that isn't a block, typed or by "=", is refused and asked again. + for (answer, name) in [("WB08ZZ", "WB08ZZ"), ("=", "WB15")] { + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"{answer}\")", file(&dir, "WB15.dwg"))); + assert!(s.log.iter().any(|l| *l == format!("Block \"{name}\" not found.")), "{answer}: {:?}", s.log); + assert!(s.prompt_text().contains("Enter name of existing block"), "{}", s.prompt_text()); + s.cancel(); + } + assert!(!std::path::Path::new(&file(&dir, "WB15.dwg")).exists()); +} + +#[test] +fn a_name_without_extension_is_a_dwg_and_a_dxf_asks_for_its_precision() { + // WBLOCK-004: no extension. + let (mut s, a, b, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(0.0 0.0) {} \"\")", file(&dir, "WB04"), ent(a))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&file(&dir, "WB04.dwg")), ["LINE 0,0"]); + // WBLOCK-010: a DXF file asks for its decimal places first; Enter takes 16. + let path = file(&dir, "WB10.dxf"); + s.cmdline("-wblock").unwrap(); + s.cmdline(&path).unwrap(); + assert!(s.log.iter().any(|l| l.starts_with("Current DXF settings: Precision= 16 Format= ASCII")), "{:?}", s.log); + assert!(s.prompt_text().ends_with("Enter decimal places of accuracy (0 to 16) or [Binary/Preview] <16>:"), "{}", s.prompt_text()); + s.cancel(); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" \"\" '(0.0 0.0) {} \"\")", ent(b))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["CIRCLE"]); + assert!(!alive(&s, b)); +} + +#[test] +fn undo_brings_the_written_objects_back() { + // WBM-09: U after -WBLOCK. + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(5.0 5.0) {} {} \"\")", file(&dir, "M09.dwg"), ent(a), ent(b))); + assert!(!alive(&s, a) && !alive(&s, b)); + s.cmdline("u").unwrap(); + assert!(alive(&s, a) && alive(&s, b) && alive(&s, c)); +} + +#[test] +fn the_json_form_of_dash_wblock_erases_what_it_writes() { + let (mut s, a, b, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.execute("wblock", &json!({ "path": file(&dir, "keep.dwg"), "handles": [a.hex()] })).unwrap(); + assert!(alive(&s, a), "WBLOCK's JSON form keeps them"); + s.execute("-wblock", &json!({ "path": file(&dir, "erase.dwg"), "handles": [b.hex()], "base": [5, 5] })).unwrap(); + assert_eq!(read(&file(&dir, "erase.dwg")), ["CIRCLE"]); + assert!(!alive(&s, b)); + // Nothing selected writes no file, as at the command line. + let none = file(&dir, "none.dwg"); + assert!(s.execute("-wblock", &json!({ "path": none, "handles": [] })).is_err()); + assert!(s.execute("-wblock", &json!({ "path": none, "handles": [b.hex()] })).is_err(), "erased already"); + assert!(s.execute("-wblock", &json!({ "path": none })).is_err()); + assert!(!std::path::Path::new(&none).exists()); +}