Files
OneCAD-Draft/tests/annotative_context_synthesis.rs
T
Hakan SevenandClaude Opus 5.5 f537aff75c feat: node graph overlay; move to opencadcodec/opencadkernel/opencadgraph
The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.

A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 07:34:23 +03:00

220 lines
8.8 KiB
Rust

// Faz 4c end-to-end: OCS synthesizes a real per-object annotation context
// (extension-dict chain + `AcDb*ObjectContextData` leaf) for an entity, and it
// survives a DWG save/reload — i.e. an OCS-authored annotative object carries a
// genuine per-scale representation, interoperably, not just the native flag.
use codec::entities::{EntityType, MText};
use codec::objects::{Dictionary, ObjectContextKind, ObjectType, Scale};
use codec::types::Vector3;
use codec::{CadDocument, DxfVersion, Handle};
use OpenCADStudio::io;
use OpenCADStudio::scene::annotative;
#[test]
fn empty_annotation_scales_is_not_annotative() {
// An object whose AcDbContextDataManager -> ACDB_ANNOTATIONSCALES collection
// is EMPTY (a single-representation marker with no per-scale reps — common in
// files where objects were flagged annotative but never given a scale) must
// NOT be treated as annotative. Otherwise it gets (mis)scaled by the
// annotation factor inside annotation-scaled viewports, ballooning the text.
let mut doc = CadDocument::with_version(DxfVersion::AC1032);
let mut m = MText::new();
m.is_annotative = false; // no native flag, no annotative style
let ent = doc.add_entity(EntityType::MText(m)).unwrap();
// Build xdict -> "AcDbContextDataManager" -> "ACDB_ANNOTATIONSCALES" (empty).
let mk = |doc: &mut CadDocument, owner: Handle| -> Handle {
let h = doc.allocate_handle();
let mut d = Dictionary::new();
d.handle = h;
d.owner = owner;
doc.objects.insert(h, ObjectType::Dictionary(d));
h
};
let xd = mk(&mut doc, ent);
let mgr = mk(&mut doc, xd);
let coll = mk(&mut doc, mgr); // left empty
if let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&xd) {
d.add_entry("AcDbContextDataManager", mgr);
}
if let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&mgr) {
d.add_entry("ACDB_ANNOTATIONSCALES", coll);
}
if let Some(e) = doc.get_entity_mut(ent) {
e.common_mut().xdictionary_handle = Some(xd);
}
assert!(
!annotative::is_annotative(&doc, doc.get_entity(ent).unwrap()),
"empty ACDB_ANNOTATIONSCALES must not read as annotative"
);
// Adding a real per-scale representation makes it annotative again.
let sh = doc.allocate_handle();
let mut s = Scale::new("1:50", 1.0, 50.0);
s.handle = sh;
doc.objects.insert(sh, ObjectType::Scale(s));
assert!(annotative::create_annotation_context(&mut doc, ent, sh));
assert!(
annotative::is_annotative(&doc, doc.get_entity(ent).unwrap()),
"a non-empty scale list must read as annotative"
);
}
/// Walk an entity's xdict → "AcDbContextDataManager" → "ACDB_ANNOTATIONSCALES"
/// and return its leaf handles.
fn context_leaves(doc: &CadDocument, entity: Handle) -> Vec<Handle> {
let Some(xd) = doc.get_entity(entity).and_then(|e| e.common().xdictionary_handle) else {
return vec![];
};
let Some(mgr) = annotative::as_dict(doc, xd).and_then(|d| d.get("AcDbContextDataManager")) else {
return vec![];
};
let Some(coll) = annotative::as_dict(doc, mgr).and_then(|d| d.get("ACDB_ANNOTATIONSCALES"))
else {
return vec![];
};
annotative::as_dict(doc, coll)
.map(|d| d.entries.iter().map(|(_, h)| *h).collect())
.unwrap_or_default()
}
#[test]
fn ocs_synthesized_mtext_context_survives_dwg_roundtrip() {
let mut doc = CadDocument::with_version(DxfVersion::AC1032);
// An MTEXT plus a named scale to attach it to.
let mut m = MText::new();
m.insertion_point = Vector3::new(100.0, 200.0, 0.0);
m.rectangle_width = 50.0;
m.is_annotative = true;
let ent = doc.add_entity(EntityType::MText(m)).expect("add mtext");
let sh = doc.allocate_handle();
let mut scale = Scale::new("1:50", 1.0, 50.0);
scale.handle = sh;
doc.objects.insert(sh, ObjectType::Scale(scale));
// OCS synthesizes the whole context chain.
assert!(
annotative::create_annotation_context(&mut doc, ent, sh),
"create_annotation_context should succeed for MTEXT"
);
// Idempotent: a second call for the same scale must not add a second leaf.
assert!(annotative::create_annotation_context(&mut doc, ent, sh));
assert_eq!(context_leaves(&doc, ent).len(), 1, "duplicate leaf created");
// Full DWG round-trip through the OCS IO layer.
let bytes = io::save_to_bytes(&doc, "dwg", DxfVersion::AC1032).expect("save dwg");
let doc2 = io::load_bytes("rt.dwg", bytes).expect("reload dwg");
// The reloaded entity still carries exactly one leaf, a modeled MTEXT
// context, whose 340 resolves to the "1:50" scale.
let leaves = context_leaves(&doc2, ent);
assert_eq!(leaves.len(), 1, "context leaf lost across round-trip");
match doc2.objects.get(&leaves[0]) {
Some(ObjectType::ObjectContextData(c)) => {
assert!(
matches!(c.kind, ObjectContextKind::MText(_)),
"leaf is not an MTEXT context"
);
match doc2.objects.get(&c.scale) {
Some(ObjectType::Scale(s)) => assert_eq!(s.name, "1:50", "wrong scale link"),
other => panic!("scale handle did not resolve to a Scale: {other:?}"),
}
}
other => panic!("leaf is not an ObjectContextData: {other:?}"),
}
// The reader also side-maps the leaf's annotation scale.
assert!(
doc2.context_scales.contains_key(&leaves[0]),
"leaf missing from context_scales"
);
}
#[test]
fn ocs_synthesized_block_context_survives_dwg_roundtrip() {
use codec::entities::Insert;
let mut doc = CadDocument::with_version(DxfVersion::AC1032);
let mut ins = Insert::new("BLK", Vector3::new(10.0, 20.0, 0.0));
ins.rotation = 0.5;
let ent = doc.add_entity(EntityType::Insert(ins)).expect("add insert");
let sh = doc.allocate_handle();
let mut scale = Scale::new("1:100", 1.0, 100.0);
scale.handle = sh;
doc.objects.insert(sh, ObjectType::Scale(scale));
assert!(annotative::create_annotation_context(&mut doc, ent, sh));
let bytes = io::save_to_bytes(&doc, "dwg", DxfVersion::AC1032).expect("save dwg");
let doc2 = io::load_bytes("rt.dwg", bytes).expect("reload dwg");
let leaves = context_leaves(&doc2, ent);
assert_eq!(leaves.len(), 1, "block context leaf lost");
match doc2.objects.get(&leaves[0]) {
Some(ObjectType::ObjectContextData(c)) => {
assert!(matches!(c.kind, ObjectContextKind::BlkRef { .. }), "not a BLKREF context");
}
other => panic!("leaf is not an ObjectContextData: {other:?}"),
}
}
#[test]
fn object_scale_membership_add_remove_roundtrips() {
let mut doc = CadDocument::with_version(DxfVersion::AC1032);
let mut m = MText::new();
m.insertion_point = Vector3::new(1.0, 2.0, 0.0);
let ent = doc.add_entity(EntityType::MText(m)).unwrap();
let mk_scale = |doc: &mut CadDocument, name: &str, d: f64| {
let h = doc.allocate_handle();
let mut s = Scale::new(name, 1.0, d);
s.handle = h;
doc.objects.insert(h, ObjectType::Scale(s));
h
};
let s50 = mk_scale(&mut doc, "1:50", 50.0);
let s100 = mk_scale(&mut doc, "1:100", 100.0);
// Add two memberships.
assert!(annotative::create_annotation_context(&mut doc, ent, s50));
assert!(annotative::create_annotation_context(&mut doc, ent, s100));
let mut names: Vec<String> = annotative::object_scale_memberships(&doc, ent)
.into_iter()
.map(|(n, _)| n)
.collect();
names.sort();
assert_eq!(names, vec!["1:100", "1:50"], "both memberships expected");
// Remove one, keep the other; survives a round-trip.
assert!(annotative::remove_annotation_context_for_scale(&mut doc, ent, s50));
let bytes = io::save_to_bytes(&doc, "dwg", DxfVersion::AC1032).unwrap();
let doc2 = io::load_bytes("rt.dwg", bytes).unwrap();
let remaining: Vec<String> = annotative::object_scale_memberships(&doc2, ent)
.into_iter()
.map(|(n, _)| n)
.collect();
assert_eq!(remaining, vec!["1:100"], "exactly the un-removed scale remains");
// Removing the last representation tears the chain down → non-annotative.
let mut doc3 = doc2.clone();
// resolve the surviving scale handle in the reloaded doc
let s100b = doc3
.objects
.iter()
.find_map(|(h, o)| matches!(o, ObjectType::Scale(s) if s.name == "1:100").then_some(*h))
.unwrap();
assert!(annotative::remove_annotation_context_for_scale(&mut doc3, ent, s100b));
assert!(
annotative::object_scale_memberships(&doc3, ent).is_empty(),
"no memberships after removing the last"
);
assert!(
!annotative::is_annotative(&doc3, doc3.get_entity(ent).unwrap()),
"object should be non-annotative once its last representation is gone"
);
}