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 Seven 2026-07-13 22:38:29 +03:00
commit c0844ad55c
14 changed files with 922 additions and 30 deletions

4
Cargo.lock generated
View file

@ -73,7 +73,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.4.0"
source = "git+https://github.com/HakanSeven12/acadrust?branch=main#c9fe9828583c6fe704191c08b942e959601ad4c0"
source = "git+https://github.com/HakanSeven12/acadrust?branch=main#92ff8bec6e5f08db830cb533c36e6f36211cbbf7"
dependencies = [
"ahash 0.8.12",
"anyhow",
@ -3178,7 +3178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
dependencies = [
"bytecount",
"memchr 1.0.2",
"memchr 2.8.2",
"nom 8.0.0",
]

View file

@ -780,6 +780,12 @@ impl OpenCADStudio {
}
}
// OBJECTSCALE — open the Annotation Object Scale dialog for the
// selected object (add / remove its per-object scale representations).
"OBJECTSCALE" => {
return Some(Task::done(Message::AnnoObjectScaleOpen));
}
// DATALINK <path.csv> — import a CSV file into a table placed at the
// origin (one-time import; a live re-reading link is future work).
cmd if cmd == "DATALINK" || cmd.starts_with("DATALINK ") => {
@ -873,7 +879,7 @@ impl OpenCADStudio {
}
}
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS" | "OBJECTSCALE"
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS"
| "UOSNAP" => {
self.command_line
.push_info(&format!("{cmd}: not yet implemented."));

View file

@ -311,6 +311,7 @@ inventory::submit!(crate::command::CommandRegistration {
"ANNOSCALE",
"CANNOSCALE",
"SCALELISTEDIT",
"OBJECTSCALE",
// Import CSV into a table + LandXML survey points.
"DATALINK",
"LANDXMLIMPORT",

View file

@ -666,6 +666,9 @@ pub(super) struct OpenCADStudio {
/// The scale row being renamed inline (double-click), + its edit buffer.
scale_rename: Option<String>,
scale_rename_buf: String,
/// The entity the Annotation Object Scale dialog is editing (its per-object
/// annotation-scale membership).
anno_object_scale_target: Option<acadrust::types::Handle>,
/// Open transaction for the scale manager — restored if the window closes
/// without Apply, mirroring the style managers' staging.
scale_stage: Option<crate::app::style_ops::ScaleStage>,
@ -1118,6 +1121,9 @@ pub enum ModalKind {
LayerDeleteWarning,
Aliases,
ScaleManager,
/// Add / remove the annotation scales a single selected object has a
/// per-object representation for.
AnnoObjectScale,
}
/// Identifies a DimStyle field that can be edited in the dialog.
@ -1469,6 +1475,10 @@ pub enum Message {
CloseScalePopup,
/// Open the annotation-scale manager (from the scale popup's Manage row).
ScaleManagerOpen,
/// Open the Annotation Object Scale dialog for the current single selection.
AnnoObjectScaleOpen,
/// Toggle whether the dialog's object has a representation for this scale.
AnnoObjectScaleToggle(String),
/// Select a scale row in the manager (loads it into the editor).
ScaleManagerSelect(String),
/// Add a new scale to the list (staged) and select it for editing.
@ -2269,6 +2279,7 @@ impl OpenCADStudio {
scale_manager_paper_buf: String::new(),
scale_manager_drawing_buf: String::new(),
scale_rename: None,
anno_object_scale_target: None,
scale_rename_buf: String::new(),
scale_stage: None,
layout_manager_rename_buf: String::new(),

View file

@ -669,6 +669,7 @@ impl OpenCADStudio {
let anno: Option<(&str, Option<&str>)> = match entity {
acadrust::EntityType::Text(_)
| acadrust::EntityType::MText(_)
| acadrust::EntityType::Insert(_)
| acadrust::EntityType::Leader(_) => Some(("annotative", None)),
acadrust::EntityType::MultiLeader(_) => {
Some(("enable_annotation_scale", None))
@ -696,25 +697,35 @@ impl OpenCADStudio {
),
);
}
// MTEXT carries a per-object annotative flag, so its
// row is an editable toggle (like MLeader's); the
// style-derived types stay read-only Yes/No.
// Objects that carry a per-object annotation context
// (MTEXT via its native flag, single-line TEXT via the
// context alone) get an editable toggle: turning it on
// synthesizes a real per-scale representation. The
// remaining types are style-driven and stay read-only.
if anno_field == "annotative" {
if let acadrust::EntityType::MText(t) = entity {
set_row_value(
match entity {
acadrust::EntityType::MText(t) => set_row_value(
&mut sections,
"annotative",
crate::scene::model::object::PropValue::BoolToggle {
field: "is_annotative",
value: t.is_annotative,
},
);
} else {
set_row(
),
acadrust::EntityType::Text(_)
| acadrust::EntityType::Insert(_) => set_row_value(
&mut sections,
"annotative",
crate::scene::model::object::PropValue::BoolToggle {
field: "annotative_ctx",
value: is_anno,
},
),
_ => set_row(
&mut sections,
"annotative",
if is_anno { "Yes" } else { "No" }.to_string(),
);
),
}
}
if is_anno {

View file

@ -1681,6 +1681,57 @@ impl OpenCADStudio {
self.active_modal = Some(crate::app::ModalKind::ScaleManager);
Task::none()
}
Message::AnnoObjectScaleOpen => {
// The dialog edits a single object's per-scale memberships.
let i = self.active_tab;
let handles = self.property_target_handles(i);
if handles.len() == 1 {
// Only object types that carry a per-object context.
let ok = matches!(
self.tabs[i].scene.document.get_entity(handles[0]),
Some(
acadrust::EntityType::Text(_)
| acadrust::EntityType::MText(_)
| acadrust::EntityType::Insert(_)
)
);
if ok {
self.anno_object_scale_target = Some(handles[0]);
self.active_modal = Some(crate::app::ModalKind::AnnoObjectScale);
} else {
self.command_line.push_info(
"OBJECTSCALE applies to a single Text, MText or block reference.",
);
}
} else {
self.command_line
.push_info("Select one object first, then run OBJECTSCALE.");
}
Task::none()
}
Message::AnnoObjectScaleToggle(name) => {
let i = self.active_tab;
if let Some(entity) = self.anno_object_scale_target {
if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) {
self.push_undo_snapshot(i, "OBJECTSCALE");
let doc = &mut self.tabs[i].scene.document;
let is_member = crate::scene::annotative::object_scale_memberships(doc, entity)
.iter()
.any(|(_, h)| *h == sh);
if is_member {
crate::scene::annotative::remove_annotation_context_for_scale(
doc, entity, sh,
);
} else {
crate::scene::annotative::create_annotation_context(doc, entity, sh);
}
self.tabs[i].dirty = true;
self.invalidate_property_targets(i, &[entity]);
self.refresh_properties();
}
}
Task::none()
}
Message::ScaleManagerSelect(name) => {
// Stage the current editor edits before switching so they aren't
// lost, then load the newly-selected scale.
@ -2472,22 +2523,45 @@ impl OpenCADStudio {
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
match field {
// Per-object annotative flag (MTEXT / MULTILEADER): a
// doc-aware toggle so turning it off also removes the
// per-object annotation context, not just the flag.
"is_annotative" | "enable_annotation_scale" => {
let cur = match self.tabs[i].scene.document.get_entity(handle) {
// Per-object annotative toggle: MTEXT/MULTILEADER carry
// a native flag; single-line TEXT is annotative purely
// by the presence of a per-object context. A doc-aware
// toggle so turning it on synthesizes a real per-scale
// representation and turning it off removes it (not just
// the flag).
"is_annotative" | "enable_annotation_scale" | "annotative_ctx" => {
let doc = &self.tabs[i].scene.document;
let cur = match doc.get_entity(handle) {
Some(acadrust::EntityType::MText(t)) => t.is_annotative,
Some(acadrust::EntityType::MultiLeader(m)) => {
m.enable_annotation_scale
}
_ => continue,
// TEXT (and any other context-only type): its
// annotative state is whether a context exists.
Some(e) => crate::scene::annotative::is_annotative(doc, e),
None => continue,
};
crate::scene::annotative::set_entity_annotative(
&mut self.tabs[i].scene.document,
handle,
!cur,
);
// Turning it on also gives the object a real
// per-scale representation at the current
// annotation scale (not just the native flag),
// so it interoperates as a genuine annotative
// object. Off is handled inside set_entity_*.
if !cur {
if let Some(sh) =
self.tabs[i].scene.current_annotation_scale_handle()
{
crate::scene::annotative::create_annotation_context(
&mut self.tabs[i].scene.document,
handle,
sh,
);
}
}
}
"invisible" => {
if let Some(entity) =

View file

@ -1574,6 +1574,7 @@ impl OpenCADStudio {
LayerDeleteWarning => (440, 200),
Aliases => (480, 520),
ScaleManager => (520, 360),
AnnoObjectScale => (360, 420),
};
Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H))
}

View file

@ -95,6 +95,52 @@ impl OpenCADStudio {
360,
)
}
super::super::ModalKind::AnnoObjectScale => {
let tab = &self.tabs[self.active_tab];
let entity = self.anno_object_scale_target;
// Which scales the object currently has a representation for.
let members: Vec<acadrust::types::Handle> = entity
.map(|h| {
crate::scene::annotative::object_scale_memberships(
&tab.scene.document,
h,
)
.into_iter()
.map(|(_, sh)| sh)
.collect()
})
.unwrap_or_default();
let label = entity
.and_then(|h| tab.scene.document.get_entity(h))
.map(|e| match e {
acadrust::EntityType::Text(_) => "TEXT",
acadrust::EntityType::MText(_) => "MTEXT",
acadrust::EntityType::Insert(_) => "BLOCK",
acadrust::EntityType::MultiLeader(_) => "MULTILEADER",
_ => "OBJECT",
})
.unwrap_or("");
let scales: Vec<(String, String, bool)> = tab
.scene
.scale_list()
.into_iter()
.map(|(name, _, _)| {
let sh = tab.scene.scale_object_handle(&name);
let ratio = tab
.scene
.scale_paper_drawing(&name)
.map(|(p, d)| format!("{p}:{d}"))
.unwrap_or_default();
let is_member = sh.map(|h| members.contains(&h)).unwrap_or(false);
(name, ratio, is_member)
})
.collect();
sized(
crate::ui::style::anno_object_scale::view_window(&label, &scales),
360,
420,
)
}
super::super::ModalKind::Plotstyle => sized(
crate::ui::style::plotstyle::view_window(
self.active_plot_style.as_ref(),

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)
}

View file

@ -1943,7 +1943,7 @@ impl Scene {
/// Scales are matched by their `name` field, not the dictionary key — some
/// files key the entries by an internal identifier (`*A1`, `A0`, …) that
/// bears no relation to the scale name.
fn scale_object_handle(&self, name: &str) -> Option<Handle> {
pub(crate) fn scale_object_handle(&self, name: &str) -> Option<Handle> {
use acadrust::objects::ObjectType;
self.document.objects.iter().find_map(|(h, o)| match o {
ObjectType::Scale(s) if !s.is_temporary && s.name.eq_ignore_ascii_case(name) => {
@ -1953,6 +1953,32 @@ impl Scene {
})
}
/// Resolve the current annotation scale (CANNOSCALE) to a real `Scale`
/// object handle, materializing the object from the scale list when the
/// drawing names the scale but has no `Scale` object for it. Returns `None`
/// when there is no current annotation scale. Used when giving an object a
/// per-object annotation context (the context's `340` must point at a real
/// `AcDbScale`).
pub(crate) fn current_annotation_scale_handle(&mut self) -> Option<Handle> {
let name = self.document.header.current_annotation_scale.clone();
if name.trim().is_empty() {
return None;
}
self.scale_handle_ensuring(&name)
}
/// Resolve a named annotation scale to a real `Scale` object handle,
/// materializing the object from the scale list when the drawing names the
/// scale (e.g. a virtual fallback scale) but has no `Scale` object for it.
pub(crate) fn scale_handle_ensuring(&mut self, name: &str) -> Option<Handle> {
if let Some(h) = self.scale_object_handle(name) {
return Some(h);
}
let (paper, drawing) = self.scale_paper_drawing(name).unwrap_or((1.0, 1.0));
self.add_scale(name, paper, drawing);
self.scale_object_handle(name)
}
/// Add a named annotation scale to the drawing's `ACAD_SCALELIST`. Returns
/// `false` if a scale with that name already exists. Creates the SCALELIST
/// dictionary (and registers it in the root dictionary) when the drawing

View file

@ -0,0 +1,105 @@
//! Annotation Object Scale dialog — add / remove the annotation scales a single
//! selected object carries a per-object representation for.
//!
//! Each drawing scale is a row; a checkmark marks the scales the object is a
//! member of. Clicking a row toggles membership: adding synthesizes a per-scale
//! context (`AcDb*ObjectContextData`) at that scale, removing drops it. Shares
//! the style / scale managers' frame so it looks consistent.
use crate::app::Message;
use crate::ui::style::style_manager::{hdivider, tb_button, BG, BORDER, DIM, LIST, TB, TEXT};
use iced::widget::{column, container, mouse_area, row, scrollable, text, Space};
use iced::{Background, Border, Color, Element, Fill, Theme};
const MEMBER_CHECK: Color = Color {
r: 0.30,
g: 0.82,
b: 0.36,
a: 1.0,
};
/// `scales` is `(name, "paper:drawing" ratio, is_member)`. Every label is cloned
/// into the widget tree, so the returned element borrows nothing from the args.
pub fn view_window(
object_label: &str,
scales: &[(String, String, bool)],
) -> Element<'static, Message> {
let toolbar = container(
row![
text(format!("Object: {object_label}"))
.size(11)
.color(TEXT),
Space::new().width(Fill),
tb_button("Close", Message::CloseModal, true),
]
.spacing(4)
.align_y(iced::Center),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(TB)),
..Default::default()
})
.width(Fill)
.padding([5, 8]);
let rows: Vec<Element<'_, Message>> = scales
.iter()
.map(|(name, ratio, member)| {
let check = crate::ui::icons::check_cell(*member, MEMBER_CHECK);
let label = row![
check,
text(name.clone()).size(11).color(TEXT).width(Fill),
text(ratio.clone()).size(10).color(DIM),
]
.spacing(4)
.align_y(iced::Center);
let cell = container(label)
.padding([4, 8])
.width(Fill)
.style(move |_: &Theme| container::Style {
text_color: Some(TEXT),
..Default::default()
});
mouse_area(cell)
.on_press(Message::AnnoObjectScaleToggle(name.clone()))
.into()
})
.collect();
let list = container(scrollable(column(rows).spacing(1)).height(Fill))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(LIST)),
border: Border {
color: BORDER,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.width(Fill)
.height(Fill)
.padding(2);
let body = container(
column![
text("Click a scale to add or remove the object's representation for it.")
.size(10)
.color(DIM),
list,
]
.spacing(6)
.height(Fill),
)
.width(Fill)
.height(Fill)
.padding(12);
container(column![toolbar, hdivider(), body])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.width(Fill)
.height(Fill)
.into()
}

View file

@ -7,4 +7,5 @@ pub mod tablestyle;
pub mod point_style;
pub mod style_list;
pub mod scale_manager;
pub mod anno_object_scale;
pub mod style_manager;

View file

@ -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(" ")
}

View file

@ -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"
);
}