mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-25 19:36:49 -06:00
* 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> * Dynamic block grips: show and drag a dynamic reference's grips as AutoCAD does A selected dynamic block reference shows its grips: an arrow at each end of a distance, a square at a position, a down-pointing triangle for a visibility or lookup list, only those its visibility state lists. Dragging an arrow changes the distance along the parameter (snapped to its values; a typed number sets it), a square moves the position, a list grip offers its choices; actions and lookups follow, with a live preview. doc: dynblock::grips, drag_grip; grip objects by graph id. engine: blocks.reference.grips, blocks.reference.grip, Session::start_dyn_grip. ui: canvas dynamic grips (GRIPDYNCOLOR-like token), list popup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tests: read the shared DOOR fixture with LF line ends whatever the checkout gives 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> * Dynamic grips: Ukrainian labels for the new commands; copy the hot grip instead of cloning 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>
539 lines
28 KiB
Rust
539 lines
28 KiB
Rust
//! 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
|
|
}
|
|
|
|
/// The DOOR drawing as `code value` lines (shared with the engine's dynamic grip tests), with
|
|
/// LF line ends whatever a checkout gave the file.
|
|
fn fixture() -> String {
|
|
include_str!("data/dynblock_door.txt").replace("\r\n", "\n")
|
|
}
|
|
|
|
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)))
|
|
.chain(m.grip_objects.iter().map(|(n, h)| (*h, *n)))
|
|
.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.grip_objects = m.grip_objects.iter().map(|(n, h)| (*n, id(h))).collect();
|
|
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");
|
|
}
|
|
|
|
/// The fixture with references to Door: one with Door's own values (handle 80), and two to
|
|
/// anonymous blocks with values of their own, as another program leaves them: 81 (scale 2,
|
|
/// Closed, Width 30, so Size is Narrow) and 82 (Width 33, which no Size row has).
|
|
fn with_references() -> String {
|
|
let record =
|
|
|h: &str, name: &str| format!("0 BLOCK_RECORD\n5 {h}\n330 1\n100 AcDbSymbolTableRecord\n100 AcDbBlockTableRecord\n2 {name}\n340 0\n70 0\n");
|
|
let block = |h: &str, rec: &str, name: &str| {
|
|
format!(
|
|
"0 BLOCK\n5 {h}\n330 {rec}\n100 AcDbEntity\n8 0\n100 AcDbBlockBegin\n2 {name}\n70 1\n10 0\n20 0\n30 0\n3 {name}\n1\n0 LINE\n5 {h}1\n330 {rec}\n100 AcDbEntity\n8 0\n100 AcDbLine\n10 0\n20 0\n30 0\n11 30\n21 0\n31 0\n0 ENDBLK\n5 {h}2\n330 {rec}\n100 AcDbEntity\n8 0\n100 AcDbBlockEnd\n"
|
|
)
|
|
};
|
|
let insert = |h: &str, block: &str, xdict: Option<&str>, scale: f64| {
|
|
let x = xdict.map(|x| format!("102 {{ACAD_XDICTIONARY\n360 {x}\n102 }}\n")).unwrap_or_default();
|
|
format!(
|
|
"0 INSERT\n5 {h}\n{x}330 1F\n100 AcDbEntity\n8 0\n100 AcDbBlockReference\n2 {block}\n10 100\n20 0\n30 0\n41 {scale}\n42 {scale}\n43 {scale}\n"
|
|
)
|
|
};
|
|
let dict = |h: &str, owner: &str, entries: &[(&str, &str)]| {
|
|
let e: String = entries.iter().map(|(k, v)| format!("3 {k}\n360 {v}\n")).collect();
|
|
format!("0 DICTIONARY\n5 {h}\n330 {owner}\n100 AcDbDictionary\n280 1\n281 1\n{e}")
|
|
};
|
|
let xrecord =
|
|
|h: &str, owner: &str, body: &str| format!("0 XRECORD\n5 {h}\n330 {owner}\n100 AcDbXrecord\n280 1\n1071 1\n1071 2\n70 25\n70 104\n{body}");
|
|
// The values of a reference: representation data pointing at Door's record (1D) and one
|
|
// XRECORD per parameter graph id.
|
|
let values = |p: &str, owner: &str, vis: &str, width: f64, size: &str| {
|
|
[
|
|
dict(&format!("{p}0"), owner, &[("AcDbBlockRepresentation", &format!("{p}1"))]),
|
|
dict(&format!("{p}1"), &format!("{p}0"), &[("AcDbRepData", &format!("{p}2")), ("AppDataCache", &format!("{p}3"))]),
|
|
format!("0 ACDB_BLOCKREPRESENTATION_DATA\n5 {p}2\n330 {p}1\n100 AcDbBlockRepresentationData\n70 1\n340 1D\n"),
|
|
dict(&format!("{p}3"), &format!("{p}1"), &[("ACAD_ENHANCEDBLOCKDATA", &format!("{p}4"))]),
|
|
dict(
|
|
&format!("{p}4"),
|
|
&format!("{p}3"),
|
|
&[("1", &format!("{p}5")), ("2", &format!("{p}6")), ("3", &format!("{p}7")), ("4", &format!("{p}8"))],
|
|
),
|
|
xrecord(&format!("{p}5"), &format!("{p}4"), &format!("10 18\n20 40\n30 0\n1 {vis}\n")),
|
|
xrecord(&format!("{p}6"), &format!("{p}4"), &format!("10 0\n20 -4\n30 0\n10 {width}\n20 -4\n30 0\n10 0\n20 0\n30 -1\n")),
|
|
xrecord(&format!("{p}7"), &format!("{p}4"), "40 0\n"),
|
|
xrecord(&format!("{p}8"), &format!("{p}4"), &format!("10 -6\n20 40\n30 0\n1 {size}\n")),
|
|
]
|
|
.concat()
|
|
};
|
|
let src = dxf(&fixture());
|
|
let lines = |s: String| dxf(&s);
|
|
src.replace("0\nENDTAB\n", &format!("{}0\nENDTAB\n", lines(record("A1", "*U1") + &record("B1", "*U2"))))
|
|
.replace(
|
|
"0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nENDSEC\n",
|
|
&lines(
|
|
block("A2", "A1", "*U1")
|
|
+ &block("B2", "B1", "*U2")
|
|
+ "0 ENDSEC\n0 SECTION\n2 ENTITIES\n"
|
|
+ &insert("80", "Door", None, 1.0)
|
|
+ &insert("81", "*U1", Some("C0"), 2.0)
|
|
+ &insert("82", "*U2", Some("D0"), 1.0)
|
|
+ "0 ENDSEC\n",
|
|
),
|
|
)
|
|
.replace(
|
|
"0\nENDSEC\n0\nEOF\n",
|
|
&format!("{}0\nENDSEC\n0\nEOF\n", lines(values("C", "81", "Closed", 30.0, "Narrow") + &values("D", "82", "Open", 33.0, "Narrow"))),
|
|
)
|
|
}
|
|
|
|
fn props(d: &cadcraft_doc::Drawing, h: &str) -> (String, Vec<(String, String, Vec<String>)>) {
|
|
let (name, ps) = cadcraft_doc::dynblock::custom_properties(d, Handle::parse_hex(h).unwrap()).unwrap();
|
|
(name, ps.into_iter().map(|p| (p.name, p.value, p.choices)).collect())
|
|
}
|
|
|
|
#[test]
|
|
fn dynamic_block_references_show_their_values() {
|
|
let d = read_dxf(with_references().as_bytes()).unwrap();
|
|
assert_eq!(d.model.len(), 3, "the three references were read");
|
|
let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
|
|
|
|
// Door's own values.
|
|
let (name, p) = props(&d, "80");
|
|
assert_eq!(name, "Door");
|
|
assert_eq!(
|
|
p,
|
|
[
|
|
("Visibility1".into(), "Open".into(), s(&["Open", "Closed"])),
|
|
("Width".into(), "36.0000".into(), s(&["30.0000", "32.0000", "36.0000"])),
|
|
("Size".into(), "Wide".into(), s(&["Narrow", "Wide"])),
|
|
]
|
|
);
|
|
|
|
// An anonymous block with values of its own, scaled 2: lengths in drawing units.
|
|
let r = cadcraft_doc::dynblock::reference(&d, h("81")).unwrap();
|
|
assert_eq!(r.definition, "Door");
|
|
assert_eq!(r.values.len(), 3, "the action's record has no value");
|
|
let (name, p) = props(&d, "81");
|
|
assert_eq!(name, "Door");
|
|
assert_eq!(
|
|
p,
|
|
[
|
|
("Visibility1".into(), "Closed".into(), s(&["Open", "Closed"])),
|
|
("Width".into(), "60.0000".into(), s(&["60.0000", "64.0000", "72.0000"])),
|
|
("Size".into(), "Narrow".into(), s(&["Narrow", "Wide"])),
|
|
]
|
|
);
|
|
|
|
// A width no Size row has: the lookup shows its "no match" name, whatever was stored.
|
|
let (_, p) = props(&d, "82");
|
|
assert_eq!(p[2], ("Size".into(), "Custom".into(), s(&["Narrow", "Wide"])));
|
|
|
|
// Other references and objects aren't dynamic block references.
|
|
assert!(cadcraft_doc::dynblock::reference(&d, h("30")).is_none(), "a line in the block");
|
|
assert!(cadcraft_doc::dynblock::reference(&d, h("FFFF")).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn dynamic_block_references_survive_a_save() {
|
|
let d = read_dxf(with_references().as_bytes()).unwrap();
|
|
let again = read_dxf(write_dxf(&d).as_bytes()).unwrap();
|
|
for hx in ["80", "81", "82"] {
|
|
assert_eq!(props(&again, hx), props(&d, hx), "{hx}");
|
|
}
|
|
}
|
|
|
|
fn block_of(d: &cadcraft_doc::Drawing, h: &str) -> String {
|
|
match d.entity(Handle::parse_hex(h).unwrap()).map(|e| &e.kind) {
|
|
Some(cadcraft_doc::EntityKind::Insert(i)) => i.block.clone(),
|
|
_ => panic!("no reference {h}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn changing_the_visibility_state_makes_a_new_anonymous_block() {
|
|
let mut d = read_dxf(with_references().as_bytes()).unwrap();
|
|
// Door's own reference to Closed: the swing (lines 31, 32) hidden, the leaf (30) shown.
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("80"), "visibility1", "closed").unwrap();
|
|
let name = block_of(&d, "80");
|
|
assert!(name.starts_with("*U") && name != "*U1" && name != "*U2", "{name}");
|
|
let shown: Vec<bool> = d.block(&name).unwrap().entities.iter().map(|e| e.common.visible).collect();
|
|
assert_eq!(shown, [true, false, false]);
|
|
assert!(d.block("Door").unwrap().entities.iter().all(|e| e.common.visible), "the dynamic block is unchanged");
|
|
let (def, p) = props(&d, "80");
|
|
assert_eq!((def.as_str(), p[0].1.as_str()), ("Door", "Closed"));
|
|
|
|
// A reference with values of its own (an anonymous block) back to Open: its value record is
|
|
// updated, and its old anonymous block, used by nothing else, goes.
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("81"), "Visibility1", "Open").unwrap();
|
|
assert_eq!(props(&d, "81").1[0].1, "Open");
|
|
assert!(d.block("*U1").is_none(), "the old anonymous block is dropped");
|
|
assert_eq!(props(&d, "81").1[1].1, "60.0000", "its other values stay");
|
|
|
|
// Both survive a save.
|
|
let again = read_dxf(write_dxf(&d).as_bytes()).unwrap();
|
|
assert_eq!(props(&again, "80").1[0].1, "Closed");
|
|
assert_eq!(props(&again, "81").1[0].1, "Open");
|
|
let shown: Vec<bool> = again.block(&block_of(&again, "80")).unwrap().entities.iter().map(|e| e.common.visible).collect();
|
|
assert_eq!(shown, [true, false, false]);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_values_are_refused_and_change_nothing() {
|
|
let mut d = read_dxf(with_references().as_bytes()).unwrap();
|
|
let before = d.clone();
|
|
let ps = cadcraft_doc::dynblock::custom_properties(&d, h("80")).unwrap().1;
|
|
let editable: Vec<(&str, bool)> = ps.iter().map(|p| (p.name.as_str(), p.editable)).collect();
|
|
assert_eq!(editable, [("Visibility1", true), ("Width", true), ("Size", true)]);
|
|
for (name, value) in
|
|
[("Visibility1", "Ajar"), ("Size", "Huge"), ("Width", "wide"), ("Width", "0"), ("Width", "-3"), ("Width", "inf"), ("Nope", "x"), ("", "")]
|
|
{
|
|
assert!(cadcraft_doc::dynblock::set_property(&mut d, h("80"), name, value).is_err(), "{name}={value}");
|
|
}
|
|
assert!(cadcraft_doc::dynblock::set_property(&mut d, h("30"), "Visibility1", "Open").is_err(), "not a reference");
|
|
assert!(cadcraft_doc::dynblock::set_property(&mut d, h("FFFF"), "Visibility1", "Open").is_err());
|
|
assert_eq!(d, before);
|
|
}
|
|
|
|
/// The ends of the lines of the block reference `insert` shows, in block coordinates.
|
|
fn line_ends(d: &cadcraft_doc::Drawing, insert: &str) -> Vec<(f64, f64)> {
|
|
let b = d.block(&block_of(d, insert)).unwrap();
|
|
b.entities.iter().filter_map(|e| if let cadcraft_doc::EntityKind::Line(l) = &e.kind { Some((l.b.x, l.b.y)) } else { None }).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_distance_stretches_the_block_and_snaps_to_its_values() {
|
|
let mut d = read_dxf(with_references().as_bytes()).unwrap();
|
|
// Width 36 → 30: the stretch moves the leaf's end (line 30, vertex 1) by -6 in x.
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("80"), "Width", "30").unwrap();
|
|
assert_eq!(line_ends(&d, "80")[0], (30.0, 0.0));
|
|
let p = props(&d, "80").1;
|
|
assert_eq!((p[1].1.as_str(), p[2].1.as_str()), ("30.0000", "Narrow"), "the lookup follows");
|
|
// 33 is not in Width's list (30, 32, 36): the nearest is taken.
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("80"), "width", "33").unwrap();
|
|
assert_eq!(line_ends(&d, "80")[0], (32.0, 0.0));
|
|
assert_eq!(props(&d, "80").1[2].1, "Custom", "no Size row has 32");
|
|
// Lengths are in drawing units: reference 81 is scaled 2, so 72 is Width 36.
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("81"), "Width", "72").unwrap();
|
|
assert_eq!(props(&d, "81").1[1].1, "72.0000");
|
|
// A save keeps the stretched geometry and the values.
|
|
let again = read_dxf(write_dxf(&d).as_bytes()).unwrap();
|
|
assert_eq!(line_ends(&again, "80")[0], (32.0, 0.0));
|
|
assert_eq!(props(&again, "80").1[1].1, "32.0000");
|
|
}
|
|
|
|
#[test]
|
|
fn a_lookup_value_sets_its_distance() {
|
|
let mut d = read_dxf(with_references().as_bytes()).unwrap();
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("80"), "Size", "narrow").unwrap();
|
|
assert_eq!(line_ends(&d, "80")[0], (30.0, 0.0));
|
|
let p = props(&d, "80").1;
|
|
assert_eq!((p[1].1.as_str(), p[2].1.as_str()), ("30.0000", "Narrow"));
|
|
}
|
|
|
|
#[test]
|
|
fn connections_are_read_in_either_group_order() {
|
|
// AutoCAD writes 92 301 93 302; the DWG bridge 92 93 301 302.
|
|
let src = fixture().replace("92 2\n93 2\n301 EndXDelta\n302 EndYDelta", "92 2\n301 EndXDelta\n93 2\n302 EndYDelta");
|
|
assert_ne!(src, fixture());
|
|
let d = read_dxf(dxf(&src).as_bytes()).unwrap();
|
|
let m = dynamic_block(&d, "Door").unwrap();
|
|
assert_eq!(m.actions[0].connections, [(2, "EndXDelta".to_string()), (2, "EndYDelta".to_string())]);
|
|
}
|
|
|
|
#[test]
|
|
fn parameters_in_a_stretch_selection_are_carried_along() {
|
|
// A point parameter (Position1 at (35, 0), inside the stretch frame) in the stretch's selection.
|
|
let point = "0 BLOCKPOINTPARAMETER\n5 68\n330 61\n100 AcDbEvalExpr\n90 7\n98 33\n99 232\n100 AcDbBlockElement\n300 Point\n98 33\n99 232\n1071 0\n100 AcDbBlockParameter\n280 1\n281 0\n100 AcDbBlock1PtParameter\n1010 35\n1020 0\n1030 0\n93 12\n170 0\n171 0\n100 AcDbBlockPointParameter\n303 Position1\n304\n1011 35\n1021 4\n1031 0\n";
|
|
let src = fixture()
|
|
.replace("71 1\n330 30\n1010 40", "71 2\n330 30\n330 68\n1010 40")
|
|
.replace("91 5\n93 32\n95 6\n360 67", "91 5\n93 32\n95 6\n360 67\n92 -1\n92 -1\n92 -1\n92 -1\n91 6\n93 32\n95 7\n360 68")
|
|
.replace("0 BLOCKFLIPGRIP", &format!("{point}0 BLOCKFLIPGRIP"));
|
|
let mut d = read_dxf(dxf(&src).as_bytes()).unwrap();
|
|
assert!(dynamic_block(&d, "Door").unwrap().parameters.iter().any(|p| p.kind.label() == Some("Position1")));
|
|
// A reference to Door with Door's own values.
|
|
let at = d.model.iter().count();
|
|
assert_eq!(at, 0);
|
|
let ins = cadcraft_doc::Insert {
|
|
block: "Door".into(),
|
|
insert: cadcraft_doc::geom::Vec3::new(0.0, 0.0, 0.0),
|
|
scale: cadcraft_doc::geom::Vec3::new(1.0, 1.0, 1.0),
|
|
rotation: 0.0,
|
|
attribs: Vec::new(),
|
|
cols: 1,
|
|
rows: 1,
|
|
col_spacing: 0.0,
|
|
row_spacing: 0.0,
|
|
};
|
|
let r = d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Insert(ins)).unwrap();
|
|
cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap();
|
|
let ps = cadcraft_doc::dynblock::custom_properties(&d, r).unwrap().1;
|
|
let pos: Vec<(&str, &str)> = ps.iter().filter(|p| p.name.starts_with("Position1")).map(|p| (p.name.as_str(), p.value.as_str())).collect();
|
|
assert_eq!(pos, [("Position1 X", "29.0000"), ("Position1 Y", "0.0000")]);
|
|
// Whether a parameter's point is in the stretch frame (34,-2)-(38,2) is decided where Door
|
|
// has it, as AutoCAD evaluates from the block: moved out to x = 60 first, it still follows.
|
|
{
|
|
let mut d = d.clone();
|
|
cadcraft_doc::dynblock::set_property(&mut d, r, "Position1 X", "60").unwrap();
|
|
cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "36").unwrap();
|
|
let ps = cadcraft_doc::dynblock::custom_properties(&d, r).unwrap().1;
|
|
assert_eq!(ps.iter().find(|p| p.name == "Position1 X").map(|p| p.value.as_str()), Some("66.0000"));
|
|
}
|
|
// A position set directly is recorded as the drag of its grip (12), from where Door has it.
|
|
cadcraft_doc::dynblock::set_property(&mut d, r, "Position1 X", "20").unwrap();
|
|
let x = d.foreign.entities.get(&r).and_then(|x| x.xdict).unwrap();
|
|
let cache = dict_entry(&d, x, "AcDbBlockRepresentation").and_then(|rep| dict_entry(&d, rep, "AppDataCache")).unwrap();
|
|
let history = dict_entry(&d, cache, "ACAD_ENHANCEDBLOCKHISTORY").unwrap();
|
|
let tags: Vec<(i32, String)> =
|
|
d.foreign.objects.iter().find(|o| o.handle == history).unwrap().tags.iter().map(|t| (t.code, t.value.clone())).collect();
|
|
let grip = tags.iter().position(|t| *t == (1071, "12".to_string())).expect("grip entry");
|
|
assert_eq!(tags[grip + 1..grip + 3], [(300, "GRIPLOC".to_string()), (11, "-15".to_string())]);
|
|
}
|
|
|
|
/// The object entry `key` of dictionary `dict` refers to.
|
|
fn dict_entry(d: &cadcraft_doc::Drawing, dict: Handle, key: &str) -> Option<Handle> {
|
|
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
|
|
let at = o.tags.iter().position(|t| t.code == 3 && t.value == key)?;
|
|
o.tags.get(at + 1).and_then(|t| Handle::parse_hex(&t.value))
|
|
}
|
|
|
|
/// The groups of the reference's AppDataCache entry `key` (an object or a dictionary's keys).
|
|
fn cache_entry(d: &cadcraft_doc::Drawing, insert: &str, key: &str) -> Vec<(i32, String)> {
|
|
let x = d.foreign.entities.get(&h(insert)).and_then(|x| x.xdict).expect("xdict");
|
|
let cache = dict_entry(d, x, "AcDbBlockRepresentation").and_then(|r| dict_entry(d, r, "AppDataCache")).expect("cache");
|
|
let Some(e) = dict_entry(d, cache, key) else { return Vec::new() };
|
|
let o = d.foreign.objects.iter().find(|o| o.handle == e).expect("object");
|
|
o.tags.iter().filter(|t| !matches!(t.code, 5 | 102 | 330 | 100 | 280)).map(|t| (t.code, t.value.clone())).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_change_is_recorded_in_the_references_history_and_data() {
|
|
// Width's grips are graph ids 8 (base) and 9 (end).
|
|
let src = with_references().replace("170\n4\n91\n0\n91\n0\n", "170\n4\n91\n8\n91\n9\n");
|
|
let mut d = read_dxf(src.as_bytes()).unwrap();
|
|
assert_eq!(dynamic_block(&d, "Door").unwrap().parameters.iter().find(|p| p.node == 2).unwrap().grips, [8, 9]);
|
|
let pairs = |v: &[(i32, &str)]| v.iter().map(|(c, s)| (*c, s.to_string())).collect::<Vec<_>>();
|
|
|
|
// Door's own reference: new data with a record for each one the other references have.
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("80"), "Visibility1", "Closed").unwrap();
|
|
let keys: Vec<String> = cache_entry(&d, "80", "ACAD_ENHANCEDBLOCKDATA").into_iter().filter(|(c, _)| *c == 3).map(|(_, k)| k).collect();
|
|
assert_eq!(keys, ["1", "2", "3", "4"]);
|
|
assert_eq!(cache_entry(&d, "80", "ACAD_ENHANCEDBLOCKHISTORY"), pairs(&[(1070, "3"), (1071, "1"), (300, "Visibility1"), (1, "Closed")]));
|
|
assert_eq!(d.block(&block_of(&d, "80")).unwrap().units, 0, "anonymous copies have no units");
|
|
|
|
// A distance as typed in Properties: by name, in block units; it replaces a drag of its
|
|
// grips. Entries are replaced, not added.
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("80"), "Width", "30").unwrap();
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("80"), "Width", "32").unwrap();
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("80"), "Visibility1", "Open").unwrap();
|
|
let want = pairs(&[(1070, "3"), (1071, "2"), (300, "Width"), (40, "32"), (1071, "1"), (300, "Visibility1"), (1, "Open")]);
|
|
assert_eq!(cache_entry(&d, "80", "ACAD_ENHANCEDBLOCKHISTORY"), want);
|
|
|
|
// A reference with data and no history gets one (81 is scaled 2: 64 is 32 in block units);
|
|
// both survive a save.
|
|
cadcraft_doc::dynblock::set_property(&mut d, h("81"), "Width", "64").unwrap();
|
|
let again = read_dxf(write_dxf(&d).as_bytes()).unwrap();
|
|
let saved = pairs(&[(1070, "3"), (1071, "2"), (300, "Width"), (40, "32.0")]);
|
|
assert_eq!(cache_entry(&again, "81", "ACAD_ENHANCEDBLOCKHISTORY"), saved);
|
|
let saved = pairs(&[(1070, "3"), (1071, "2"), (300, "Width"), (40, "32.0"), (1071, "1"), (300, "Visibility1"), (1, "Open")]);
|
|
assert_eq!(cache_entry(&again, "80", "ACAD_ENHANCEDBLOCKHISTORY"), saved);
|
|
}
|
|
|
|
/// Door with grips: Width's end grip (graph id 9, an object shown in the Open state; its base
|
|
/// grip 8 has no object) and the visibility parameter's grip (10).
|
|
fn with_grips() -> cadcraft_doc::Drawing {
|
|
let grip = "0 BLOCKLINEARGRIP\n5 68\n330 61\n100 AcDbEvalExpr\n90 9\n98 33\n99 232\n100 AcDbBlockElement\n300 End Grip\n98 33\n99 232\n1071 0\n";
|
|
let src = fixture()
|
|
.replace("170 4\n91 0\n91 0\n", "170 4\n91 8\n91 9\n")
|
|
.replace("1030 0\n93 0\n170 0\n171 0\n100 AcDbBlockVisibilityParameter", "1030 0\n93 10\n170 0\n171 0\n100 AcDbBlockVisibilityParameter")
|
|
.replace("91 5\n93 32\n95 6\n360 67", "91 5\n93 32\n95 6\n360 67\n92 -1\n92 -1\n92 -1\n92 -1\n91 6\n93 32\n95 9\n360 68")
|
|
.replace("95 1\n333 63\n", "95 2\n333 63\n333 68\n")
|
|
.replace("0 BLOCKFLIPGRIP", &format!("{grip}0 BLOCKFLIPGRIP"));
|
|
assert_eq!(src.matches("91 9\n").count(), 1, "the fixture changed");
|
|
read_dxf(dxf(&src).as_bytes()).unwrap()
|
|
}
|
|
|
|
/// A reference to Door at (100, 0), scale 1.
|
|
fn door_at_100(d: &mut cadcraft_doc::Drawing) -> Handle {
|
|
let ins = cadcraft_doc::Insert {
|
|
block: "Door".into(),
|
|
insert: cadcraft_doc::geom::Vec3::new(100.0, 0.0, 0.0),
|
|
scale: cadcraft_doc::geom::Vec3::new(1.0, 1.0, 1.0),
|
|
rotation: 0.0,
|
|
attribs: Vec::new(),
|
|
cols: 1,
|
|
rows: 1,
|
|
col_spacing: 0.0,
|
|
row_spacing: 0.0,
|
|
};
|
|
d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Insert(ins)).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn dynamic_references_show_the_grips_of_their_state() {
|
|
use cadcraft_doc::dynblock::{GripPart, GripShape, grips};
|
|
let mut d = with_grips();
|
|
let r = door_at_100(&mut d);
|
|
let g = grips(&d, r);
|
|
let summary: Vec<(i32, GripPart, GripShape, (f64, f64), (f64, f64))> =
|
|
g.iter().map(|g| (g.node, g.part, g.shape, (g.at.x, g.at.y), (g.dir.x, g.dir.y))).collect();
|
|
// The visibility grip (always), Width's end arrow pointing along it (its base grip has no
|
|
// object, so it isn't shown).
|
|
assert_eq!(
|
|
summary,
|
|
[(1, GripPart::At, GripShape::List, (118.0, 40.0), (0.0, 0.0)), (2, GripPart::End, GripShape::Linear, (136.0, -4.0), (1.0, 0.0))]
|
|
);
|
|
assert_eq!(g[0].choices, ["Open", "Closed"]);
|
|
assert_eq!(g[1].property, "Width");
|
|
// Closed lists no grips: only the visibility grip is left.
|
|
cadcraft_doc::dynblock::set_property(&mut d, r, "Visibility1", "Closed").unwrap();
|
|
assert_eq!(grips(&d, r).iter().map(|g| g.node).collect::<Vec<_>>(), [1]);
|
|
// Not a dynamic block reference: no grips.
|
|
assert!(grips(&d, h("30")).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dragging_a_distance_grip_changes_the_distance() {
|
|
use cadcraft_doc::dynblock::{GripPart, drag_grip};
|
|
use cadcraft_doc::geom::Vec2;
|
|
let mut d = with_grips();
|
|
let r = door_at_100(&mut d);
|
|
// The end arrow dragged to x = 130.4 (off the parameter's line too): Width 30.4 snaps to 30,
|
|
// the stretch follows and so does the lookup.
|
|
drag_grip(&mut d, r, 2, GripPart::End, Vec2::new(130.4, 7.0)).unwrap();
|
|
assert_eq!(line_ends(&d, &r.hex())[0], (30.0, 0.0));
|
|
let p = cadcraft_doc::dynblock::custom_properties(&d, r).unwrap().1;
|
|
assert_eq!((p[1].value.as_str(), p[2].value.as_str()), ("30.0000", "Narrow"));
|
|
// The base grip: the distance from the new base to the end (32 here).
|
|
drag_grip(&mut d, r, 2, GripPart::Base, Vec2::new(98.0, -4.0)).unwrap();
|
|
assert_eq!(cadcraft_doc::dynblock::custom_properties(&d, r).unwrap().1[1].value, "32.0000");
|
|
// Past the other end, a non-finite point, a grip that doesn't drag: refused, nothing changes.
|
|
let before = d.clone();
|
|
for (node, part, to) in [
|
|
(2, GripPart::End, Vec2::new(50.0, 0.0)),
|
|
(2, GripPart::End, Vec2::new(f64::NAN, 0.0)),
|
|
(1, GripPart::At, Vec2::new(120.0, 40.0)),
|
|
(99, GripPart::End, Vec2::new(130.0, 0.0)),
|
|
] {
|
|
assert!(drag_grip(&mut d, r, node, part, to).is_err(), "{node} {part:?} {to:?}");
|
|
}
|
|
assert!(drag_grip(&mut d, h("30"), 2, GripPart::End, Vec2::new(130.0, 0.0)).is_err(), "not a reference");
|
|
assert_eq!(d, before);
|
|
}
|