diff --git a/examples/inspect_anno.rs b/examples/inspect_anno.rs new file mode 100644 index 000000000..f21023539 --- /dev/null +++ b/examples/inspect_anno.rs @@ -0,0 +1,233 @@ +// Inspect DIMSCALE, dimstyles, text heights, and xdata to diagnose +// annotation-scale bugs in paper-space viewports. +// +// cargo run --release --example inspect_anno -- + +use acadrust::entities::{Dimension, EntityType}; +use acadrust::io::dwg::DwgReader; + +fn main() -> Result<(), Box> { + let path = std::env::args().nth(1).expect("usage: inspect_anno "); + let doc = DwgReader::from_file(&path)?.read()?; + println!("file: {}\n", path); + + println!("─── header ──────────────────────────────"); + println!(" DIMSCALE (dim_scale) = {}", doc.header.dim_scale); + println!(" DIMTXT (dim_text_height)= {}", doc.header.dim_text_height); + println!(" DIMASZ (dim_arrow_size) = {}", doc.header.dim_arrow_size); + println!(" TEXTSIZE (text_height) = {}", doc.header.text_height); + println!(" LTSCALE = {}", doc.header.linetype_scale); + println!(" PSLTSCALE (psls) = {}", doc.header.paper_space_linetype_scaling); + println!(" CELTSCALE = {}", doc.header.current_entity_linetype_scale); + println!(" viewport_scale_factor = {}", doc.header.viewport_scale_factor); + println!(); + println!("─── linetypes (first 10) ────────────────"); + let mut shown = 0; + for lt in doc.line_types.iter() { + if shown >= 10 { break; } + println!( + " {:<24} pattern_len={:.4} elements={}", + lt.name, lt.pattern_length, lt.elements.len() + ); + shown += 1; + } + println!(); + + println!("─── dim_styles ──────────────────────────"); + println!( + " {:<24} {:>10} {:>10} {:>10} {:>10}", + "name", "dimscale", "dimtxt", "dimasz", "dimexe" + ); + for s in doc.dim_styles.iter() { + println!( + " {:<24} {:>10.4} {:>10.4} {:>10.4} {:>10.4}", + s.name, s.dimscale, s.dimtxt, s.dimasz, s.dimexe + ); + } + println!(); + + println!("─── text_styles ─────────────────────────"); + for s in doc.text_styles.iter() { + println!( + " {:<24} height={:.4} last_height={:.4} font={:?}", + s.name, s.height, s.last_height, s.font_file + ); + } + println!(); + + let mut n_text = 0u32; + let mut n_text_with_xdata = 0u32; + let mut n_text_with_acanno = 0u32; + let mut n_mtext = 0u32; + let mut n_mtext_with_xdata = 0u32; + let mut n_mtext_with_acanno = 0u32; + let mut n_dim = 0u32; + let mut n_dim_with_xdata = 0u32; + let mut n_dim_with_acanno = 0u32; + let mut xd_app_counts: std::collections::BTreeMap = Default::default(); + + let mut sample_text_h: Vec = vec![]; + let mut sample_mtext_h: Vec = vec![]; + let mut sample_dim_style: std::collections::BTreeMap = Default::default(); + // For MTEXT: bucket by owner block name (paper vs model) and height range. + let mut mtext_by_owner: std::collections::BTreeMap> = Default::default(); + + let acanno_keys = ["AcAnnoPO", "AcAnnotativeData", "AcAnnoScaleObj", "AcDbXrecObject"]; + + for e in doc.entities() { + match e { + EntityType::Text(t) => { + n_text += 1; + let xd = &t.common.extended_data; + let has_xd = !xd.records().is_empty(); + if has_xd { + n_text_with_xdata += 1; + for r in xd.records() { + *xd_app_counts.entry(r.application_name.clone()).or_default() += 1; + } + } + if acanno_keys.iter().any(|k| xd.get_record(k).is_some()) { + n_text_with_acanno += 1; + } + if sample_text_h.len() < 10 { + sample_text_h.push(t.height); + } + } + EntityType::MText(t) => { + n_mtext += 1; + let xd = &t.common.extended_data; + let has_xd = !xd.records().is_empty(); + if has_xd { + n_mtext_with_xdata += 1; + for r in xd.records() { + *xd_app_counts.entry(r.application_name.clone()).or_default() += 1; + } + } + if acanno_keys.iter().any(|k| xd.get_record(k).is_some()) { + n_mtext_with_acanno += 1; + } + if sample_mtext_h.len() < 10 { + sample_mtext_h.push(t.height); + } + // Look up the owning block-record by handle and label as + // "*Model_Space" / "*Paper_Space*" / "". + let owner_h = t.common.owner_handle; + let owner_label = doc + .block_records + .iter() + .find_map(|br: &acadrust::BlockRecord| { + if br.handle == owner_h { + Some(br.name.clone()) + } else { + None + } + }) + .unwrap_or_else(|| format!("h{}", owner_h.value())); + mtext_by_owner + .entry(owner_label) + .or_default() + .push(t.height); + } + EntityType::Dimension(d) => { + n_dim += 1; + let base = match d { + Dimension::Aligned(d) => &d.base, + Dimension::Linear(d) => &d.base, + Dimension::Radius(d) => &d.base, + Dimension::Diameter(d) => &d.base, + Dimension::Angular2Ln(d) => &d.base, + Dimension::Angular3Pt(d) => &d.base, + Dimension::Ordinate(d) => &d.base, + }; + let xd = &base.common.extended_data; + let has_xd = !xd.records().is_empty(); + if has_xd { + n_dim_with_xdata += 1; + for r in xd.records() { + *xd_app_counts.entry(r.application_name.clone()).or_default() += 1; + } + } + if acanno_keys.iter().any(|k| xd.get_record(k).is_some()) { + n_dim_with_acanno += 1; + } + *sample_dim_style.entry(base.style_name.clone()).or_default() += 1; + } + _ => {} + } + } + + println!("─── annotation entity counts ────────────"); + println!( + " TEXT total={:<5} xdata={:<5} AcAnno={}", + n_text, n_text_with_xdata, n_text_with_acanno + ); + println!( + " MTEXT total={:<5} xdata={:<5} AcAnno={}", + n_mtext, n_mtext_with_xdata, n_mtext_with_acanno + ); + println!( + " DIMENSION total={:<5} xdata={:<5} AcAnno={}", + n_dim, n_dim_with_xdata, n_dim_with_acanno + ); + println!(); + + println!("─── xdata application names seen ────────"); + for (k, v) in &xd_app_counts { + println!(" {:>5} {}", v, k); + } + println!(); + + if !sample_text_h.is_empty() { + println!("─── first 10 TEXT heights ───────────────"); + for h in &sample_text_h { + println!(" {:.4}", h); + } + println!(); + } + if !sample_mtext_h.is_empty() { + println!("─── first 10 MTEXT heights ──────────────"); + for h in &sample_mtext_h { + println!(" {:.4}", h); + } + println!(); + } + if !mtext_by_owner.is_empty() { + println!("─── MTEXT by owner ──────────────────────"); + for (owner, heights) in &mtext_by_owner { + let mut h = heights.clone(); + h.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let min = h.first().copied().unwrap_or(0.0); + let max = h.last().copied().unwrap_or(0.0); + let median = h[h.len() / 2]; + let n_below_10 = h.iter().filter(|&&x| x < 10.0).count(); + let n_above_100 = h.iter().filter(|&&x| x > 100.0).count(); + println!( + " {:<20} n={:<4} min={:.2} med={:.2} max={:.2} <10:{} >100:{}", + owner, + h.len(), + min, + median, + max, + n_below_10, + n_above_100 + ); + } + println!(); + } + + if !sample_dim_style.is_empty() { + println!("─── dimension styles used (count) ───────"); + for (k, v) in &sample_dim_style { + let used_style = doc.dim_styles.iter().find(|s| s.name == *k); + let ds = used_style.map(|s| s.dimscale).unwrap_or(f64::NAN); + let dt = used_style.map(|s| s.dimtxt).unwrap_or(f64::NAN); + let da = used_style.map(|s| s.dimasz).unwrap_or(f64::NAN); + println!( + " {:>5}× style=\"{}\" dimscale={:.4} dimtxt={:.4} dimasz={:.4}", + v, k, ds, dt, da + ); + } + } + + Ok(()) +} diff --git a/src/app/commands.rs b/src/app/commands.rs index 106a96d5c..e9420c701 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -2493,7 +2493,11 @@ impl H7CAD { vp.center.clone(), vp.width, vp.height, - vp.custom_scale, + crate::scene::vp_effective_scale( + vp.custom_scale, + vp.view_height, + vp.height, + ), vp.status.is_on, vp.status.locked, )) diff --git a/src/entities/viewport.rs b/src/entities/viewport.rs index 457321fe8..79d91d073 100644 --- a/src/entities/viewport.rs +++ b/src/entities/viewport.rs @@ -105,7 +105,12 @@ fn grips(vp: &Viewport) -> Vec { fn properties(vp: &Viewport) -> PropSection { let scale_opts: Vec = STANDARD_SCALES.iter().map(|(s, _)| s.to_string()).collect(); - let current_scale_label = scale_label(vp.custom_scale); + let effective_scale = crate::scene::vp_effective_scale( + vp.custom_scale, + vp.view_height, + vp.height, + ); + let current_scale_label = scale_label(effective_scale); let view_opts: Vec = STD_VIEWS.iter().map(|s| s.to_string()).collect(); let current_view = viewport_view_label(vp); @@ -124,8 +129,8 @@ fn properties(vp: &Viewport) -> PropSection { edit("Center Z", "center_z", vp.center.z), edit("Width", "vp_w", vp.width), edit("Height", "vp_h", vp.height), - // Numeric scale entry. - edit("Scale (num)", "vscale", vp.custom_scale), + // Numeric scale entry (derived from view_height; writes both). + edit("Scale (num)", "vscale", effective_scale), // Standard scale picker. Property { label: "Scale".into(), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index b7e57f173..f49d75408 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -70,6 +70,24 @@ use std::sync::Arc; /// GPU Pipeline to skip re-uploading geometry when switching tabs. static GEOMETRY_EPOCH: AtomicU64 = AtomicU64::new(1); +/// Resolve a viewport's paper-to-model scale ratio from its two +/// DXF-derived sources. +/// +/// `view_height` (model-space view extent) is the canonical source — it +/// is what AutoCAD actually uses to draw, and what we keep in sync on +/// every write. `custom_scale` is consulted only when `view_height` is +/// missing or zero (some third-party exporters omit it). +#[inline] +pub fn vp_effective_scale(custom_scale: f64, view_height: f64, vp_height: f64) -> f64 { + if view_height.abs() > 1e-9 { + return vp_height / view_height; + } + if custom_scale.abs() > 1e-9 { + return custom_scale; + } + 1.0 +} + /// Pre-built entity caches returned by [`build_derived_caches`]. /// Produced in the file-load background task so the UI thread only assigns. #[derive(Debug, Clone)] @@ -839,14 +857,11 @@ impl Scene { self.document.entities().find_map(|e| { if let EntityType::Viewport(vp) = e { if Self::is_content_viewport(vp) && vp.common.owner_handle == layout_block { - let scale = if vp.custom_scale.abs() > 1e-9 { - vp.custom_scale - } else if vp.view_height.abs() > 1e-9 { - vp.height / vp.view_height - } else { - 1.0 - }; - return Some(scale); + return Some(vp_effective_scale( + vp.custom_scale, + vp.view_height, + vp.height, + )); } } None @@ -1821,12 +1836,18 @@ impl Scene { effective_view_center = (self.world_offset[0], self.world_offset[1]); } - let scale = if effective_view_height.abs() > 1e-9 { + // When the saved view did not overlap the model cluster we just + // forced `effective_view_height` to a fit value above — that + // override must win regardless of the configured priority, + // otherwise broken files render blank under custom_scale-first. + let scale = if !saved_overlaps { vp.height as f32 / effective_view_height - } else if vp.custom_scale.abs() > 1e-9 { - vp.custom_scale as f32 } else { - 1.0 + vp_effective_scale( + vp.custom_scale, + effective_view_height as f64, + vp.height, + ) as f32 }; let pcx = vp.center.x as f32; @@ -1994,13 +2015,8 @@ impl Scene { Some(acadrust::EntityType::Viewport(vp)) => vp, _ => return paper_pt, }; - let scale = if vp.custom_scale.abs() > 1e-9 { - vp.custom_scale as f32 - } else if vp.view_height.abs() > 1e-9 { - (vp.height / vp.view_height) as f32 - } else { - 1.0 - }; + let scale = + vp_effective_scale(vp.custom_scale, vp.view_height, vp.height) as f32; if scale.abs() < 1e-9 { return paper_pt; } @@ -2083,13 +2099,8 @@ impl Scene { if let Some(cp) = cursor_paper { // Compute the model-space point under the cursor before zoom. - let scale_before = if vp.custom_scale.abs() > 1e-9 { - vp.custom_scale as f32 - } else if vp.view_height.abs() > 1e-9 { - (vp.height / vp.view_height) as f32 - } else { - 1.0 - }; + let scale_before = + vp_effective_scale(vp.custom_scale, vp.view_height, vp.height) as f32; let cx = vp.center.x as f32; let cy = vp.center.y as f32; let tx = vp.view_target.x as f32; @@ -4327,13 +4338,8 @@ impl Scene { let (frozen, vp_anno_scale) = match self.document.get_entity(vp_handle) { Some(EntityType::Viewport(vp)) => { let f: HSet = vp.frozen_layers.iter().cloned().collect(); - let vp_scale = if vp.custom_scale.abs() > 1e-9 { - vp.custom_scale - } else if vp.view_height.abs() > 1e-9 { - vp.height / vp.view_height - } else { - 1.0 - }; + let vp_scale = + vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); let anno = if vp_scale > 1e-9 { (1.0 / vp_scale) as f32 } else { diff --git a/src/scene/tessellate.rs b/src/scene/tessellate.rs index 87a0a8b4b..11e49d2e2 100644 --- a/src/scene/tessellate.rs +++ b/src/scene/tessellate.rs @@ -120,17 +120,20 @@ pub fn tessellate( let name = handle.value().to_string(); // Determine effective annotation scale for this entity. - // AutoCAD marks annotative objects with "AcAnnoPO" xdata (R2007+). - // If no xdata at all: old file without annotation system → treat as annotative. - // If xdata present but no "AcAnnoPO": R2007+ file, entity is non-annotative → no scaling. + // + // 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 = if xdata.is_empty() { - // Old DXF / no annotation metadata — treat as annotative. - true - } else { - xdata.get_record("AcAnnoPO").is_some() - }; + let is_annotative = xdata.get_record("AcAnnoPO").is_some() + || xdata.get_record("AcAnnotativeData").is_some(); if is_annotative { anno_scale } else { @@ -465,22 +468,33 @@ pub fn tessellate_dimension( }; let name = handle.value().to_string(); let style_name = &dim.base().style_name; - let (arrow_size, dimexo, dimexe) = document - .dim_styles - .iter() - .find(|s| { - s.name.eq_ignore_ascii_case(style_name) - || (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) + let style = document.dim_styles.iter().find(|s| { + s.name.eq_ignore_ascii_case(style_name) + || (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) + }); + + // DIMSCALE rule: + // dimstyle.dimscale > 0 → that's the *final* multiplier; ignore anno_scale. + // dimstyle.dimscale == 0 → annotative: use anno_scale (= 1/vp_scale in viewports). + let dim_scale = style + .map(|s| { + if s.dimscale > 1e-6 { + s.dimscale + } else { + anno_scale as f64 + } }) + .unwrap_or(1.0); + let (arrow_size, dimexo, dimexe, dim_txt) = style .map(|s| { - let scale = (if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }) * anno_scale as f64; ( - ((s.dimasz * scale) as f32).max(0.001), - (s.dimexo * scale) as f32, - (s.dimexe * scale) as f32, + ((s.dimasz * dim_scale) as f32).max(0.001), + (s.dimexo * dim_scale) as f32, + (s.dimexe * dim_scale) as f32, + s.dimtxt * dim_scale, ) }) - .unwrap_or((0.12, 0.0, 0.0)); + .unwrap_or((0.12, 0.0, 0.0, 2.5)); let points = dimension_geometry(dim, arrow_size, dimexo, dimexe, world_offset); let key_vertices = points .iter() @@ -508,7 +522,7 @@ pub fn tessellate_dimension( fill_tris: vec![], }]; - if let Some(text) = dimension_text_entity(dim) { + if let Some(text) = dimension_text_entity(dim, dim_txt) { let mut wire = tessellate( document, handle, @@ -519,7 +533,9 @@ pub fn tessellate_dimension( [0.0; 8], line_weight_px, world_offset, - anno_scale, + // dim text already baked dim_scale into its height — don't + // let the inner tessellate re-apply anno_scale. + 1.0, ); wire.name = name; wires.push(wire); @@ -1688,7 +1704,7 @@ fn dimension_snap_pts(dim: &Dimension, world_offset: [f64; 3]) -> Vec<(Vec3, Sna } } -fn dimension_text_entity(dim: &Dimension) -> Option { +fn dimension_text_entity(dim: &Dimension, text_height: f64) -> Option { let value = dimension_text_value(dim)?; // Use f64 position directly to avoid f32 round-trip precision loss at large // coordinates (e.g. Turkish UTM ~4,000,000 m). tessellate() will apply @@ -1701,7 +1717,7 @@ fn dimension_text_entity(dim: &Dimension) -> Option { dimension_text_natural_rotation(dim) }; let mut text = Text::with_value(value, pos_f64) - .with_height(dimension_text_height(dim)) + .with_height(text_height) .with_rotation(rotation); text.style = base.style_name.clone(); text.common = base.common.clone(); @@ -1767,15 +1783,6 @@ fn dimension_text_position(dim: &Dimension, world_offset: [f64; 3]) -> Vec3 { } } -fn dimension_text_height(dim: &Dimension) -> f64 { - let scale = (dim.measurement().abs() * 0.12).clamp(0.25, 2.0); - if scale.is_finite() { - scale - } else { - 0.25 - } -} - fn vec3_local(v: Vector3, off: [f64; 3]) -> Vec3 { Vec3::new( (v.x - off[0]) as f32, diff --git a/src/ui/scale_popup.rs b/src/ui/scale_popup.rs index 30bc06f31..5874affc1 100644 --- a/src/ui/scale_popup.rs +++ b/src/ui/scale_popup.rs @@ -27,7 +27,7 @@ const COMMON_SCALES: &[(&str, f32, f64)] = &[ /// /// - `is_model`: true = model space (dispatches SetAnnotationScale), false = paper space (SetViewportScale). /// - `current_anno_scale`: current annotation_scale from Scene (used to highlight active row in model space). -/// - `viewport_scale`: current vp.custom_scale (used to highlight in paper space). +/// - `viewport_scale`: current effective vp scale, view_height-first (used to highlight in paper space). pub fn scale_popup_overlay( is_model: bool, current_anno_scale: f32,