diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs new file mode 100644 index 00000000..f406e1ee --- /dev/null +++ b/crates/doc/src/dynblock.rs @@ -0,0 +1,510 @@ +//! Dynamic blocks made in another program, read from the data CADCraft keeps for them. +//! +//! A dynamic block's behaviour lives in objects CADCraft does not write itself and keeps as +//! [`Foreign`](crate::foreign::Foreign) data: the block's BLOCK_RECORD has an extension dictionary +//! whose `ACAD_ENHANCEDBLOCK` entry is an evaluation graph (`ACAD_EVALUATION_GRAPH`), and the +//! graph lists the block's *parameters* (a distance, a point, visibility states, a lookup…) and +//! *actions* (what changing a parameter does to the block's objects: stretch, move, look up…). +//! [`dynamic_block`] reads them into the model below without changing the kept data, so a drawing +//! saved again still carries them exactly as read. +//! +//! Every count and index here comes from the file: they are capped by the data actually present +//! and unknown or damaged objects are skipped, never trusted. + +use std::collections::HashMap; + +use cadcraft_geom::{Vec2, Vec3}; +use serde::Serialize; + +use crate::foreign::ForeignObject; +use crate::{Drawing, Handle, RawTag}; + +/// Most objects one evaluation graph is read with. +const MAX_NODES: usize = 10_000; + +/// Handles as hex strings in JSON, as commands report them. +fn hex(h: &Handle, s: S) -> Result { + s.serialize_str(&h.hex()) +} +fn hexes(hs: &[Handle], s: S) -> Result { + s.collect_seq(hs.iter().map(|h| h.hex())) +} +fn hex_vertices(v: &[(Handle, Vec)], s: S) -> Result { + s.collect_seq(v.iter().map(|(h, i)| (h.hex(), i))) +} + +/// A dynamic block's parameters and actions. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DynamicBlock { + pub parameters: Vec, + pub actions: Vec, + /// Graph members of types not modelled here (grips, constraints…), by DXF type. + pub other: Vec, +} + +/// One parameter: a property a reference to the block can change. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Parameter { + /// Its id in the evaluation graph (actions and lookups refer to parameters by it). + pub node: i32, + #[serde(serialize_with = "hex")] + pub handle: Handle, + /// The name the block editor shows ("Linear", "Visibility State"). + pub element: String, + /// The parameter's properties appear in a reference's Properties palette. + pub show_properties: bool, + pub kind: ParameterKind, +} + +/// What a parameter is, with its definition geometry in block coordinates. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum ParameterKind { + /// A distance between two points (Distance1). + Linear { base: Vec3, end: Vec3, label: String, description: String, values: ValueSet }, + /// A distance and an angle from a base point. + Polar { base: Vec3, end: Vec3 }, + /// Horizontal and vertical distances from a base point. + Xy { base: Vec3, end: Vec3 }, + /// An angle about a base point. + Rotation { base: Vec3, end: Vec3 }, + /// Aligns the reference with nearby geometry. + Alignment { base: Vec3, end: Vec3 }, + /// Mirrors the objects about a reflection line. + Flip { base: Vec3, end: Vec3 }, + /// A position (Position1). + Point { at: Vec3, label: String, description: String }, + /// Named sets of visible objects. + Visibility { + at: Vec3, + label: String, + description: String, + #[serde(serialize_with = "hexes")] + controlled: Vec, + states: Vec, + }, + /// A named value chosen from a lookup table. + Lookup { at: Vec3, label: String, description: String }, + /// Moves the block's base point. + BasePoint { at: Vec3 }, + /// A parameter type not modelled yet. + Other { dxf_type: String }, +} + +impl ParameterKind { + /// The property name a reference shows ("Distance1", "Visibility1"), when it has one. + pub fn label(&self) -> Option<&str> { + match self { + ParameterKind::Linear { label, .. } + | ParameterKind::Point { label, .. } + | ParameterKind::Visibility { label, .. } + | ParameterKind::Lookup { label, .. } => Some(label), + _ => None, + } + } +} + +/// One visibility state: its name and the objects shown in it (the parameter's other controlled +/// objects are hidden). +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VisibilityState { + pub name: String, + #[serde(serialize_with = "hexes")] + pub visible: Vec, + /// The parameters and actions active in this state (their objects). + #[serde(serialize_with = "hexes")] + pub active: Vec, +} + +/// The values a distance or angle may take. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValueSet { + /// Which of the limits below apply (see the methods). + pub flags: i32, + pub minimum: f64, + pub maximum: f64, + pub increment: f64, + /// The allowed values, for a list. + pub list: Vec, +} + +impl ValueSet { + pub fn has_minimum(&self) -> bool { + self.flags & 1 != 0 + } + pub fn has_maximum(&self) -> bool { + self.flags & 2 != 0 + } + pub fn has_increment(&self) -> bool { + self.flags & 4 != 0 + } + pub fn is_list(&self) -> bool { + self.flags & 8 != 0 + } +} + +/// One action: what changing parameters does to the block's objects. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Action { + pub node: i32, + #[serde(serialize_with = "hex")] + pub handle: Handle, + /// Its name in the block editor ("Stretch", "Lookup1"). + pub name: String, + /// Where the block editor shows it. + pub at: Vec3, + /// The objects it acts on. + #[serde(serialize_with = "hexes")] + pub selection: Vec, + /// Graph ids of the parameters it follows, with the parameter value each connection reads + /// ("EndXDelta"). + pub connections: Vec<(i32, String)>, + pub kind: ActionKind, +} + +/// What an action does. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum ActionKind { + /// Moves the vertices inside `frame` of the objects listed (all of an object not listed). + Stretch { + frame: Vec, + #[serde(serialize_with = "hex_vertices")] + vertices: Vec<(Handle, Vec)>, + multiplier: f64, + angle_offset: f64, + }, + /// A table: each row sets the listed parameter values together. + Lookup { + columns: Vec, + rows: Vec>, + }, + Move, + Scale, + Rotate, + Flip, + Array, + PolarStretch, + /// An action type not modelled yet. + Other { + dxf_type: String, + }, +} + +/// A lookup table column: the parameter value it reads or sets. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LookupColumn { + /// The parameter's graph id. + pub node: i32, + /// The value it stands for ("VisibilityState", "lookupString"). + pub property: String, + /// A lookup property (shown on the reference) rather than an input. + pub lookup: bool, + /// What the reference shows when no row matches ("Custom"). + pub unmatched: String, +} + +/// The groups of an object split at its subclass markers (100): `(marker, groups after it)`. +fn sections(tags: &[RawTag]) -> Vec<(&str, &[RawTag])> { + let mut out: Vec<(&str, &[RawTag])> = Vec::new(); + let mut start = 0; + let mut name = ""; + for (i, t) in tags.iter().enumerate() { + if t.code == 100 { + out.push((name, tags.get(start..i).unwrap_or_default())); + name = t.value.as_str(); + start = i + 1; + } + } + out.push((name, tags.get(start..).unwrap_or_default())); + out +} + +/// The groups after subclass marker `marker`, empty when the object has none. +fn section<'a>(secs: &[(&str, &'a [RawTag])], marker: &str) -> &'a [RawTag] { + secs.iter().find(|(n, _)| n.eq_ignore_ascii_case(marker)).map_or(&[], |(_, t)| t) +} + +/// Reads groups in order, as an object writes them. +struct Cursor<'a> { + tags: &'a [RawTag], + at: usize, + /// Groups scanned so far. A missing group neither moves the cursor nor ends the loops that + /// ask for it, so a hostile record could make nested counts rescan it again and again; past + /// `budget` every lookup fails at once and every count is zero. + work: usize, + budget: usize, +} + +impl<'a> Cursor<'a> { + fn new(tags: &'a [RawTag]) -> Self { + Cursor { tags, at: 0, work: 0, budget: tags.len().saturating_mul(16).saturating_add(1024) } + } + /// The next group with `code` from here on (the cursor moves past it). + fn next(&mut self, code: i32) -> Option<&'a str> { + if self.work >= self.budget { + return None; + } + let rest = self.tags.get(self.at..)?; + let Some(rel) = rest.iter().position(|t| t.code == code) else { + self.work = self.work.saturating_add(rest.len().max(1)); + return None; + }; + self.work = self.work.saturating_add(rel + 1); + self.at += rel + 1; + self.tags.get(self.at - 1).map(|t| t.value.as_str()) + } + fn string(&mut self, code: i32) -> String { + self.next(code).unwrap_or_default().to_string() + } + fn int(&mut self, code: i32) -> i32 { + self.next(code).and_then(|v| v.trim().parse().ok()).unwrap_or(0) + } + fn real(&mut self, code: i32) -> f64 { + self.next(code).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).unwrap_or(0.0) + } + /// A count read from the file, capped by the groups left (each item needs at least one). + fn count(&mut self, code: i32) -> usize { + let n = usize::try_from(self.int(code)).unwrap_or(0).min(self.tags.len().saturating_sub(self.at)); + if self.work >= self.budget { 0 } else { n } + } + fn handle(&mut self, code: i32) -> Option { + self.next(code).and_then(Handle::parse_hex) + } + fn handles(&mut self, code: i32, n: usize) -> Vec { + (0..n).filter_map(|_| self.handle(code)).collect() + } + /// A point written with codes `x`, `x + 10`, `x + 20`. + fn point(&mut self, x: i32) -> Vec3 { + let px = self.real(x); + let py = self.real(x + 10); + let at = self.at; + // Z is optional: only take it when it is the very next group. + let pz = match self.tags.get(at) { + Some(t) if t.code == x + 20 => self.real(x + 20), + _ => 0.0, + }; + Vec3::new(px, py, pz) + } +} + +/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one. +fn graph_members<'a>(objects: &HashMap, xdict: Handle) -> Option> { + let dict = objects.get(&xdict)?; + let mut graph = None; + let mut tags = dict.tags.iter(); + while let Some(t) = tags.next() { + if t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK") { + graph = tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value)); + break; + } + } + let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?; + let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value)); + Some(members.take(MAX_NODES).filter_map(|h| objects.get(&h).copied()).collect()) +} + +/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a +/// block's own drawing), or `None` when it has none. +pub fn dynamic_block(d: &Drawing, name: &str) -> Option { + let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?; + let objects: HashMap = d.foreign.objects.iter().map(|o| (o.handle, o)).collect(); + let mut out = DynamicBlock::default(); + for o in graph_members(&objects, xdict)? { + let secs = sections(&o.tags); + let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90); + let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300); + let kind = o.kind.to_ascii_uppercase(); + if kind.ends_with("PARAMETER") { + let show_properties = Cursor::new(section(&secs, "AcDbBlockParameter")).int(280) != 0; + out.parameters.push(Parameter { node, handle: o.handle, element, show_properties, kind: parameter(&kind, &secs) }); + } else if kind.ends_with("ACTION") { + out.actions.push(action(o.handle, node, element, &kind, &secs)); + } else { + out.other.push(o.kind.clone()); + } + } + Some(out) +} + +fn parameter(kind: &str, secs: &[(&str, &[RawTag])]) -> ParameterKind { + let two = || { + let mut c = Cursor::new(section(secs, "AcDbBlock2PtParameter")); + (c.point(1010), c.point(1011)) + }; + let one = || Cursor::new(section(secs, "AcDbBlock1PtParameter")).point(1010); + match kind { + "BLOCKLINEARPARAMETER" => { + let (base, end) = two(); + let mut c = Cursor::new(section(secs, "AcDbBlockLinearParameter")); + let label = c.string(305); + let description = c.string(306); + ParameterKind::Linear { base, end, label, description, values: value_set(&mut c) } + } + "BLOCKPOINTPARAMETER" => { + let mut c = Cursor::new(section(secs, "AcDbBlockPointParameter")); + ParameterKind::Point { at: one(), label: c.string(303), description: c.string(304) } + } + "BLOCKVISIBILITYPARAMETER" => visibility(one(), section(secs, "AcDbBlockVisibilityParameter")), + "BLOCKLOOKUPPARAMETER" => { + let mut c = Cursor::new(section(secs, "AcDbBlockLookupParameter")); + ParameterKind::Lookup { at: one(), label: c.string(303), description: c.string(304) } + } + "BLOCKBASEPOINTPARAMETER" => ParameterKind::BasePoint { at: one() }, + "BLOCKPOLARPARAMETER" | "BLOCKXYPARAMETER" | "BLOCKROTATIONPARAMETER" | "BLOCKALIGNMENTPARAMETER" | "BLOCKFLIPPARAMETER" => { + let (base, end) = two(); + match kind { + "BLOCKPOLARPARAMETER" => ParameterKind::Polar { base, end }, + "BLOCKXYPARAMETER" => ParameterKind::Xy { base, end }, + "BLOCKROTATIONPARAMETER" => ParameterKind::Rotation { base, end }, + "BLOCKALIGNMENTPARAMETER" => ParameterKind::Alignment { base, end }, + _ => ParameterKind::Flip { base, end }, + } + } + other => ParameterKind::Other { dxf_type: other.to_string() }, + } +} + +/// A value set after a parameter's name and description: description (307), flags (96), +/// minimum, maximum and increment (141–143), then the listed values (175 count, 144 each). +fn value_set(c: &mut Cursor) -> ValueSet { + c.next(307); + let flags = c.int(96); + let (minimum, maximum, increment) = (c.real(141), c.real(142), c.real(143)); + let n = c.count(175); + let list = (0..n).filter_map(|_| c.next(144).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite())).collect(); + ValueSet { flags, minimum, maximum, increment, list } +} + +/// A visibility parameter: its name and description (301, 302), the objects it controls (93 +/// count, 331 each), then each state (92 count): name (303), visible objects (94, 332) and the +/// parameters and actions active in it (95, 333). +fn visibility(at: Vec3, tags: &[RawTag]) -> ParameterKind { + let mut c = Cursor::new(tags); + let label = c.string(301); + let description = c.string(302); + let n = c.count(93); + let controlled = c.handles(331, n); + let states = (0..c.count(92)) + .map(|_| { + let name = c.string(303); + let n = c.count(94); + let visible = c.handles(332, n); + let n = c.count(95); + VisibilityState { name, visible, active: c.handles(333, n) } + }) + .collect(); + ParameterKind::Visibility { at, label, description, controlled, states } +} + +fn action(handle: Handle, node: i32, name: String, kind: &str, secs: &[(&str, &[RawTag])]) -> Action { + let mut c = Cursor::new(section(secs, "AcDbBlockAction")); + c.next(70); + let n = c.count(71); + let selection = c.handles(330, n); + let at = c.point(1010); + let (connections, kind) = match kind { + "BLOCKSTRETCHACTION" => stretch(section(secs, "AcDbBlockStretchAction")), + "BLOCKLOOKUPACTION" => (Vec::new(), lookup(section(secs, "AcDbBlockLookupAction"))), + "BLOCKMOVEACTION" => (two_connections(section(secs, "AcDbBlockMoveAction")), ActionKind::Move), + "BLOCKSCALEACTION" => (Vec::new(), ActionKind::Scale), + "BLOCKROTATEACTION" => (Vec::new(), ActionKind::Rotate), + "BLOCKFLIPACTION" => (Vec::new(), ActionKind::Flip), + "BLOCKARRAYACTION" => (Vec::new(), ActionKind::Array), + "BLOCKPOLARSTRETCHACTION" => (Vec::new(), ActionKind::PolarStretch), + other => (Vec::new(), ActionKind::Other { dxf_type: other.to_string() }), + }; + Action { node, handle, name, at, selection, connections, kind } +} + +/// Two connections: parameter ids (92, 93) and the values they read (301, 302). +fn two_connections(tags: &[RawTag]) -> Vec<(i32, String)> { + let mut c = Cursor::new(tags); + let (a, b) = (c.int(92), c.int(93)); + let (na, nb) = (c.string(301), c.string(302)); + vec![(a, na), (b, nb)] +} + +/// A stretch action: its two connections, the frame (72 count, 1011/1021 points), the objects +/// whose vertices it moves (73 count; each a 331 handle, a 74 count and 94 vertex indices), then +/// the distance multiplier (140) and angle offset (141). +fn stretch(tags: &[RawTag]) -> (Vec<(i32, String)>, ActionKind) { + let connections = two_connections(tags); + let mut c = Cursor::new(tags); + let n = c.count(72); + let frame = (0..n) + .map(|_| { + let x = c.real(1011); + Vec2::new(x, c.real(1021)) + }) + .collect(); + let objects = c.count(73); + let vertices = (0..objects) + .filter_map(|_| { + let h = c.handle(331)?; + let n = c.count(74); + Some((h, (0..n).map(|_| c.int(94)).collect())) + }) + .collect(); + c.next(75); + let (multiplier, angle_offset) = (c.real(140), c.real(141)); + (connections, ActionKind::Stretch { frame, vertices, multiplier, angle_offset }) +} + +/// A lookup table: rows (92) and columns (93), the cells row by row (302, after a 301 header), +/// then each column: unmatched name (303 then 305), parameter id (94), lookup flag (282) and the +/// parameter value it stands for (304). +fn lookup(tags: &[RawTag]) -> ActionKind { + let mut c = Cursor::new(tags); + let rows = c.count(92); + let cols = c.count(93); + c.next(301); + let cells = rows.saturating_mul(cols).min(tags.len()); + let flat: Vec = (0..cells).map(|_| c.string(302)).collect(); + let rows = flat.chunks(cols.max(1)).map(<[String]>::to_vec).collect(); + let columns = (0..cols) + .map(|_| { + c.next(303); + let node = c.int(94); + let lookup = c.int(282) != 0; + let unmatched = c.string(305); + let property = c.string(304); + LookupColumn { node, property, lookup, unmatched } + }) + .collect(); + ActionKind::Lookup { columns, rows } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tag(code: i32, value: &str) -> RawTag { + RawTag { code, value: value.to_string() } + } + + /// Counts that promise groups the record doesn't have: each nested loop would rescan the + /// record for them. The work budget keeps this linear in the record's size. + #[test] + fn hostile_counts_with_missing_groups_stay_linear() { + let n = 20_000; + let mut tags = vec![tag(93, "0"), tag(92, &n.to_string())]; + tags.extend((0..n).map(|_| tag(94, &n.to_string()))); + let ParameterKind::Visibility { states, .. } = visibility(Vec3::default(), &tags) else { + panic!("a visibility parameter") + }; + assert!(states.len() <= n && states.iter().all(|s| s.visible.is_empty())); + + let mut tags = vec![tag(73, &n.to_string())]; + tags.extend((0..n).flat_map(|_| [tag(331, "1F"), tag(74, &n.to_string())])); + let (_, ActionKind::Stretch { vertices, .. }) = stretch(&tags) else { panic!("a stretch action") }; + let ints: usize = vertices.iter().map(|(_, v)| v.len()).sum(); + assert!(ints <= 16 * tags.len() + 1024, "{ints}"); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1b6f167d..6462d3ac 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -36,6 +36,7 @@ pub use store::EntityStore; pub use tables::*; /// File data CADCraft doesn't model, kept for saving (#528). +pub mod dynblock; pub mod foreign; #[derive(Debug, thiserror::Error)] diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index e02a9ff2..c7d5bd8d 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -43,6 +43,10 @@ pub fn specs() -> Vec { attedit::dash_attedit_spec(), CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"), CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"), + CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic) + .params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)") + .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") @@ -326,9 +330,22 @@ fn run_base(s: &mut Session, p: &Value) -> Result { fn run_list(s: &mut Session, _p: &Value) -> Result { let d = s.doc()?; - Ok( - json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description})).collect::>() }), - ) + let block = |b: &Block| { + let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some(); + json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic}) + }; + Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::>() })) +} + +/// A dynamic block's parameters and actions as read from the file (null for a block that has +/// none). +fn run_dynamic(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("blocks.dynamic", "`name` is required"))?; + let d = s.doc()?; + if d.block(name).is_none() && !name.trim().eq_ignore_ascii_case("*Model_Space") { + return Err(bad("blocks.dynamic", format!("no block `{name}`"))); + } + serde_json::to_value(cadcraft_doc::dynblock::dynamic_block(d, name)).map_err(|e| bad("blocks.dynamic", e.to_string())) } // ---------------- interactive ---------------- diff --git a/crates/engine/tests/dynamic_block_data.rs b/crates/engine/tests/dynamic_block_data.rs new file mode 100644 index 00000000..a6892bdd --- /dev/null +++ b/crates/engine/tests/dynamic_block_data.rs @@ -0,0 +1,89 @@ +//! `blocks.dynamic` and `blocks.list` report a dynamic block's parameters and actions as read +//! from another program's file (kept foreign data, built by hand here). + +use std::sync::Arc; + +use cadcraft_engine::Session; +use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject}; +use cadcraft_engine::doc::{Handle, RawTag}; +use serde_json::json; + +fn obj(handle: u64, kind: &str, tags: &[(i32, &str)]) -> ForeignObject { + ForeignObject { handle: Handle(handle), kind: kind.into(), tags: tags.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect() } +} + +/// A session whose block "Door" has a visibility parameter with two states. +fn session() -> Session { + let mut s = Session::new(); + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone(); + s.execute("block", &json!({ "name": "Door", "base": [0, 0], "handles": [l] })).unwrap(); + let line = s.doc().unwrap().block("Door").unwrap().entities.handles()[0].hex(); + let mut f = Foreign::default(); + f.records.insert(("BLOCK_RECORD".into(), "DOOR".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() }); + f.objects.push(obj(0xF00, "DICTIONARY", &[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")])); + f.objects.push(obj(0xF01, "ACAD_EVALUATION_GRAPH", &[(100, "AcDbEvalGraph"), (91, "0"), (95, "1"), (360, "F02")])); + f.objects.push(obj( + 0xF02, + "BLOCKVISIBILITYPARAMETER", + &[ + (100, "AcDbEvalExpr"), + (90, "1"), + (100, "AcDbBlockElement"), + (300, "Visibility State"), + (100, "AcDbBlockParameter"), + (280, "1"), + (100, "AcDbBlock1PtParameter"), + (1010, "2"), + (1020, "3"), + (100, "AcDbBlockVisibilityParameter"), + (301, "Visibility1"), + (302, ""), + (93, "1"), + (331, &line), + (92, "2"), + (303, "Open"), + (94, "1"), + (332, &line), + (95, "0"), + (303, "Closed"), + (94, "0"), + (95, "0"), + ], + )); + let mut d = s.doc().unwrap().clone(); + d.foreign = Arc::new(f); + s.open_drawing(d, "dynamic", None); + s +} + +#[test] +fn dynamic_block_data_is_reported() { + let mut s = session(); + let list = s.execute("blocks.list", &json!({})).unwrap(); + let flags: Vec<_> = list["blocks"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap().to_string(), b["dynamic"].clone())).collect(); + assert!(flags.contains(&("Door".into(), json!(true))), "{flags:?}"); + + let m = s.execute("blocks.dynamic", &json!({ "name": "DOOR" })).unwrap(); + let p = &m["parameters"][0]; + assert_eq!((p["node"].clone(), p["element"].clone(), p["showProperties"].clone()), (json!(1), json!("Visibility State"), json!(true))); + assert_eq!(p["kind"]["type"], "visibility"); + assert_eq!(p["kind"]["label"], "Visibility1"); + assert_eq!(p["kind"]["at"], json!({ "x": 2.0, "y": 3.0, "z": 0.0 })); + let states: Vec<_> = + p["kind"]["states"].as_array().unwrap().iter().map(|st| (st["name"].clone(), st["visible"].as_array().unwrap().len())).collect(); + assert_eq!(states, [(json!("Open"), 1), (json!("Closed"), 0)]); + assert!(p["kind"]["controlled"][0].is_string(), "handles are hex strings"); + assert_eq!(m["actions"], json!([])); +} + +#[test] +fn blocks_without_dynamic_data_and_bad_names() { + let mut s = session(); + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap()["handles"][0].clone(); + s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l] })).unwrap(); + assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "Window" })).unwrap(), json!(null)); + assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "*Model_Space" })).unwrap(), json!(null)); + for p in [json!({}), json!({ "name": 3 }), json!({ "name": "Nope" }), json!(null)] { + assert!(s.execute("blocks.dynamic", &p).is_err(), "{p}"); + } +} diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 1177f74c..0dbda4d4 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1506,7 +1506,21 @@ pub fn tags(d: &Drawing, version: Version) -> Vec { /// The R2000 DXF the DWG bridge converts to a `dwg` version DWG: its entity types are the DWG /// version's (MULTILEADER from 2007). pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String { - cadcraft_dxf::write_ascii(&tags_for(d, Version::R2000, dwg)) + let mut tags = tags_for(d, Version::R2000, dwg); + bridge_markers(&mut tags); + cadcraft_dxf::write_ascii(&tags) +} + +/// Subclass markers the DWG bridge knows by another spelling (it matches them exactly): +/// AutoCAD writes a dynamic block lookup parameter's as `AcDbBlockLookUpParameter`, the bridge +/// reads `AcDbBlockLookupParameter`; otherwise the parameter loses its name and its lookup +/// action in the DWG, and AutoCAD's lookups stop working. +fn bridge_markers(tags: &mut [Tag]) { + for t in tags.iter_mut().filter(|t| t.code == 100) { + if t.str().trim().eq_ignore_ascii_case("AcDbBlockLookupParameter") { + *t = Tag::s(100, "AcDbBlockLookupParameter"); + } + } } /// The drawing's DXF tags, declaring `version` in `$ACADVER`, with what `target` (the version the diff --git a/crates/io/tests/dynblock_read.rs b/crates/io/tests/dynblock_read.rs new file mode 100644 index 00000000..c1080565 --- /dev/null +++ b/crates/io/tests/dynblock_read.rs @@ -0,0 +1,532 @@ +//! Dynamic blocks from another program: their parameters and actions are read into +//! `cadcraft_doc::dynblock`, and survive a save unchanged. +//! +//! The fixture is synthetic, written for this test: a DOOR block whose BLOCK_RECORD carries an +//! evaluation graph with a visibility parameter (Open, Closed), a linear parameter (Width, a list +//! of values), a stretch action on it, a lookup parameter and its lookup table, and a grip. + +use cadcraft_doc::dynblock::{ActionKind, DynamicBlock, ParameterKind, dynamic_block}; +use cadcraft_doc::{Handle, RawTag}; +use cadcraft_io::{read_dxf, write_dxf}; + +/// DXF text from lines of `code value` (the value may be empty). +fn dxf(src: &str) -> String { + let mut out = String::new(); + for line in src.lines().map(str::trim).filter(|l| !l.is_empty()) { + let (code, value) = line.split_once(' ').unwrap_or((line, "")); + out.push_str(&format!("{code}\n{value}\n")); + } + out +} + +const FIXTURE: &str = " +0 SECTION +2 HEADER +9 $ACADVER +1 AC1032 +9 $HANDSEED +5 200 +0 ENDSEC +0 SECTION +2 TABLES +0 TABLE +2 BLOCK_RECORD +5 1 +330 0 +100 AcDbSymbolTable +70 3 +0 BLOCK_RECORD +5 1F +330 1 +100 AcDbSymbolTableRecord +100 AcDbBlockTableRecord +2 *Model_Space +340 0 +0 BLOCK_RECORD +5 1E +330 1 +100 AcDbSymbolTableRecord +100 AcDbBlockTableRecord +2 *Paper_Space +340 0 +0 BLOCK_RECORD +5 1D +102 {ACAD_XDICTIONARY +360 60 +102 } +330 1 +100 AcDbSymbolTableRecord +100 AcDbBlockTableRecord +2 Door +340 0 +70 0 +280 1 +281 1 +0 ENDTAB +0 ENDSEC +0 SECTION +2 BLOCKS +0 BLOCK +5 20 +330 1F +100 AcDbEntity +8 0 +100 AcDbBlockBegin +2 *Model_Space +70 0 +10 0 +20 0 +30 0 +3 *Model_Space +1 +0 ENDBLK +5 21 +330 1F +100 AcDbEntity +8 0 +100 AcDbBlockEnd +0 BLOCK +5 24 +330 1D +100 AcDbEntity +8 0 +100 AcDbBlockBegin +2 Door +70 0 +10 0 +20 0 +30 0 +3 Door +1 +0 LINE +5 30 +330 1D +100 AcDbEntity +8 0 +100 AcDbLine +10 0 +20 0 +30 0 +11 36 +21 0 +31 0 +0 LINE +5 31 +330 1D +100 AcDbEntity +8 0 +100 AcDbLine +10 0 +20 0 +30 0 +11 0 +21 36 +31 0 +0 LINE +5 32 +330 1D +100 AcDbEntity +8 0 +100 AcDbLine +10 0 +20 0 +30 0 +11 25 +21 25 +31 0 +0 ENDBLK +5 25 +330 1D +100 AcDbEntity +8 0 +100 AcDbBlockEnd +0 ENDSEC +0 SECTION +2 ENTITIES +0 ENDSEC +0 SECTION +2 OBJECTS +0 DICTIONARY +5 C +330 0 +100 AcDbDictionary +281 1 +0 DICTIONARY +5 60 +330 1D +100 AcDbDictionary +280 1 +281 1 +3 ACAD_ENHANCEDBLOCK +360 61 +0 ACAD_EVALUATION_GRAPH +5 61 +102 {ACAD_REACTORS +330 60 +102 } +330 60 +100 AcDbEvalGraph +96 6 +97 6 +91 0 +93 32 +95 1 +360 62 +92 -1 +92 -1 +92 -1 +92 -1 +91 1 +93 32 +95 2 +360 63 +92 -1 +92 -1 +92 -1 +92 -1 +91 2 +93 32 +95 3 +360 64 +92 -1 +92 -1 +92 -1 +92 -1 +91 3 +93 32 +95 4 +360 65 +92 -1 +92 -1 +92 -1 +92 -1 +91 4 +93 32 +95 5 +360 66 +92 -1 +92 -1 +92 -1 +92 -1 +91 5 +93 32 +95 6 +360 67 +92 -1 +92 -1 +92 -1 +92 -1 +0 BLOCKVISIBILITYPARAMETER +5 62 +330 61 +100 AcDbEvalExpr +90 1 +98 33 +99 232 +100 AcDbBlockElement +300 Visibility State +98 33 +99 232 +1071 0 +100 AcDbBlockParameter +280 1 +281 0 +100 AcDbBlock1PtParameter +1010 18 +1020 40 +1030 0 +93 0 +170 0 +171 0 +100 AcDbBlockVisibilityParameter +281 1 +301 Visibility1 +302 Door swing shown or not +91 0 +93 2 +331 31 +331 32 +92 2 +303 Open +94 2 +332 31 +332 32 +95 1 +333 63 +303 Closed +94 0 +95 0 +0 BLOCKLINEARPARAMETER +5 63 +330 61 +100 AcDbEvalExpr +90 2 +98 33 +99 232 +100 AcDbBlockElement +300 Linear +98 33 +99 232 +1071 0 +100 AcDbBlockParameter +280 1 +281 0 +100 AcDbBlock2PtParameter +1010 0 +1020 -4 +1030 0 +1011 36 +1021 -4 +1031 0 +170 4 +91 0 +91 0 +91 0 +91 0 +171 0 +172 0 +173 0 +174 0 +177 0 +100 AcDbBlockLinearParameter +305 Width +306 Door leaf width +140 0 +307 +96 8 +141 0 +142 0 +143 0 +175 3 +144 30 +144 32 +144 36 +0 BLOCKSTRETCHACTION +5 64 +330 61 +100 AcDbEvalExpr +90 3 +98 33 +99 232 +100 AcDbBlockElement +300 Stretch +98 33 +99 232 +1071 0 +100 AcDbBlockAction +70 0 +71 1 +330 30 +1010 40 +1020 -8 +1030 0 +100 AcDbBlockStretchAction +92 2 +93 2 +301 EndXDelta +302 EndYDelta +72 2 +1011 34 +1021 -2 +1011 38 +1021 2 +73 1 +331 30 +74 1 +94 1 +75 0 +140 1 +141 0 +280 0 +0 BLOCKLOOKUPPARAMETER +5 65 +330 61 +100 AcDbEvalExpr +90 4 +98 33 +99 232 +100 AcDbBlockElement +300 Lookup +98 33 +99 232 +1071 0 +100 AcDbBlockParameter +280 1 +281 0 +100 AcDbBlock1PtParameter +1010 -6 +1020 40 +1030 0 +93 0 +170 0 +171 0 +100 AcDbBlockLookUpParameter +94 5 +303 Size +304 Standard door sizes +0 BLOCKLOOKUPACTION +5 66 +330 61 +100 AcDbEvalExpr +90 5 +98 33 +99 232 +100 AcDbBlockElement +300 Lookup1 +98 33 +99 232 +1071 0 +100 AcDbBlockAction +70 0 +71 0 +1010 -4 +1020 38 +1030 0 +100 AcDbBlockLookupAction +92 2 +93 2 +301 +302 30 +302 Narrow +302 36 +302 Wide +303 +94 2 +95 1 +96 0 +282 0 +305 Custom +281 0 +304 UpdatedDistance +303 +94 4 +95 1 +96 0 +282 1 +305 Custom +281 1 +304 lookupString +280 1 +0 BLOCKFLIPGRIP +5 67 +330 61 +100 AcDbEvalExpr +90 6 +98 33 +99 232 +100 AcDbBlockElement +300 Grip +98 33 +99 232 +1071 0 +0 ENDSEC +0 EOF +"; + +fn h(hex: &str) -> Handle { + Handle::parse_hex(hex).unwrap() +} + +#[test] +fn a_dynamic_block_reads_its_parameters_and_actions() { + let d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap(); + assert_eq!(d.block("DOOR").unwrap().entities.len(), 3); + let m = dynamic_block(&d, "door").expect("Door is a dynamic block"); + assert!(dynamic_block(&d, "*Model_Space").is_none(), "model space has no graph here"); + + let labels: Vec<_> = m.parameters.iter().map(|p| p.kind.label().unwrap_or("-")).collect(); + assert_eq!(labels, ["Visibility1", "Width", "Size"]); + let ParameterKind::Visibility { states, controlled, description, .. } = &m.parameters[0].kind else { panic!("{:?}", m.parameters[0]) }; + assert_eq!(description, "Door swing shown or not"); + assert_eq!(controlled, &[h("31"), h("32")]); + let names: Vec<_> = states.iter().map(|s| (s.name.as_str(), s.visible.len(), s.active.len())).collect(); + assert_eq!(names, [("Open", 2, 1), ("Closed", 0, 0)]); + + let p = &m.parameters[1]; + assert_eq!((p.node, p.element.as_str(), p.show_properties), (2, "Linear", true)); + let ParameterKind::Linear { base, end, values, description, .. } = &p.kind else { panic!("{p:?}") }; + assert_eq!((base.x, base.y, end.x, end.y), (0.0, -4.0, 36.0, -4.0)); + assert_eq!(description, "Door leaf width"); + assert!(values.is_list() && !values.has_minimum()); + assert_eq!(values.list, [30.0, 32.0, 36.0]); + + let names: Vec<_> = m.actions.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["Stretch", "Lookup1"]); + let a = &m.actions[0]; + assert_eq!(a.selection, [h("30")]); + assert_eq!(a.connections, [(2, "EndXDelta".to_string()), (2, "EndYDelta".to_string())]); + let ActionKind::Stretch { frame, vertices, multiplier, .. } = &a.kind else { panic!("{a:?}") }; + assert_eq!(frame.len(), 2); + assert_eq!((frame[0].x, frame[1].y), (34.0, 2.0)); + assert_eq!(vertices, &[(h("30"), vec![1])]); + assert_eq!(*multiplier, 1.0); + + let ActionKind::Lookup { columns, rows } = &m.actions[1].kind else { panic!("{:?}", m.actions[1]) }; + assert_eq!(rows, &[vec!["30".to_string(), "Narrow".to_string()], vec!["36".to_string(), "Wide".to_string()]]); + let cols: Vec<_> = columns.iter().map(|c| (c.node, c.property.as_str(), c.lookup, c.unmatched.as_str())).collect(); + assert_eq!(cols, [(2, "UpdatedDistance", false, "Custom"), (4, "lookupString", true, "Custom")]); + + assert_eq!(m.other, ["BLOCKFLIPGRIP"]); +} + +#[test] +fn a_saved_drawing_keeps_the_dynamic_block_as_read() { + let d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap(); + let before = dynamic_block(&d, "Door").unwrap(); + let again = read_dxf(write_dxf(&d).as_bytes()).unwrap(); + // The objects may get new handles on save; the block's objects keep theirs, and the graph's + // references between its members follow the new ones. + assert_eq!(by_node(dynamic_block(&again, "Door").unwrap()), by_node(before.clone())); + // And through DWG: the lookup parameter (AutoCAD writes its marker AcDbBlockLookUpParameter) + // keeps its name and lookup action. + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(by_node(dynamic_block(&back, "Door").unwrap()), by_node(before)); +} + +/// The model with each parameter and action object named by its graph id instead of its handle. +fn by_node(mut m: DynamicBlock) -> DynamicBlock { + let ids: std::collections::HashMap = + m.parameters.iter().map(|p| (p.handle, p.node)).chain(m.actions.iter().map(|a| (a.handle, a.node))).collect(); + let id = |h: &Handle| Handle(ids.get(h).copied().unwrap_or(-1) as u64); + for p in &mut m.parameters { + p.handle = id(&p.handle); + if let ParameterKind::Visibility { states, .. } = &mut p.kind { + for s in states { + s.active = s.active.iter().map(id).collect(); + } + } + } + for a in &mut m.actions { + a.handle = id(&a.handle); + } + m +} + +#[test] +fn damaged_dynamic_block_data_is_skipped_not_trusted() { + // Counts far beyond the groups present, values that aren't numbers, a graph member that + // doesn't exist and a dictionary entry with no graph: reading must not panic or hang. + let src = FIXTURE + .replace("93 2\n331 31", "93 999999999\n331 31") + .replace("92 2\n303 Open", "92 -7\n303 Open") + .replace("175 3", "175 2147483647") + .replace("144 32", "144 nan") + .replace("72 2\n1011 34", "72 99999\n1011 34") + .replace("92 2\n93 2\n301\n", "92 1000000\n93 1000000\n301\n") + .replace("360 67", "360 FFFF"); + let d = read_dxf(dxf(&src).as_bytes()).unwrap(); + let m = dynamic_block(&d, "Door").unwrap(); + assert_eq!(m.parameters.len(), 3); + let ParameterKind::Linear { values, .. } = &m.parameters[1].kind else { panic!() }; + assert!(values.list.len() <= 3 && values.list.iter().all(|v| v.is_finite()), "{:?}", values.list); + assert!(m.other.is_empty(), "a member that doesn't exist is skipped"); + + let tags = |v: &[(i32, &str)]| v.iter().map(|(c, s)| RawTag { code: *c, value: (*s).into() }).collect::>(); + let mut d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap(); + let f = std::sync::Arc::make_mut(&mut d.foreign); + if let Some(dict) = f.objects.iter_mut().find(|o| o.handle == h("60")) { + dict.tags = tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK")]); + } + assert!(dynamic_block(&d, "Door").is_none(), "no graph handle"); +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1e5ec45b..0f2777a3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1282,3 +1282,4 @@ Origin: {x}, {y} Початок: {x}, {y} Sheet Books to PDF... Книги аркушів у PDF... Not a number or distance: {text} Не число й не відстань: {text} + Dynamic Block Data Дані динамічного блока diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c15fd6cc..0cf2724f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (VIEWPORT frozen layers as group 341; code pages, #505; native MULTILEADER, #459; foreign data kept, #528; PDF text, layers and sheets, #567, #571; templates and user files, #552; plotter devices, #564) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dynamic block data read into a model) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -93,6 +93,8 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr | Objects: GROUP, MLINESTYLE, MLEADERSTYLE, IMAGEDEF, SCALE | yes | yes (groups #417, scale list #468) | | | 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) | +| 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 | **DXF ≈ 50% read, ≈ 30% write** (estimated; weights: common 2D entities 50%, annotation objects @@ -137,6 +139,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 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) | | 2026-10-10 | minor | WIPEOUT and gradients written since #147/#162; other save losses remain (#201, #204, #208, #209, #213, #223) |