forked from 1b-agent/opencadstudio-upstream
feat(annotative): synthesize per-object contexts + editable UI
Make objects genuinely annotative — carrying real per-scale representations that interoperate with other CAD apps — rather than just a native flag. `create_annotation_context` synthesizes the extension-dict chain (AcDbContextDataManager -> ACDB_ANNOTATIONSCALES -> per-scale leaf) from the entity's placement; the editable Annotative toggle now covers Text and block references as well as MText/MLeader; and a new "Annotation Object Scale" dialog (OBJECTSCALE) adds/removes an object's per-scale memberships. Requires the acadrust ObjectContextData encoder (Cargo.lock bump). Also fixes a render bug where objects carrying an *empty* ACDB_ANNOTATIONSCALES (a single-representation marker with no per-scale reps) were treated as annotative and (mis)scaled by the annotation factor in scaled paper viewports, ballooning the text: an object is now annotative-by-context only when its scale collection is non-empty, so such objects render at their base geometry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
31d3023167
commit
c0844ad55c
14 files changed
+922
-30
No files matched your search
@@ -0,0 +1,151 @@
|
||||
// Faz 4c-0 preserve-verify (risk #10): an *untouched* annotative file must
|
||||
// round-trip every per-object annotation-context leaf without dropping or
|
||||
// mutating it. acadrust does not model `AcDb*ObjectContextData` — it keeps each
|
||||
// leaf verbatim as `Unknown{raw_dwg_data}` and re-emits it on same-version save,
|
||||
// while side-mapping the leaf's `340` annotation-scale handle into
|
||||
// `context_scales`. This certifies that passthrough is intact at the current
|
||||
// acadrust HEAD before any encoder work touches the save path.
|
||||
//
|
||||
// Uses the golden reference `~/Downloads/0718-mbmdmc.dwg` (AC1032/R2018). The
|
||||
// test skips (does not fail) when that file is absent so it never breaks CI.
|
||||
|
||||
use OpenCADStudio::io;
|
||||
use acadrust::objects::ObjectType;
|
||||
|
||||
const GOLDEN: &str = "/home/hakanseven/Downloads/0718-mbmdmc.dwg";
|
||||
|
||||
/// Raw bytes of every annotation-context leaf, sorted so the comparison is
|
||||
/// independent of handle ordering/renumbering across the round-trip.
|
||||
fn leaf_blobs(doc: &acadrust::CadDocument) -> Vec<Vec<u8>> {
|
||||
let mut blobs: Vec<Vec<u8>> = doc
|
||||
.context_scales
|
||||
.keys()
|
||||
.filter_map(|h| match doc.objects.get(h) {
|
||||
Some(ObjectType::Unknown { raw_dwg_data: Some(raw), .. }) => Some(raw.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
blobs.sort();
|
||||
blobs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotative_leaf_contexts_survive_dwg_roundtrip() {
|
||||
let Ok(bytes) = std::fs::read(GOLDEN) else {
|
||||
eprintln!("SKIP: golden file {GOLDEN} not present");
|
||||
return;
|
||||
};
|
||||
|
||||
let doc = io::load_bytes("0718-mbmdmc.dwg", bytes).expect("load golden");
|
||||
let n0 = doc.context_scales.len();
|
||||
let leaves0 = leaf_blobs(&doc);
|
||||
eprintln!(
|
||||
"loaded: version={:?} context_scales={} preserved_leaf_blobs={}",
|
||||
doc.version,
|
||||
n0,
|
||||
leaves0.len()
|
||||
);
|
||||
assert!(n0 > 0, "golden file must carry per-object annotation contexts");
|
||||
|
||||
// Same-version DWG round-trip (AC1032 -> AC1032).
|
||||
let out = io::save_to_bytes(&doc, "dwg", doc.version).expect("save dwg bytes");
|
||||
let doc2 = io::load_bytes("roundtrip.dwg", out).expect("reload dwg bytes");
|
||||
let n1 = doc2.context_scales.len();
|
||||
let leaves1 = leaf_blobs(&doc2);
|
||||
eprintln!("reloaded: context_scales={} preserved_leaf_blobs={}", n1, leaves1.len());
|
||||
|
||||
assert_eq!(n0, n1, "annotation-context leaf COUNT changed across round-trip");
|
||||
assert_eq!(
|
||||
leaves0.len(),
|
||||
leaves1.len(),
|
||||
"preserved leaf raw-byte-blob count changed across round-trip"
|
||||
);
|
||||
assert_eq!(
|
||||
leaves0, leaves1,
|
||||
"annotation-context leaf raw bytes are NOT byte-identical after round-trip"
|
||||
);
|
||||
}
|
||||
|
||||
/// Diagnostic: tally the golden file's context leaves by their DXF class name so
|
||||
/// we know which leaf type to encode + byte-diff first (4c-1). Not an assertion.
|
||||
#[test]
|
||||
fn annotative_leaf_type_breakdown() {
|
||||
let Ok(bytes) = std::fs::read(GOLDEN) else {
|
||||
eprintln!("SKIP: golden file {GOLDEN} not present");
|
||||
return;
|
||||
};
|
||||
let doc = io::load_bytes("0718-mbmdmc.dwg", bytes).expect("load golden");
|
||||
|
||||
let mut tally: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
let mut sizes: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
|
||||
for h in doc.context_scales.keys() {
|
||||
if let Some(ObjectType::Unknown { type_name, raw_dwg_data, .. }) = doc.objects.get(h) {
|
||||
// type_name is "DWG_OBJ_<type_code>"; resolve the code to a class name.
|
||||
let name = type_name
|
||||
.rsplit('_')
|
||||
.next()
|
||||
.and_then(|n| n.parse::<i16>().ok())
|
||||
.and_then(|code| doc.classes.iter().find(|c| c.class_number == code))
|
||||
.map(|c| c.dxf_name.clone())
|
||||
.unwrap_or_else(|| type_name.clone());
|
||||
*tally.entry(name.clone()).or_default() += 1;
|
||||
let len = raw_dwg_data.as_ref().map(|r| r.len()).unwrap_or(0);
|
||||
let e = sizes.entry(name).or_insert((usize::MAX, 0));
|
||||
e.0 = e.0.min(len);
|
||||
e.1 = e.1.max(len);
|
||||
}
|
||||
}
|
||||
eprintln!("=== context-leaf type breakdown ({} total) ===", doc.context_scales.len());
|
||||
for (name, n) in &tally {
|
||||
let (lo, hi) = sizes[name];
|
||||
eprintln!(" {n:>4} {name} raw_bytes {lo}..{hi}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Dump one real BLKREF context leaf: its raw data bytes, handle bits, owner,
|
||||
/// and its 340 scale target — to reverse the exact bit layout for the encoder.
|
||||
#[test]
|
||||
fn dump_one_blkref_leaf() {
|
||||
let Ok(bytes) = std::fs::read(GOLDEN) else {
|
||||
eprintln!("SKIP: golden file {GOLDEN} not present");
|
||||
return;
|
||||
};
|
||||
let doc = io::load_bytes("0718-mbmdmc.dwg", bytes).expect("load golden");
|
||||
|
||||
// Find the BLKREF class number.
|
||||
let blkref_code = doc
|
||||
.classes
|
||||
.iter()
|
||||
.find(|c| c.dxf_name == "ACDB_BLKREFOBJECTCONTEXTDATA_CLASS")
|
||||
.map(|c| c.class_number);
|
||||
eprintln!("BLKREF class_number = {blkref_code:?}");
|
||||
|
||||
let mut shown = 0;
|
||||
for (h, scale_h) in &doc.context_scales {
|
||||
if shown >= 2 {
|
||||
break;
|
||||
}
|
||||
if let Some(ObjectType::Unknown {
|
||||
type_name,
|
||||
owner,
|
||||
raw_dwg_data: Some(raw),
|
||||
raw_dwg_handle_bits,
|
||||
raw_dwg_version,
|
||||
..
|
||||
}) = doc.objects.get(h)
|
||||
{
|
||||
let code: Option<i16> = type_name.rsplit('_').next().and_then(|n| n.parse().ok());
|
||||
if code != blkref_code {
|
||||
continue;
|
||||
}
|
||||
shown += 1;
|
||||
eprintln!("--- BLKREF leaf handle={h:?} owner={owner:?} scale_target={scale_h:?} ver={raw_dwg_version:?}");
|
||||
eprintln!(" data ({} B): {}", raw.len(), hex(raw));
|
||||
eprintln!(" handle_bits: {:?}", raw_dwg_handle_bits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// 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 acadrust::entities::{EntityType, MText};
|
||||
use acadrust::objects::{Dictionary, ObjectContextKind, ObjectType, Scale};
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{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 mut 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 acadrust::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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user