diff --git a/crates/engine/src/cmd/blocks/blockreplace.rs b/crates/engine/src/cmd/blocks/blockreplace.rs index c20012784..03bac655d 100644 --- a/crates/engine/src/cmd/blocks/blockreplace.rs +++ b/crates/engine/src/cmd/blocks/blockreplace.rs @@ -27,6 +27,20 @@ fn named_block(d: &Drawing, name: &str) -> Option { d.block(name.trim()).filter(|b| !b.anonymous && !b.name.starts_with('*') && !b.name.contains('|')).map(|b| b.name.clone()) } +/// Why the references of block `old` cannot be replaced: a dynamic block's references keep their +/// property values in data that names the block. +fn not_replaceable(d: &Drawing, old: &str) -> Option { + cadcraft_doc::dynblock::dynamic_block(d, old).map(|_| format!("Block \"{old}\" is a dynamic block; its references cannot be replaced.")) +} + +/// Why block `new` cannot replace block `old`: references of `old` inside `new` would make `new` +/// reference itself. +fn cannot_replace_with(d: &Drawing, old: &str, new: &str) -> Option { + let blk = d.block(new)?; + references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::>(), old) + .then(|| format!("Block \"{new}\" references \"{old}\", so it cannot replace it.")) +} + /// Replace every reference of `old` by a reference of `new`; with `purge`, remove `old`'s /// definition when nothing references it afterwards. Returns (references replaced, purged). fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, bool)> { @@ -46,15 +60,10 @@ fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, if old.eq_ignore_ascii_case(&new) { return Err(bad(cmd, "the replacement block must be a different block")); } - // A dynamic block's references keep their property values in data that names the block. - if cadcraft_doc::dynblock::dynamic_block(d, &old).is_some() { - return Err(bad(cmd, format!("Block \"{old}\" is a dynamic block; its references cannot be replaced."))); + if let Some(why) = not_replaceable(d, &old).or_else(|| cannot_replace_with(d, &old, &new)) { + return Err(bad(cmd, why)); } let blk = d.block(&new).cloned().ok_or_else(|| bad(cmd, format!("Block \"{new}\" not found.")))?; - // References of `old` inside `new` would make `new` reference itself. - if references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::>(), &old) { - return Err(bad(cmd, format!("Block \"{new}\" references \"{old}\", so it cannot replace it."))); - } let defs = attdefs(&blk); let scale = d.current_anno_scale(); let d = s.doc_mut()?; @@ -132,17 +141,24 @@ struct BlockreplaceM { } impl BlockreplaceM { - /// Take `name` as the answer to the current name prompt. + /// Take `name` as the answer to the current name prompt. A block that cannot take part is + /// refused here, so the prompt asks again. fn take(&mut self, s: &Session, stage: Stage, name: &str) -> Result<()> { let d = s.doc()?; let name = named_block(d, name).ok_or_else(|| EngineError::Other(format!("Block \"{}\" not found.", name.trim())))?; if stage == Stage::Old { + if let Some(why) = not_replaceable(d, &name) { + return Err(EngineError::Other(why)); + } self.old = name; self.stage = Stage::New; } else { if name.eq_ignore_ascii_case(&self.old) { return Err(EngineError::Other("The replacement block must be a different block.".into())); } + if let Some(why) = cannot_replace_with(d, &self.old, &name) { + return Err(EngineError::Other(why)); + } self.new = name; self.stage = Stage::Purge; } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f6cb4412d..dab5d4eb7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -463,29 +463,53 @@ fn run_copybase(s: &mut Session, p: &Value) -> Result { Ok(json!({ "copied": n })) } -/// Copy the objects that are not on locked layers to the clipboard and erase them. -fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result { - let hs: Vec = hs.iter().copied().filter(|h| !super::curves::is_locked(s, *h)).collect(); +/// Copy the objects that are not on locked layers to the clipboard and erase them. Returns how +/// many were cut and the message to show: how many were on a locked layer, and that nothing was +/// cut when that is all of them (the clipboard then keeps what it held). +fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result<(usize, Option)> { + let mut seen = std::collections::HashSet::new(); + let live: Vec = { + let d = s.doc()?; + hs.iter().copied().filter(|h| seen.insert(*h) && d.entity(*h).is_some()).collect() + }; + let (hs, locked): (Vec, Vec) = live.into_iter().partition(|h| !super::curves::is_locked(s, *h)); + let note = match locked.len() { + 0 => None, + 1 => Some("1 object was on a locked layer.".to_string()), + n => Some(format!("{n} objects were on a locked layer.")), + }; + if hs.is_empty() { + let msg = note.map_or_else(|| "Nothing was cut.".to_string(), |m| format!("{m} Nothing was cut.")); + return Ok((0, Some(msg))); + } let n = copy_to_clip(s, &hs, base)?; let d = s.doc_mut()?; for h in &hs { d.remove_entity(*h); } s.set_selection(Vec::new()); - Ok(n) + Ok((n, note)) +} + +fn cut_result(n: usize, msg: Option) -> Value { + let mut r = json!({ "cut": n }); + if let Some(m) = msg { + r["message"] = json!(m); + } + r } fn run_cut(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; - let n = cut_to_clip(s, &hs, None)?; - Ok(json!({ "cut": n })) + let (n, msg) = cut_to_clip(s, &hs, None)?; + Ok(cut_result(n, msg)) } fn run_cutbase(s: &mut Session, p: &Value) -> Result { let base = point_req("cutbase", p, "base")?; let hs = targets(s, p)?; - let n = cut_to_clip(s, &hs, Some(base))?; - Ok(json!({ "cut": n })) + let (n, msg) = cut_to_clip(s, &hs, Some(base))?; + Ok(cut_result(n, msg)) } /// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its @@ -680,7 +704,9 @@ struct CopyBaseM { impl CopyBaseM { fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result { if self.cut { - cut_to_clip(s, hs, self.base)?; + if let (_, Some(m)) = cut_to_clip(s, hs, self.base)? { + s.echo(m); + } } else { copy_to_clip(s, hs, self.base)?; } diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index bccd7b2d8..3b6837342 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -140,8 +140,12 @@ struct Signer<'a> { blocks: HashMap, work: usize, /// When set, a block's signature also covers its base point, units, annotative and overlay - /// flags and xref file, with a relative xref path taken from the folder given, and the - /// signatures of a multileader and a dimension cover their blocks'. + /// flags and xref file, with relative xref, image and data link paths taken from the folder + /// given, and the + /// signatures of a multileader and a dimension cover their blocks'. Each object in a block + /// then also counts with its visibility, transparency, thickness and extrusion, and an + /// anonymous block's signature leaves out its name (a dimension also leaves its block's name + /// out of its own geometry; an INSERT or MLEADER keeps it). frames: Option>, } @@ -190,9 +194,15 @@ impl<'a> Signer<'a> { return *v; } let mut h = std::collections::hash_map::DefaultHasher::new(); - key.hash(&mut h); + let block = self.d.block(name); + // With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the + // same block can have another generated name in another drawing. Only a dimension's + // geometry leaves the name out, so this matches renamed `*D` blocks, not `*U` references. + if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) { + key.hash(&mut h); + } if depth < MAX_BLOCK_DEPTH - && let Some(b) = self.d.block(name) + && let Some(b) = block { if let Some(folder) = self.frames { let mut xref = b.xref_path.clone(); @@ -206,8 +216,20 @@ impl<'a> Signer<'a> { break; } self.work += 1; - self.geometry(&e.kind, depth + 1).hash(&mut h); + match self.frames { + // An image's file and a table's data link, from the folder given. + Some(folder) if matches!(e.kind, EntityKind::Image(_) | EntityKind::Table(_)) => { + let mut k = e.kind.clone(); + export::rebase(&mut k, folder); + self.geometry(&k, depth + 1).hash(&mut h); + } + _ => self.geometry(&e.kind, depth + 1).hash(&mut h), + } properties(e).hash(&mut h); + if self.frames.is_some() { + let c = &e.common; + format!("{:?}|{}|{}|{:?}", c.transparency, c.visible, (c.thickness * 1e6).round(), c.extrusion).hash(&mut h); + } } } let v = h.finish(); diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 0bb063cda..f3801049d 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -5,8 +5,9 @@ //! only in this drawing or changed in it are drawn in the overlay's green, objects only in the //! other drawing or changed there in its red, and objects in both in gray (color 8). A block //! reference, dimension or multileader refers to a copy of its block whose objects all have that -//! color (`COMPARE_CURRENT_`, `COMPARE_COMPARED_`, `COMPARE_COMMON_`), and a -//! table or tolerance uses a recolored copy of its style; xrefs keep their own colors. The new +//! color (`COMPARE_CURRENT_`, `COMPARE_COMPARED_`, `COMPARE_COMMON_`; for a +//! dimension's `*D` block, another `*D` block), and a table or tolerance uses a recolored copy +//! of its style; xrefs keep their own colors. The new //! drawing has this drawing's layers, styles and blocks, plus the layers and styles of the other //! drawing that this one lacks and the blocks its copied objects refer to; an anonymous block of //! the other drawing whose name this one uses is copied under a free name. Relative xref, image @@ -130,7 +131,7 @@ pub(super) fn rebase_path(p: &mut String, folder: Option<&Path>) { } /// Rebase the file an object refers to: an image's file, a table's data link. -fn rebase(k: &mut EntityKind, folder: Option<&Path>) { +pub(super) fn rebase(k: &mut EntityKind, folder: Option<&Path>) { match k { EntityKind::Image(i) => rebase_path(&mut i.path, folder), EntityKind::Table(t) => { @@ -326,6 +327,34 @@ struct Tinted { uncolored: bool, /// Objects copied into block definitions so far, counted against [`MAX_COPIED`]. copied: usize, + next: NextFree, + /// The drawing's block names in upper case, filled on the first block copy. Only + /// [`tinted_block`] adds blocks after that, and it adds their names here too. + blocks: Option>, +} + +/// The next index to try for each name base. Names are only added while a snapshot is built, +/// so an index found taken stays taken, and each search starts where the last one for the same +/// base stopped: a name costs O(1) amortised instead of a scan from 1. +#[derive(Default)] +struct NextFree(HashMap<(&'static str, String), usize>); + +impl NextFree { + /// The first of `name(i)` for `i` from 1 that `taken` doesn't hold, skipping the indices + /// earlier searches for `base` in namespace `kind` passed. `n` is how many names there are, + /// so one of `name(1)`…`name(n + 1)` is free. The caller adds the returned name. + fn find(&mut self, kind: &'static str, base: &str, n: usize, name: impl Fn(usize) -> String, taken: impl Fn(&str) -> bool) -> String { + let key = (kind, base.to_ascii_uppercase()); + let start = self.0.get(&key).copied().unwrap_or(1); + let i = (start..=n.saturating_add(1)).find(|&i| !taken(&name(i))).unwrap_or_else(|| n.saturating_add(2).max(start)); + self.0.insert(key, i.saturating_add(1)); + name(i) + } +} + +/// `base` for 1, `base_2`, `base_3`… after it. +fn suffixed(base: &str, i: usize) -> String { + if i == 1 { base.to_string() } else { format!("{base}_{i}") } } /// The word a comparison color adds to the names of its recolored copies. @@ -341,10 +370,7 @@ fn tag(color: Color) -> &'static str { /// `base`, or `base_2`, `base_3`… when `taken` holds that name. `n` is how many names there are. fn free_name(base: &str, n: usize, taken: impl Fn(&str) -> bool) -> String { - (1..=n + 1) - .map(|i| if i == 1 { base.to_string() } else { format!("{base}_{i}") }) - .find(|c| !taken(c)) - .unwrap_or_else(|| format!("{base}_{}", n + 2)) + (1..=n + 1).map(|i| suffixed(base, i)).find(|c| !taken(c)).unwrap_or_else(|| suffixed(base, n + 2)) } /// Give an object its comparison color: its own color, its attributes' colors, and, through @@ -358,22 +384,27 @@ fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, dep }; for a in attribs.into_iter().flatten() { a.props.color = Some(color); + if let Some(ml) = a.multiline.as_mut() { + ml.mtext.contents = without_color_codes(&ml.mtext.contents); + } } - // Colors an object carries apart from its own: inline MTEXT colors, a dimension style's - // DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element colors, the style colors of - // a table or a tolerance (through a recolored copy of the style) and inline cell colors. Hatch - // backgrounds, multiline fills and table cell fills are left out. + // Colors an object carries apart from its own: inline MTEXT colors (in multiline attributes + // too), a dimension style's DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element + // colors, the style colors of a table or a tolerance (through a recolored copy of the style) + // and inline cell colors. Hatch backgrounds, multiline fills and table cell fills are left out. match &mut e.kind { EntityKind::MText(t) => t.contents = without_color_codes(&t.contents), + EntityKind::AttDef(a) => { + if let Some(ml) = a.multiline.as_mut() { + ml.mtext.contents = without_color_codes(&ml.mtext.contents); + } + } EntityKind::MLeader(m) => { if let Some(t) = m.text.as_mut() { t.contents = without_color_codes(&t.contents); } } EntityKind::Dimension(dm) => { - // Drawn again from its geometry in the object's color; a named copy of its `*D` - // block would stay behind in saved files. - dm.block = None; if let Ok(by_block) = serde_json::to_value(Color::ByBlock) { for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] { dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var)); @@ -408,6 +439,12 @@ fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, dep } _ => {} } + if let EntityKind::Dimension(dm) = &mut e.kind { + // A dimension whose block can't be copied (past the limits) is drawn again from its + // geometry: its DIMCLR* overrides are ByBlock, so the original block would keep its colors. + dm.block = dm.block.take().and_then(|n| tinted_block(d, &n, color, tinted, depth)); + return; + } each_ref(&mut e.kind, |n| { if let Some(copy) = tinted_block(d, n, color, tinted, depth) { *n = copy; @@ -423,9 +460,14 @@ fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tint return Some(n.clone()); } let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); - let new = free_name(&format!("COMPARE_{}_{}", tag(color), st.name), d.dim_styles.len(), |n| { - d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) - }); + let base = format!("COMPARE_{}_{}", tag(color), st.name); + let new = tinted.next.find( + "dimension style", + &base, + d.dim_styles.len(), + |i| suffixed(&base, i), + |n| d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)), + ); d.dim_styles.push(DimStyle { name: new.clone(), dim_line_color: Color::ByBlock, @@ -445,9 +487,14 @@ fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Ti return Some(n.clone()); } let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); - let new = free_name(&format!("COMPARE_{}_{}", tag(color), st.name), d.table_styles.len(), |n| { - d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) - }); + let base = format!("COMPARE_{}_{}", tag(color), st.name); + let new = tinted.next.find( + "table style", + &base, + d.table_styles.len(), + |i| suffixed(&base, i), + |n| d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)), + ); let names = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].into_iter().map(str::to_string).chain(st.cell_styles.iter().map(|c| c.name.clone())); let mut cell_styles: Vec = Vec::new(); @@ -516,11 +563,23 @@ fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, return None; } tinted.copied += b.entities.len(); - let new = free_name(&format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')), d.blocks.len(), |n| d.block(n).is_some()); + // A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of + // drawing the dimension again. + let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D")); + let Tinted { next, blocks, .. } = tinted; + let taken = blocks.get_or_insert_with(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect()); + let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase()); + let new = if dim_block { + next.find("block", "*D", d.blocks.len(), |i| format!("*D{i}"), is_taken) + } else { + let base = format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')); + next.find("block", &base, d.blocks.len(), |i| suffixed(&base, i), is_taken) + }; + taken.insert(new.to_ascii_uppercase()); tinted.names.insert(key, new.clone()); // Hold the name while the nested copies pick theirs. d.blocks.insert(new.clone(), Arc::new(Block::new(&new))); - let mut copy = Block { name: new.clone(), anonymous: false, entities: EntityStore::new(), ..(*b).clone() }; + let mut copy = Block { name: new.clone(), anonymous: dim_block, entities: EntityStore::new(), ..(*b).clone() }; for e in b.entities.iter() { let h = d.new_handle(); let mut e = Entity { handle: h, ..(**e).clone() }; @@ -592,18 +651,19 @@ fn copy_blocks<'a>( // Names `d` does not use stay; the others get free names, avoiding the kept ones. let mut renames = Renames::new(); let mut taken: HashSet = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect(); + let mut next = NextFree::default(); for b in needed.iter().filter(|b| d.block(&b.name).is_none()) { taken.insert(b.name.to_ascii_uppercase()); renames.insert(b.name.to_ascii_uppercase(), b.name.clone()); } for b in needed.iter().filter(|b| d.block(&b.name).is_some()) { + let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase()); let free = if b.anonymous { let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect(); - (1..=taken.len() + 1).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) + next.find("anonymous block", &prefix, taken.len(), |i| format!("{prefix}{i}"), is_taken) } else { - Some(free_name(&b.name, taken.len(), |n| taken.contains(&n.to_ascii_uppercase()))) + next.find("block", &b.name, taken.len(), |i| suffixed(&b.name, i), is_taken) }; - let Some(free) = free else { continue }; taken.insert(free.to_ascii_uppercase()); renames.insert(b.name.to_ascii_uppercase(), free); } diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 26f074a96..49e0424c2 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -933,6 +933,8 @@ pub(super) struct AreaM { volume: bool, /// Areas measured in volume mode that still wait for their height, oldest first. pending: Vec, + /// The first point of a height given by two points. + height_from: Option, } impl AreaM { @@ -960,6 +962,19 @@ impl AreaM { AreaMode::Subtract => self.total - v, } } + /// Measure the oldest pending area's volume with height `h`. + fn height(&mut self, s: &mut Session, h: f64) -> Step { + match self.pending.first().map(|a| a * h.abs()) { + Some(v) if v.is_finite() && self.total_with(v).is_finite() => { + self.pending.remove(0); + self.volume_measured(s, v) + } + _ => { + s.echo("The volume is too large."); + Step::Continue + } + } + } /// Report a volume (and the running total in add/subtract mode); ends the command in single mode. fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step { s.echo(volume_text(s, v)); @@ -1003,7 +1018,10 @@ impl Interactive for AreaM { } fn prompt(&self, _s: &Session) -> Prompt { if !self.pending.is_empty() { - return Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::NUMBER); + return match self.height_from { + Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p), + None => Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::POINT_OR_NUMBER), + }; } if self.object { return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT); @@ -1023,17 +1041,21 @@ impl Interactive for AreaM { } fn input(&mut self, s: &mut Session, i: Input) -> Result { if !self.pending.is_empty() { - if let Input::Text(t) = i { - match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { - Some(h) => match self.pending.first().map(|a| a * h.abs()) { - Some(v) if v.is_finite() && self.total_with(v).is_finite() => { - self.pending.remove(0); - return Ok(self.volume_measured(s, v)); - } - _ => s.echo("The volume is too large."), - }, - None => s.echo("Requires a distance."), + // A height is typed, or given by two points (their distance). + match (i, self.height_from) { + (Input::Point(p), None) => self.height_from = Some(p), + (Input::Point(p), Some(from)) => { + self.height_from = None; + return Ok(self.height(s, p.dist(from))); } + (Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { + Some(h) => return Ok(self.height(s, h)), + None => s.echo("Requires a distance."), + }, + (Input::Enter, None) => s.echo("Requires a height."), + (Input::Enter, Some(_)) => s.echo("Requires a second point."), + (Input::Text(_), Some(_)) => s.echo("Requires a point."), + _ => {} } return Ok(Step::Continue); } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 75dc0b046..c89204c22 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -539,10 +539,17 @@ impl Interactive for LtscaleM { } } +/// The largest THICKNESS, either way. +const MAX_THICKNESS: f64 = 1e12; + +fn thickness_in_range(t: f64) -> bool { + t.is_finite() && t.abs() <= MAX_THICKNESS +} + fn run_thickness(s: &mut Session, p: &Value) -> Result { let t = f64_req("thickness", p, "thickness")?; - if !t.is_finite() || t.abs() > 1e12 { - return Err(bad("thickness", "thickness is out of range")); + if !thickness_in_range(t) { + return Err(bad("thickness", "thickness is out of range (-1e12 to 1e12)")); } s.doc_mut()?.header.set_f64("THICKNESS", t); Ok(json!({ "thickness": t })) @@ -565,9 +572,16 @@ impl Interactive for ThicknessM { Input::Text(t) => t, _ => return Ok(Step::Continue), }; - match super::machines::number(&text).map(|t| run_thickness(s, &json!({ "thickness": t }))) { - Some(Ok(_)) => Ok(Step::Done), - _ => { + match super::machines::number(&text) { + Some(t) if thickness_in_range(t) => { + run_thickness(s, &json!({ "thickness": t }))?; + Ok(Step::Done) + } + Some(_) => { + s.echo("Requires a value between -1e12 and 1e12."); + Ok(Step::Continue) + } + None => { s.echo("Requires a number."); Ok(Step::Continue) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 731256107..8c590dd97 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1317,6 +1317,12 @@ fn format_thickness_asks_for_the_thickness() { // After text that is not a number, the prompt asks again. s.cmdline("thick").unwrap(); assert!(s.current_prompt().is_some(), "asked again"); + assert_eq!(s.log.last().map(String::as_str), Some("Requires a number.")); + // A value out of range says so, and the prompt asks again. + s.cmdline("2e12").unwrap(); + assert!(s.current_prompt().is_some(), "asked again"); + assert_eq!(s.log.last().map(String::as_str), Some("Requires a value between -1e12 and 1e12.")); + assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), 0.0); // A negative thickness is accepted. s.cmdline("-1.5").unwrap(); assert!(s.current_prompt().is_none()); diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs index c410db91c..650d63092 100644 --- a/crates/engine/tests/clipboard.rs +++ b/crates/engine/tests/clipboard.rs @@ -182,7 +182,7 @@ fn cutbase_cuts_with_the_given_base_point() { s.active = a; let r = s.execute("cutbase", &json!({ "handles": handles(&s), "base": [5, 5] })).unwrap(); // The wall is on a locked layer, so only the door is cut. - assert_eq!(r, json!({ "cut": 1 })); + assert_eq!(r, json!({ "cut": 1, "message": "1 object was on a locked layer." })); assert_eq!(s.clipboard.len(), 1); assert_eq!(s.clipboard_base, Vec2::new(5.0, 5.0)); let left = model(&s); @@ -201,6 +201,35 @@ fn cutbase_cuts_with_the_given_base_point() { assert_eq!(s.doc().unwrap().model.len(), 2); } +#[test] +fn cutting_only_locked_objects_keeps_the_clipboard() { + let mut s = Session::empty(); + let mut da = drawing_a(); + for l in &mut da.layers { + l.locked = true; + } + s.open_drawing(da, "A", None); + let hs = handles(&s); + s.execute("copyclip", &json!({ "handles": [hs[0]] })).unwrap(); + let held = s.clipboard.clone(); + let r = s.execute("cutclip", &json!({ "handles": hs })).unwrap(); + assert_eq!(r, json!({ "cut": 0, "message": "2 objects were on a locked layer. Nothing was cut." })); + assert_eq!(model(&s).len(), 2, "nothing was erased"); + assert_eq!(s.clipboard.len(), 1, "the clipboard keeps what it held"); + assert_eq!(s.clipboard[0].handle, held[0].handle); + + // CUTBASE at the command line says so too. + s.start("cutbase").unwrap(); + s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap(); + let wall = s.doc().unwrap().model.iter().next().unwrap().handle; + s.input(cadcraft_engine::Input::Pick(vec![wall])).unwrap(); + s.input(cadcraft_engine::Input::Enter).unwrap(); + assert!(s.running.is_none()); + assert!(s.log.iter().any(|l| l.contains("1 object was on a locked layer. Nothing was cut.")), "{:?}", s.log); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(model(&s).len(), 2); +} + #[test] fn cutbase_and_copybase_need_a_base_point_and_take_each_object_once() { let mut s = Session::empty(); diff --git a/crates/engine/tests/it/blockreplace.rs b/crates/engine/tests/it/blockreplace.rs index eefb23f1f..86b8c42e3 100644 --- a/crates/engine/tests/it/blockreplace.rs +++ b/crates/engine/tests/it/blockreplace.rs @@ -1,8 +1,9 @@ //! BLOCKREPLACE (Express Tools): every reference of one block, in model space, the layouts and //! other blocks, becomes a reference of another at the same insertion point, scale and rotation. +use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject}; use cadcraft_engine::doc::geom::Vec3; -use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, Space}; +use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, RawTag, Space}; use cadcraft_engine::{Input, Session}; use serde_json::{Value, json}; @@ -184,3 +185,39 @@ fn blockreplace_no_keeps_the_replaced_definition() { assert!(d.block("A").is_some()); assert_eq!(refs(d.model.iter()), ["N", "B"]); } + +#[test] +fn blockreplace_refuses_a_block_as_soon_as_its_name_is_given() { + let (mut s, _) = drawing(); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + // N holds a reference of A: refused at once, and the prompt asks for another name. + s.cmdline("N").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Block \"N\" references \"A\"")), "{:?}", s.log); + assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text()); + s.cmdline("B").unwrap(); + assert!(s.prompt_text().contains("Purge the unreferenced block definition A?"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + + // A dynamic block is refused as the block to replace. + let mut d = s.doc().unwrap().clone(); + let mut f = Foreign::default(); + f.records.insert(("BLOCK_RECORD".into(), "A".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() }); + let tags = |t: &[(i32, &str)]| t.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect(); + f.objects.push(ForeignObject { + handle: Handle(0xF00), + kind: "DICTIONARY".into(), + tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]), + }); + f.objects.push(ForeignObject { handle: Handle(0xF01), kind: "ACAD_EVALUATION_GRAPH".into(), tags: tags(&[(100, "AcDbEvalGraph")]) }); + d.foreign = std::sync::Arc::new(f); + s.open_drawing(d, "dynamic", None); + let e = s.execute("blockreplace", &json!({"block": "A", "with": "B"})).unwrap_err().to_string(); + assert!(e.contains("dynamic block"), "{e}"); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Block \"A\" is a dynamic block")), "{:?}", s.log); + assert!(s.prompt_text().contains("Enter the name of the block to replace"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]); +} diff --git a/crates/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs index 2d2e13905..79d28737e 100644 --- a/crates/engine/tests/it/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -351,13 +351,197 @@ fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition( .unwrap_or_default(); assert_eq!(drawn, [Some("*D2".to_string())], "the other drawing's PART is copied with its dimension block"); assert_eq!(ends(&s, "*D2"), vec![5.0]); - // The colored copies redraw their dimensions instead of keeping colored copies of `*D` blocks. + // The colored copies of PART refer to colored copies of the dimension block, which are + // anonymous `*D` blocks of their own, not the originals. let d = s.doc().unwrap(); - let dim_copy = |n: &str| { - n.starts_with("COMPARE_") - && n.rsplit('_').next().is_some_and(|t| t.len() > 1 && t.starts_with('D') && t[1..].bytes().all(|c| c.is_ascii_digit())) + let copies: Vec = d + .blocks + .values() + .filter(|b| b.name.starts_with("COMPARE_")) + .flat_map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.block.clone().unwrap_or_default()), + _ => None, + }) + .collect::>() + }) + .collect(); + assert!(!copies.is_empty(), "PART has colored copies"); + for n in &copies { + assert!(n.starts_with("*D") && !["*D1", "*D2"].contains(&n.as_str()), "{n} in {copies:?}"); + assert!(d.block(n).is_some_and(|b| b.anonymous), "{n} is an anonymous block"); + } +} + +#[test] +fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_drawings_definition() { + use cadcraft_engine::doc::{DimKind, Dimension}; + // PART holds the same dimension in both drawings; the block it draws holds the same line but + // is named `*D1` here and `*D3` in the other drawing. + let blocks = |drawn_name: &str, first: u64| { + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some(drawn_name.into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim))); + let mut drawn = Block::new(drawn_name); + drawn.entities.push(Entity::new(Handle(first + 1), line(1.0))); + [part, drawn] }; - assert!(!d.blocks.keys().any(|n| dim_copy(n)), "{:?}", d.blocks.keys().collect::>()); + let mut s = Session::new(); + for b in blocks("*D1", 0x700) { + s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b)); + } + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + for b in blocks("*D3", 0x8000) { + other.blocks.insert(b.name.clone(), Arc::new(b)); + } + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + assert!(s.doc().unwrap().block("PART_2").is_none(), "PART draws the same thing in both drawings"); +} + +#[test] +fn a_named_block_whose_line_is_hidden_or_thicker_in_the_other_drawing_shows_the_other_definition() { + let run = |theirs: Common| { + let part = |common: Common, first: u64| { + let mut part = Block::new("PART"); + let mut e = Entity::new(Handle(first), line(1.0)); + e.common = common; + part.entities.push(e); + part + }; + let mut s = Session::new(); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(Common::default(), 0x700))); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + other.blocks.insert("PART".into(), Arc::new(part(theirs, 0x8000))); + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + s.doc().unwrap().block("PART_2").is_some() + }; + assert!(run(Common { visible: false, ..Common::default() }), "a hidden line"); + assert!(run(Common { thickness: 2.0, ..Common::default() }), "a line with thickness"); + assert!(!run(Common::default()), "the same line"); +} + +#[test] +fn a_named_block_with_the_same_relative_image_path_from_another_folder_shows_the_other_definition() { + // PART holds an image at `a.png` in both drawings; this drawing is unsaved, the other one is + // a file in another folder, so its `a.png` is another file. + let part = |first: u64| { + let at = serde_json::to_value(Vec3::ZERO).unwrap(); + let image = serde_json::from_value(json!({"insert": at, "u": at, "v": at, "size": [1.0, 1.0], "path": "a.png"})).unwrap(); + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(first), EntityKind::Image(image))); + part + }; + let dir = std::env::temp_dir().join("compareexport-image-folder"); + let mut s = Session::new(); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(0x700))); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + other.blocks.insert("PART".into(), Arc::new(part(0x8000))); + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", Some(dir.join("other.dxf").to_string_lossy().to_string())); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let paths: Vec = d + .block("PART_2") + .map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Image(i) => Some(i.path.clone()), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + assert_eq!(paths.len(), 1, "the other drawing's PART is copied"); + assert_eq!(std::path::Path::new(&paths[0]).parent(), Some(std::path::absolute(&dir).unwrap().as_path())); +} + +#[test] +fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() { + use cadcraft_engine::doc::{DimKind, Dimension}; + // The dimension's block holds a line its parameters would not draw: a block read from a file. + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + let mut s = Session::new(); + let mut drawn = Block::new("*D1"); + drawn.entities.push(Entity::new(Handle(0x701), line(123.0))); + s.doc_mut().unwrap().blocks.insert("*D1".into(), Arc::new(drawn)); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim)).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap(); + let block = read + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Dimension(dm) => dm.block.clone(), + _ => None, + }) + .unwrap(); + let ends: Vec = read + .block(&block) + .unwrap() + .entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some(l.b.x), + _ => None, + }) + .collect(); + assert_eq!(ends, [123.0], "the saved dimension block is the copy's"); } #[test] @@ -448,6 +632,51 @@ fn colors_carried_apart_from_the_object_color_are_replaced() { assert_eq!(d.dim_style("Standard").unwrap().dim_line_color, Color::Index(1)); } +#[test] +fn inline_colors_of_multiline_attributes_are_replaced() { + use cadcraft_engine::doc::Attrib; + let attrib = |contents: &str| -> Attrib { + let at = serde_json::to_value(Vec3::ZERO).unwrap(); + serde_json::from_value(json!({ + "tag": "T", + "text": {"insert": at, "height": 1.0, "value": "x"}, + "multiline": {"mtext": {"insert": at, "height": 1.0, "contents": contents}, "readAs": "x"}, + })) + .unwrap() + }; + let mut s = Session::new(); + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(0x700), EntityKind::AttDef(attrib("\\C1;x")))); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part)); + let reference = Insert { attribs: vec![attrib("\\C3;x")], ..insert("PART") }; + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let multiline = |a: &Attrib| a.multiline.as_ref().map(|m| m.mtext.contents.clone()); + let mut blocks = Vec::new(); + for e in members(&s, r["current"].as_str().unwrap()) { + if let EntityKind::Insert(i) = &e.kind { + assert_eq!(i.attribs.iter().map(multiline).collect::>(), [Some("x".to_string())], "the reference's attribute"); + blocks.push(i.block.clone()); + } + } + assert_eq!(blocks.len(), 1); + let d = s.doc().unwrap(); + let defs: Vec> = d + .block(&blocks[0]) + .unwrap() + .entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::AttDef(a) => Some(multiline(a)), + _ => None, + }) + .collect(); + assert_eq!(defs, [Some("x".to_string())], "the attribute definition in the recolored block"); +} + #[test] fn edits_after_compare_count_and_objects_keep_their_layers() { let mut s = Session::new(); @@ -575,6 +804,64 @@ fn says_when_blocks_both_drawings_define_differently_were_left_out() { assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]); } +#[test] +fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() { + use cadcraft_engine::doc::{DimKind, Dimension, MAX_BLOCK_DEPTH}; + // B1 holds B2, …, and the innermost block holds a dimension: its `*D1` copy would be past + // the nesting limit. + let mut s = Session::new(); + let d = s.doc_mut().unwrap(); + let mut drawn = Block::new("*D1"); + drawn.entities.push(Entity::new(Handle(0x600), line(30.0))); + d.blocks.insert("*D1".into(), Arc::new(drawn)); + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + for k in 1..=MAX_BLOCK_DEPTH { + let mut b = Block::new(&format!("B{k}")); + let kind = if k == MAX_BLOCK_DEPTH { EntityKind::Dimension(dim.clone()) } else { EntityKind::Insert(insert(&format!("B{}", k + 1))) }; + b.entities.push(Entity::new(Handle(0x700 + k as u64), kind)); + d.blocks.insert(b.name.clone(), Arc::new(b)); + } + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("B1"))).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let dims: Vec> = d + .blocks + .values() + .filter(|b| b.name.starts_with("COMPARE_")) + .flat_map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.block.clone()), + _ => None, + }) + .collect::>() + }) + .collect(); + // The colored copy keeps no block, so it is drawn in the comparison color, not with `*D1`'s. + assert_eq!(dims, [None], "{:?}", d.blocks.keys().collect::>()); +} + #[test] fn needs_a_comparison_of_the_current_drawing() { let mut s = Session::new(); diff --git a/crates/engine/tests/it/inquiry_keywords.rs b/crates/engine/tests/it/inquiry_keywords.rs index 1a46bc5c9..5a2c4cb6e 100644 --- a/crates/engine/tests/it/inquiry_keywords.rs +++ b/crates/engine/tests/it/inquiry_keywords.rs @@ -229,6 +229,14 @@ fn measuregeom_volume_multiplies_an_area_by_a_height() { assert!(has(&log, "Requires a distance."), "{log:?}"); assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text()); + // Enter is not a height; two points give the height as their distance. + let (mut s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "", "1,1"]); + assert!(has(&log, "Requires a height."), "{log:?}"); + assert!(s.prompt_text().contains("Specify second point"), "{}", s.prompt_text()); + s.cmdline("7,9").unwrap(); + assert!(has(&s.log, "Volume = 60.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); + // The JSON forms, by points and by handle. let mut s = Session::new(); let r = s.execute("measuregeom", &json!({"mode": "volume", "points": [[0, 0], [4, 0], [4, 3]], "height": 10})).unwrap(); diff --git a/docs/gaps.md b/docs/gaps.md index 5f8e2833e..a4a12cff8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (DIMTFILL and DIMTFILLCLR: F7 narrowed; COMPAREEXPORT exports a comparison to a new drawing: F22 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **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 @@ -29,7 +29,7 @@ do them first. | 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 (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 | | 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; 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; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | +| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects; with DIMTFILL 1 the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | | F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 | | F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 | | F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing MTEXT cannot be edited (no DDEDIT/MTEDIT), stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_ vs .dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed | | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed |