Dynamic blocks: read parameters and actions from AutoCAD files (#838)

* 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 <[email protected]>

* 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 <[email protected]>

* Dynamic blocks: a work budget on the record cursor, so counts promising missing groups can't make nested loops rescan a hostile record (with regression test)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 05:40:12 -04:00
1 parent e773d93164
commit fbc7037a10
8 files changed
+1172 -5

No files matched your search

+510
View File
@@ -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<S: serde::Serializer>(h: &Handle, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&h.hex())
}
fn hexes<S: serde::Serializer>(hs: &[Handle], s: S) -> Result<S::Ok, S::Error> {
s.collect_seq(hs.iter().map(|h| h.hex()))
}
fn hex_vertices<S: serde::Serializer>(v: &[(Handle, Vec<i32>)], s: S) -> Result<S::Ok, S::Error> {
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<Parameter>,
pub actions: Vec<Action>,
/// Graph members of types not modelled here (grips, constraints…), by DXF type.
pub other: Vec<String>,
}
/// 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<Handle>,
states: Vec<VisibilityState>,
},
/// 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<Handle>,
/// The parameters and actions active in this state (their objects).
#[serde(serialize_with = "hexes")]
pub active: Vec<Handle>,
}
/// 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<f64>,
}
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<Handle>,
/// 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<Vec2>,
#[serde(serialize_with = "hex_vertices")]
vertices: Vec<(Handle, Vec<i32>)>,
multiplier: f64,
angle_offset: f64,
},
/// A table: each row sets the listed parameter values together.
Lookup {
columns: Vec<LookupColumn>,
rows: Vec<Vec<String>>,
},
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::<f64>().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<Handle> {
self.next(code).and_then(Handle::parse_hex)
}
fn handles(&mut self, code: i32, n: usize) -> Vec<Handle> {
(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<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
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(&sections(&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<DynamicBlock> {
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
let objects: HashMap<Handle, &ForeignObject> = 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::<f64>().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<String> = (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}");
}
}
+1
View File
@@ -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)]
+20 -3
View File
@@ -43,6 +43,10 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
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::<Vec<_>>() }),
)
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::<Vec<_>>() }))
}
/// 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<Value> {
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 ----------------
+89
View File
@@ -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}");
}
}
+15 -1
View File
@@ -1506,7 +1506,21 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
/// 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
+532
View File
@@ -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<Handle, i32> =
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::<Vec<_>>();
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");
}
+1
View File
@@ -1282,3 +1282,4 @@
Origin: {x}, {y} Початок: {x}, {y}
Sheet Books to PDF... Книги аркушів у PDF...
Not a number or distance: {text} Не число й не відстань: {text}
Dynamic Block Data Дані динамічного блока
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
1282
1283
1284
1285
+4 -1
View File
@@ -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) |