Files
cadcraft/crates/doc/src/dynblock/reference.rs
T
0d138c2954 Dynamic block references: distances, positions, stretch and move actions, lookups both ways (stacked on #860) (#878)
* 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 <noreply@anthropic.com>

* Dynamic block references: their values and custom properties

A dynamic block reference's extension dictionary says which dynamic block it
shows (AcDbBlockRepresentation ▸ AcDbRepData) and the values it was given
(AppDataCache ▸ ACAD_ENHANCEDBLOCKDATA, one XRECORD per parameter graph id).
`dynblock::reference` reads them; a reference to the dynamic block itself
has the block's values.

- `dynblock::custom_properties`: the Properties palette's Custom section, in
  parameter order, only parameters shown in Properties: visibility state,
  lookup value worked out from the lookup tables (their "no match" name, e.g.
  Custom, when the inputs match no row), distances and point positions in
  drawing units (block units × the reference's scale), with the choices of
  each (states, lookup values, a distance's list).
- Properties: a dynamic block reference shows its dynamic block's name
  instead of its anonymous block's, and a read-only Custom section.
- `blocks.reference {handle}` returns the definition, values and properties.
- Tests: synthetic references (own values, an anonymous block scaled 2, a
  width no lookup row has), survival through a save, the engine command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dynamic block references: change the visibility state

Choosing a visibility state (or a lookup value whose tables set only
visibility states) in Properties ▸ Custom, or `blocks.reference.set
{handle, name, value}`, changes a dynamic block reference as AutoCAD does:

- The reference gets a new anonymous block: its current one with the
  state's objects shown and the parameter's other objects hidden (matched
  to the dynamic block's objects by their AcDbBlockRepETag element tags).
  The old anonymous block goes when nothing else uses it.
- AutoCAD's link data is written: the new block's record carries
  AcDbBlockRepBTag (the dynamic block's record), copied objects keep their
  element tags, and the reference's AcDbBlockRepresentation data records the
  value (an existing value record is updated; a new one copies another
  reference's record for the same parameter when there is one).
- An attribute whose definition the state hides is written hidden (60).
- Changes CADCraft can't make yet (distances, positions, lookups that set
  them) are refused with a message and change nothing; Properties shows
  only what can change as a dropdown (`CustomProperty::editable`).
- Tests: the synthetic DOOR references (own values and an anonymous block),
  survival through a save, refusals, and the engine command with undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dynamic block references: distances, positions, stretch and move actions, lookup chaining

Linear distances (snapped to their value sets) and point positions can be
changed; the stretch and move actions that follow them run (listed vertices
or whole objects, multiplier and angle offset), parameters in an action's
selection are carried along, associative hatches refill from their
stretched outlines and the reference's attributes move with their
definitions. Lookup tables run both ways (a changed input gives its table's
lookup value, which sets the other tables' inputs). Connections are read in
either group order. Checked against AutoCAD's own references (*U10, *U12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dynamic block references: record changes in the history as AutoCAD does; full value data for new references

A changed reference's ACAD_ENHANCEDBLOCKHISTORY says what was done to it: a
visibility state or lookup value by property name, a distance or position as
the drag of its grips (GRIPLOC, how far each grip is from where the dynamic
block has it). A reference that had no data gets a record for everything
other references of the same dynamic block have, with its own values.
Templates come only from references of the same dynamic block; anonymous
copies have no units; boundary reactors follow the copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dynamic block history: record only the grips a change moves

AutoCAD's history holds the user's own drags; a grip that only moved with
another parameter (a stretch carrying it) gets no entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dynamic block history: a distance by name, as typed in Properties

AutoCAD records a distance typed in Properties as the parameter's name and
value in block units (1071 id, 300 Distance2, 40 40.0), replacing drags of
its grips; positions stay grip drags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Dynamic blocks: carried parameter points are in a stretch frame where the block has them

AutoCAD evaluates actions from the dynamic block itself, so a parameter point
another action has moved out of a stretch frame still follows it. Testing the
current position instead changed a stretched label's width when its
visibility changed (Distance2 183.75 became 184.45; AutoCAD keeps 183.75).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 05:45:56 -04:00

284 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<i32, Value>,
}
/// 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<String>,
/// CADCraft can change it ([`super::set_property`]): a visibility state, a distance, a
/// position, or a lookup whose tables set only those.
pub editable: bool,
}
fn objects(d: &Drawing) -> HashMap<Handle, &ForeignObject> {
d.foreign.objects.iter().map(|o| (o.handle, o)).collect()
}
/// The object entry `key` of dictionary `dict` refers to.
fn entry(objects: &HashMap<Handle, &ForeignObject>, dict: Handle, key: &str) -> Option<Handle> {
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<Handle, &ForeignObject>, 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<Value> {
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::<f64>().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<Reference> {
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::<i32>(), 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::<f64>(), value.trim().parse::<f64>()) {
(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<i32, Value>,
}
impl Current<'_> {
/// Parameter `node`'s value of property `property`, as table cells write it.
fn property(&self, node: i32, property: &str) -> Option<String> {
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<String>) {
let mut found: Option<Option<String>> = None;
let mut unmatched = String::from("Custom");
let mut choices: Vec<String> = 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<CustomProperty>)> {
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, editable: true });
}
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(), editable: true });
out.push(CustomProperty { name: format!("{label} Y"), value: number(d, (at.y - base.y) * sy), choices: Vec::new(), editable: true });
}
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(), editable: true });
}
ParameterKind::Lookup { label, .. } => {
let (value, choices) = cur.lookup(p.node);
let editable = lookup_sets_what_changes(&m, p.node);
out.push(CustomProperty { name: label.clone(), value, choices, editable });
}
_ => {}
}
}
Some((r.definition, out))
}
/// Whether every lookup table that sets lookup parameter `node` sets only what
/// [`super::set_property`] can apply with it: visibility states and distances.
fn lookup_sets_what_changes(m: &DynamicBlock, node: i32) -> bool {
let mut any = false;
for a in &m.actions {
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
if rows.is_empty() || !columns.iter().any(|c| c.lookup && c.node == node) {
continue;
}
any = true;
if !columns
.iter()
.all(|c| matches!(c.property.to_ascii_lowercase().as_str(), "lookupstring" | "visibilitystate" | "updateddistance" | "distance"))
{
return false;
}
}
any
}