forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
440 lines
18 KiB
Rust
440 lines
18 KiB
Rust
//! Shared annotative-object detection + annotation-scale resolution.
|
|
//!
|
|
//! Both the Properties panel (Annotative row / applied scale name) and the
|
|
//! tessellation bake (which scales annotative content by the current annotation
|
|
//! scale) must agree on *which* entities are annotative — so that logic lives
|
|
//! here, once. An entity is annotative if it carries a per-object annotation
|
|
//! context, the legacy annotative XDATA, or an annotative style.
|
|
|
|
use acadrust::entities::{EntityCommon, EntityType};
|
|
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.
|
|
pub fn as_dict(doc: &CadDocument, handle: Handle) -> Option<&Dictionary> {
|
|
match doc.objects.get(&handle) {
|
|
Some(ObjectType::Dictionary(d)) => Some(d),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Resolve the drawing's root named-objects dictionary, creating one if the
|
|
/// file has none reachable.
|
|
///
|
|
/// The canonical pointer is `header.named_objects_dict_handle`, but DWGs
|
|
/// written by some programs leave it dangling — pointing at a handle that never
|
|
/// loaded, or at a non-dictionary — while the real named-object sub-dictionaries
|
|
/// (`ACAD_LAYOUT`, `ACAD_SCALELIST`, …) are instead owned by an unrelated handle
|
|
/// that is not a dictionary. When the pointer can't be resolved we adopt any
|
|
/// top-level (`owner == NULL`) dictionary as the root; failing that we synthesise
|
|
/// a fresh, empty root so that *registering* a new named-object entry (a page
|
|
/// setup, an annotation scale, the `CTAB` variable) actually persists instead of
|
|
/// silently no-opping against a missing dictionary.
|
|
///
|
|
/// Idempotent: the resolved or created handle is written back to the header, so
|
|
/// later calls return the same dictionary rather than minting another root. On a
|
|
/// well-formed drawing this returns the existing root untouched.
|
|
pub fn root_named_dict_handle(doc: &mut CadDocument) -> Handle {
|
|
let h = doc.header.named_objects_dict_handle;
|
|
if matches!(doc.objects.get(&h), Some(ObjectType::Dictionary(_))) {
|
|
return h;
|
|
}
|
|
// A top-level dictionary is already present (the standard root shape) — adopt
|
|
// the richest one (matching the DWG writer's own root heuristic) and repair
|
|
// the stale header pointer.
|
|
if let Some(root) = doc
|
|
.objects
|
|
.iter()
|
|
.filter_map(|(k, o)| match o {
|
|
ObjectType::Dictionary(d) if d.owner.is_null() => Some((*k, d.entries.len())),
|
|
_ => None,
|
|
})
|
|
.max_by_key(|&(_, n)| n)
|
|
.map(|(k, _)| k)
|
|
{
|
|
doc.header.named_objects_dict_handle = root;
|
|
return root;
|
|
}
|
|
// Nothing reachable — build a fresh, empty root named-objects dictionary.
|
|
let nh = doc.allocate_handle();
|
|
let mut d = Dictionary::new();
|
|
d.handle = nh;
|
|
d.owner = Handle::NULL;
|
|
doc.objects.insert(nh, ObjectType::Dictionary(d));
|
|
doc.header.named_objects_dict_handle = nh;
|
|
nh
|
|
}
|
|
|
|
/// Set the per-object annotative flag on the entity types that carry one
|
|
/// (MTEXT, MULTILEADER). Turning it off also strips the per-object annotation
|
|
/// context and legacy markers via [`clear_annotation_context`] so the object
|
|
/// stops resolving annotative; turning it on leaves the base geometry as the
|
|
/// single (implicit, current-scale) representation. Other entity types get
|
|
/// their annotative state from a style and are not toggled here.
|
|
pub fn set_entity_annotative(doc: &mut CadDocument, handle: Handle, want: bool) {
|
|
if let Some(e) = doc.get_entity_mut(handle) {
|
|
match e {
|
|
EntityType::MText(t) => t.is_annotative = want,
|
|
EntityType::MultiLeader(m) => m.enable_annotation_scale = want,
|
|
_ => {}
|
|
}
|
|
}
|
|
if !want {
|
|
clear_annotation_context(doc, handle);
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
/// on it. The shared `SCALE` objects in `ACAD_SCALELIST` are document-level and
|
|
/// left intact.
|
|
pub fn clear_annotation_context(doc: &mut CadDocument, handle: Handle) {
|
|
if let Some(xdict_h) = doc.get_entity(handle).and_then(|e| e.common().xdictionary_handle) {
|
|
// Collect the manager subtree (manager dict, its scales dict, the leaves)
|
|
// before mutating, then drop them.
|
|
let mut remove = Vec::new();
|
|
if let Some(mgr_h) = as_dict(doc, xdict_h).and_then(|d| d.get("AcDbContextDataManager")) {
|
|
remove.push(mgr_h);
|
|
if let Some(scales_h) =
|
|
as_dict(doc, mgr_h).and_then(|d| d.get("ACDB_ANNOTATIONSCALES"))
|
|
{
|
|
remove.push(scales_h);
|
|
if let Some(scales) = as_dict(doc, scales_h) {
|
|
for (_, leaf) in &scales.entries {
|
|
remove.push(*leaf);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(ObjectType::Dictionary(xd)) = doc.objects.get_mut(&xdict_h) {
|
|
xd.entries.retain(|(k, _)| k != "AcDbContextDataManager");
|
|
}
|
|
for h in remove {
|
|
doc.objects.remove(&h);
|
|
}
|
|
}
|
|
// Strip the legacy annotative XDATA markers the detection also honours.
|
|
crate::scene::view::dispatch::set_entity_xdata(doc, handle, "AcAnnoPO", None);
|
|
crate::scene::view::dispatch::set_entity_xdata(doc, handle, "AcAnnotativeData", None);
|
|
}
|
|
|
|
/// Does a style name resolve to `name` (or to "Standard" when `name` is blank)?
|
|
fn name_matches(style_name: &str, name: &str) -> bool {
|
|
style_name.eq_ignore_ascii_case(name)
|
|
|| (name.trim().is_empty() && style_name.eq_ignore_ascii_case("Standard"))
|
|
}
|
|
|
|
fn text_style_annotative(doc: &CadDocument, name: &str) -> bool {
|
|
doc.text_styles
|
|
.iter()
|
|
.find(|s| name_matches(&s.name, name))
|
|
.is_some_and(|s| s.annotative)
|
|
}
|
|
|
|
fn dim_style_annotative(doc: &CadDocument, name: &str) -> bool {
|
|
doc.dim_styles
|
|
.iter()
|
|
.find(|s| name_matches(&s.name, name))
|
|
.is_some_and(|s| s.annotative)
|
|
}
|
|
|
|
fn mleader_style_annotative(doc: &CadDocument, handle: Option<Handle>) -> bool {
|
|
let Some(h) = handle else {
|
|
return false;
|
|
};
|
|
doc.objects.iter().any(|(oh, o)| {
|
|
matches!(o, ObjectType::MultiLeaderStyle(s) if *oh == h && s.is_annotative)
|
|
})
|
|
}
|
|
|
|
fn table_style_annotative(doc: &CadDocument, handle: Option<Handle>) -> bool {
|
|
let Some(h) = handle else {
|
|
return false;
|
|
};
|
|
doc.objects
|
|
.iter()
|
|
.any(|(oh, o)| matches!(o, ObjectType::TableStyle(s) if *oh == h && s.annotative))
|
|
}
|
|
|
|
/// 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 {
|
|
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(|coll| !coll.entries.is_empty())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Whether an entity participates in annotation scaling.
|
|
pub fn is_annotative(doc: &CadDocument, entity: &EntityType) -> bool {
|
|
// Per-object annotation context (works regardless of style).
|
|
if has_context_manager(doc, entity.common()) {
|
|
return true;
|
|
}
|
|
// Legacy annotative XDATA markers.
|
|
let xd = &entity.common().extended_data;
|
|
if xd.get_record("AcAnnoPO").is_some() || xd.get_record("AcAnnotativeData").is_some() {
|
|
return true;
|
|
}
|
|
// Annotative via the assigned style (or the entity's own flag).
|
|
match entity {
|
|
EntityType::Text(t) => text_style_annotative(doc, &t.style),
|
|
EntityType::MText(t) => t.is_annotative || text_style_annotative(doc, &t.style),
|
|
EntityType::Dimension(d) => dim_style_annotative(doc, &d.base().style_name),
|
|
EntityType::Leader(l) => dim_style_annotative(doc, &l.dimension_style),
|
|
EntityType::MultiLeader(ml) => {
|
|
ml.enable_annotation_scale || mleader_style_annotative(doc, ml.style_handle)
|
|
}
|
|
EntityType::Table(t) => table_style_annotative(doc, t.table_style_handle),
|
|
_ => false,
|
|
}
|
|
}
|
|
|