From 73b8edac6119fc174bc92bd8ed3d8f37256277fb Mon Sep 17 00:00:00 2001 From: codyvogt Date: Tue, 13 Oct 2026 03:42:37 -0600 Subject: [PATCH] Dynamic block references: custom properties in Properties (stacked on #838) (#853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Dynamic blocks: read parameters and actions from AutoCAD files A dynamic block's evaluation graph (kept as foreign data since #528) is read into a typed model, `cadcraft_doc::dynblock`, without changing the kept data: parameters (linear with value sets, point, visibility states, lookup, base point; polar, XY, rotation, alignment and flip by geometry) and actions (stretch with its frame and moved vertices, lookup tables; move, scale, rotate, flip, array, polar stretch by kind), each with its graph id and the parameters it follows. - `blocks.dynamic {name}` returns the model as JSON (handles as hex); `blocks.list` marks dynamic blocks. - Counts and values come from the file: capped by the groups present, non-finite numbers and missing members skipped. - Tests: a synthetic DOOR block (visibility, linear list, stretch, lookup), its survival through a save, damaged data, and the engine commands. Co-Authored-By: Claude Opus 5.5 * Dynamic block references: their values and custom properties A dynamic block reference's extension dictionary says which dynamic block it shows (AcDbBlockRepresentation ▸ AcDbRepData) and the values it was given (AppDataCache ▸ ACAD_ENHANCEDBLOCKDATA, one XRECORD per parameter graph id). `dynblock::reference` reads them; a reference to the dynamic block itself has the block's values. - `dynblock::custom_properties`: the Properties palette's Custom section, in parameter order, only parameters shown in Properties: visibility state, lookup value worked out from the lookup tables (their "no match" name, e.g. Custom, when the inputs match no row), distances and point positions in drawing units (block units × the reference's scale), with the choices of each (states, lookup values, a distance's list). - Properties: a dynamic block reference shows its dynamic block's name instead of its anonymous block's, and a read-only Custom section. - `blocks.reference {handle}` returns the definition, values and properties. - Tests: synthetic references (own values, an anonymous block scaled 2, a width no lookup row has), survival through a save, the engine command. Co-Authored-By: Claude Opus 5.5 * DWG: dynamic block lookup parameters keep their name and lookup action AutoCAD writes a lookup parameter's subclass marker as AcDbBlockLookUpParameter; the DWG bridge reads AcDbBlockLookupParameter and matches it exactly, so the parameter's name (303) and lookup action (94) were lost in DWG files and AutoCAD's lookups stopped working (a visibility change no longer resized the block). The bridge DXF now uses its spelling. Found by driving AutoCAD on CADCraft's output. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/dynblock.rs | 3 + crates/doc/src/dynblock/reference.rs | 259 ++++++++++++++++++++++ crates/engine/src/cmd/blocks.rs | 20 ++ crates/engine/tests/dynamic_block_data.rs | 21 ++ crates/io/tests/dynblock_read.rs | 120 ++++++++++ crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/palettes.rs | 27 ++- docs/file-format-parity.md | 2 + 8 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 crates/doc/src/dynblock/reference.rs diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs index f406e1ee..0e8f53c7 100644 --- a/crates/doc/src/dynblock.rs +++ b/crates/doc/src/dynblock.rs @@ -19,6 +19,9 @@ use serde::Serialize; use crate::foreign::ForeignObject; use crate::{Drawing, Handle, RawTag}; +mod reference; +pub use reference::{CustomProperty, Reference, Value, custom_properties, reference}; + /// Most objects one evaluation graph is read with. const MAX_NODES: usize = 10_000; diff --git a/crates/doc/src/dynblock/reference.rs b/crates/doc/src/dynblock/reference.rs new file mode 100644 index 00000000..02df21cf --- /dev/null +++ b/crates/doc/src/dynblock/reference.rs @@ -0,0 +1,259 @@ +//! Dynamic block references: which dynamic block a block reference shows, the values it was given +//! and the custom properties those make. +//! +//! A reference whose values differ from the block's own refers to an anonymous block (`*U12`) +//! holding the result, and its extension dictionary says what it is: `AcDbBlockRepresentation` +//! has `AcDbRepData` (pointing at the dynamic block's BLOCK_RECORD) and an `AppDataCache` whose +//! `ACAD_ENHANCEDBLOCKDATA` dictionary holds one XRECORD per parameter, keyed by its graph id, with +//! the value in block coordinates. A reference to the dynamic block itself has the block's values. + +use std::collections::{BTreeMap, HashMap}; + +use cadcraft_geom::Vec3; +use serde::Serialize; + +use super::{ActionKind, DynamicBlock, ParameterKind, dynamic_block}; +use crate::foreign::ForeignObject; +use crate::{Drawing, EntityKind, Handle}; + +/// Most values one reference is read with. +const MAX_VALUES: usize = 10_000; + +/// A parameter's value on one reference, in block coordinates. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum Value { + /// A visibility state's name or a lookup value. + Text { value: String }, + /// A point parameter's position. + Point { at: Vec3 }, + /// A two-point parameter's points. + Points { base: Vec3, end: Vec3 }, +} + +/// A dynamic block reference. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Reference { + /// The dynamic block it shows (its name in the drawing). + pub definition: String, + /// The values it was given, by parameter graph id; parameters missing here have the block's + /// own values. + pub values: BTreeMap, +} + +/// One custom property of a reference, as the Properties palette shows it. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomProperty { + pub name: String, + pub value: String, + /// The values it may be set to, when it is a choice (visibility states, lookup values, a + /// distance's list). + pub choices: Vec, +} + +fn objects(d: &Drawing) -> HashMap { + d.foreign.objects.iter().map(|o| (o.handle, o)).collect() +} + +/// The object entry `key` of dictionary `dict` refers to. +fn entry(objects: &HashMap, dict: Handle, key: &str) -> Option { + let o = objects.get(&dict)?; + let mut tags = o.tags.iter(); + while let Some(t) = tags.next() { + if t.code == 3 && t.value.eq_ignore_ascii_case(key) { + return tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value)); + } + } + None +} + +/// Every entry of dictionary `dict`: (key, object). +fn entries(objects: &HashMap, dict: Handle) -> Vec<(String, Handle)> { + let Some(o) = objects.get(&dict) else { return Vec::new() }; + let mut out = Vec::new(); + let mut key: Option<&str> = None; + for t in &o.tags { + match t.code { + 3 => key = Some(t.value.as_str()), + 350 | 360 => { + if let (Some(k), Some(h)) = (key.take(), Handle::parse_hex(&t.value)) { + out.push((k.to_string(), h)); + } + } + _ => {} + } + } + out +} + +/// A value XRECORD: its text (group 1) when it has one, else its points (10/20/30). +fn value(o: &ForeignObject) -> Option { + if let Some(t) = o.tags.iter().find(|t| t.code == 1) { + return Some(Value::Text { value: t.value.clone() }); + } + let num = |code: i32, i: usize| { + o.tags.iter().filter(|t| t.code == code).nth(i).and_then(|t| t.value.trim().parse::().ok()).filter(|v| v.is_finite()) + }; + let point = |i: usize| Some(Vec3::new(num(10, i)?, num(20, i)?, num(30, i).unwrap_or(0.0))); + match (point(0), point(1)) { + (Some(base), Some(end)) => Some(Value::Points { base, end }), + (Some(at), None) => Some(Value::Point { at }), + _ => None, + } +} + +/// The dynamic block reference `insert` is, or `None` when it isn't one. +pub fn reference(d: &Drawing, insert: Handle) -> Option { + let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None }; + let objects = objects(d); + let rep = d.foreign.entities.get(&insert).and_then(|x| x.xdict).and_then(|x| entry(&objects, x, "AcDbBlockRepresentation")); + let Some(rep) = rep else { + // No representation: a reference to a dynamic block with its own values. + return dynamic_block(d, &ins.block) + .is_some() + .then(|| Reference { definition: d.block(&ins.block).map_or_else(|| ins.block.clone(), |b| b.name.clone()), values: BTreeMap::new() }); + }; + let data = entry(&objects, rep, "AcDbRepData").and_then(|h| objects.get(&h).copied())?; + let target = data.tags.iter().find(|t| t.code == 340).and_then(|t| Handle::parse_hex(&t.value))?; + let (kind, name) = d.foreign.targets.get(&target)?; + let definition = d.block(name).filter(|_| kind == "BLOCK_RECORD")?.name.clone(); + dynamic_block(d, &definition)?; + let mut values = BTreeMap::new(); + let cache = entry(&objects, rep, "AppDataCache").and_then(|c| entry(&objects, c, "ACAD_ENHANCEDBLOCKDATA")); + for (key, h) in cache.map(|c| entries(&objects, c)).unwrap_or_default().into_iter().take(MAX_VALUES) { + if let (Ok(node), Some(v)) = (key.trim().parse::(), objects.get(&h).and_then(|o| value(o))) { + values.insert(node, v); + } + } + Some(Reference { definition, values }) +} + +/// A number as the drawing shows lengths (LUPREC decimals). +fn number(d: &Drawing, v: f64) -> String { + let prec = usize::try_from(d.header.i64("LUPREC", 4).clamp(0, 8)).unwrap_or(4); + format!("{v:.prec$}") +} + +/// Whether table cell `cell` reads as `value` (numbers compare as numbers). +fn same(cell: &str, value: &str) -> bool { + match (cell.trim().parse::(), value.trim().parse::()) { + (Ok(a), Ok(b)) => (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0), + _ => cell.trim().eq_ignore_ascii_case(value.trim()), + } +} + +/// The current values of a reference's parameters, for evaluating its lookup tables. +struct Current<'a> { + m: &'a DynamicBlock, + values: &'a BTreeMap, +} + +impl Current<'_> { + /// Parameter `node`'s value of property `property`, as table cells write it. + fn property(&self, node: i32, property: &str) -> Option { + let p = self.m.parameters.iter().find(|p| p.node == node)?; + let v = self.values.get(&node); + match (&p.kind, property.to_ascii_lowercase().as_str()) { + (ParameterKind::Visibility { states, .. }, "visibilitystate") => match v { + Some(Value::Text { value }) => Some(value.clone()), + _ => states.first().map(|s| s.name.clone()), + }, + (ParameterKind::Linear { base, end, .. }, "updateddistance" | "distance") => { + let (b, e) = match v { + Some(Value::Points { base, end }) => (*base, *end), + _ => (*base, *end), + }; + Some(format!("{}", (e - b).len())) + } + (ParameterKind::Lookup { .. }, "lookupstring") => match v { + Some(Value::Text { value }) => Some(value.clone()), + _ => None, + }, + _ => None, + } + } + + /// Lookup parameter `node`'s value: the value every lookup table that sets it gives for the + /// current inputs, or the tables' "no match" name ("Custom") when they don't agree or a table + /// has no matching row. Also the values it may be set to. + fn lookup(&self, node: i32) -> (String, Vec) { + let mut found: Option> = None; + let mut unmatched = String::from("Custom"); + let mut choices: Vec = Vec::new(); + for a in &self.m.actions { + let ActionKind::Lookup { columns, rows } = &a.kind else { continue }; + let Some(out) = columns.iter().position(|c| c.lookup && c.node == node) else { continue }; + if rows.is_empty() { + continue; + } + if let Some(c) = columns.get(out) { + unmatched = c.unmatched.clone(); + } + for r in rows { + if let Some(v) = r.get(out).filter(|v| !choices.contains(v)) { + choices.push(v.clone()); + } + } + let hit = rows.iter().find(|r| { + columns.iter().enumerate().filter(|(_, c)| !c.lookup).all(|(i, c)| match (r.get(i), self.property(c.node, &c.property)) { + (Some(cell), Some(v)) => same(cell, &v), + _ => false, + }) + }); + let v = hit.and_then(|r| r.get(out).cloned()); + found = Some(match found { + None => v, + Some(prev) if prev == v => prev, + Some(_) => None, + }); + } + (found.flatten().unwrap_or(unmatched), choices) + } +} + +/// The custom properties block reference `insert` shows (the Properties palette's Custom +/// section): its dynamic block's name and, in parameter order, each parameter shown in Properties +/// with its value. Lengths and positions are in drawing units (block units × the reference's +/// scale); positions are relative to the block's base point. +pub fn custom_properties(d: &Drawing, insert: Handle) -> Option<(String, Vec)> { + let r = reference(d, insert)?; + let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None }; + let m = dynamic_block(d, &r.definition)?; + let base = d.block(&r.definition).map(|b| b.base).unwrap_or_default(); + let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs()); + let cur = Current { m: &m, values: &r.values }; + let mut out = Vec::new(); + for p in m.parameters.iter().filter(|p| p.show_properties) { + let v = r.values.get(&p.node); + match &p.kind { + ParameterKind::Linear { base: b, end: e, label, values, .. } => { + let (b, e) = match v { + Some(Value::Points { base, end }) => (*base, *end), + _ => (*b, *e), + }; + let choices = if values.is_list() { values.list.iter().map(|x| number(d, x * sx)).collect() } else { Vec::new() }; + out.push(CustomProperty { name: label.clone(), value: number(d, (e - b).len() * sx), choices }); + } + ParameterKind::Point { at, label, .. } => { + let at = match v { + Some(Value::Point { at }) => *at, + _ => *at, + }; + out.push(CustomProperty { name: format!("{label} X"), value: number(d, (at.x - base.x) * sx), choices: Vec::new() }); + out.push(CustomProperty { name: format!("{label} Y"), value: number(d, (at.y - base.y) * sy), choices: Vec::new() }); + } + ParameterKind::Visibility { label, states, .. } => { + let value = cur.property(p.node, "VisibilityState").unwrap_or_default(); + out.push(CustomProperty { name: label.clone(), value, choices: states.iter().map(|s| s.name.clone()).collect() }); + } + ParameterKind::Lookup { label, .. } => { + let (value, choices) = cur.lookup(p.node); + out.push(CustomProperty { name: label.clone(), value, choices }); + } + _ => {} + } + } + Some((r.definition, out)) +} diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index c7d5bd8d..b9d071c3 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -47,6 +47,10 @@ pub fn specs() -> Vec { .params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)") .enabled(has_doc) .noundo(), + CommandSpec::new("blocks.reference", "Dynamic Block Reference", run_reference) + .params("{handle (a block reference)} → {definition, values (by parameter graph id), properties: [{name, value, choices}]} | null (not a dynamic block reference)") + .enabled(has_doc) + .noundo(), CommandSpec::new("purge", "Purge", purge::run) .alias(&["pu", "-purge"]) .params("{type?: all|blocks|dimstyles|groups|layers|linetypes|mleaderstyles|tablestyles|textstyles|zerolength|emptytext (default all: every named type), names?: \"A*,B\" (default *)} → purged") @@ -348,6 +352,22 @@ fn run_dynamic(s: &mut Session, p: &Value) -> Result { serde_json::to_value(cadcraft_doc::dynblock::dynamic_block(d, name)).map_err(|e| bad("blocks.dynamic", e.to_string())) } +/// The dynamic block a block reference shows, the values it was given and its custom properties +/// (null for any other reference). +fn run_reference(s: &mut Session, p: &Value) -> Result { + let h = super::curves::handle_param(p, "handle").ok_or_else(|| bad("blocks.reference", "`handle` is required"))?; + let d = s.doc()?; + match d.entity(h).map(|e| &e.kind) { + Some(EntityKind::Insert(_)) => {} + Some(_) => return Err(bad("blocks.reference", "`handle` is not a block reference")), + None => return Err(bad("blocks.reference", format!("no object {}", h.hex()))), + } + let (Some(r), Some((_, properties))) = (cadcraft_doc::dynblock::reference(d, h), cadcraft_doc::dynblock::custom_properties(d, h)) else { + return Ok(Value::Null); + }; + Ok(json!({ "definition": r.definition, "values": r.values, "properties": properties })) +} + // ---------------- interactive ---------------- /// -BLOCK's JSON form: BLOCK with the selection erased unless `keep` says otherwise. diff --git a/crates/engine/tests/dynamic_block_data.rs b/crates/engine/tests/dynamic_block_data.rs index a6892bdd..00db5a38 100644 --- a/crates/engine/tests/dynamic_block_data.rs +++ b/crates/engine/tests/dynamic_block_data.rs @@ -87,3 +87,24 @@ fn blocks_without_dynamic_data_and_bad_names() { assert!(s.execute("blocks.dynamic", &p).is_err(), "{p}"); } } + +#[test] +fn a_reference_to_a_dynamic_block_reports_its_custom_properties() { + let mut s = session(); + // BLOCK made the line a reference to Door, which has Door's own values. + let insert = s.doc().unwrap().model.handles()[0].hex(); + let r = s.execute("blocks.reference", &json!({ "handle": insert })).unwrap(); + assert_eq!(r["definition"], "Door"); + assert_eq!(r["values"], json!({})); + assert_eq!(r["properties"], json!([{ "name": "Visibility1", "value": "Open", "choices": ["Open", "Closed"] }])); + + // A plain block's reference, objects that aren't references and bad parameters. + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap()["handles"][0].clone(); + let plain = s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l.clone()] })).unwrap()["insert"].clone(); + assert_eq!(s.execute("blocks.reference", &json!({ "handle": plain })).unwrap(), json!(null)); + let l = s.execute("line", &json!({ "points": [[0, 0], [2, 2]] })).unwrap()["handles"][0].clone(); + assert!(s.execute("blocks.reference", &json!({ "handle": l })).is_err()); + for p in [json!({}), json!({ "handle": "zz" }), json!({ "handle": "FFFFFF" }), json!(null)] { + assert!(s.execute("blocks.reference", &p).is_err(), "{p}"); + } +} diff --git a/crates/io/tests/dynblock_read.rs b/crates/io/tests/dynblock_read.rs index c1080565..da1d8d27 100644 --- a/crates/io/tests/dynblock_read.rs +++ b/crates/io/tests/dynblock_read.rs @@ -530,3 +530,123 @@ fn damaged_dynamic_block_data_is_skipped_not_trusted() { } assert!(dynamic_block(&d, "Door").is_none(), "no graph handle"); } + +/// The fixture with references to Door: one with Door's own values (handle 80), and two to +/// anonymous blocks with values of their own, as another program leaves them: 81 (scale 2, +/// Closed, Width 30, so Size is Narrow) and 82 (Width 33, which no Size row has). +fn with_references() -> String { + let record = + |h: &str, name: &str| format!("0 BLOCK_RECORD\n5 {h}\n330 1\n100 AcDbSymbolTableRecord\n100 AcDbBlockTableRecord\n2 {name}\n340 0\n70 0\n"); + let block = |h: &str, rec: &str, name: &str| { + format!( + "0 BLOCK\n5 {h}\n330 {rec}\n100 AcDbEntity\n8 0\n100 AcDbBlockBegin\n2 {name}\n70 1\n10 0\n20 0\n30 0\n3 {name}\n1\n0 LINE\n5 {h}1\n330 {rec}\n100 AcDbEntity\n8 0\n100 AcDbLine\n10 0\n20 0\n30 0\n11 30\n21 0\n31 0\n0 ENDBLK\n5 {h}2\n330 {rec}\n100 AcDbEntity\n8 0\n100 AcDbBlockEnd\n" + ) + }; + let insert = |h: &str, block: &str, xdict: Option<&str>, scale: f64| { + let x = xdict.map(|x| format!("102 {{ACAD_XDICTIONARY\n360 {x}\n102 }}\n")).unwrap_or_default(); + format!( + "0 INSERT\n5 {h}\n{x}330 1F\n100 AcDbEntity\n8 0\n100 AcDbBlockReference\n2 {block}\n10 100\n20 0\n30 0\n41 {scale}\n42 {scale}\n43 {scale}\n" + ) + }; + let dict = |h: &str, owner: &str, entries: &[(&str, &str)]| { + let e: String = entries.iter().map(|(k, v)| format!("3 {k}\n360 {v}\n")).collect(); + format!("0 DICTIONARY\n5 {h}\n330 {owner}\n100 AcDbDictionary\n280 1\n281 1\n{e}") + }; + let xrecord = + |h: &str, owner: &str, body: &str| format!("0 XRECORD\n5 {h}\n330 {owner}\n100 AcDbXrecord\n280 1\n1071 1\n1071 2\n70 25\n70 104\n{body}"); + // The values of a reference: representation data pointing at Door's record (1D) and one + // XRECORD per parameter graph id. + let values = |p: &str, owner: &str, vis: &str, width: f64, size: &str| { + [ + dict(&format!("{p}0"), owner, &[("AcDbBlockRepresentation", &format!("{p}1"))]), + dict(&format!("{p}1"), &format!("{p}0"), &[("AcDbRepData", &format!("{p}2")), ("AppDataCache", &format!("{p}3"))]), + format!("0 ACDB_BLOCKREPRESENTATION_DATA\n5 {p}2\n330 {p}1\n100 AcDbBlockRepresentationData\n70 1\n340 1D\n"), + dict(&format!("{p}3"), &format!("{p}1"), &[("ACAD_ENHANCEDBLOCKDATA", &format!("{p}4"))]), + dict( + &format!("{p}4"), + &format!("{p}3"), + &[("1", &format!("{p}5")), ("2", &format!("{p}6")), ("3", &format!("{p}7")), ("4", &format!("{p}8"))], + ), + xrecord(&format!("{p}5"), &format!("{p}4"), &format!("10 18\n20 40\n30 0\n1 {vis}\n")), + xrecord(&format!("{p}6"), &format!("{p}4"), &format!("10 0\n20 -4\n30 0\n10 {width}\n20 -4\n30 0\n10 0\n20 0\n30 -1\n")), + xrecord(&format!("{p}7"), &format!("{p}4"), "40 0\n"), + xrecord(&format!("{p}8"), &format!("{p}4"), &format!("10 -6\n20 40\n30 0\n1 {size}\n")), + ] + .concat() + }; + let src = dxf(FIXTURE); + let lines = |s: String| dxf(&s); + src.replace("0\nENDTAB\n", &format!("{}0\nENDTAB\n", lines(record("A1", "*U1") + &record("B1", "*U2")))) + .replace( + "0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nENDSEC\n", + &lines( + block("A2", "A1", "*U1") + + &block("B2", "B1", "*U2") + + "0 ENDSEC\n0 SECTION\n2 ENTITIES\n" + + &insert("80", "Door", None, 1.0) + + &insert("81", "*U1", Some("C0"), 2.0) + + &insert("82", "*U2", Some("D0"), 1.0) + + "0 ENDSEC\n", + ), + ) + .replace( + "0\nENDSEC\n0\nEOF\n", + &format!("{}0\nENDSEC\n0\nEOF\n", lines(values("C", "81", "Closed", 30.0, "Narrow") + &values("D", "82", "Open", 33.0, "Narrow"))), + ) +} + +fn props(d: &cadcraft_doc::Drawing, h: &str) -> (String, Vec<(String, String, Vec)>) { + let (name, ps) = cadcraft_doc::dynblock::custom_properties(d, Handle::parse_hex(h).unwrap()).unwrap(); + (name, ps.into_iter().map(|p| (p.name, p.value, p.choices)).collect()) +} + +#[test] +fn dynamic_block_references_show_their_values() { + let d = read_dxf(with_references().as_bytes()).unwrap(); + assert_eq!(d.model.len(), 3, "the three references were read"); + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + + // Door's own values. + let (name, p) = props(&d, "80"); + assert_eq!(name, "Door"); + assert_eq!( + p, + [ + ("Visibility1".into(), "Open".into(), s(&["Open", "Closed"])), + ("Width".into(), "36.0000".into(), s(&["30.0000", "32.0000", "36.0000"])), + ("Size".into(), "Wide".into(), s(&["Narrow", "Wide"])), + ] + ); + + // An anonymous block with values of its own, scaled 2: lengths in drawing units. + let r = cadcraft_doc::dynblock::reference(&d, h("81")).unwrap(); + assert_eq!(r.definition, "Door"); + assert_eq!(r.values.len(), 3, "the action's record has no value"); + let (name, p) = props(&d, "81"); + assert_eq!(name, "Door"); + assert_eq!( + p, + [ + ("Visibility1".into(), "Closed".into(), s(&["Open", "Closed"])), + ("Width".into(), "60.0000".into(), s(&["60.0000", "64.0000", "72.0000"])), + ("Size".into(), "Narrow".into(), s(&["Narrow", "Wide"])), + ] + ); + + // A width no Size row has: the lookup shows its "no match" name, whatever was stored. + let (_, p) = props(&d, "82"); + assert_eq!(p[2], ("Size".into(), "Custom".into(), s(&["Narrow", "Wide"]))); + + // Other references and objects aren't dynamic block references. + assert!(cadcraft_doc::dynblock::reference(&d, h("30")).is_none(), "a line in the block"); + assert!(cadcraft_doc::dynblock::reference(&d, h("FFFF")).is_none()); +} + +#[test] +fn dynamic_block_references_survive_a_save() { + let d = read_dxf(with_references().as_bytes()).unwrap(); + let again = read_dxf(write_dxf(&d).as_bytes()).unwrap(); + for hx in ["80", "81", "82"] { + assert_eq!(props(&again, hx), props(&d, hx), "{hx}"); + } +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0f2777a3..ba4564cc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1283,3 +1283,5 @@ Sheet Books to PDF... Книги аркушів у PDF... Not a number or distance: {text} Не число й не відстань: {text} Dynamic Block Data Дані динамічного блока + Custom Користувацькі + Dynamic Block Reference Посилання на динамічний блок diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 466b95bc..0af53281 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -576,6 +576,21 @@ fn unavailable(ui: &mut egui::Ui, text: &str) { value_box(ui, crate::i18n::t(text), false, false).on_hover_text(crate::tl!("Not available yet")); } +type Custom = Option<(String, Vec)>; + +/// The dynamic block name and custom properties of block reference `h`, worked out once per +/// drawing revision. +fn custom_properties(ui: &egui::Ui, app: &CadApp, h: cadcraft_engine::doc::Handle) -> Custom { + let st = app.session.state().ok()?; + let id = egui::Id::new(("dynamic-block-custom", st.uid, st.revision, h)); + if let Some(c) = ui.data(|d| d.get_temp::(id)) { + return c; + } + let c = cadcraft_engine::doc::dynblock::custom_properties(&st.doc, h); + ui.data_mut(|d| d.insert_temp(id, c.clone())); + c +} + /// A greyed value computed from the geometry. fn read_only(ui: &mut egui::Ui, text: &str) { value_box(ui, crate::i18n::t(text), false, false).on_hover_text(crate::tl!("Read-only")); @@ -1042,12 +1057,22 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); } EntityKind::Insert(i) => { - prop_row(ui, "Name", |ui| read_only(ui, &i.block)); + // A dynamic block reference shows its dynamic block's name, not its anonymous + // block's (`*U12`), and its custom properties below. + let custom = custom_properties(ui, app, e.handle); + let name = custom.as_ref().map_or_else(|| i.block.clone(), |(n, _)| n.clone()); + prop_row(ui, "Name", |ui| read_only(ui, &name)); prop_row(ui, "Position X", |ui| read_only(ui, &len(i.insert.x))); prop_row(ui, "Position Y", |ui| read_only(ui, &len(i.insert.y))); prop_row(ui, "Position Z", |ui| read_only(ui, &len(i.insert.z))); num(ui, "Rotation", "rotation", real(i.rotation.to_degrees()), &mut set, &mut bad, &ids); num(ui, "Scale", "scale", real(i.scale.x), &mut set, &mut bad, &ids); + if let Some((_, props)) = custom.filter(|(_, p)| !p.is_empty()) { + group(ui, "Custom"); + for p in &props { + prop_row(ui, &p.name, |ui| read_only(ui, &p.value)); + } + } } _ => {} } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0cf2724f..0917ed23 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -94,6 +94,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr | Objects: VISUALSTYLE, PLOTSETTINGS, materials, field objects and other unmodelled objects | kept raw (#528) | written back as read | not editable | | Foreign xdata, extension dictionaries, reactors, unmodelled objects, colour books | kept raw (#528); an entity's xdata is part of the entity, so COPY, MIRROR, ARRAY and paste keep it on the copies (EXPLODE pieces don't) | written back, handles remapped, references to deleted objects dropped | through DWG: xdata on table records and blocks, entity colour books and reactors, custom-class objects lost (acadrust) | | Objects: dynamic block data (ACAD_EVALUATION_GRAPH, BLOCK…PARAMETER, BLOCK…ACTION, grips) | read into a model (`dynblock`, `blocks.dynamic`): parameters, visibility states, value sets, stretch and lookup actions; kept raw (#528) | written back as read | not applied to references or editable yet; DWG open and save keep it too (checked with an AutoCAD dynamic block) | +| Objects: dynamic block data (ACAD_EVALUATION_GRAPH, BLOCK…PARAMETER, BLOCK…ACTION, grips) | read into a model (`dynblock`, `blocks.dynamic`): parameters, visibility states, value sets, stretch and lookup actions; references: their dynamic block, values and custom properties (`blocks.reference`, Properties ▸ Custom, read-only); kept raw (#528) | written back as read | values can't be changed yet; DWG open and save keep it too (checked with an AutoCAD dynamic block) | | Foreign xdata, extension dictionaries, reactors, unmodelled objects, colour books | kept raw (#528) | written back, handles remapped, references to deleted objects dropped | through DWG: xdata on table records and blocks, entity colour books and reactors, custom-class objects lost (acadrust) | | Handles and owners | entity handles kept; others regenerated | consistent | stable across CADCraft re-saves (#495); other programs' table and object handles renumbered once | @@ -139,6 +140,7 @@ our output in AutoCAD black-box. | 2026-10-12 | minor | DXF/DWG save: the Model layout's plot settings are written as CADCraft plots model space, drawing extents scaled to fit (PLOTSETTINGS 74 = 1, 75 = 0), instead of plot type 5 "Layout information", which other programs' Plot dialogs can't use for model space | | 2026-10-12 | minor | VIEWPORT frozen layers also written and read as AutoCAD's group 341 layer handles, so third-party viewers honour per-viewport freezing in DXF (DWG converter gap noted) | | 2026-10-12 | minor | HATCH: associative hatches inside blocks keep their source objects; boundaries carry `{ACAD_REACTORS}` | +| 2026-10-12 | minor | Dynamic block references: definition, values and custom properties read (`blocks.reference`, Properties ▸ Custom) | | 2026-10-12 | minor | Dynamic block data row: parameters and actions read into a model (`blocks.dynamic`); kept by DXF and DWG save | | 2026-10-11 | minor | Rows refreshed for merged work: code pages and `\M+` (#505), MULTILEADER (#459), IMAGE, MLINE/HELIX/REGION/TOLERANCE, VPORT/VIEW/UCS tables, modelled and raw objects, foreign data (#528), DWT templates and user `.lin`/`.pat`/`.pgp` files (#552), PDF output (#567, #571), plotter devices (#564), data extraction (#433, #534); weighted percentages not re-measured | | 2026-10-11 | minor | DXF (ASCII and binary) and DWG saved at 2000–2018, 2018 by default (#421) |