diff --git a/Cargo.lock b/Cargo.lock index dab55031..aeb289bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index a847e647..fbc6429f 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -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 — 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.")); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 71e03cb7..06b69a93 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -311,6 +311,7 @@ inventory::submit!(crate::command::CommandRegistration { "ANNOSCALE", "CANNOSCALE", "SCALELISTEDIT", + "OBJECTSCALE", // Import CSV into a table + LandXML survey points. "DATALINK", "LANDXMLIMPORT", diff --git a/src/app/mod.rs b/src/app/mod.rs index 2bbbcf8a..de8a2db7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -666,6 +666,9 @@ pub(super) struct OpenCADStudio { /// The scale row being renamed inline (double-click), + its edit buffer. scale_rename: Option, scale_rename_buf: String, + /// The entity the Annotation Object Scale dialog is editing (its per-object + /// annotation-scale membership). + anno_object_scale_target: Option, /// Open transaction for the scale manager — restored if the window closes /// without Apply, mirroring the style managers' staging. scale_stage: Option, @@ -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(), diff --git a/src/app/properties.rs b/src/app/properties.rs index 234cb671..fda29189 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -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 { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 7a0129ca..101388ea 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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) = diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index c648a9a5..4fd92ec2 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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)) } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 65c9c1cf..507afd9a 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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 = 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(), diff --git a/src/scene/annotative.rs b/src/scene/annotative.rs index 228e34d2..23ecda2b 100644 --- a/src/scene/annotative.rs +++ b/src/scene/annotative.rs @@ -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 { + 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) -> 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) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 7f72b6d6..c7b0e056 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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 { + pub(crate) fn scale_object_handle(&self, name: &str) -> Option { 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 { + 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 { + 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 diff --git a/src/ui/style/anno_object_scale.rs b/src/ui/style/anno_object_scale.rs new file mode 100644 index 00000000..693919cc --- /dev/null +++ b/src/ui/style/anno_object_scale.rs @@ -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> = 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() +} diff --git a/src/ui/style/mod.rs b/src/ui/style/mod.rs index cc9f26a0..0b94e522 100644 --- a/src/ui/style/mod.rs +++ b/src/ui/style/mod.rs @@ -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; diff --git a/tests/annotative_context_roundtrip.rs b/tests/annotative_context_roundtrip.rs new file mode 100644 index 00000000..4fc69ea4 --- /dev/null +++ b/tests/annotative_context_roundtrip.rs @@ -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> { + let mut blobs: Vec> = 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 = Default::default(); + let mut sizes: std::collections::BTreeMap = 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_"; resolve the code to a class name. + let name = type_name + .rsplit('_') + .next() + .and_then(|n| n.parse::().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 = 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::>().join(" ") +} diff --git a/tests/annotative_context_synthesis.rs b/tests/annotative_context_synthesis.rs new file mode 100644 index 00000000..4821eb20 --- /dev/null +++ b/tests/annotative_context_synthesis.rs @@ -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 { + 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 = 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 = 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" + ); +}