feat(annotative): scale text/dims/tables/blocks at the annotation scale + editable property pickers

Render: annotative-ness is resolved centrally in scene::annotative::is_annotative
(per-object context dictionary, legacy XDATA, or annotative style) so the bake and
the properties panel agree. Text, dimensions, tables and blocks now display at the
current annotation scale in model space. An annotative block scales as one uniform
unit about its insertion point — its internal geometry and attributes are carried by
that scale instead of being scaled individually, fixing the block-attribute size
regression. Tables scale their column/row/margin geometry.

Properties: the handle- and flag-backed rows are now editable pickers — MLEADER
multileader style / text style / arrowhead / leader linetype, General Material, and
Plot style (named plot-style mode only; the color-dependent mode stays read-only,
which is correct). A picked name is resolved back to its handle in the update loop,
where the document is in scope. Annotative Yes/No and the annotation-scale row are
shown per object.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-12 23:16:26 +03:00
commit 1b037a68aa
9 changed files with 537 additions and 226 deletions

View file

@ -95,95 +95,177 @@ impl OpenCADStudio {
let mut sections =
dispatch::properties_sectioned(handle, entity, &text_style_names);
// Resolve a custom material handle (material_flags == 3) to
// its name in the 3D Visualization group.
// Turn the Material row into an editable picker: the source
// options (ByLayer / ByBlock) plus every named material the
// drawing defines. A custom flag (3) shows the stored handle's
// material name as the selection.
{
let doc = &self.tabs[i].scene.document;
let common = entity.common();
if common.material_flags == 3 {
if let Some(mh) = common.material_handle {
if let Some(name) = self.tabs[i]
.scene
.document
let mat_names: Vec<String> = doc
.objects
.iter()
.find_map(|(h, o)| match o {
.filter_map(|(_, o)| match o {
acadrust::objects::ObjectType::Material(m) => Some(m.name.clone()),
_ => None,
})
.collect();
let selected = match common.material_flags {
0 => "ByLayer".to_string(),
1 => "ByBlock".to_string(),
_ => common
.material_handle
.and_then(|mh| {
doc.objects.iter().find_map(|(h, o)| match o {
acadrust::objects::ObjectType::Material(m) if *h == mh => {
Some(m.name.clone())
}
_ => None,
})
{
})
.unwrap_or_else(|| "ByLayer".to_string()),
};
let mut options = vec!["ByLayer".to_string(), "ByBlock".to_string()];
options.extend(mat_names);
for section in sections.iter_mut() {
if let Some(row) =
section.props.iter_mut().find(|p| p.field == "material")
{
row.value =
crate::scene::model::object::PropValue::ReadOnly(
name.clone(),
);
row.value = crate::scene::model::object::PropValue::Choice {
selected: selected.clone(),
options: options.clone(),
};
}
}
}
// In named plot-style mode (PSTYLEMODE=1) the Plot style row
// picks a named style from the drawing's plot style table. In
// the color-dependent mode it stays read-only (the object's
// color drives the plot style), so it is left untouched.
if self.tabs[i].scene.document.header.plotstyle_mode {
let doc = &self.tabs[i].scene.document;
let dict_h = doc.header.acad_plotstylename_dict_handle;
if let Some(dict) = crate::scene::annotative::as_dict(doc, dict_h) {
let common = entity.common();
let mut options = vec!["ByLayer".to_string(), "ByBlock".to_string()];
options.extend(dict.entries.iter().map(|(n, _)| n.clone()));
let selected = match common.plotstyle_flags {
0 => "ByLayer".to_string(),
1 => "ByBlock".to_string(),
_ => common
.plotstyle_handle
.and_then(|ph| {
dict.entries
.iter()
.find(|(_, h)| *h == ph)
.map(|(n, _)| n.clone())
})
.unwrap_or_else(|| "ByLayer".to_string()),
};
for section in sections.iter_mut() {
if let Some(row) =
section.props.iter_mut().find(|p| p.field == "plot_style")
{
row.value = crate::scene::model::object::PropValue::Choice {
selected: selected.clone(),
options: options.clone(),
};
}
}
}
}
// Resolve MLEADER handle-backed rows (multileader style, text
// style, arrowhead block, leader linetype) to display names.
// Turn the MLEADER handle-backed rows (multileader style, text
// style, arrowhead block, leader linetype) into editable name
// pickers. The current handle resolves to a display name; the
// options list every candidate the drawing offers. Applying a
// pick resolves the name back to a handle in the update loop.
if let acadrust::EntityType::MultiLeader(ml) = entity {
let doc = &self.tabs[i].scene.document;
let mut set_named = |field: &str, name: String| {
// Option lists.
let mleader_styles: Vec<String> = doc
.objects
.iter()
.filter_map(|(_, o)| match o {
acadrust::objects::ObjectType::MultiLeaderStyle(s) => {
Some(s.name.clone())
}
_ => None,
})
.collect();
// A leader with no linetype handle draws ByBlock; expose that
// as the first option so the default is selectable.
let ltype_names: Vec<String> = std::iter::once("ByBlock".to_string())
.chain(
doc.line_types
.iter()
.map(|l| l.name.clone())
.filter(|n| !n.is_empty()),
)
.collect();
// Arrowheads are blocks; the closed-filled default has no
// block, so seed the list with it and add the arrowhead
// blocks (leading underscore) present in the drawing.
let arrow_names: Vec<String> = std::iter::once("Closed filled".to_string())
.chain(
doc.block_records
.iter()
.filter(|b| b.name.starts_with('_'))
.map(|b| b.name.clone()),
)
.collect();
let tstyle_names = text_style_names.clone();
// Currently selected names.
let cur_style = ml
.style_handle
.and_then(|h| {
doc.objects.iter().find_map(|(oh, o)| match o {
acadrust::objects::ObjectType::MultiLeaderStyle(s)
if *oh == h =>
{
Some(s.name.clone())
}
_ => None,
})
})
.unwrap_or_else(|| "Standard".to_string());
let cur_tstyle = ml
.text_style_handle
.and_then(|h| {
doc.text_styles.iter().find(|s| s.handle == h).map(|s| s.name.clone())
})
.unwrap_or_else(|| "Standard".to_string());
let cur_arrow = ml
.arrowhead_handle
.and_then(|h| {
doc.block_records.iter().find(|b| b.handle == h).map(|b| b.name.clone())
})
.unwrap_or_else(|| "Closed filled".to_string());
let cur_ltype = ml
.line_type_handle
.and_then(|h| {
doc.line_types.iter().find(|l| l.handle == h).map(|l| l.name.clone())
})
.unwrap_or_else(|| "ByBlock".to_string());
let mut set_choice =
|field: &str, selected: String, options: Vec<String>| {
for section in sections.iter_mut() {
if let Some(row) =
section.props.iter_mut().find(|p| p.field == field)
{
row.value =
crate::scene::model::object::PropValue::ReadOnly(
name.clone(),
);
crate::scene::model::object::PropValue::Choice {
selected: selected.clone(),
options: options.clone(),
};
}
}
};
if let Some(h) = ml.style_handle {
if let Some(name) = doc.objects.iter().find_map(|(oh, o)| match o {
acadrust::objects::ObjectType::MultiLeaderStyle(s) if *oh == h => {
Some(s.name.clone())
}
_ => None,
}) {
set_named("mleader_style", name);
}
}
if let Some(h) = ml.text_style_handle {
if let Some(name) = doc
.text_styles
.iter()
.find(|s| s.handle == h)
.map(|s| s.name.clone())
{
set_named("text_style_handle", name);
}
}
if let Some(h) = ml.arrowhead_handle {
if let Some(name) = doc
.block_records
.iter()
.find(|b| b.handle == h)
.map(|b| b.name.clone())
{
set_named("arrowhead_handle", name);
}
}
if let Some(h) = ml.line_type_handle {
if let Some(name) = doc
.line_types
.iter()
.find(|l| l.handle == h)
.map(|l| l.name.clone())
{
set_named("line_type_handle", name);
}
}
set_choice("mleader_style", cur_style, mleader_styles);
set_choice("text_style_handle", cur_tstyle, tstyle_names);
set_choice("arrowhead_handle", cur_arrow, arrow_names);
set_choice("line_type_handle", cur_ltype, ltype_names);
}
// Inject viewport-only properties that require doc access.
@ -487,27 +569,42 @@ impl OpenCADStudio {
// are walked from the entity's extension dictionary — both
// need the document, so they are resolved here.
{
let anno = match entity {
acadrust::EntityType::Text(t) => {
Some((text_style_annotative(doc, &t.style), "annotative"))
// Which entities show an Annotative row, the field it uses,
// and — for those that don't already carry the row
// (dimension / table) — the existing field to insert it
// after. MLeader uses its editable toggle field.
let anno: Option<(&str, Option<&str>)> = match entity {
acadrust::EntityType::Text(_)
| acadrust::EntityType::MText(_)
| acadrust::EntityType::Leader(_) => Some(("annotative", None)),
acadrust::EntityType::MultiLeader(_) => {
Some(("enable_annotation_scale", None))
}
acadrust::EntityType::MText(t) => Some((
t.is_annotative || text_style_annotative(doc, &t.style),
"annotative",
)),
acadrust::EntityType::Leader(l) => {
Some((dim_style_annotative(doc, &l.dimension_style), "annotative"))
acadrust::EntityType::Dimension(_) => {
Some(("annotative", Some("style_name")))
}
acadrust::EntityType::Table(_) => {
Some(("annotative", Some("tbl_style_handle")))
}
acadrust::EntityType::MultiLeader(ml) => Some((
ml.enable_annotation_scale
|| mleader_style_annotative(doc, ml.style_handle),
"enable_annotation_scale",
)),
_ => None,
};
if let Some((is_anno, anno_field)) = anno {
// MLeader keeps its editable toggle; the read-only
// text rows get an explicit Yes/No.
if let Some((anno_field, insert_after)) = anno {
let is_anno = crate::scene::annotative::is_annotative(doc, entity);
// Dimensions/tables carry no Annotative row yet — add one
// right after their style row.
if let Some(anchor) = insert_after {
insert_row_after(
&mut sections,
anchor,
crate::entities::common::ro_prop(
"Annotative",
"annotative",
"No",
),
);
}
// The read-only text rows get an explicit Yes/No; MLeader
// keeps its editable toggle.
if anno_field == "annotative" {
set_row(
&mut sections,
@ -516,19 +613,16 @@ impl OpenCADStudio {
);
}
if is_anno {
let names = entity_scale_names(doc, entity.common());
let display = if names.is_empty() {
doc.header.current_annotation_scale.clone()
} else {
names.join(", ")
};
// The applied annotation scale follows the current
// annotation scale (CANNOSCALE / the status-bar
// scale pill), not a per-object stored value.
insert_row_after(
&mut sections,
anno_field,
crate::entities::common::ro_prop(
"Annotative scale",
"annotative_scale",
display,
doc.header.current_annotation_scale.clone(),
),
);
}
@ -1243,93 +1337,6 @@ fn find_dim_style<'a>(
})
}
/// Is the named text style annotative?
fn text_style_annotative(doc: &acadrust::CadDocument, name: &str) -> bool {
doc.text_styles
.iter()
.find(|s| {
s.name.eq_ignore_ascii_case(name)
|| (name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard"))
})
.map_or(false, |s| s.annotative)
}
/// Is the named dimension style annotative?
fn dim_style_annotative(doc: &acadrust::CadDocument, name: &str) -> bool {
find_dim_style(doc, name).map_or(false, |s| s.annotative)
}
/// Is the multileader style (by handle) annotative?
fn mleader_style_annotative(doc: &acadrust::CadDocument, handle: Option<acadrust::Handle>) -> bool {
let Some(h) = handle else {
return false;
};
doc.objects.iter().any(|(oh, o)| {
matches!(o, acadrust::objects::ObjectType::MultiLeaderStyle(s) if *oh == h && s.is_annotative)
})
}
/// Resolve a handle to a `Dictionary` object, if it is one.
fn as_dict(
doc: &acadrust::CadDocument,
handle: acadrust::Handle,
) -> Option<&acadrust::objects::Dictionary> {
match doc.objects.get(&handle) {
Some(acadrust::objects::ObjectType::Dictionary(d)) => Some(d),
_ => None,
}
}
/// Case-insensitive dictionary entry lookup by key.
fn dict_get(dict: &acadrust::objects::Dictionary, key: &str) -> Option<acadrust::Handle> {
dict.entries
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, h)| *h)
}
/// The AcDbScale object's ratio name (e.g. "1:50"), by handle.
fn scale_name(doc: &acadrust::CadDocument, handle: acadrust::Handle) -> Option<String> {
match doc.objects.get(&handle) {
Some(acadrust::objects::ObjectType::Scale(s)) => Some(s.name.clone()),
_ => None,
}
}
/// The annotation-scale name(s) assigned to an entity, walked from its extension
/// dictionary → AcDbContextDataManager → ACDB_ANNOTATIONSCALES.
///
/// The collection's entry keys are internal anonymous names (e.g. "*A1"); the
/// real scale is each leaf's group-340 SCALE handle, which acadrust surfaces via
/// `doc.context_scales` (context-leaf handle → AcDbScale handle). We resolve each
/// leaf handle to its scale, then the scale to its ratio name. Empty when the
/// entity has no per-scale context (it then uses the drawing's current scale).
fn entity_scale_names(
doc: &acadrust::CadDocument,
common: &acadrust::entities::EntityCommon,
) -> Vec<String> {
let Some(xd) = common.xdictionary_handle else {
return Vec::new();
};
let Some(coll) = as_dict(doc, xd)
.and_then(|d| dict_get(d, "AcDbContextDataManager"))
.and_then(|h| as_dict(doc, h))
.and_then(|m| dict_get(m, "ACDB_ANNOTATIONSCALES"))
.and_then(|h| as_dict(doc, h))
else {
return Vec::new();
};
let mut names: Vec<String> = Vec::new();
for (_, leaf) in &coll.entries {
if let Some(name) = doc.context_scales.get(leaf).and_then(|sh| scale_name(doc, *sh)) {
if !names.contains(&name) {
names.push(name);
}
}
}
names
}
/// Insert `row` immediately after the first property whose field matches.
fn insert_row_after(
sections: &mut [crate::scene::model::object::PropSection],

View file

@ -1243,6 +1243,158 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
self.tabs[i].scene.camera_generation += 1;
}
} else if matches!(
field,
"mleader_style"
| "text_style_handle"
| "arrowhead_handle"
| "line_type_handle"
) {
// Resolve a picked name back to the handle the MLEADER
// stores. The style/text-style rows keep their existing
// handle on a failed lookup; the arrowhead/linetype rows
// take the resolved value directly (None = the default
// "Closed filled" / "ByBlock" option).
let doc = &self.tabs[i].scene.document;
let resolved: Option<acadrust::Handle> = match field {
"mleader_style" => doc.objects.iter().find_map(|(h, o)| match o {
acadrust::objects::ObjectType::MultiLeaderStyle(s)
if s.name == value =>
{
Some(*h)
}
_ => None,
}),
"text_style_handle" => doc
.text_styles
.iter()
.find(|s| s.name == value)
.map(|s| s.handle),
"arrowhead_handle" => {
if value == "Closed filled" {
None
} else {
doc.block_records
.iter()
.find(|b| b.name == value)
.map(|b| b.handle)
}
}
"line_type_handle" => {
if value == "ByBlock" {
None
} else {
doc.line_types
.iter()
.find(|l| l.name == value)
.map(|l| l.handle)
}
}
_ => None,
};
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
}
if let Some(acadrust::EntityType::MultiLeader(ml)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
match field {
"mleader_style" => {
if let Some(h) = resolved {
ml.style_handle = Some(h);
}
}
"text_style_handle" => {
if let Some(h) = resolved {
ml.text_style_handle = Some(h);
}
}
"arrowhead_handle" => ml.arrowhead_handle = resolved,
"line_type_handle" => ml.line_type_handle = resolved,
_ => {}
}
}
}
} else if field == "plot_style" {
// Named plot-style pick: ByLayer / ByBlock clear the
// handle; a named style resolves through the drawing's
// ACAD_PLOTSTYLENAME dictionary to its placeholder handle.
let dict_h =
self.tabs[i].scene.document.header.acad_plotstylename_dict_handle;
let ph: Option<acadrust::Handle> =
crate::scene::annotative::as_dict(&self.tabs[i].scene.document, dict_h)
.and_then(|d| {
d.entries
.iter()
.find(|(n, _)| *n == value)
.map(|(_, h)| *h)
});
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
}
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle)
{
let common = entity.common_mut();
match value.as_str() {
"ByLayer" => {
common.plotstyle_flags = 0;
common.plotstyle_handle = None;
}
"ByBlock" => {
common.plotstyle_flags = 1;
common.plotstyle_handle = None;
}
_ => {
if let Some(h) = ph {
common.plotstyle_flags = 3;
common.plotstyle_handle = Some(h);
}
}
}
}
}
} else if field == "material" {
// Material source: ByLayer / ByBlock clear the handle; a
// named material sets flag 3 + its handle (resolved here
// because the update loop holds the document).
let mat_handle: Option<acadrust::Handle> = self.tabs[i]
.scene
.document
.objects
.iter()
.find_map(|(h, o)| match o {
acadrust::objects::ObjectType::Material(m) if m.name == value => {
Some(*h)
}
_ => None,
});
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
}
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle)
{
let common = entity.common_mut();
match value.as_str() {
"ByLayer" => {
common.material_flags = 0;
common.material_handle = None;
}
"ByBlock" => {
common.material_flags = 1;
common.material_handle = None;
}
_ => {
if let Some(h) = mat_handle {
common.material_flags = 3;
common.material_handle = Some(h);
}
}
}
}
}
} else {
for &handle in &handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle)

View file

@ -37,7 +37,7 @@ fn properties(ins: &Insert) -> Vec<PropSection> {
},
})
.collect();
vec![
let mut sections = vec![
PropSection {
title: "Geometry".into(),
props: vec![
@ -54,24 +54,24 @@ fn properties(ins: &Insert) -> Vec<PropSection> {
props: vec![
ro("Name", "block", ins.block_name.clone()),
edit("Rotation", "rotation", ins.rotation.to_degrees()),
ro("Block Unit", "block_unit", String::new()),
ro("Unit factor", "unit_factor", String::new()),
ro(
"Annotative",
"annotative",
if annotative { "Yes" } else { "No" },
),
ro("Block Unit", "block_unit", String::new()),
ro("Unit factor", "unit_factor", String::new()),
],
},
PropSection {
];
// The Attributes group appears only when the block actually has attributes.
if !attrs.is_empty() {
sections.push(PropSection {
title: "Attributes".into(),
props: attrs,
},
PropSection {
title: "Custom".into(),
props: Vec::new(),
},
]
});
}
sections
}
fn apply_geom_prop(ins: &mut Insert, field: &str, value: &str) {
@ -206,12 +206,39 @@ pub(crate) fn append_insert_attribute_wires(
if attmode == 0 {
return;
}
// An annotative block scales as ONE uniform unit about its insertion point;
// its attributes are carried by that scale (AutoCAD even forbids annotative
// attributes inside annotative blocks, to avoid double-scaling). So pre-scale
// the annotative attribute about the insertion point and tessellate it at a
// neutral 1.0 — the text path must never scale it a second time.
let annotative = ins
.common
.extended_data
.get_record("AcAnnotativeData")
.is_some();
let block_scale = if annotative { anno_scale as f64 } else { 1.0 };
let ip = ins.insert_point;
let scale_about = |q: acadrust::types::Vector3| {
acadrust::types::Vector3::new(
ip.x + (q.x - ip.x) * block_scale,
ip.y + (q.y - ip.y) * block_scale,
ip.z + (q.z - ip.z) * block_scale,
)
};
for attr in &ins.attributes {
let per_attr_hidden = attr.common.invisible || attr.flags.invisible;
if attmode == 1 && per_attr_hidden {
continue;
}
let attr_entity = EntityType::AttributeEntity(attr.clone());
let attr_entity = EntityType::AttributeEntity({
let mut a = attr.clone();
if (block_scale - 1.0).abs() > 1e-6 {
a.height *= block_scale;
a.insertion_point = scale_about(a.insertion_point);
a.alignment_point = scale_about(a.alignment_point);
}
a
});
let (sub_color, sub_plen, sub_pat, sub_lw_px, sub_aci) = render::render_style_for_block_sub(
document,
&attr_entity,
@ -237,7 +264,9 @@ pub(crate) fn append_insert_attribute_wires(
sub_plen * pslt_factor,
sub_pat.map(|v| v * pslt_factor),
sub_lw_px,
anno_scale,
// Neutral: the attribute is already scaled as part of the block unit
// above, so the text path must not scale it a second time.
1.0,
None,
bg_color,
false,

View file

@ -367,6 +367,10 @@ pub fn tessellate_table(
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
// Annotation scale: multiplies the table's paper-size geometry so an
// annotative table renders at the current annotation scale. 1.0 for a
// non-annotative table (its geometry is already at model size).
anno_scale: f32,
) -> Vec<crate::scene::model::wire_model::WireModel> {
use crate::scene::convert::tess_util::aci_to_rgba;
use crate::scene::model::wire_model::WireModel;
@ -431,7 +435,7 @@ pub fn tessellate_table(
let mut off = 0.0f32;
let mut v = vec![0.0f32];
for col in &tab.columns {
off += col.width as f32;
off += col.width as f32 * anno_scale;
v.push(off);
}
v
@ -440,7 +444,7 @@ pub fn tessellate_table(
let mut off = 0.0f32;
let mut v = vec![0.0f32];
for row in &tab.rows {
off += row.height as f32;
off += row.height as f32 * anno_scale;
v.push(off);
}
v
@ -450,8 +454,8 @@ pub fn tessellate_table(
let header_suppressed = table_style.map(|t| t.header_suppressed).unwrap_or(false);
let h_margin = table_style
.map(|t| t.horizontal_margin as f32)
.unwrap_or(0.0);
let v_margin = table_style.map(|t| t.vertical_margin as f32).unwrap_or(0.0);
.unwrap_or(0.0) * anno_scale;
let v_margin = table_style.map(|t| t.vertical_margin as f32).unwrap_or(0.0) * anno_scale;
let lookup_style = |hh: acadrust::Handle| -> Option<&acadrust::tables::TextStyle> {
document.text_styles.iter().find(|s| s.handle == hh)
@ -526,7 +530,7 @@ pub fn tessellate_table(
let row_bot = row_offsets
.get(ri + 1)
.copied()
.unwrap_or(row_top + row.height as f32);
.unwrap_or(row_top + row.height as f32 * anno_scale);
let row_mid = (row_top + row_bot) * 0.5;
let row_style: Option<&acadrust::objects::RowCellStyle> = table_style.map(|ts| {
let kind = match (title_suppressed, header_suppressed, ri) {
@ -544,7 +548,7 @@ pub fn tessellate_table(
for (ci, cell) in row.cells.iter().enumerate() {
let col_left = col_offsets[ci];
let col_width = tab.columns.get(ci).map(|c| c.width as f32).unwrap_or(1.0);
let col_width = tab.columns.get(ci).map(|c| c.width as f32).unwrap_or(1.0) * anno_scale;
let col_right = col_left + col_width;
let tl = origin + h * col_left + v_flow * row_top;
let tr = origin + h * col_right + v_flow * row_top;
@ -648,7 +652,7 @@ pub fn tessellate_table(
})
.or_else(|| row_style.map(|s| s.text_height).filter(|h| *h > 1e-6))
.map(|h| h as f32)
.unwrap_or(0.18);
.unwrap_or(0.18) * anno_scale;
let m_h = if h_margin > 1e-6 {
h_margin
} else {

98
src/scene/annotative.rs Normal file
View file

@ -0,0 +1,98 @@
//! Shared annotative-object detection + annotation-scale resolution.
//!
//! Both the Properties panel (Annotative row / applied scale name) and the
//! tessellation bake (which scales annotative content by the current annotation
//! scale) must agree on *which* entities are annotative — so that logic lives
//! here, once. An entity is annotative if it carries a per-object annotation
//! context, the legacy annotative XDATA, or an annotative style.
use acadrust::entities::{EntityCommon, EntityType};
use acadrust::objects::{Dictionary, ObjectType};
use acadrust::{CadDocument, Handle};
/// Resolve a handle to a `Dictionary` object, if it is one.
pub fn as_dict(doc: &CadDocument, handle: Handle) -> Option<&Dictionary> {
match doc.objects.get(&handle) {
Some(ObjectType::Dictionary(d)) => Some(d),
_ => None,
}
}
/// Does a style name resolve to `name` (or to "Standard" when `name` is blank)?
fn name_matches(style_name: &str, name: &str) -> bool {
style_name.eq_ignore_ascii_case(name)
|| (name.trim().is_empty() && style_name.eq_ignore_ascii_case("Standard"))
}
fn text_style_annotative(doc: &CadDocument, name: &str) -> bool {
doc.text_styles
.iter()
.find(|s| name_matches(&s.name, name))
.is_some_and(|s| s.annotative)
}
fn dim_style_annotative(doc: &CadDocument, name: &str) -> bool {
doc.dim_styles
.iter()
.find(|s| name_matches(&s.name, name))
.is_some_and(|s| s.annotative)
}
fn mleader_style_annotative(doc: &CadDocument, handle: Option<Handle>) -> bool {
let Some(h) = handle else {
return false;
};
doc.objects.iter().any(|(oh, o)| {
matches!(o, ObjectType::MultiLeaderStyle(s) if *oh == h && s.is_annotative)
})
}
fn table_style_annotative(doc: &CadDocument, handle: Option<Handle>) -> bool {
let Some(h) = handle else {
return false;
};
doc.objects
.iter()
.any(|(oh, o)| matches!(o, ObjectType::TableStyle(s) if *oh == h && s.annotative))
}
/// Whether an object carries a per-object annotation context — its extension
/// dictionary holds an `AcDbContextDataManager`. This catches objects that are
/// annotative by context even when their style is not.
fn has_context_manager(doc: &CadDocument, common: &EntityCommon) -> bool {
common
.xdictionary_handle
.and_then(|h| as_dict(doc, h))
.map(|d| {
d.entries
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("AcDbContextDataManager"))
})
.unwrap_or(false)
}
/// Whether an entity participates in annotation scaling.
pub fn is_annotative(doc: &CadDocument, entity: &EntityType) -> bool {
// Per-object annotation context (works regardless of style).
if has_context_manager(doc, entity.common()) {
return true;
}
// Legacy annotative XDATA markers.
let xd = &entity.common().extended_data;
if xd.get_record("AcAnnoPO").is_some() || xd.get_record("AcAnnotativeData").is_some() {
return true;
}
// Annotative via the assigned style (or the entity's own flag).
match entity {
EntityType::Text(t) => text_style_annotative(doc, &t.style),
EntityType::MText(t) => t.is_annotative || text_style_annotative(doc, &t.style),
EntityType::Dimension(d) => dim_style_annotative(doc, &d.base().style_name),
EntityType::Leader(l) => dim_style_annotative(doc, &l.dimension_style),
EntityType::MultiLeader(ml) => {
ml.enable_annotation_scale || mleader_style_annotative(doc, ml.style_handle)
}
EntityType::Table(t) => table_style_annotative(doc, t.table_style_handle),
_ => false,
}
}

View file

@ -591,9 +591,29 @@ pub fn expand_insert(
// from native content.
is_xref: bool,
bg_color: [f32; 4],
// Current annotation scale. An annotative block scales as one uniform unit
// about its insertion point; a non-annotative block is unaffected.
anno_scale: f32,
) -> Option<Vec<WireModel>> {
let defn = cache.defn(&ins.block_name)?;
let xform = ins.get_transform();
let mut xform = ins.get_transform();
// Annotative blocks (the flag lives on the block definition; the instance is
// marked with the AcAnnotativeData XDATA) scale as ONE uniform unit about
// their insertion point — internal geometry/text/attributes are carried by
// this transform, never scaled individually (which would double-scale).
if (anno_scale - 1.0).abs() > 1e-6
&& ins
.common
.extended_data
.get_record("AcAnnotativeData")
.is_some()
{
let p = ins.insert_point;
let scale_about_p = Transform::from_translation(Vector3::new(-p.x, -p.y, -p.z))
.then(&Transform::from_scale(anno_scale as f64))
.then(&Transform::from_translation(Vector3::new(p.x, p.y, p.z)));
xform = xform.then(&scale_about_p);
}
let name = ins_handle.value().to_string();
let mut batches = Batches::default();
let mut visited: Vec<String> = Vec::with_capacity(8);

View file

@ -438,8 +438,16 @@ pub(crate) fn tessellate_entity(
// No baked block (e.g. a table created in-app) — synthesise coloured
// geometry from the rows + TableStyle so fills/colours/borders/margins
// are honoured instead of the monochrome fallback.
// Annotative tables scale with the current annotation scale (their
// stored geometry is at paper size); non-annotative tables are already
// model-size, so pass 1.0.
let table_anno = if crate::scene::annotative::is_annotative(document, e) {
anno_scale
} else {
1.0
};
let mut wires = crate::entities::table::tessellate_table(
tab, document, sel, entity_color, line_weight_px,
tab, document, sel, entity_color, line_weight_px, table_anno,
);
if !wires.is_empty() {
let aabb = entity_aabb(e);
@ -516,6 +524,7 @@ pub(crate) fn tessellate_entity(
world_per_pixel,
is_xref,
bg_color,
anno_scale,
) {
// XCLIP: if this INSERT carries an enabled spatial filter,
// clip the expanded block geometry to the boundary polygon so

View file

@ -103,26 +103,17 @@ pub fn tessellate(
};
let name = handle.value().to_string();
// Determine effective annotation scale for this entity.
// Determine the effective annotation scale for this entity.
//
// AutoCAD's R2007+ "annotative" system marks objects via extension-
// dictionary records or "AcAnnoPO" / "AcAnnotativeData" xdata. Only
// entities so marked should be auto-scaled by the viewport's
// paper-scale; everything else is treated as manually pre-scaled
// (old DXF/DWG convention with $DIMSCALE and oversized text).
//
// Default: NOT annotative (anno_scale = 1.0). Opt-in via explicit
// xdata marker. Files that mark every entity annotative are rare; the
// pre-R2007 manual-scale convention is far more common in field data.
let anno_scale = {
let xdata = &entity.common().extended_data;
let is_annotative = xdata.get_record("AcAnnoPO").is_some()
|| xdata.get_record("AcAnnotativeData").is_some();
if is_annotative {
// Only annotative entities are auto-scaled by the current annotation scale;
// everything else is manually pre-scaled (old convention with $DIMSCALE and
// oversized text). Annotative-ness is resolved centrally from the entity's
// per-object context, legacy XDATA, or annotative style (see
// `scene::annotative::is_annotative`) so the bake and the panel agree.
let anno_scale = if crate::scene::annotative::is_annotative(document, entity) {
anno_scale
} else {
1.0
}
};
// A HATCH is drawn as a fill by the hatch pipeline and highlighted via a

View file

@ -5,6 +5,7 @@
// pick — hit-testing, selection, grips, spatial index, xclip
// view — camera, transforms, viewport, render pipeline driver
// cache — block-definition and property caches
pub mod annotative;
pub mod cache;
pub mod convert;
pub mod model;
@ -1158,12 +1159,12 @@ impl Scene {
} else {
self.paper_bg_color
};
let anno = if self.current_layout == "Model" {
self.annotation_scale
} else {
1.0
};
let built = cache::block_cache::BlockCache::build(&self.document, anno, bg);
// Block definitions are cached at block-local size (annotation scale
// 1.0). An annotative block scales as ONE unit at the INSERT level, so
// its internal geometry / text / attributes must NOT be scaled
// individually (that would double-scale — AutoCAD even forbids
// annotative attributes inside annotative blocks for this reason).
let built = cache::block_cache::BlockCache::build(&self.document, 1.0, bg);
let arc = Arc::new(built);
*self.block_defn_cache.borrow_mut() = Some((self.block_epoch, Arc::clone(&arc)));
arc