-WBLOCK at the command line, as AutoCAD runs it (#2594)

* -WBLOCK at the command line, as AutoCAD runs it

WBLOCK had only its JSON form, so a script's -WBLOCK did nothing: the
objects it should write and erase stayed in the drawing. This adds the
command-line command, with AutoCAD 2024's prompts:

- "Current settings: Object conversion=Delete", then the output file. A
  name without .dwg or .dxf gets .dwg. A DXF file asks for its decimal
  places first. A file that exists asks "... already exists, do you want
  to replace it? [Yes/No] <N>"; No ends the command.
- An existing block by name, "=" (the block named as the file), "*" (the
  whole drawing), or Enter for objects. A name that isn't a block is
  answered Block "NAME" not found. and asked again.
- For objects: the base point (with mOde), then the objects, which are
  written and erased. AutoCAD erases them whatever mOde says, and keeps
  Delete as the setting. Nothing selected writes nothing.

Its JSON form, -wblock, erases what it writes. WBLOCK's JSON form moves
into the new module unchanged, sharing the writer. The web build refuses
-WBLOCK rather than erase objects it can't save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* -WBLOCK's JSON form: nothing to write writes no file

With no handles, an empty selection or only handles of objects that
aren't there, the JSON form wrote an empty drawing. It now refuses with
"no objects selected" before writing or erasing anything, as the command
line writes nothing when nothing is selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* -WBLOCK: only Yes replaces an existing file

At "... already exists, do you want to replace it? [Yes/No] <N>", any
text starting with "y" counted as Yes, so "yodel" replaced the file, and
any other text ended the command. Now only the Yes keyword replaces it,
No and Enter end the command, and anything else gets "Invalid option
keyword." and the prompt again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WBLOCK and -WBLOCK write their file through a temporary file

Replacing an existing file truncated it first, so a write that failed
part way lost it. The file now goes through atomic_write::write_atomic,
as SAVE, EXPORT and autosave write theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2ez4gcx
2026-11-01 14:37:52 -05:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 520394858c
commit 210cfc2a1d
4 changed files with 572 additions and 58 deletions
+4 -57
View File
@@ -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<CommandSpec> {
vec![
@@ -49,7 +50,8 @@ pub fn specs() -> Vec<CommandSpec> {
.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<Value> {
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
}
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
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<Entity> = 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<Value> {
let at = point_req("base", p, "at")?;
s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at)));
+1 -1
View File
@@ -59,7 +59,7 @@ pub(super) fn file_ref(s: &Session, t: &str) -> Option<FileRef> {
}
/// 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(),
+323
View File
@@ -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<Vec2>),
/// The whole drawing.
Whole,
}
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Item = Entity>) -> 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] <N>".
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<Step> {
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<Step> {
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::<u8>().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<Step> {
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<Step> {
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)
}
}
+244
View File
@@ -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<Vec<u8>, 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<String> {
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)] <define new 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] <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] <N>:")), "{}", 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] <N>:"), "{}", 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());
}