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:
parent
31d3023167
commit
c0844ad55c
14 changed files with 922 additions and 30 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -73,7 +73,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "acadrust"
|
name = "acadrust"
|
||||||
version = "0.4.0"
|
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 = [
|
dependencies = [
|
||||||
"ahash 0.8.12",
|
"ahash 0.8.12",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|
@ -3178,7 +3178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
|
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytecount",
|
"bytecount",
|
||||||
"memchr 1.0.2",
|
"memchr 2.8.2",
|
||||||
"nom 8.0.0",
|
"nom 8.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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).
|
// origin (one-time import; a live re-reading link is future work).
|
||||||
cmd if cmd == "DATALINK" || cmd.starts_with("DATALINK ") => {
|
cmd if cmd == "DATALINK" || cmd.starts_with("DATALINK ") => {
|
||||||
|
|
@ -873,7 +879,7 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS" | "OBJECTSCALE"
|
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS"
|
||||||
| "UOSNAP" => {
|
| "UOSNAP" => {
|
||||||
self.command_line
|
self.command_line
|
||||||
.push_info(&format!("{cmd}: not yet implemented."));
|
.push_info(&format!("{cmd}: not yet implemented."));
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,7 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||||
"ANNOSCALE",
|
"ANNOSCALE",
|
||||||
"CANNOSCALE",
|
"CANNOSCALE",
|
||||||
"SCALELISTEDIT",
|
"SCALELISTEDIT",
|
||||||
|
"OBJECTSCALE",
|
||||||
// Import CSV into a table + LandXML survey points.
|
// Import CSV into a table + LandXML survey points.
|
||||||
"DATALINK",
|
"DATALINK",
|
||||||
"LANDXMLIMPORT",
|
"LANDXMLIMPORT",
|
||||||
|
|
|
||||||
|
|
@ -666,6 +666,9 @@ pub(super) struct OpenCADStudio {
|
||||||
/// The scale row being renamed inline (double-click), + its edit buffer.
|
/// The scale row being renamed inline (double-click), + its edit buffer.
|
||||||
scale_rename: Option<String>,
|
scale_rename: Option<String>,
|
||||||
scale_rename_buf: 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
|
/// Open transaction for the scale manager — restored if the window closes
|
||||||
/// without Apply, mirroring the style managers' staging.
|
/// without Apply, mirroring the style managers' staging.
|
||||||
scale_stage: Option<crate::app::style_ops::ScaleStage>,
|
scale_stage: Option<crate::app::style_ops::ScaleStage>,
|
||||||
|
|
@ -1118,6 +1121,9 @@ pub enum ModalKind {
|
||||||
LayerDeleteWarning,
|
LayerDeleteWarning,
|
||||||
Aliases,
|
Aliases,
|
||||||
ScaleManager,
|
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.
|
/// Identifies a DimStyle field that can be edited in the dialog.
|
||||||
|
|
@ -1469,6 +1475,10 @@ pub enum Message {
|
||||||
CloseScalePopup,
|
CloseScalePopup,
|
||||||
/// Open the annotation-scale manager (from the scale popup's Manage row).
|
/// Open the annotation-scale manager (from the scale popup's Manage row).
|
||||||
ScaleManagerOpen,
|
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).
|
/// Select a scale row in the manager (loads it into the editor).
|
||||||
ScaleManagerSelect(String),
|
ScaleManagerSelect(String),
|
||||||
/// Add a new scale to the list (staged) and select it for editing.
|
/// 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_paper_buf: String::new(),
|
||||||
scale_manager_drawing_buf: String::new(),
|
scale_manager_drawing_buf: String::new(),
|
||||||
scale_rename: None,
|
scale_rename: None,
|
||||||
|
anno_object_scale_target: None,
|
||||||
scale_rename_buf: String::new(),
|
scale_rename_buf: String::new(),
|
||||||
scale_stage: None,
|
scale_stage: None,
|
||||||
layout_manager_rename_buf: String::new(),
|
layout_manager_rename_buf: String::new(),
|
||||||
|
|
|
||||||
|
|
@ -669,6 +669,7 @@ impl OpenCADStudio {
|
||||||
let anno: Option<(&str, Option<&str>)> = match entity {
|
let anno: Option<(&str, Option<&str>)> = match entity {
|
||||||
acadrust::EntityType::Text(_)
|
acadrust::EntityType::Text(_)
|
||||||
| acadrust::EntityType::MText(_)
|
| acadrust::EntityType::MText(_)
|
||||||
|
| acadrust::EntityType::Insert(_)
|
||||||
| acadrust::EntityType::Leader(_) => Some(("annotative", None)),
|
| acadrust::EntityType::Leader(_) => Some(("annotative", None)),
|
||||||
acadrust::EntityType::MultiLeader(_) => {
|
acadrust::EntityType::MultiLeader(_) => {
|
||||||
Some(("enable_annotation_scale", None))
|
Some(("enable_annotation_scale", None))
|
||||||
|
|
@ -696,25 +697,35 @@ impl OpenCADStudio {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// MTEXT carries a per-object annotative flag, so its
|
// Objects that carry a per-object annotation context
|
||||||
// row is an editable toggle (like MLeader's); the
|
// (MTEXT via its native flag, single-line TEXT via the
|
||||||
// style-derived types stay read-only Yes/No.
|
// 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 anno_field == "annotative" {
|
||||||
if let acadrust::EntityType::MText(t) = entity {
|
match entity {
|
||||||
set_row_value(
|
acadrust::EntityType::MText(t) => set_row_value(
|
||||||
&mut sections,
|
&mut sections,
|
||||||
"annotative",
|
"annotative",
|
||||||
crate::scene::model::object::PropValue::BoolToggle {
|
crate::scene::model::object::PropValue::BoolToggle {
|
||||||
field: "is_annotative",
|
field: "is_annotative",
|
||||||
value: t.is_annotative,
|
value: t.is_annotative,
|
||||||
},
|
},
|
||||||
);
|
),
|
||||||
} else {
|
acadrust::EntityType::Text(_)
|
||||||
set_row(
|
| 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,
|
&mut sections,
|
||||||
"annotative",
|
"annotative",
|
||||||
if is_anno { "Yes" } else { "No" }.to_string(),
|
if is_anno { "Yes" } else { "No" }.to_string(),
|
||||||
);
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if is_anno {
|
if is_anno {
|
||||||
|
|
|
||||||
|
|
@ -1681,6 +1681,57 @@ impl OpenCADStudio {
|
||||||
self.active_modal = Some(crate::app::ModalKind::ScaleManager);
|
self.active_modal = Some(crate::app::ModalKind::ScaleManager);
|
||||||
Task::none()
|
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) => {
|
Message::ScaleManagerSelect(name) => {
|
||||||
// Stage the current editor edits before switching so they aren't
|
// Stage the current editor edits before switching so they aren't
|
||||||
// lost, then load the newly-selected scale.
|
// lost, then load the newly-selected scale.
|
||||||
|
|
@ -2472,22 +2523,45 @@ impl OpenCADStudio {
|
||||||
self.push_undo_snapshot(i, "CHPROP");
|
self.push_undo_snapshot(i, "CHPROP");
|
||||||
for &handle in &handles {
|
for &handle in &handles {
|
||||||
match field {
|
match field {
|
||||||
// Per-object annotative flag (MTEXT / MULTILEADER): a
|
// Per-object annotative toggle: MTEXT/MULTILEADER carry
|
||||||
// doc-aware toggle so turning it off also removes the
|
// a native flag; single-line TEXT is annotative purely
|
||||||
// per-object annotation context, not just the flag.
|
// by the presence of a per-object context. A doc-aware
|
||||||
"is_annotative" | "enable_annotation_scale" => {
|
// toggle so turning it on synthesizes a real per-scale
|
||||||
let cur = match self.tabs[i].scene.document.get_entity(handle) {
|
// 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::MText(t)) => t.is_annotative,
|
||||||
Some(acadrust::EntityType::MultiLeader(m)) => {
|
Some(acadrust::EntityType::MultiLeader(m)) => {
|
||||||
m.enable_annotation_scale
|
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(
|
crate::scene::annotative::set_entity_annotative(
|
||||||
&mut self.tabs[i].scene.document,
|
&mut self.tabs[i].scene.document,
|
||||||
handle,
|
handle,
|
||||||
!cur,
|
!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" => {
|
"invisible" => {
|
||||||
if let Some(entity) =
|
if let Some(entity) =
|
||||||
|
|
|
||||||
|
|
@ -1574,6 +1574,7 @@ impl OpenCADStudio {
|
||||||
LayerDeleteWarning => (440, 200),
|
LayerDeleteWarning => (440, 200),
|
||||||
Aliases => (480, 520),
|
Aliases => (480, 520),
|
||||||
ScaleManager => (520, 360),
|
ScaleManager => (520, 360),
|
||||||
|
AnnoObjectScale => (360, 420),
|
||||||
};
|
};
|
||||||
Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H))
|
Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,52 @@ impl OpenCADStudio {
|
||||||
360,
|
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(
|
super::super::ModalKind::Plotstyle => sized(
|
||||||
crate::ui::style::plotstyle::view_window(
|
crate::ui::style::plotstyle::view_window(
|
||||||
self.active_plot_style.as_ref(),
|
self.active_plot_style.as_ref(),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@
|
||||||
//! context, the legacy annotative XDATA, or an annotative style.
|
//! context, the legacy annotative XDATA, or an annotative style.
|
||||||
|
|
||||||
use acadrust::entities::{EntityCommon, EntityType};
|
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};
|
use acadrust::{CadDocument, Handle};
|
||||||
|
|
||||||
/// Resolve a handle to a `Dictionary` object, if it is one.
|
/// 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
|
/// Remove an entity's per-object annotation context — the extension-dictionary
|
||||||
/// `AcDbContextDataManager` → `ACDB_ANNOTATIONSCALES` → per-scale leaf subtree —
|
/// `AcDbContextDataManager` → `ACDB_ANNOTATIONSCALES` → per-scale leaf subtree —
|
||||||
/// and the legacy annotative XDATA markers, so [`is_annotative`] no longer fires
|
/// 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))
|
.any(|(oh, o)| matches!(o, ObjectType::TableStyle(s) if *oh == h && s.annotative))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an object carries a per-object annotation context — its extension
|
/// Whether an object carries a per-object annotation context with at least one
|
||||||
/// dictionary holds an `AcDbContextDataManager`. This catches objects that are
|
/// per-scale representation — its extension dictionary holds an
|
||||||
/// annotative by context even when their style is not.
|
/// `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 {
|
fn has_context_manager(doc: &CadDocument, common: &EntityCommon) -> bool {
|
||||||
common
|
let key = |d: &Dictionary, name: &str| {
|
||||||
.xdictionary_handle
|
|
||||||
.and_then(|h| as_dict(doc, h))
|
|
||||||
.map(|d| {
|
|
||||||
d.entries
|
d.entries
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(k, _)| k.eq_ignore_ascii_case("AcDbContextDataManager"))
|
.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)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1943,7 +1943,7 @@ impl Scene {
|
||||||
/// Scales are matched by their `name` field, not the dictionary key — some
|
/// Scales are matched by their `name` field, not the dictionary key — some
|
||||||
/// files key the entries by an internal identifier (`*A1`, `A0`, …) that
|
/// files key the entries by an internal identifier (`*A1`, `A0`, …) that
|
||||||
/// bears no relation to the scale name.
|
/// 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;
|
use acadrust::objects::ObjectType;
|
||||||
self.document.objects.iter().find_map(|(h, o)| match o {
|
self.document.objects.iter().find_map(|(h, o)| match o {
|
||||||
ObjectType::Scale(s) if !s.is_temporary && s.name.eq_ignore_ascii_case(name) => {
|
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
|
/// Add a named annotation scale to the drawing's `ACAD_SCALELIST`. Returns
|
||||||
/// `false` if a scale with that name already exists. Creates the SCALELIST
|
/// `false` if a scale with that name already exists. Creates the SCALELIST
|
||||||
/// dictionary (and registers it in the root dictionary) when the drawing
|
/// dictionary (and registers it in the root dictionary) when the drawing
|
||||||
|
|
|
||||||
105
src/ui/style/anno_object_scale.rs
Normal file
105
src/ui/style/anno_object_scale.rs
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -7,4 +7,5 @@ pub mod tablestyle;
|
||||||
pub mod point_style;
|
pub mod point_style;
|
||||||
pub mod style_list;
|
pub mod style_list;
|
||||||
pub mod scale_manager;
|
pub mod scale_manager;
|
||||||
|
pub mod anno_object_scale;
|
||||||
pub mod style_manager;
|
pub mod style_manager;
|
||||||
|
|
|
||||||
151
tests/annotative_context_roundtrip.rs
Normal file
151
tests/annotative_context_roundtrip.rs
Normal 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(" ")
|
||||||
|
}
|
||||||
219
tests/annotative_context_synthesis.rs
Normal file
219
tests/annotative_context_synthesis.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue