Block Editor: BEDIT, BSAVE, BSAVEAS and BCLOSE for static blocks (#824)
* Block Editor: BEDIT, BSAVE, BSAVEAS and BCLOSE for static blocks BEDIT opens a block definition in the drawing's own tab with its base point at the origin, and the block's objects are edited with the ordinary commands. BSAVE writes them back into the definition, so every reference in the drawing updates (one undo step in the drawing); BSAVEAS saves under a new name; BCLOSE goes back to the drawing, asking whether to save unsaved changes. - The drawing waits untouched while the editor is open (blockedit.rs): its undo history, selection and views come back on BCLOSE. Objects left unchanged are saved back exactly, so no coordinate drifts. - Files save the drawing outside the editor; autosave keeps the editor's unsaved changes in the block; the close/quit prompt's Save puts them into the block first. - Right-click a selected block reference: Block Editor. In the editor the canvas is tinted, the base point is marked, and a bar offers Save Block, Save Block As and Close Block Editor (also in the right-click menu). The Block tool set's Block Editor button is now live. BLOCKEDITOR sysvar. - Layout and paper-space commands are refused while the editor is open; a block can't be saved to contain a reference to itself. - Dynamic blocks: their data is kept; the editor says it can't edit their parameters yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * BEDIT: find dynamic block data on the block's BLOCK_RECORD A dynamic block's ACAD_ENHANCEDBLOCK dictionary hangs off its BLOCK_RECORD table entry, not the BLOCK entity, so the "parameters are kept" note never showed for AutoCAD files. Regression test with the data laid out as an AutoCAD DXF has it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * BEDIT: a dynamic block reference opens its dynamic block A dynamic block reference with values of its own shows an anonymous block (`*U12`), so the right-click Block Editor (and BEDIT on a picked or selected reference) refused it and asked for a name. The anonymous block's record names its dynamic block in AcDbBlockRepBTag xdata; BEDIT now opens that, as AutoCAD does. Regression test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
62fb5ae79a
commit
767576ec9e
|
|
@ -109,6 +109,9 @@ pub enum Job {
|
|||
struct Tracked {
|
||||
/// The drawing as last autosaved (a weak pointer: compared, never kept alive).
|
||||
written: Option<Weak<Drawing>>,
|
||||
/// The drawing outside the Block Editor as last autosaved (the same as `written` when the
|
||||
/// editor is closed): saving the editor's changes into the block changes only this.
|
||||
host: Option<Weak<Drawing>>,
|
||||
/// Its autosave file, once written (or adopted).
|
||||
file: Option<String>,
|
||||
/// When a change not yet autosaved was first seen (seconds).
|
||||
|
|
@ -150,8 +153,9 @@ impl Autosaver {
|
|||
/// Recovery, whose current state it holds): later autosaves overwrite it, and it is deleted
|
||||
/// once the drawing is saved or closed.
|
||||
pub fn adopt(&mut self, s: &Session, uid: u64, path: &str) {
|
||||
let written = s.docs.iter().find(|d| d.uid == uid).map(|d| Arc::downgrade(&d.doc));
|
||||
self.docs.insert(uid, Tracked { written, file: Some(path.to_string()), since: None });
|
||||
let st = s.docs.iter().find(|d| d.uid == uid);
|
||||
let (written, host) = (st.map(|d| Arc::downgrade(&d.doc)), st.map(|d| Arc::downgrade(d.drawing())));
|
||||
self.docs.insert(uid, Tracked { written, host, file: Some(path.to_string()), since: None });
|
||||
}
|
||||
|
||||
/// The autosave files this app has written or adopted.
|
||||
|
|
@ -194,7 +198,8 @@ impl Autosaver {
|
|||
*t = Tracked::default();
|
||||
continue;
|
||||
}
|
||||
let changed = t.written.as_ref().is_none_or(|w| !std::ptr::eq(w.as_ptr(), Arc::as_ptr(&st.doc)));
|
||||
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
|
||||
let changed = !same(&t.written, &st.doc) || !same(&t.host, st.drawing());
|
||||
if !changed {
|
||||
t.since = None;
|
||||
continue;
|
||||
|
|
@ -209,8 +214,10 @@ impl Autosaver {
|
|||
.unwrap_or_else(|| std::path::Path::new(dir).join(file_name(&st.title, &self.instance, st.uid)).to_string_lossy().to_string());
|
||||
t.file = Some(path.clone());
|
||||
t.written = Some(Arc::downgrade(&st.doc));
|
||||
t.host = Some(Arc::downgrade(st.drawing()));
|
||||
t.since = None;
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc: st.doc.clone() });
|
||||
// In the Block Editor: the drawing with the editor's unsaved changes in the block.
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
|
||||
}
|
||||
jobs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
//! The Block Editor's state (BEDIT). While it is open, the drawing's tab shows the block's objects
|
||||
//! as its model space, moved so that the block's base point is at the origin, and every command
|
||||
//! edits them as it would any drawing. The drawing itself waits, untouched, with its undo history,
|
||||
//! selection and views: BSAVE writes the objects back into the block definition (so every
|
||||
//! reference shows them), BCLOSE puts the drawing back. The commands are in
|
||||
//! `cmd::blocks::bedit`.
|
||||
//!
|
||||
//! Layer, style and block table changes made in the editor belong to the drawing too, as in
|
||||
//! AutoCAD: saving keeps the editor's tables and only puts back what is the drawing's own (its
|
||||
//! model and paper space objects, groups, constraints, tiled viewports and hidden objects).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, EntityStore};
|
||||
use cadcraft_geom::{Mat3, Vec2};
|
||||
|
||||
use crate::{DocState, EngineError, Result, Snapshot};
|
||||
|
||||
/// The undo step a save into the block records in the drawing.
|
||||
const UNDO_LABEL: &str = "Block Editor";
|
||||
|
||||
/// The Block Editor open in a drawing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BlockEdit {
|
||||
/// The block being edited, as the drawing names it.
|
||||
pub name: String,
|
||||
/// The drawing outside the editor: its drawing, undo history, selection and views, put back
|
||||
/// by BCLOSE. Its own `block_edit` is always `None`.
|
||||
pub host: Box<DocState>,
|
||||
/// The editor's drawing as last saved into the block; later changes are unsaved.
|
||||
pub saved: Arc<Drawing>,
|
||||
/// The block's objects as the drawing has them (block coordinates): objects left unchanged in
|
||||
/// the editor are saved back exactly as they were.
|
||||
original: EntityStore,
|
||||
/// The block's base point, at the origin in the editor.
|
||||
base: Vec2,
|
||||
}
|
||||
|
||||
/// Why a block can't be opened in the Block Editor, if it can't.
|
||||
pub fn refusal(d: &Drawing, name: &str) -> Option<String> {
|
||||
let Some(b) = d.block(name) else { return Some(format!("Block \"{name}\" not found.")) };
|
||||
if b.xref_path.is_some() {
|
||||
return Some(format!("\"{}\" is an external reference; open its drawing (XOPEN) to edit it.", b.name));
|
||||
}
|
||||
if b.anonymous || b.name.starts_with('*') {
|
||||
return Some(format!("\"{}\" is an anonymous block made by another object; it can't be edited in the Block Editor.", b.name));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `store` moved by `m`, objects in the same order with the same handles.
|
||||
fn moved(store: &EntityStore, m: &Mat3) -> EntityStore {
|
||||
let mut out = EntityStore::new();
|
||||
for e in store.iter() {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(m);
|
||||
out.push(e);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether any object of `store` refers to block `name`, directly or through nested blocks of `d`.
|
||||
fn refers_to(d: &Drawing, store: &EntityStore, name: &str) -> bool {
|
||||
let mut todo: Vec<String> = store.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(b) = todo.pop() {
|
||||
if b.eq_ignore_ascii_case(name) {
|
||||
return true;
|
||||
}
|
||||
if seen.insert(b.to_ascii_uppercase())
|
||||
&& let Some(blk) = d.block(&b)
|
||||
{
|
||||
todo.extend(blk.entities.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl BlockEdit {
|
||||
/// The editor's objects in block coordinates, ready to be the block's: unchanged objects are
|
||||
/// the block's own, the others are moved back from the editor's origin to the base point.
|
||||
fn block_objects(&self, editor: &Drawing) -> EntityStore {
|
||||
if self.base == Vec2::ZERO {
|
||||
return editor.model.clone();
|
||||
}
|
||||
let (to_editor, back) = (Mat3::translate(-self.base), Mat3::translate(self.base));
|
||||
let mut out = EntityStore::new();
|
||||
for e in editor.model.iter() {
|
||||
let unchanged = self.original.get(e.handle).filter(|o| {
|
||||
let mut shown = (***o).clone();
|
||||
shown.kind.transform(&to_editor);
|
||||
shown == **e
|
||||
});
|
||||
match unchanged {
|
||||
Some(o) => out.push((**o).clone()),
|
||||
None => {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(&back);
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The drawing with the editor's objects saved into block `name` (the edited block, or a new
|
||||
/// name for BSAVEAS), from `editor`, the editor's drawing.
|
||||
pub fn compose(&self, editor: &Drawing, name: &str) -> Result<Drawing> {
|
||||
if refers_to(editor, &editor.model, name) {
|
||||
return Err(EngineError::Other(format!("Block \"{name}\" can't contain a reference to itself.")));
|
||||
}
|
||||
let host = &self.host.doc;
|
||||
let mut d = editor.clone();
|
||||
d.model = host.model.clone();
|
||||
d.layouts = host.layouts.clone();
|
||||
d.groups = host.groups.clone();
|
||||
d.constraints = host.constraints.clone();
|
||||
d.vports = host.vports.clone();
|
||||
d.hidden_objects = host.hidden_objects.clone();
|
||||
let entities = self.block_objects(editor);
|
||||
// The edited block's properties (the editor's definition, else the drawing's): the objects
|
||||
// are placed for its base point, also when saved under another name (BSAVEAS).
|
||||
let template = editor.block(&self.name).or_else(|| host.block(&self.name));
|
||||
let mut b = match template {
|
||||
Some(b) => (**b).clone(),
|
||||
None => cadcraft_doc::Block::new(name),
|
||||
};
|
||||
// Block names are case-insensitive: an existing block keeps its name's spelling.
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
if !b.name.eq_ignore_ascii_case(name) {
|
||||
b.name = key.clone();
|
||||
}
|
||||
b.base.x = self.base.x;
|
||||
b.base.y = self.base.y;
|
||||
b.entities = entities;
|
||||
d.blocks.insert(key, Arc::new(b));
|
||||
Ok(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// The drawing as a file saves it: outside the Block Editor's changes while it is open.
|
||||
pub fn drawing(&self) -> &Arc<Drawing> {
|
||||
match &self.block_edit {
|
||||
Some(be) => &be.host.doc,
|
||||
None => &self.doc,
|
||||
}
|
||||
}
|
||||
/// [`DocState::drawing`], to change it (a save's file version).
|
||||
pub fn drawing_arc_mut(&mut self) -> &mut Arc<Drawing> {
|
||||
match &mut self.block_edit {
|
||||
Some(be) => &mut be.host.doc,
|
||||
None => &mut self.doc,
|
||||
}
|
||||
}
|
||||
/// The block the Block Editor is editing, when it is open.
|
||||
pub fn block_editor(&self) -> Option<&str> {
|
||||
self.block_edit.as_ref().map(|b| b.name.as_str())
|
||||
}
|
||||
/// The Block Editor has changes not yet saved into the block.
|
||||
pub fn block_unsaved(&self) -> bool {
|
||||
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
|
||||
}
|
||||
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
|
||||
/// block, so a crash doesn't lose them.
|
||||
pub fn recovery_drawing(&self) -> Arc<Drawing> {
|
||||
match &self.block_edit {
|
||||
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()),
|
||||
Some(be) => be.host.doc.clone(),
|
||||
None => self.doc.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Block Editor on block `name` (BEDIT).
|
||||
pub fn open_block_editor(&mut self, name: &str) -> Result<()> {
|
||||
if let Some(open) = self.block_editor() {
|
||||
return Err(EngineError::Other(format!("The Block Editor is already open on \"{open}\"; close it first (BCLOSE).")));
|
||||
}
|
||||
if let Some(why) = refusal(&self.doc, name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
let block = self.doc.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let base = block.base.xy();
|
||||
let mut d = (*self.doc).clone();
|
||||
d.model = if base == Vec2::ZERO { block.entities.clone() } else { moved(&block.entities, &Mat3::translate(-base)) };
|
||||
// The drawing's own groups, constraints and hidden objects stay with it.
|
||||
d.groups.clear();
|
||||
d.constraints.clear();
|
||||
d.hidden_objects.clear();
|
||||
let host = Box::new(self.clone());
|
||||
let editor = Arc::new(d);
|
||||
self.doc = editor.clone();
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.undo_ctl = Default::default();
|
||||
self.selection.clear();
|
||||
self.previous_selection.clear();
|
||||
self.space = cadcraft_doc::Space::Model;
|
||||
self.mspace = None;
|
||||
self.tiles = Default::default();
|
||||
self.views.clear();
|
||||
self.view_history.clear();
|
||||
self.block_edit = Some(BlockEdit { name: block.name.clone(), host, saved: editor, original: block.entities.clone(), base });
|
||||
self.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save the Block Editor's objects into the block (BSAVE), or into a new block `as_name`
|
||||
/// (BSAVEAS), which the editor then edits. One undo step in the drawing.
|
||||
pub fn save_block_editor(&mut self, as_name: Option<&str>) -> Result<()> {
|
||||
let Some(be) = &self.block_edit else { return Err(EngineError::Other("The Block Editor is not open.".into())) };
|
||||
let name = as_name.unwrap_or(&be.name).to_string();
|
||||
let d = be.compose(&self.doc, &name)?;
|
||||
let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default();
|
||||
let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name);
|
||||
let editor = self.doc.clone();
|
||||
let Some(be) = &mut self.block_edit else { return Ok(()) };
|
||||
let before = be.host.doc.clone();
|
||||
let selection = be.host.selection.clone();
|
||||
be.host.push_undo(Snapshot { label: UNDO_LABEL.into(), doc: before, selection });
|
||||
be.host.doc = Arc::new(d);
|
||||
be.host.revision += 1;
|
||||
be.original = original;
|
||||
be.saved = editor;
|
||||
be.name = name;
|
||||
self.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the Block Editor (BCLOSE), saving its changes into the block first when `save`.
|
||||
pub fn close_block_editor(&mut self, save: bool) -> Result<()> {
|
||||
if self.block_edit.is_none() {
|
||||
return Err(EngineError::Other("The Block Editor is not open.".into()));
|
||||
}
|
||||
if save && self.block_unsaved() {
|
||||
self.save_block_editor(None)?;
|
||||
}
|
||||
let Some(be) = self.block_edit.take() else { return Ok(()) };
|
||||
let host = *be.host;
|
||||
self.doc = host.doc;
|
||||
self.undo = host.undo;
|
||||
self.redo = host.redo;
|
||||
self.undo_ctl = host.undo_ctl;
|
||||
// The drawing comes back with nothing selected; Previous is what was selected before.
|
||||
self.selection.clear();
|
||||
self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };
|
||||
self.space = host.space;
|
||||
self.mspace = host.mspace;
|
||||
self.tiles = host.tiles;
|
||||
self.views = host.views;
|
||||
self.view_history = host.view_history;
|
||||
self.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing.
|
||||
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing, the
|
||||
//! Block Editor.
|
||||
|
||||
pub(crate) mod bedit;
|
||||
mod purge;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
@ -76,6 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.into_iter()
|
||||
.chain(xref::specs())
|
||||
.chain(refedit::specs())
|
||||
.chain(bedit::specs())
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
//! BEDIT, BSAVE, BSAVEAS and BCLOSE: the Block Editor. BEDIT opens a block definition in the
|
||||
//! drawing's own tab, its base point at the origin; the block's objects are edited with the
|
||||
//! ordinary commands; BSAVE saves them into the definition, so every reference to the block in the
|
||||
//! drawing shows the change; BCLOSE goes back to the drawing, saving or discarding what isn't saved.
|
||||
//! The state is [`crate::blockedit`].
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::curves::{KW, handle_value};
|
||||
use crate::cmd::{CommandSpec, bad, bool_or, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("bedit", "Block Editor", run_bedit)
|
||||
.menu(&["Tools", "Block Editor"])
|
||||
.alias(&["be", "-bedit"])
|
||||
.params("{name? | handle? (a block reference: edits its block); default the one selected block reference} → {block}")
|
||||
.enabled(not_editing)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BeditM))),
|
||||
CommandSpec::new("bsave", "Save Block", run_bsave).params("{} → {block}").enabled(editing).noundo(),
|
||||
CommandSpec::new("bsaveas", "Save Block As", run_bsaveas)
|
||||
.params("{name, replace?: bool (redefine an existing block of that name)} → {block}")
|
||||
.enabled(editing)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BsaveasM::default()))),
|
||||
CommandSpec::new("bclose", "Close Block Editor", run_bclose)
|
||||
.params("{save?: bool (default true; false discards the changes not saved into the block)} → {block, saved}")
|
||||
.enabled(editing)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BcloseM))),
|
||||
]
|
||||
}
|
||||
|
||||
fn editing(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.state() {
|
||||
Ok(st) if st.block_edit.is_some() => Ok(()),
|
||||
Ok(_) => Err("The Block Editor is not open.".into()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn not_editing(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.state() {
|
||||
Ok(st) => match st.block_editor() {
|
||||
Some(name) => Err(format!("The Block Editor is already open on \"{name}\"; close it first (BCLOSE).")),
|
||||
None => Ok(()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that leave model space, which the Block Editor is: not available while it is open.
|
||||
const NOT_IN_EDITOR: &[&str] = &[
|
||||
"layout",
|
||||
"layout.new",
|
||||
"layout.delete",
|
||||
"layout.rename",
|
||||
"layout.copy",
|
||||
"layout.set",
|
||||
"mview",
|
||||
"mview.polygonal",
|
||||
"mspace",
|
||||
"pspace",
|
||||
"chspace",
|
||||
];
|
||||
|
||||
/// Why command `id` can't run now, when the Block Editor is open and it is one of [`NOT_IN_EDITOR`].
|
||||
pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option<String> {
|
||||
let name = s.state().ok()?.block_editor()?;
|
||||
NOT_IN_EDITOR.contains(&id).then(|| format!("Not available in the Block Editor (editing \"{name}\"); close it first (BCLOSE)."))
|
||||
}
|
||||
|
||||
/// The block a block reference refers to.
|
||||
fn block_of(d: &Drawing, h: Handle) -> Option<String> {
|
||||
match d.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(i)) => Some(dynamic_definition(d, &i.block).unwrap_or_else(|| i.block.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dynamic block whose anonymous block `name` is (a dynamic block reference with values of
|
||||
/// its own shows one, `*U12`): its BLOCK_RECORD's `AcDbBlockRepBTag` xdata names the dynamic
|
||||
/// block's record. `None` for other blocks.
|
||||
fn dynamic_definition(d: &Drawing, name: &str) -> Option<String> {
|
||||
if !name.starts_with('*') {
|
||||
return None;
|
||||
}
|
||||
let x = &d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.to_ascii_uppercase()))?.xdata;
|
||||
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case("AcDbBlockRepBTag"))?;
|
||||
let record = x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1005).and_then(|t| Handle::parse_hex(&t.value))?;
|
||||
let (kind, block) = d.foreign.targets.get(&record)?;
|
||||
(kind == "BLOCK_RECORD").then(|| d.block(block).map(|b| b.name.clone())).flatten()
|
||||
}
|
||||
|
||||
/// Whether block `name` carries dynamic block data from another program (parameters and actions
|
||||
/// in its `ACAD_ENHANCEDBLOCK` dictionary), which CADCraft keeps but can't edit yet. The dictionary
|
||||
/// hangs off the block's BLOCK_RECORD table entry.
|
||||
fn has_dynamic_data(d: &Drawing, name: &str) -> bool {
|
||||
let key = ("BLOCK_RECORD".to_string(), name.to_ascii_uppercase());
|
||||
let Some(xdict) = d.foreign.records.get(&key).and_then(|x| x.xdict) else { return false };
|
||||
d.foreign.objects.iter().any(|o| o.handle == xdict && o.tags.iter().any(|t| t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK")))
|
||||
}
|
||||
|
||||
/// Open the Block Editor on block `name` and frame its objects.
|
||||
fn open(s: &mut Session, name: &str) -> Result<Value> {
|
||||
s.state_mut()?.open_block_editor(name)?;
|
||||
let name = s.state()?.block_editor().unwrap_or(name).to_string();
|
||||
// The block's objects with room around them (and the editor's bar above them).
|
||||
s.zoom_extents()?;
|
||||
let c = s.state()?.view().center;
|
||||
s.zoom_about(0.75, c)?;
|
||||
s.echo(format!("Block Editor: editing \"{name}\". BSAVE saves it into the drawing; BCLOSE closes the editor."));
|
||||
if s.state().is_ok_and(|st| st.block_edit.as_ref().is_some_and(|be| has_dynamic_data(&be.host.doc, &be.name))) {
|
||||
s.echo("This is a dynamic block: its parameters and actions are kept but can't be edited in CADCraft yet.");
|
||||
}
|
||||
Ok(json!({ "block": name }))
|
||||
}
|
||||
|
||||
/// The one block reference selected, if exactly one is.
|
||||
fn selected_reference(s: &Session) -> Option<String> {
|
||||
let d = s.doc().ok()?;
|
||||
let refs: Vec<String> = s.selection().iter().filter_map(|h| block_of(d, *h)).collect();
|
||||
match refs.as_slice() {
|
||||
[one] => Some(one.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_bedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = match (str_param(p, "name"), p.get("handle")) {
|
||||
(Some(n), _) => n.to_string(),
|
||||
(None, Some(h)) => {
|
||||
let h = handle_value(h).ok_or_else(|| bad("bedit", "`handle` must be a handle"))?;
|
||||
block_of(s.doc()?, h).ok_or_else(|| bad("bedit", "`handle` is not a block reference"))?
|
||||
}
|
||||
(None, None) => selected_reference(s).ok_or_else(|| bad("bedit", "`name` or `handle` is required (or select one block reference)"))?,
|
||||
};
|
||||
open(s, &name)
|
||||
}
|
||||
|
||||
fn run_bsave(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.state_mut()?.save_block_editor(None)?;
|
||||
let name = s.state()?.block_editor().unwrap_or_default().to_string();
|
||||
s.echo(format!("Block \"{name}\" saved."));
|
||||
Ok(json!({ "block": name }))
|
||||
}
|
||||
|
||||
/// Check a BSAVEAS name: valid, and not another existing block unless `replace`.
|
||||
fn check_save_as(s: &Session, name: &str, replace: bool) -> Result<()> {
|
||||
if !super::valid_block_name(name) {
|
||||
return Err(bad("bsaveas", format!("\"{name}\" is not a valid block name")));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let current = s.state()?.block_editor().is_some_and(|b| b.eq_ignore_ascii_case(name));
|
||||
if !current && !replace && d.block(name).is_some() {
|
||||
return Err(bad("bsaveas", format!("Block \"{name}\" already exists; pass `replace: true` to redefine it")));
|
||||
}
|
||||
if let Some(why) = d.block(name).filter(|_| !current).and_then(|_| crate::blockedit::refusal(d, name)) {
|
||||
return Err(bad("bsaveas", why));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_bsaveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("bsaveas", "`name` is required"))?.trim().to_string();
|
||||
check_save_as(s, &name, bool_or(p, "replace", false))?;
|
||||
s.state_mut()?.save_block_editor(Some(&name))?;
|
||||
let name = s.state()?.block_editor().unwrap_or_default().to_string();
|
||||
s.echo(format!("Block \"{name}\" saved; the Block Editor now edits it."));
|
||||
Ok(json!({ "block": name }))
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, save: bool) -> Result<Value> {
|
||||
let st = s.state()?;
|
||||
let name = st.block_editor().unwrap_or_default().to_string();
|
||||
let saved = save && st.block_unsaved();
|
||||
s.state_mut()?.close_block_editor(save)?;
|
||||
if saved {
|
||||
s.echo(format!("Block \"{name}\" saved."));
|
||||
}
|
||||
Ok(json!({ "block": name, "saved": saved }))
|
||||
}
|
||||
|
||||
fn run_bclose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
close(s, bool_or(p, "save", true))
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
/// BEDIT: the selected block reference's block, else a block name typed or a reference picked.
|
||||
struct BeditM;
|
||||
|
||||
impl Interactive for BeditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BEDIT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
match selected_reference(s) {
|
||||
Some(name) => open(s, &name).map(|_| Step::Done),
|
||||
None => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Enter block name or select a block reference [?]", Accept { point: true, number: false, text: true, select: true, enter: true })
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Text(t) if t.trim() == "?" => {
|
||||
let names: Vec<String> = {
|
||||
let d = s.doc()?;
|
||||
d.blocks.values().filter(|b| crate::blockedit::refusal(d, &b.name).is_none()).map(|b| b.name.clone()).collect()
|
||||
};
|
||||
s.echo(if names.is_empty() { "No blocks to edit.".to_string() } else { names.join(", ") });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Text(t) | Input::Keyword(t) => open(s, t.trim()).map(|_| Step::Done),
|
||||
Input::Pick(hs) => {
|
||||
let name = {
|
||||
let d = s.doc()?;
|
||||
hs.iter().find_map(|h| block_of(d, *h))
|
||||
};
|
||||
match name {
|
||||
Some(n) => open(s, &n).map(|_| Step::Done),
|
||||
None => Err(EngineError::Other("Select a block reference.".into())),
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BCLOSE: asks whether to save changes not saved yet.
|
||||
struct BcloseM;
|
||||
|
||||
impl Interactive for BcloseM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BCLOSE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if s.state()?.block_unsaved() { Ok(Step::Continue) } else { close(s, false).map(|_| Step::Done) }
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let name = s.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default();
|
||||
Prompt::new(format!("Save changes to block \"{name}\"?"), KW).kw(&["Yes", "No"]).default("Y")
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let answer = match i {
|
||||
Input::Enter => "Y".to_string(),
|
||||
Input::Keyword(k) | Input::Text(k) => k,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
match answer.trim().chars().next().map(|c| c.to_ascii_uppercase()) {
|
||||
Some('Y') => close(s, true).map(|_| Step::Done),
|
||||
Some('N') => close(s, false).map(|_| Step::Done),
|
||||
_ => Err(EngineError::Other("Enter Yes or No.".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BSAVEAS: a new block name; an existing block is redefined only when confirmed.
|
||||
#[derive(Default)]
|
||||
struct BsaveasM {
|
||||
/// The name given, waiting for "redefine?" because a block of that name exists.
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
impl BsaveasM {
|
||||
fn save(s: &mut Session, name: &str, replace: bool) -> Result<Step> {
|
||||
run_bsaveas(s, &json!({ "name": name, "replace": replace })).map(|_| Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BsaveasM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BSAVEAS"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match &self.name {
|
||||
Some(n) => Prompt::new(format!("Block \"{n}\" already exists. Redefine it?"), KW).kw(&["Yes", "No"]).default("N"),
|
||||
None => Prompt::new("Enter block name", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(n) = self.name.clone() {
|
||||
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_uppercase().starts_with('Y'));
|
||||
return if yes { Self::save(s, &n, true) } else { Ok(Step::Done) };
|
||||
}
|
||||
let name = match i {
|
||||
Input::Text(t) => t.trim().to_string(),
|
||||
Input::Enter => return Ok(Step::Done),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let current = s.state()?.block_editor().is_some_and(|b| b.eq_ignore_ascii_case(&name));
|
||||
if !current && s.doc()?.block(&name).is_some() {
|
||||
check_save_as(s, &name, true)?;
|
||||
self.name = Some(name);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Self::save(s, &name, false)
|
||||
}
|
||||
}
|
||||
|
|
@ -135,11 +135,13 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
|
|||
if opts.binary && !is_dxf(path) {
|
||||
return Err(bad(cmd, "binary applies to DXF files only"));
|
||||
}
|
||||
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
|
||||
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
|
||||
let r = if *opts == SaveAs::default() {
|
||||
(hooks.write)(s.doc()?, path)
|
||||
(hooks.write)(d, path)
|
||||
} else {
|
||||
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
|
||||
w(s.doc()?, path, opts)
|
||||
w(d, path, opts)
|
||||
};
|
||||
r.map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
|
@ -385,10 +387,10 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
|
|||
let st = s.state_mut()?;
|
||||
st.binary_dxf = opts.binary;
|
||||
if let Some(v) = opts.version
|
||||
&& st.doc.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
|
||||
&& st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
|
||||
{
|
||||
std::sync::Arc::make_mut(&mut st.doc).header.set_str("ACADVER", v.code());
|
||||
st.saved = st.doc.clone();
|
||||
std::sync::Arc::make_mut(st.drawing_arc_mut()).header.set_str("ACADVER", v.code());
|
||||
st.saved = st.drawing().clone();
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
|
|
@ -424,7 +426,7 @@ fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&st
|
|||
return Ok(bytes.len());
|
||||
}
|
||||
let st = s.state_mut()?;
|
||||
st.saved = st.doc.clone();
|
||||
st.saved = st.drawing().clone();
|
||||
st.path = Some(path.to_string());
|
||||
st.title = file_name(path);
|
||||
// A BEgin group still open when the drawing is saved ends there.
|
||||
|
|
|
|||
|
|
@ -286,6 +286,7 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"title": st.title,
|
||||
"path": st.path,
|
||||
"dirty": st.is_dirty(),
|
||||
"blockEditor": st.block_editor(),
|
||||
"space": space.tab_name(),
|
||||
"entityCount": d.entity_count(),
|
||||
"counts": counts,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
pub mod assoc;
|
||||
pub mod audit;
|
||||
pub mod autosave;
|
||||
pub mod blockedit;
|
||||
pub mod cmd;
|
||||
mod finite;
|
||||
pub mod grip_dims;
|
||||
|
|
@ -137,6 +138,9 @@ pub struct DocState {
|
|||
pub uid: u64,
|
||||
/// The drawing is saved as binary DXF (it was opened from one, or last saved as one).
|
||||
pub binary_dxf: bool,
|
||||
/// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the
|
||||
/// drawing itself waits in it ([`DocState::drawing`]).
|
||||
pub block_edit: Option<blockedit::BlockEdit>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
|
@ -162,10 +166,13 @@ impl DocState {
|
|||
view_history: Vec::new(),
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
binary_dxf: false,
|
||||
block_edit: None,
|
||||
}
|
||||
}
|
||||
/// The drawing has changes not saved to its file, or the Block Editor has changes not yet
|
||||
/// saved into its block.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!Arc::ptr_eq(&self.doc, &self.saved)
|
||||
!Arc::ptr_eq(self.drawing(), &self.saved) || self.block_unsaved()
|
||||
}
|
||||
/// After undo/redo replaced the drawing (`before` is the one shown until then): if the current
|
||||
/// layout no longer exists, follow it to its other name when it was renamed (same tab order,
|
||||
|
|
@ -813,7 +820,11 @@ impl Session {
|
|||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
|
||||
if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) {
|
||||
return Err(EngineError::Disabled(spec.id.into(), why));
|
||||
}
|
||||
let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid));
|
||||
let editing = self.state().is_ok_and(|s| s.block_edit.is_some());
|
||||
let run = spec.run;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params)));
|
||||
let result = match result {
|
||||
|
|
@ -836,7 +847,10 @@ impl Session {
|
|||
(Some(m), Ok(_)) => Err(EngineError::Other(m)),
|
||||
(_, r) => r,
|
||||
};
|
||||
self.after_command(before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2), spec.undoable && result.is_ok());
|
||||
// Opening or closing the Block Editor swaps the whole drawing: nothing in it changed.
|
||||
if self.state().is_ok_and(|s| s.block_edit.is_some()) == editing {
|
||||
self.after_command(before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2), spec.undoable && result.is_ok());
|
||||
}
|
||||
let result = match (result, &before) {
|
||||
(Ok(v), Some((doc, sel, uid))) if spec.undoable => self.refuse_non_finite(Some(*uid), doc, sel, spec.id).map(|()| v),
|
||||
(r, _) => r,
|
||||
|
|
@ -917,6 +931,9 @@ impl Session {
|
|||
self.cancel();
|
||||
}
|
||||
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
|
||||
if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) {
|
||||
return Err(EngineError::Disabled(spec.id.into(), why));
|
||||
}
|
||||
if nested {
|
||||
// The outer command stays the one Enter repeats.
|
||||
self.echo(format!(">>{}", spec.id.to_ascii_uppercase()));
|
||||
|
|
@ -1648,6 +1665,7 @@ impl Session {
|
|||
serde_json::json!({
|
||||
"index": i, "title": d.title, "path": d.path, "dirty": d.is_dirty(),
|
||||
"entities": d.doc.entity_count(), "active": i == self.active, "revision": d.revision,
|
||||
"blockEditor": d.block_editor(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
|||
"CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()),
|
||||
"DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()),
|
||||
"DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)),
|
||||
"BLOCKEDITOR" => json!(i32::from(s.state().is_ok_and(|d| d.block_edit.is_some()))),
|
||||
// The template NEW/QNEW start from (a profile setting, like FONTALT; "" = none).
|
||||
"QNEWTEMPLATE" => json!(crate::cmd::file::qnew_template()),
|
||||
"CTAB" => json!(s.state().map(|d| d.space.tab_name().to_string()).unwrap_or_default()),
|
||||
|
|
@ -167,7 +168,8 @@ fn as_pt(v: &Value) -> Option<cadcraft_geom::Vec2> {
|
|||
}
|
||||
|
||||
/// Variables `get` reports from session or drawing state that `set` can't change.
|
||||
const READ_ONLY: &[&str] = &["CMDNAMES", "DWGNAME", "DBMOD", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX", "UNDOCTL", "UNDOMARKS"];
|
||||
const READ_ONLY: &[&str] =
|
||||
&["CMDNAMES", "DWGNAME", "DBMOD", "BLOCKEDITOR", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX", "UNDOCTL", "UNDOMARKS"];
|
||||
|
||||
pub fn is_read_only(name: &str) -> bool {
|
||||
READ_ONLY.iter().any(|r| r.eq_ignore_ascii_case(name.trim())) || table::is_read_only(&name.trim().to_ascii_uppercase())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,298 @@
|
|||
//! The Block Editor (BEDIT, BSAVE, BSAVEAS, BCLOSE): a block is edited with the ordinary
|
||||
//! commands, its base point at the origin, and saving it updates every reference in the drawing.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::{EntityKind, Line};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Value {
|
||||
s.execute("line", &json!({ "points": [a, b] })).unwrap()["handles"][0].clone()
|
||||
}
|
||||
|
||||
/// A drawing with block "Door" (one line from (10,10) to (12,10), base point (10,10)) and two
|
||||
/// references to it; returns the references.
|
||||
fn door(s: &mut Session) -> (Value, Value) {
|
||||
let l = line(s, [10.0, 10.0], [12.0, 10.0]);
|
||||
let a = s.execute("block", &json!({ "name": "Door", "base": [10, 10], "handles": [l] })).unwrap()["insert"].clone();
|
||||
let b = s.execute("insert", &json!({ "name": "Door", "at": [50, 0] })).unwrap()["handle"].clone();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
fn lines_of(s: &Session, block: &str) -> Vec<Line> {
|
||||
let b = s.doc().unwrap().block(block).unwrap().clone();
|
||||
b.entities.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn editor(s: &Session) -> Option<String> {
|
||||
s.state().unwrap().block_editor().map(str::to_string)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_the_block_updates_every_reference() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = door(&mut s);
|
||||
let model = s.doc().unwrap().model.clone();
|
||||
|
||||
s.execute("bedit", &json!({ "name": "door" })).unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
// The editor shows the block's objects with its base point at the origin.
|
||||
let shown: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect();
|
||||
assert!(matches!(&shown[..], [EntityKind::Line(l)] if l.a == Vec3::new(0.0, 0.0, 0.0) && l.b == Vec3::new(2.0, 0.0, 0.0)), "{shown:?}");
|
||||
assert_eq!(s.execute("getvar", &json!({ "name": "BLOCKEDITOR" })).unwrap()["value"], json!(1));
|
||||
|
||||
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
|
||||
assert!(s.state().unwrap().block_unsaved() && s.state().unwrap().is_dirty());
|
||||
let r = s.execute("bclose", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({ "block": "Door", "saved": true }));
|
||||
assert_eq!(editor(&s), None);
|
||||
|
||||
// The new line is in the definition, in block coordinates; the drawing's own objects and both
|
||||
// references are as they were.
|
||||
let ls = lines_of(&s, "Door");
|
||||
assert_eq!(ls.len(), 2);
|
||||
assert_eq!((ls[1].a, ls[1].b), (Vec3::new(10.0, 10.0, 0.0), Vec3::new(10.0, 13.0, 0.0)));
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.model.same_as(&model) || d.model == model);
|
||||
for h in [&a, &b] {
|
||||
let h = cadcraft_engine::cmd::curves::handle_value(h).unwrap();
|
||||
assert!(matches!(&d.entity(h).unwrap().kind, EntityKind::Insert(i) if i.block == "Door"));
|
||||
}
|
||||
|
||||
// One undo step in the drawing takes the edit back.
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_without_saving_leaves_the_drawing_as_it_was() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
s.script("ERASE ALL\n\n").unwrap();
|
||||
assert!(s.doc().unwrap().model.is_empty());
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo);
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_objects_keep_their_exact_coordinates() {
|
||||
let mut s = Session::new();
|
||||
// A base point whose coordinates don't survive x - b + b exactly.
|
||||
let l = line(&mut s, [0.1, 0.7], [0.3, 0.9]);
|
||||
s.execute("block", &json!({ "name": "B", "base": [0.7, 0.1], "handles": [l] })).unwrap();
|
||||
let original = lines_of(&s, "B");
|
||||
s.execute("bedit", &json!({ "name": "B" })).unwrap();
|
||||
line(&mut s, [0.0, 0.0], [1.0, 0.0]);
|
||||
s.execute("bsave", &json!({})).unwrap();
|
||||
assert!(!s.state().unwrap().block_unsaved());
|
||||
assert_eq!(editor(&s).as_deref(), Some("B"), "BSAVE keeps the editor open");
|
||||
assert_eq!(lines_of(&s, "B")[0], original[0]);
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
let ls = lines_of(&s, "B");
|
||||
assert_eq!(ls[0], original[0]);
|
||||
assert_eq!(ls.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsaveas_makes_a_new_block_and_keeps_the_old_one() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
|
||||
let r = s.execute("bsaveas", &json!({ "name": "Door2" })).unwrap();
|
||||
assert_eq!(r["block"], "Door2");
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door2"));
|
||||
assert!(s.execute("bsaveas", &json!({ "name": "Door" })).is_err(), "an existing block is redefined only with replace");
|
||||
assert!(s.execute("bsaveas", &json!({ "name": "bad*name" })).is_err());
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1);
|
||||
let new = lines_of(&s, "Door2");
|
||||
assert_eq!(new.len(), 2);
|
||||
assert_eq!(s.doc().unwrap().block("Door2").unwrap().base, s.doc().unwrap().block("Door").unwrap().base);
|
||||
|
||||
// Redefining another block (typed in another case) keeps its name and takes the edited
|
||||
// block's base point, so the objects keep their places relative to it.
|
||||
let l = line(&mut s, [100.0, 100.0], [101.0, 100.0]);
|
||||
s.execute("block", &json!({ "name": "Other", "base": [100, 100], "handles": [l] })).unwrap();
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
s.execute("bsaveas", &json!({ "name": "OTHER", "replace": true })).unwrap();
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let other = d.block("Other").unwrap();
|
||||
assert_eq!(other.name, "Other");
|
||||
assert_eq!(other.base, d.block("Door").unwrap().base);
|
||||
assert_eq!(lines_of(&s, "Other"), lines_of(&s, "Door"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_block_editor_refuses() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
assert!(s.execute("bedit", &json!({ "name": "Nope" })).is_err());
|
||||
assert!(s.execute("bedit", &json!({})).is_err(), "no name and no block reference selected");
|
||||
assert!(s.execute("bsave", &json!({})).is_err(), "not open");
|
||||
assert!(s.execute("bclose", &json!({})).is_err(), "not open");
|
||||
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
assert!(s.execute("bedit", &json!({ "name": "Door" })).is_err(), "already open");
|
||||
let r = s.execute("layout.set", &json!({ "name": "Layout1" }));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("Block Editor")));
|
||||
assert_eq!(s.layout_space(), cadcraft_engine::doc::Space::Model);
|
||||
|
||||
// A reference to the block itself can't be saved into it.
|
||||
s.execute("insert", &json!({ "name": "Door", "at": [5, 5] })).unwrap();
|
||||
assert!(s.execute("bsave", &json!({})).is_err_and(|e| e.to_string().contains("itself")));
|
||||
assert!(s.execute("bclose", &json!({})).is_err());
|
||||
assert!(editor(&s).is_some(), "a failed save keeps the editor open");
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1);
|
||||
|
||||
// Anonymous blocks are made by other objects.
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
d.blocks.insert("*U1".into(), std::sync::Arc::new(cadcraft_engine::doc::Block::new("*U1")));
|
||||
s.open_drawing(d, "anon", None);
|
||||
assert!(s.execute("bedit", &json!({ "name": "*U1" })).is_err_and(|e| e.to_string().contains("anonymous")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
for p in
|
||||
[json!({ "name": 5 }), json!({ "handle": "zz" }), json!({ "handle": -1 }), json!({ "handle": "FFFFFFFFFFFFFFFFFF" }), json!(null), json!([])]
|
||||
{
|
||||
assert!(s.execute("bedit", &p).is_err(), "{p}");
|
||||
}
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
for p in [json!({ "name": "" }), json!({ "name": " " }), json!({ "name": 7 }), json!({ "name": "x".repeat(300) })] {
|
||||
assert!(s.execute("bsaveas", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("bclose", &json!({ "save": "maybe" })).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_reference_opens_its_block_and_bclose_asks_to_save() {
|
||||
let mut s = Session::new();
|
||||
let (a, _) = door(&mut s);
|
||||
s.set_selection(vec![cadcraft_engine::cmd::curves::handle_value(&a).unwrap()]);
|
||||
s.start("bedit").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
|
||||
// Nothing changed: BCLOSE closes at once.
|
||||
s.script("BCLOSE\n").unwrap();
|
||||
assert_eq!(editor(&s), None);
|
||||
|
||||
// Typed name, a change, then BCLOSE No discards it.
|
||||
s.script("BEDIT Door\nLINE 0,0 5,5\n\nBCLOSE\nN\n").unwrap();
|
||||
assert_eq!(editor(&s), None);
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1);
|
||||
// BCLOSE Yes saves it.
|
||||
s.script("BEDIT Door\nLINE 0,0 5,5\n\nBCLOSE\n\n").unwrap();
|
||||
assert_eq!(lines_of(&s, "Door").len(), 2);
|
||||
assert_eq!(s.execute("getvar", &json!({ "name": "BLOCKEDITOR" })).map(|v| v["value"].clone()).unwrap_or(Value::Null), json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_save_writes_the_drawing_outside_the_editor() {
|
||||
// The writer records how many objects the drawing's model space has, and block Door's.
|
||||
set_io(IoHooks {
|
||||
read: |_, _| Err("no reader in this test".into()),
|
||||
write: |d, _| Ok(format!("{} {}", d.model.len(), d.block("Door").map_or(0, |b| b.entities.len())).into_bytes()),
|
||||
plot: None,
|
||||
});
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-bedit-{}.dxf", std::process::id()));
|
||||
let path = path.to_string_lossy().to_string();
|
||||
s.execute("saveas", &json!({ "path": path })).unwrap();
|
||||
assert!(!s.state().unwrap().is_dirty());
|
||||
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
|
||||
// Autosave keeps the editor's unsaved change in the block.
|
||||
let rec = s.state().unwrap().recovery_drawing();
|
||||
assert_eq!(rec.block("Door").unwrap().entities.len(), 2);
|
||||
assert_eq!(rec.model.len(), 2);
|
||||
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "2 1", "the drawing, without the unsaved block change");
|
||||
assert!(s.state().unwrap().is_dirty(), "the block change is still unsaved");
|
||||
s.execute("bsave", &json!({})).unwrap();
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "2 2");
|
||||
assert!(!s.state().unwrap().is_dirty());
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
assert!(!s.state().unwrap().is_dirty());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_block_from_another_program_says_its_parameters_are_kept() {
|
||||
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
|
||||
use cadcraft_engine::doc::{Handle, RawTag};
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
// As a DXF from AutoCAD has it: the block record's extension dictionary holds ACAD_ENHANCEDBLOCK.
|
||||
let mut f = Foreign::default();
|
||||
let xdict = Handle(0xF00);
|
||||
f.records.insert(("BLOCK_RECORD".into(), "DOOR".into()), ForeignExtra { xdict: Some(xdict), ..Default::default() });
|
||||
let tag = |code, value: &str| RawTag { code, value: value.into() };
|
||||
f.objects.push(ForeignObject { handle: xdict, kind: "DICTIONARY".into(), tags: vec![tag(3, "ACAD_ENHANCEDBLOCK"), tag(360, "F01")] });
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
d.foreign = std::sync::Arc::new(f);
|
||||
s.open_drawing(d, "dynamic", None);
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log);
|
||||
// A plain block says nothing of the kind.
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
assert!(!s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_reference_with_its_own_values_opens_its_dynamic_block() {
|
||||
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra};
|
||||
use cadcraft_engine::doc::{Block, Handle, RawTag};
|
||||
let mut s = Session::new();
|
||||
let (a, _) = door(&mut s);
|
||||
// As AutoCAD leaves it: the reference shows anonymous block *U7, whose record's
|
||||
// AcDbBlockRepBTag xdata names Door's record (file handle 1D).
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
let door_block = d.block("Door").unwrap().clone();
|
||||
d.blocks.insert("*U7".into(), std::sync::Arc::new(Block { name: "*U7".into(), anonymous: true, ..(*door_block).clone() }));
|
||||
let h = cadcraft_engine::cmd::curves::handle_value(&a).unwrap();
|
||||
d.model.modify(h, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
i.block = "*U7".into();
|
||||
}
|
||||
});
|
||||
let mut f = Foreign::default();
|
||||
let tag = |code, value: &str| RawTag { code, value: value.into() };
|
||||
f.records.insert(
|
||||
("BLOCK_RECORD".into(), "*U7".into()),
|
||||
ForeignExtra { xdata: vec![tag(1001, "AcDbBlockRepBTag"), tag(1070, "1"), tag(1005, "1D")], ..Default::default() },
|
||||
);
|
||||
f.targets.insert(Handle(0x1D), ("BLOCK_RECORD".into(), "DOOR".into()));
|
||||
d.foreign = std::sync::Arc::new(f);
|
||||
s.open_drawing(d, "dynamic", None);
|
||||
|
||||
// Selected (the right-click Block Editor) and by handle: both open Door.
|
||||
s.set_selection(vec![h]);
|
||||
s.start("bedit").unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
s.execute("bedit", &json!({ "handle": h.hex() })).unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
// Other anonymous blocks are still refused by name.
|
||||
assert!(s.execute("bedit", &json!({ "name": "*U7" })).is_err());
|
||||
}
|
||||
|
|
@ -1444,6 +1444,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
|||
Rgb(t.paper.r(), t.paper.g(), t.paper.b())
|
||||
}
|
||||
None => {
|
||||
blockedit::wash(app, &painter, rect);
|
||||
draw_grid(app, &painter, &xf);
|
||||
Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b())
|
||||
}
|
||||
|
|
@ -1540,6 +1541,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
|||
}
|
||||
app.canvas.draw_ms = crate::now_ms() - t0;
|
||||
compare::paint(app, ui.ctx(), &painter, &xf_paper, rect);
|
||||
blockedit::paint(app, ui.ctx(), &painter, &xf_paper, rect);
|
||||
// Constraint bars and dynamic dimensional constraints (model space, or inside a viewport).
|
||||
if sheet.is_none() || active_vp.is_some() {
|
||||
let clip = match &active_vp {
|
||||
|
|
@ -2342,6 +2344,8 @@ pub(crate) mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Block Editor's tint, base point and bar.
|
||||
mod blockedit;
|
||||
/// The COMPARE overlay.
|
||||
mod compare;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
//! The Block Editor on the canvas (BEDIT): a tint over the drawing area so the editor can't be
|
||||
//! mistaken for the drawing, the block's base point marked at the origin, and a bar with the
|
||||
//! block's name and Save Block, Save Block As and Close Block Editor.
|
||||
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use egui::{Rect, RichText, Stroke, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::Xf;
|
||||
use crate::CadApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
fn id() -> egui::Id {
|
||||
egui::Id::new("cadcraft-block-editor-bar")
|
||||
}
|
||||
|
||||
/// The block the Block Editor is editing in the drawing shown, and whether it has unsaved changes.
|
||||
fn editing(app: &CadApp) -> Option<(String, bool)> {
|
||||
let st = app.session.state().ok()?;
|
||||
st.block_editor().map(|n| (n.to_string(), st.block_unsaved()))
|
||||
}
|
||||
|
||||
/// Tint the drawing area while the Block Editor is open (under the drawing).
|
||||
pub(super) fn wash(app: &CadApp, p: &egui::Painter, rect: Rect) {
|
||||
if editing(app).is_some() {
|
||||
p.rect_filled(rect, 0.0, Tokens::get().accent.gamma_multiply(0.12));
|
||||
}
|
||||
}
|
||||
|
||||
/// The base point marker and the Block Editor bar (over the drawing).
|
||||
pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf: &Xf, rect: Rect) {
|
||||
let Some((name, unsaved)) = editing(app) else { return };
|
||||
let t = Tokens::get();
|
||||
// The base point: the origin of the editor's coordinates.
|
||||
let o = xf.to_screen(Vec2::ZERO);
|
||||
if rect.expand(16.0).contains(o) {
|
||||
let st = Stroke::new(1.5, t.accent);
|
||||
p.circle_stroke(o, 6.0, st);
|
||||
p.line_segment([o - vec2(12.0, 0.0), o + vec2(12.0, 0.0)], st);
|
||||
p.line_segment([o - vec2(0.0, 12.0), o + vec2(0.0, 12.0)], st);
|
||||
}
|
||||
let mut action: Option<(&str, bool)> = None;
|
||||
egui::Area::new(id()).order(egui::Order::Foreground).pivot(egui::Align2::CENTER_TOP).fixed_pos(rect.center_top() + vec2(0.0, 8.0)).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
egui::Frame::new().fill(t.cmd_bg).stroke(Stroke::new(1.0, t.accent)).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(crate::tl!("Block Editor")).color(t.accent).strong());
|
||||
ui.label(RichText::new(&name).color(t.text));
|
||||
if unsaved {
|
||||
ui.label(RichText::new("●").color(t.warn)).on_hover_text(crate::tl!("Unsaved changes"));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.add_enabled(unsaved, egui::Button::new(crate::tl!("Save Block"))).clicked() {
|
||||
action = Some(("bsave", false));
|
||||
}
|
||||
if ui.button(crate::tl!("Save Block As")).clicked() {
|
||||
action = Some(("bsaveas", true));
|
||||
}
|
||||
if ui.button(crate::tl!("Close Block Editor")).clicked() {
|
||||
action = Some(("bclose", true));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
match action {
|
||||
// Prompting commands start like a menu item; BSAVE needs no answer.
|
||||
Some((id, true)) => app.start(id),
|
||||
Some((id, false)) => {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -111,7 +111,10 @@ impl CadApp {
|
|||
if self.session.active != i {
|
||||
let _ = self.run("document.switch", json!({ "index": i }));
|
||||
}
|
||||
let r = self.run("qsave", Value::Null);
|
||||
// The Block Editor's unsaved changes go into the block first (BSAVE), then the
|
||||
// drawing is saved with them.
|
||||
let r = if self.session.state().is_ok_and(|st| st.block_unsaved()) { self.run("bsave", json!({})) } else { Ok(Value::Null) };
|
||||
let r = r.and_then(|_| self.run("qsave", Value::Null));
|
||||
let saved = r.is_ok() && self.doc_index(uid).and_then(|i| self.session.docs.get(i)).is_some_and(|d| !d.is_dirty());
|
||||
match (saved, self.doc_index(uid)) {
|
||||
(true, Some(i)) => {
|
||||
|
|
@ -305,6 +308,23 @@ mod tests {
|
|||
assert!(app.session.docs[0].is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_on_close_puts_the_block_editor_changes_into_the_block_first() {
|
||||
let mut app = app_with(&["A"]);
|
||||
let l = app.session.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone();
|
||||
app.session.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
app.session.execute("bedit", &json!({ "name": "B" })).unwrap();
|
||||
app.session.execute("line", &json!({ "points": [[0, 0], [0, 1]] })).unwrap();
|
||||
assert!(app.session.docs[0].block_unsaved());
|
||||
app.request_close(None);
|
||||
assert_eq!(app.close_prompt(), Some("A"));
|
||||
app.answer_close(Choice::Save);
|
||||
// The untitled drawing can't be saved here, so it stays open, but the block has the change.
|
||||
let st = &app.session.docs[0];
|
||||
assert!(!st.block_unsaved());
|
||||
assert_eq!(st.drawing().block("B").unwrap().entities.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_all_asks_per_dirty_drawing_and_cancel_stops() {
|
||||
let mut app = app_with(&["A", "B", "C", "D"]);
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ pub fn entries(app: &CadApp) -> Vec<Vec<Entry>> {
|
|||
.collect();
|
||||
first.extend(sub("Recent Commands", recent));
|
||||
push(&mut groups, first);
|
||||
// In the Block Editor: save the block or go back to the drawing.
|
||||
if app.session.state().is_ok_and(|st| st.block_edit.is_some()) {
|
||||
push(&mut groups, items(app, &["bsave", "bsaveas", "bclose"]));
|
||||
}
|
||||
let mut clip: Vec<Entry> = sub("Clipboard", items(app, CLIPBOARD)).into_iter().collect();
|
||||
clip.extend(sub("Isolate", items(app, ISOLATE)));
|
||||
push(&mut groups, clip);
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
|
|||
include_str!("../hatchdlg.rs"),
|
||||
include_str!("../layers.rs"),
|
||||
include_str!("../canvas/compare.rs"),
|
||||
include_str!("../canvas/blockedit.rs"),
|
||||
include_str!("../palettes.rs"),
|
||||
include_str!("../parametric.rs"),
|
||||
include_str!("../quick.rs"),
|
||||
|
|
|
|||
|
|
@ -1288,3 +1288,8 @@
|
|||
Set Dynamic Property Задати динамічну властивість
|
||||
Dynamic Block Grips Ручки динамічного блока
|
||||
Drag Dynamic Grip Перетягнути ручку динамічного блока
|
||||
# Block Editor (BEDIT).
|
||||
Save Block Зберегти блок
|
||||
Save Block As Зберегти блок як
|
||||
Close Block Editor Закрити редактор блоків
|
||||
Unsaved changes Незбережені зміни
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 8.
|
|
|
@ -152,6 +152,23 @@ fn selection_menu_has_the_object_types_edit_commands() {
|
|||
assert_eq!(labels(&[context_menu::object_entries(&app)]), ["Polyline Edit"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_block_reference_offers_the_block_editor() {
|
||||
let mut app = app();
|
||||
let l = app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap()["handles"][0].clone();
|
||||
app.run("block", json!({ "name": "Door", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
select_all(&mut app);
|
||||
let g = vec![context_menu::object_entries(&app)];
|
||||
assert_eq!(labels(&g), ["Block Editor"]);
|
||||
// Choosing it opens the Block Editor on that block; its own menu then saves and closes it.
|
||||
cadcraft_ui_egui::menus::activate(&mut app, "bedit");
|
||||
assert_eq!(app.session.state().unwrap().block_editor(), Some("Door"));
|
||||
let menu = ids(&context_menu::entries(&app));
|
||||
assert!(["bsave", "bsaveas", "bclose"].iter().all(|id| menu.iter().any(|i| i == id)), "{menu:?}");
|
||||
cadcraft_ui_egui::menus::activate(&mut app, "bclose");
|
||||
assert_eq!(app.session.state().unwrap().block_editor(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_menu_label_has_a_ukrainian_translation() {
|
||||
let mut app = app();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Where CADCraft falls short of AutoCAD
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (multiline attributes read, drawn and saved: F17 narrowed) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Block Editor for static blocks landed; rows 4 and F3 narrowed) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
Every known shortfall, one row each, ranked within each section by user impact. This is the work
|
||||
list: agents pick from here (top of a section first), and remove or update a row in the same PR
|
||||
|
|
@ -16,7 +16,7 @@ do them first.
|
|||
| 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) |
|
||||
| 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) |
|
||||
| 3 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) |
|
||||
| 4 | **[alpha blocker]** **No block editor or dynamic blocks; xrefs can't be edited in place or clipped** (xrefs, #525, and groups, #417, landed) | Most production drawings use them | 65–100 | [features](#features) |
|
||||
| 4 | **[alpha blocker]** **No dynamic blocks or REFEDIT; xrefs can't be edited in place or clipped** (xrefs, #525, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) |
|
||||
| 5 | **No autosave in the web build** (the desktop app has SAVETIME autosave, Drawing Recovery, AUDIT and RECOVER since #406) | Browser users lose unsaved work when the tab or the browser goes down | 3–5 | [features](#features) |
|
||||
| 6 | **No CI runs the Rust tests on pull requests** (`cargo fmt` on main is fixed) | open PRs land without a gate; regressions go unseen | 2–3 | [stability](#stability) |
|
||||
|
||||
|
|
@ -26,7 +26,9 @@ do them first.
|
|||
|---|---|---|---|---|
|
||||
| F1 | **[alpha blocker]** Prompt options that still print "not available yet": -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (STRETCH polygon, CP and WP, and fence selection done; TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 1–3 |
|
||||
| F2 | XREF editing in place and clipping: XCLIP is missing. REFEDIT, REFSET and REFCLOSE edit a block reference in place (working set, faded rest of the block, everything else locked, save or discard; #813); left: editing an xref in place (its source drawing is not written back; XOPEN opens it), picking nested references, a dimmed look for the locked drawing and a banner with Save and Discard, attribute sync of other references (ATTSYNC). XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 |
|
||||
| F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 |
|
||||
| F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT, BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no Edit Block Definition dialog or authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 |
|
||||
| F1 | **[alpha blocker]** Prompt options that still print "not available yet": STRETCH polygon and fence selection (crossing window only), -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/modify.rs`, `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 2–4 |
|
||||
| F2 | XREF editing in place and clipping: REFEDIT and XCLIP are missing. XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 |
|
||||
| F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no AUDITCTL `.adt` log and no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 3–5 |
|
||||
| F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 |
|
||||
| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMTFILL/DIMTFILLCLR, DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 |
|
||||
|
|
@ -153,6 +155,7 @@ Detail: [localization-parity.md](localization-parity.md).
|
|||
| 2026-10-12 | trivial | F13: HATCHEDIT refuses a bad scale or angle before editing (#724) |
|
||||
| 2026-10-12 | minor | F3: dynamic block references show their grips (distance arrows, position squares, visibility/lookup lists, per visibility state) and drag them (`blocks.reference.grips`, `blocks.reference.grip`) |
|
||||
| 2026-10-12 | minor | Multiline attributes read, drawn and saved (they lost their text before): F17 narrowed to creating them |
|
||||
| 2026-10-12 | minor | Block Editor (BEDIT, BSAVE, BSAVEAS, BCLOSE) for static blocks landed: rows 4 (65–100 → 55–90 h) and F3 (50–75 → 40–65 h) narrowed to REFEDIT and dynamic blocks |
|
||||
| 2026-10-12 | minor | SCRIPT command reading `.scr` files landed (runs a file through the same script engine as CLI/MCP; nested scripts refused): E2 removed |
|
||||
| 2026-10-11 | minor | Refreshed for merged features: code pages (#505), tracking leftovers (#441), dynamic input/grip input (#519, #425) and SPLINEDIT Join/Add/Elevate (#559) closed their top-10 rows (top 10 now has 6 rows until the next review re-ranks); MULTILEADER (#459) and xrefs (#525) narrowed rows 1 and 4; F1 narrowed to the remaining stubs (#558, #559, #547); F5, F7, F9, F12, F14, F15, F17, F20, F22, U5, U7, U10, FF2, FF4, FF10 narrowed; F18, U2, U8, U9 removed (done); U13, S3, S4, P1 updated for merged fixes |
|
||||
| 2026-10-11 | minor | German interface catalog landed: L1 row count 936 → 989, L2 narrowed (de done; 40–60 → 35–55 h) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# CADCraft roadmap detail
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (alpha-gate evidence refreshed: xrefs, plot dialog, preview, PUBLISH, plotter devices, DXF round trip) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Block Editor for static blocks: "Use blocks" evidence updated) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
Forward-looking plan: milestones with status, the current focus, and what comes next with
|
||||
estimates. The summary is [ROADMAP.md](../ROADMAP.md); the work items behind every line are in
|
||||
|
|
@ -34,7 +34,7 @@ the alpha checklist. Main platform: macOS.
|
|||
|---|---|---|---|
|
||||
| Draw and modify precisely with object snaps and tracking | **partial, blocking** | Running snaps, polar, ortho and coordinate entry work; object snap tracking, extension and parallel snaps are toggles with no behaviour; no FROM/M2P/TK or typed snap overrides; TRIM/EXTEND standard mode, OFFSET Erase/Layer/Multiple, ROTATE Reference print "not available yet"; ellipse/spline intersections approximate ([ui-parity.md](ui-parity.md), [gaps.md](gaps.md) U1, U3, U4, F1) | 35–50 |
|
||||
| Dimension and annotate | partial, not blocking | All core DIM types, associativity, DIMSTYLE variables (43 of ~80), TEXT/MTEXT, MLEADER, tables work; no DIMSTYLE dialog, MTEXT editor not in place, MLEADER thin (F7, F9, F10). A drafter can dimension a sheet end to end | 0 for the gate (beta work) |
|
||||
| Use blocks (insert library blocks, edit them, attributes, xrefs) | **partial, blocking** | Static BLOCK/INSERT/attributes, ATTSYNC, BATTMAN editing and xrefs (#525) work; no block editor (BEDIT) or REFEDIT, so a block can't be edited in place; no XCLIP; dynamic blocks from other drawings lose their behaviour (F2, F3) | 35–55 |
|
||||
| Use blocks (insert library blocks, edit them, attributes, xrefs) | **partial, blocking** | Static BLOCK/INSERT/attributes, ATTSYNC, BATTMAN editing, xrefs (#525) and the Block Editor (BEDIT/BSAVE/BCLOSE: every reference updates) work; no REFEDIT, so a block can't be edited in place in the drawing; no XCLIP; dynamic blocks from other drawings lose their behaviour (F2, F3) | 35–55 |
|
||||
| Layers, layouts and plotting a sheet | **partial, blocking** | Layers, layouts, viewports, page setups, PDF output, plot styles (CTB/STB, #410), the Plot dialog (#160), Plot Preview and PUBLISH (#186) and plotter devices (#564) work; printers only via PDF/PNG/SVG files (F14, H3) | 15–25 |
|
||||
| Save DWG that AutoCAD opens without loss, and reopen others' DWGs | **no** | DXF keeps what we don't model (#528) and writes native MULTILEADERs (#459, not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects and some foreign data; never checked in AutoCAD; no real-file tests; a 10 MB DWG failed after ~2 min (#57) (FF1–FF6) | 40–60 + owner (real-file corpus, AutoCAD check) |
|
||||
| **To alpha** | | | **≈ 125–190** |
|
||||
|
|
@ -103,6 +103,7 @@ AutoLISP surface, AI features with openly licensed local models, and every remai
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Block Editor for static blocks landed: "Use blocks" evidence updated; status and hours unchanged (REFEDIT and dynamic blocks remain) |
|
||||
| 2026-10-11 | minor | Alpha-gate evidence refreshed for merged work (#459, #525, #528, #160, #186, #564); statuses and hours unchanged |
|
||||
| 2026-10-10 | minor | Merged main: fmt fixed, Ukrainian catalog noted in M13, ready for real work 38% |
|
||||
| 2026-10-10 | minor | Alpha gate added (5 core workflows, 4 failing); stage pre-alpha |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Target-app parity: CADCraft vs AutoCAD 2027
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (hatch dialogs added to the Hatch row's evidence; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Block Editor for static blocks; blocks evidence updated, numbers not re-measured) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work
|
||||
remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall,
|
||||
|
|
@ -154,7 +154,8 @@ presence: an option that prints "not available yet" doesn't count.
|
|||
| Text and MTEXT | 6% | 35% | 30–45 | TrueType, formatting codes, stacking; MTEXT prompt options (#408), indents, tabs, bullets, numbering and columns (#547), annotative scaling (#468); editor is a dialog, not in place; no fields or SPELL (open PR #551), no SHX | [gaps](gaps.md#features) |
|
||||
| Leaders, tables, fields | 4% | 25% | 25–35 | MLEADER options and block content (#408), native MULTILEADER in DXF (#459); tables with cell styles, formulas, breaking and CSV data links (#534); no MLEADEREDIT/ALIGN/COLLECT, no FIELD | [gaps](gaps.md#features) |
|
||||
| Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); Hatch and Gradient / Hatch Edit dialogs (no Preview or Inherit Properties) | [gaps](gaps.md#features) |
|
||||
| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); no BEDIT, dynamic blocks, REFEDIT, XCLIP, DesignCenter | [gaps](gaps.md#features) |
|
||||
| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT; dynamic block references read and edited; no dynamic-block authoring, XCLIP, DesignCenter | [gaps](gaps.md#features) |
|
||||
| Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); no hatch dialog | [gaps](gaps.md#features) |
|
||||
| Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, per-VP layers, page setups, plot styles (CTB/STB), Plot dialog (#160), Plot Preview and PUBLISH (#186), plotter devices (#564), PDF with text, layers and sheets (#567, #571), polygonal/object/clipped viewports (#453), annotative scales (#468); no MVIEW Restore, no system printers | [gaps](gaps.md#features) |
|
||||
| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563), MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) |
|
||||
| Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; DOF report, G2 Smooth, ellipse/spline/block geometry and CONSTRAINTINFER landed (#546) | [geometry-parity](geometry-parity.md#constraint-solver--50) |
|
||||
|
|
@ -224,6 +225,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of
|
|||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured |
|
||||
| 2026-10-12 | minor | Blocks row evidence: Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); percentages and hours not re-measured |
|
||||
| 2026-10-11 | minor | Evidence text of the feature-area rows refreshed for merged work (#397, #406, #408, #417, #420, #433, #448, #451, #453, #459, #468, #507, #510, #525, #534, #546, #547, #558, #563, #564, #567, #571); parity percentages and hours not re-measured |
|
||||
| 2026-10-10 | minor | Merged main: dynamic input pointer boxes (#60) lift UI/UX to 43%, Ukrainian catalog (#36) lifts localization to 5%; ready 37% → 38%, mainstream 27% → 28%, essentials 45% → 46% |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (1,150–1,850 h), mainstream (570–880 h) and essentials (140–225 h); full number confirmed as an additive weighted sum, unchanged |
|
||||
|
|
|
|||
Loading…
Reference in New Issue