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:
Hakan SevenandClaude Opus 4.8 committed 2026-07-13 22:38:29 +03:00
1 parent 31d3023167
commit c0844ad55c
14 files changed
+922 -30

No files matched your search

+251 -11
View File
@@ -7,7 +7,10 @@
//! context, the legacy annotative XDATA, or an annotative style.
use acadrust::entities::{EntityCommon, EntityType};
use acadrust::objects::{Dictionary, ObjectType};
use acadrust::objects::{
Dictionary, MTextContext, ObjectContextData, ObjectContextKind, ObjectType,
};
use acadrust::types::{Vector2, Vector3};
use acadrust::{CadDocument, Handle};
/// Resolve a handle to a `Dictionary` object, if it is one.
@@ -84,6 +87,227 @@ pub fn set_entity_annotative(doc: &mut CadDocument, handle: Handle, want: bool)
}
}
/// Derive the per-scale context payload for an entity from its current
/// placement. Returns the concrete class name and the context kind, or `None`
/// for entity types that do not carry a per-object annotation context (their
/// annotative state comes from a style, e.g. DIMENSION/TABLE).
fn context_kind_for(entity: &EntityType) -> Option<(&'static str, ObjectContextKind)> {
match entity {
EntityType::Insert(ins) => Some((
"ACDB_BLKREFOBJECTCONTEXTDATA_CLASS",
ObjectContextKind::BlkRef {
rotation: ins.rotation,
insertion: ins.insert_point,
scale_factor: Vector3::new(ins.x_scale(), ins.y_scale(), ins.z_scale()),
},
)),
EntityType::Text(t) => Some((
"ACDB_TEXTOBJECTCONTEXTDATA_CLASS",
ObjectContextKind::Text {
horizontal_mode: t.horizontal_alignment as i16,
rotation: t.rotation,
insertion: Vector2::new(t.insertion_point.x, t.insertion_point.y),
alignment: t
.alignment_point
.map(|p| Vector2::new(p.x, p.y))
.unwrap_or(Vector2::new(0.0, 0.0)),
},
)),
EntityType::MText(m) => Some((
"ACDB_MTEXTOBJECTCONTEXTDATA_CLASS",
ObjectContextKind::MText(MTextContext {
attachment: m.attachment_point as i32,
// MTEXT stores a text X-axis direction; derive it from rotation.
x_axis_dir: Vector3::new(m.rotation.cos(), m.rotation.sin(), 0.0),
insertion: m.insertion_point,
rect_width: m.rectangle_width,
rect_height: 0.0,
extents_width: 0.0,
extents_height: 0.0,
column_type: 0,
columns: None,
}),
)),
_ => None,
}
}
/// Give an entity a per-object annotation context for `scale_handle`,
/// synthesizing the extension-dictionary chain it hangs from when absent:
///
/// ```text
/// entity xdict → "AcDbContextDataManager" → "ACDB_ANNOTATIONSCALES" → "*An" → leaf
/// ```
///
/// The leaf is an [`ObjectContextData`] whose placement is copied from the
/// entity's current geometry and whose `340` handle references `scale_handle`
/// (an `AcDbScale` in `ACAD_SCALELIST`). Idempotent: a leaf for that scale is
/// not duplicated. Exactly one leaf per object is marked `is_default` (the
/// first one created — the native representation). Returns `false` for entity
/// kinds that carry no per-object context (their annotative-ness is style-driven).
pub fn create_annotation_context(
doc: &mut CadDocument,
entity_handle: Handle,
scale_handle: Handle,
) -> bool {
let Some((class_name, kind)) = doc.get_entity(entity_handle).and_then(context_kind_for) else {
return false;
};
// The writer emits a 500+ class number only for registered classes.
doc.register_object_context_class(class_name);
// Extension dictionary (hard-owns its entries; 280 = 1). Create it if the
// entity has none, and point the entity at it.
let xdict_h = match doc
.get_entity(entity_handle)
.and_then(|e| e.common().xdictionary_handle)
{
Some(h) if as_dict(doc, h).is_some() => h,
_ => {
let h = doc.allocate_handle();
let mut d = Dictionary::new();
d.handle = h;
d.owner = entity_handle;
d.hard_owner = true;
doc.objects.insert(h, ObjectType::Dictionary(d));
if let Some(e) = doc.get_entity_mut(entity_handle) {
e.common_mut().xdictionary_handle = Some(h);
}
h
}
};
let mgr_h = get_or_create_child_dict(doc, xdict_h, "AcDbContextDataManager");
let coll_h = get_or_create_child_dict(doc, mgr_h, "ACDB_ANNOTATIONSCALES");
// Idempotent: if a leaf already applies to this scale, keep it.
let existing = as_dict(doc, coll_h)
.map(|d| {
d.entries.iter().any(|(_, lh)| {
matches!(
doc.objects.get(lh),
Some(ObjectType::ObjectContextData(c)) if c.scale == scale_handle
)
})
})
.unwrap_or(false);
if existing {
return true;
}
// The first representation created is the default (native) one.
let is_default = as_dict(doc, coll_h).map(|d| d.entries.is_empty()).unwrap_or(true);
let n = as_dict(doc, coll_h).map(|d| d.entries.len()).unwrap_or(0) + 1;
let key = format!("*A{n}");
let leaf_h = doc.allocate_handle();
let leaf = ObjectContextData {
handle: leaf_h,
owner_handle: coll_h,
reactors: vec![coll_h],
xdictionary_handle: None,
class_version: 3,
is_default,
scale: scale_handle,
kind,
source_raw: None,
source_handle_bits: 0,
source_version: None,
};
doc.objects
.insert(leaf_h, ObjectType::ObjectContextData(leaf));
if let Some(ObjectType::Dictionary(coll)) = doc.objects.get_mut(&coll_h) {
coll.add_entry(key, leaf_h);
}
true
}
/// The annotation scales an object currently carries a per-object context for,
/// as `(scale name, scale handle)` pairs (one per representation). Empty when
/// the object has no per-object context chain.
pub fn object_scale_memberships(doc: &CadDocument, entity: Handle) -> Vec<(String, Handle)> {
let mut out = Vec::new();
let Some(coll_h) = annotation_scales_dict(doc, entity) else {
return out;
};
if let Some(coll) = as_dict(doc, coll_h) {
for (_, lh) in &coll.entries {
if let Some(ObjectType::ObjectContextData(leaf)) = doc.objects.get(lh) {
if let Some(ObjectType::Scale(s)) = doc.objects.get(&leaf.scale) {
out.push((s.name.clone(), leaf.scale));
}
}
}
}
out
}
/// Remove the per-object context representation that applies to `scale_handle`,
/// keeping the object's other representations. When that was the object's last
/// representation the whole context chain (and the annotative markers) are torn
/// down via [`clear_annotation_context`] so the object becomes non-annotative.
/// Returns `true` if a representation was removed.
pub fn remove_annotation_context_for_scale(
doc: &mut CadDocument,
entity: Handle,
scale_handle: Handle,
) -> bool {
let Some(coll_h) = annotation_scales_dict(doc, entity) else {
return false;
};
// Find the leaf that applies to this scale.
let leaf = as_dict(doc, coll_h).and_then(|c| {
c.entries.iter().find_map(|(_, lh)| {
matches!(
doc.objects.get(lh),
Some(ObjectType::ObjectContextData(o)) if o.scale == scale_handle
)
.then_some(*lh)
})
});
let Some(leaf_h) = leaf else {
return false;
};
// If this is the object's only representation, fully de-annotate it (drop the
// whole chain AND the native flag, like the Yes→No toggle) so it stops
// resolving annotative; otherwise drop just this leaf.
let last = as_dict(doc, coll_h).map(|c| c.entries.len() <= 1).unwrap_or(true);
if last {
set_entity_annotative(doc, entity, false);
return true;
}
doc.objects.remove(&leaf_h);
if let Some(ObjectType::Dictionary(coll)) = doc.objects.get_mut(&coll_h) {
coll.entries.retain(|(_, h)| *h != leaf_h);
}
true
}
/// Resolve an entity's `ACDB_ANNOTATIONSCALES` collection dictionary handle, if
/// its context chain exists.
fn annotation_scales_dict(doc: &CadDocument, entity: Handle) -> Option<Handle> {
let xd = doc.get_entity(entity).and_then(|e| e.common().xdictionary_handle)?;
let mgr = as_dict(doc, xd).and_then(|d| d.get("AcDbContextDataManager"))?;
as_dict(doc, mgr).and_then(|d| d.get("ACDB_ANNOTATIONSCALES"))
}
/// Get the child dictionary stored under `key` in `parent_h`, creating an empty
/// one (owned by `parent_h`) and registering the entry when absent.
fn get_or_create_child_dict(doc: &mut CadDocument, parent_h: Handle, key: &str) -> Handle {
if let Some(h) = as_dict(doc, parent_h).and_then(|d| d.get(key)) {
return h;
}
let h = doc.allocate_handle();
let mut d = Dictionary::new();
d.handle = h;
d.owner = parent_h;
doc.objects.insert(h, ObjectType::Dictionary(d));
if let Some(ObjectType::Dictionary(p)) = doc.objects.get_mut(&parent_h) {
p.add_entry(key, h);
}
h
}
/// Remove an entity's per-object annotation context — the extension-dictionary
/// `AcDbContextDataManager` → `ACDB_ANNOTATIONSCALES` → per-scale leaf subtree —
/// and the legacy annotative XDATA markers, so [`is_annotative`] no longer fires
@@ -157,18 +381,34 @@ fn table_style_annotative(doc: &CadDocument, handle: Option<Handle>) -> bool {
.any(|(oh, o)| matches!(o, ObjectType::TableStyle(s) if *oh == h && s.annotative))
}
/// Whether an object carries a per-object annotation context — its extension
/// dictionary holds an `AcDbContextDataManager`. This catches objects that are
/// annotative by context even when their style is not.
/// Whether an object carries a per-object annotation context with at least one
/// per-scale representation — its extension dictionary holds an
/// `AcDbContextDataManager` whose `ACDB_ANNOTATIONSCALES` collection is
/// non-empty. This catches objects that are annotative by context even when
/// their style is not.
///
/// The non-empty requirement matters: a context manager with an *empty*
/// `ACDB_ANNOTATIONSCALES` is a single-representation marker with no per-scale
/// reps (common in files where objects were flagged annotative but never given
/// a scale). Such an object has nothing to scale *to*, so it must render at its
/// base geometry — treating it as annotative would (mis)scale it by the
/// annotation factor in annotation-scaled viewports, ballooning the text.
fn has_context_manager(doc: &CadDocument, common: &EntityCommon) -> bool {
common
.xdictionary_handle
let key = |d: &Dictionary, name: &str| {
d.entries
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, h)| *h)
};
let Some(xd) = common.xdictionary_handle.and_then(|h| as_dict(doc, h)) else {
return false;
};
let Some(mgr) = key(xd, "AcDbContextDataManager").and_then(|h| as_dict(doc, h)) else {
return false;
};
key(mgr, "ACDB_ANNOTATIONSCALES")
.and_then(|h| as_dict(doc, h))
.map(|d| {
d.entries
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("AcDbContextDataManager"))
})
.map(|coll| !coll.entries.is_empty())
.unwrap_or(false)
}