diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs index f406e1e..0e8f53c 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 0000000..02df21c --- /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 c7d5bd8..b9d071c 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 a6892bd..00db5a3 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 c108056..da1d8d2 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 0f2777a..ba4564c 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 466b95b..0af5328 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 0cf2724..0917ed2 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) |