diff --git a/src/app/properties.rs b/src/app/properties.rs index 5a63b66c2..c0599855a 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -490,32 +490,109 @@ impl OpenCADStudio { } } } - // Lines & Arrows / Text / Fit are derived from the - // assigned dimension style (same source the leader - // tessellator uses), not stored on the entity. + // Lines & Arrows / Text / Fit default to the assigned + // dimension style (the same source the tessellator + // uses); most rows are editable per-object overrides + // stored in ACAD_DSTYLE. Arrow size / block / overall + // scale and dim-line lineweight drive the render; text + // offset / vertical position round-trip to file but + // don't change the leader glyph here (its annotation + // is a separate entity). Dim-line colour is read-only. if let Some(ds) = find_dim_style(doc, &ld.dimension_style) { - set_row( + use crate::entities::dim_override as dov; + use crate::scene::model::object::PropValue; + let xd = &ld.common.extended_data; + + // Arrow block (DIMLDRBLK): override handle → name, + // else the style's arrow. Options are the arrowhead + // blocks the drawing carries plus the default. + let arrow_label = match dov::handle(xd, dov::DIMLDRBLK) { + Some(h) => doc + .block_records + .iter() + .find(|b| b.handle == h) + .map(|b| arrowhead_label(&b.name)) + .unwrap_or_else(|| "Closed filled".to_string()), + None => leader_arrow_label(doc, ds, ld.arrow_enabled), + }; + let mut arrow_opts: Vec = + std::iter::once("Closed filled".to_string()) + .chain( + doc.block_records + .iter() + .filter(|b| b.name.starts_with('_')) + .map(|b| arrowhead_label(&b.name)), + ) + .collect(); + // Keep the current value selectable even when it + // isn't in the standard list (e.g. "None", or a + // style arrow whose block isn't underscore-named). + if !arrow_opts.contains(&arrow_label) { + arrow_opts.insert(0, arrow_label.clone()); + } + set_row_value( &mut sections, "arrow_block", - leader_arrow_label(doc, ds, ld.arrow_enabled), + PropValue::Choice { + selected: arrow_label, + options: arrow_opts, + }, ); - set_row(&mut sections, "arrow_size", format!("{:.4}", ds.dimasz)); - set_row(&mut sections, "dim_line_lw", dim_lineweight_label(ds.dimlwd)); + + let asz = dov::real(xd, dov::DIMASZ).unwrap_or(ds.dimasz); + set_row_value( + &mut sections, + "arrow_size", + PropValue::EditText(format!("{asz:.4}")), + ); + + let lwd = dov::int(xd, dov::DIMLWD).unwrap_or(ds.dimlwd); + let lw_sel = dim_lineweight_label(lwd); + let mut lw_opts = lineweight_options(); + if !lw_opts.contains(&lw_sel) { + lw_opts.insert(0, lw_sel.clone()); + } + set_row_value( + &mut sections, + "dim_line_lw", + PropValue::Choice { + selected: lw_sel, + options: lw_opts, + }, + ); + + // Dim-line colour stays read-only: it lives in the + // leader's override_color, which the file format + // layer does not yet serialise, so making it + // editable would silently lose the pick on save. set_row( &mut sections, "dim_line_color", dim_color_label(ds.dimclrd, &ld.override_color), ); - set_row(&mut sections, "text_offset", format!("{:.4}", ds.dimgap)); - set_row( + + let gap = dov::real(xd, dov::DIMGAP).unwrap_or(ds.dimgap); + set_row_value( + &mut sections, + "text_offset", + PropValue::EditText(format!("{gap:.4}")), + ); + + let tad = dov::int(xd, dov::DIMTAD).unwrap_or(ds.dimtad); + set_row_value( &mut sections, "text_pos_vert", - dimtad_label(ds.dimtad).to_string(), + PropValue::Choice { + selected: dimtad_label(tad).to_string(), + options: tad_options(), + }, ); - set_row( + + let scl = dov::real(xd, dov::DIMSCALE).unwrap_or(ds.dimscale); + set_row_value( &mut sections, "dim_scale_overall", - format!("{:.4}", ds.dimscale), + PropValue::EditText(format!("{scl:.4}")), ); } } @@ -1325,6 +1402,72 @@ fn set_row( } } +/// Replace a row's value with an arbitrary control (editable field, dropdown, +/// colour picker …) rather than plain read-only text. +fn set_row_value( + sections: &mut [crate::scene::model::object::PropSection], + field: &str, + value: crate::scene::model::object::PropValue, +) { + for section in sections.iter_mut() { + if let Some(row) = section.props.iter_mut().find(|p| p.field == field) { + row.value = value; + return; + } + } +} + +/// The lineweight dropdown options (named defaults + the standard millimetre +/// steps), matching the labels `dim_lineweight_label` produces. +pub(crate) fn lineweight_options() -> Vec { + let mut v = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Default".to_string(), + ]; + for lw in [ + 0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, + 200, 211, + ] { + v.push(format!("{:.2} mm", lw as f64 / 100.0)); + } + v +} + +/// Inverse of `dim_lineweight_label`: a lineweight label → DIMLWD enum value. +pub(crate) fn dim_lineweight_from_label(label: &str) -> i16 { + match label.trim() { + "ByLayer" => -1, + "ByBlock" => -2, + "Default" => -3, + s => s + .trim_end_matches("mm") + .trim() + .parse::() + .map(|mm| (mm * 100.0).round() as i16) + .unwrap_or(-3), + } +} + +/// The vertical-text-position dropdown options, matching `dimtad_label`. +pub(crate) fn tad_options() -> Vec { + ["Centered", "Above", "Outside", "JIS", "Below"] + .iter() + .map(|s| s.to_string()) + .collect() +} + +/// Inverse of `dimtad_label`: a vertical-position label → DIMTAD value. +pub(crate) fn dimtad_from_label(label: &str) -> i16 { + match label.trim() { + "Above" => 1, + "Outside" => 2, + "JIS" => 3, + "Below" => 4, + _ => 0, + } +} + /// Resolve a dimension style by name (case-insensitive), falling back to /// "Standard" when the name is blank. fn find_dim_style<'a>( @@ -1365,7 +1508,7 @@ fn dimtad_label(dimtad: i16) -> &'static str { /// Friendly arrowhead name from an arrowhead block-record name (the `_CLOSED…` /// style internal names map to their palette labels; a null/empty name is the /// closed-filled default). -fn arrowhead_label(name: &str) -> String { +pub(crate) fn arrowhead_label(name: &str) -> String { let key = name.trim().trim_start_matches('_').to_ascii_uppercase(); let label = match key.as_str() { "" | "CLOSEDFILLED" => "Closed filled", diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 18b3c3125..731d636cf 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1316,6 +1316,82 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + } else if matches!(field, "arrow_block" | "dim_line_lw" | "text_pos_vert") { + // Leader dim-var overrides picked from a dropdown. The + // arrow block resolves a friendly arrowhead name to a + // block handle ("Closed filled" clears the override → + // reverts to the style); the other two map a label to + // the DIMLWD / DIMTAD enum value. + use crate::entities::dim_override as dov; + use acadrust::xdata::XDataValue; + let arrow_h: Option = + if field == "arrow_block" && value != "Closed filled" { + self.tabs[i] + .scene + .document + .block_records + .iter() + .find(|b| { + crate::app::properties::arrowhead_label(&b.name) == value + }) + .map(|b| b.handle) + } else { + None + }; + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + // Only leaders carry these rows; skip anything else so + // a multi-type selection can't get stray overrides. + if !matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Leader(_)) + ) { + continue; + } + let doc = &mut self.tabs[i].scene.document; + match field { + "dim_line_lw" => dov::set( + doc, + handle, + dov::DIMLWD, + Some(XDataValue::Integer16( + crate::app::properties::dim_lineweight_from_label(&value), + )), + ), + "text_pos_vert" => dov::set( + doc, + handle, + dov::DIMTAD, + Some(XDataValue::Integer16( + crate::app::properties::dimtad_from_label(&value), + )), + ), + "arrow_block" => { + // "Closed filled" is an explicit override to + // the null-handle default arrow (not a clear), + // so the pick sticks even when the style's + // arrow differs. + if value == "Closed filled" { + dov::set( + doc, + handle, + dov::DIMLDRBLK, + Some(XDataValue::Handle(acadrust::Handle::NULL)), + ); + } else if let Some(h) = arrow_h { + dov::set( + doc, + handle, + dov::DIMLDRBLK, + Some(XDataValue::Handle(h)), + ); + } + } + _ => {} + } + } } else if field == "plot_style" { // Named plot-style pick: ByLayer / ByBlock clear the // handle; a named style resolves through the drawing's @@ -1451,30 +1527,66 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if self.tabs[i].scene.is_layer_locked(handle) { continue; } - if let Some(entity) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - match field { - "linetype_scale" | "transparency" => { + match field { + "hyperlink" => { + // Stored in the standard PE_URL XDATA + // record; an empty value clears it. + let vals = if val.trim().is_empty() { + None + } else { + Some(vec![acadrust::xdata::XDataValue::String( + val.clone(), + )]) + }; + crate::scene::view::dispatch::set_entity_xdata( + &mut self.tabs[i].scene.document, + handle, + "PE_URL", + vals, + ); + } + "arrow_size" | "text_offset" | "dim_scale_overall" => { + // Real-valued leader dim-var overrides + // (ACAD_DSTYLE). A blank value clears the + // override → reverts to the style; an + // unparseable entry is ignored so a typo + // can't silently wipe a stored override. + use crate::entities::dim_override as dov; + let code = match field { + "arrow_size" => dov::DIMASZ, + "text_offset" => dov::DIMGAP, + _ => dov::DIMSCALE, + }; + let trimmed = val.trim(); + if trimmed.is_empty() { + dov::set( + &mut self.tabs[i].scene.document, + handle, + code, + None, + ); + } else if let Ok(n) = trimmed.parse::() { + dov::set( + &mut self.tabs[i].scene.document, + handle, + code, + Some(acadrust::xdata::XDataValue::Real(n)), + ); + } + } + "linetype_scale" | "transparency" => { + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_common_prop( entity, field, &val, ); } - "hyperlink" => { - // Stored in the standard PE_URL XDATA - // record; an empty value clears it. - let vals = if val.trim().is_empty() { - None - } else { - Some(vec![acadrust::xdata::XDataValue::String( - val.clone(), - )]) - }; - crate::scene::view::dispatch::set_common_xdata( - entity, "PE_URL", vals, - ); - } - _ => { + } + _ => { + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_geom_prop( entity, field, &val, ); diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs new file mode 100644 index 000000000..a727ada5a --- /dev/null +++ b/src/entities/dim_override.rs @@ -0,0 +1,98 @@ +//! Per-object dimension-variable overrides. +//! +//! A leader (or dimension) that departs from its dimension style stores the +//! changed variables in the standard `ACAD_DSTYLE` XDATA record as a list of +//! (dimvar group code, value) pairs wrapped in `{ }` control strings. Both the +//! renderer and the properties panel prefer an override over the style default, +//! so editing one of these rows writes here and the change round-trips to file. + +use acadrust::xdata::{ExtendedData, XDataValue}; +use acadrust::{CadDocument, Handle}; + +// DXF group codes of the dimension variables surfaced on the leader panel. +pub const DIMSCALE: i16 = 40; // overall scale (real) +pub const DIMASZ: i16 = 41; // arrow size (real) +pub const DIMTAD: i16 = 77; // text vertical pos (int16) +pub const DIMGAP: i16 = 147; // text offset / gap (real) +pub const DIMLWD: i16 = 371; // dim line lineweight (int16) +pub const DIMLDRBLK: i16 = 341; // leader arrow block (handle) + +/// Every (code, value) override present in the `ACAD_DSTYLE` record. +pub fn pairs(xd: &ExtendedData) -> Vec<(i16, XDataValue)> { + let Some(rec) = xd.get_record("ACAD_DSTYLE") else { + return Vec::new(); + }; + let mut out = Vec::new(); + let mut it = rec.values.iter(); + // The record is a flat stream: a 1070 code marker followed by its typed + // value, bracketed by 1002 "{" / "}" control strings (which are skipped). + while let Some(v) = it.next() { + if let XDataValue::Integer16(code) = v { + if let Some(val) = it.next() { + out.push((*code, val.clone())); + } + } + } + out +} + +/// The real-valued override for `code`, if present. +pub fn real(xd: &ExtendedData, code: i16) -> Option { + pairs(xd) + .into_iter() + .find(|(c, _)| *c == code) + .and_then(|(_, v)| match v { + XDataValue::Real(r) | XDataValue::Distance(r) | XDataValue::ScaleFactor(r) => Some(r), + _ => None, + }) +} + +/// The 16-bit-integer override for `code`, if present. +pub fn int(xd: &ExtendedData, code: i16) -> Option { + pairs(xd) + .into_iter() + .find(|(c, _)| *c == code) + .and_then(|(_, v)| match v { + XDataValue::Integer16(n) => Some(n), + _ => None, + }) +} + +/// The handle-valued override for `code`, if present. +pub fn handle(xd: &ExtendedData, code: i16) -> Option { + pairs(xd) + .into_iter() + .find(|(c, _)| *c == code) + .and_then(|(_, v)| match v { + XDataValue::Handle(h) => Some(h), + _ => None, + }) +} + +/// Set — or, with `value: None`, clear — a single override on entity `handle`, +/// leaving the other overrides in the record untouched. Clearing the last one +/// drops the whole `ACAD_DSTYLE` record. +pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option) { + let Some(entity) = doc.get_entity(handle) else { + return; + }; + let mut kept: Vec<(i16, XDataValue)> = pairs(&entity.common().extended_data) + .into_iter() + .filter(|(c, _)| *c != code) + .collect(); + if let Some(v) = value { + kept.push((code, v)); + } + let values = if kept.is_empty() { + None + } else { + let mut vals = vec![XDataValue::ControlString("{".to_string())]; + for (c, v) in kept { + vals.push(XDataValue::Integer16(c)); + vals.push(v); + } + vals.push(XDataValue::ControlString("}".to_string())); + Some(vals) + }; + crate::scene::view::dispatch::set_entity_xdata(doc, handle, "ACAD_DSTYLE", values); +} diff --git a/src/entities/leader.rs b/src/entities/leader.rs index 87db11768..1eaa61de0 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -544,12 +544,22 @@ impl LeaderTess for Leader { anno_scale: f32, ) -> crate::scene::model::wire_model::WireModel { use crate::scene::convert::tessellate::{append_arrow, arrow_from_block, ArrowKind, DimGeom}; + use crate::entities::dim_override as dov; use crate::scene::model::wire_model::WireModel; + let xd = &self.common.extended_data; let color = if selected { WireModel::SELECTED } else { entity_color }; + // A concrete DIMLWD override sets the leader line's weight; ByLayer / + // ByBlock / Default and no override keep the resolved weight passed in. + let line_weight_px = match dov::int(xd, dov::DIMLWD) { + Some(lwd) if lwd >= 0 => crate::scene::view::render::lineweight_to_px( + &acadrust::types::LineWeight::from_value(lwd), + ), + _ => line_weight_px, + }; let name = handle.value().to_string(); let p3 = |v: &acadrust::types::Vector3| -> [f32; 3] { [(v.x) as f32, (v.y) as f32, (v.z) as f32] @@ -602,21 +612,22 @@ impl LeaderTess for Leader { || (self.dimension_style.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) }); - let dim_scale = style - .map(|s| { - if s.dimscale > 1e-6 { - s.dimscale - } else { - anno_scale as f64 - } - }) + // Each of DIMSCALE / DIMASZ / DIMLDRBLK prefers a per-object override + // over the style, so an edited leader arrow renders at its new size, + // scale and shape. + let dim_scale = dov::real(xd, dov::DIMSCALE) + .filter(|v| *v > 1e-6) + .or_else(|| style.map(|s| s.dimscale).filter(|v| *v > 1e-6)) .unwrap_or(anno_scale as f64); - let arrow_size = match style { - Some(s) => (s.dimasz * dim_scale) as f32, - None => (self.text_height as f32).max(1.0) * anno_scale, + let ovr_asz = dov::real(xd, dov::DIMASZ); + let arrow_size = match (ovr_asz, style) { + (Some(a), _) => (a * dim_scale) as f32, + (None, Some(s)) => (s.dimasz * dim_scale) as f32, + (None, None) => (self.text_height as f32).max(1.0) * anno_scale, }; - let arrow = match style { - Some(s) => arrow_from_block(document, s.dimldrblk, arrow_size.max(0.001)), + let arrow_blk = dov::handle(xd, dov::DIMLDRBLK).or_else(|| style.map(|s| s.dimldrblk)); + let arrow = match arrow_blk { + Some(h) => arrow_from_block(document, h, arrow_size.max(0.001)), None => ArrowKind::Triangle { size: arrow_size.max(0.001), filled: true, diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 1d4883887..6df477506 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -2,6 +2,7 @@ pub mod arc; pub mod attribute; pub mod circle; pub mod common; +pub mod dim_override; pub mod dimension; pub mod ellipse; pub mod hatch; diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index 3c027c4df..661646a9f 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -86,15 +86,33 @@ pub fn apply_common_prop(entity: &mut EntityType, field: &str, value: &str) { } /// Replace — or, when `values` is `None`, remove — the single XDATA record for -/// application `app` on an entity's common data, preserving every other record -/// and the verbatim DWG EED blob. `ExtendedData` only appends, so a plain -/// `add_record` would leave a stale duplicate that the reader picks first; -/// rebuilding the collection is the only way to edit or clear a record. -pub fn set_common_xdata( - entity: &mut EntityType, +/// application `app` on the entity `handle`, leaving every other record intact. +/// +/// This is document-level rather than entity-level for two DWG-correctness +/// reasons the entity alone can't satisfy: +/// * the writer skips a record whose application is not registered in the +/// APPID table, so a brand-new hyperlink/override would silently vanish — +/// register the app (with a real handle) first; +/// * on a DWG read each application's XDATA is captured verbatim in +/// `raw_dwg_eed`, and that verbatim blob *wins* over a structured record on +/// save. Editing an existing record therefore also has to drop that app's +/// stale verbatim block (matched by APPID handle) so the new value wins. +/// Other applications' verbatim blocks are preserved for round-trip fidelity. +pub fn set_entity_xdata( + doc: &mut acadrust::CadDocument, + handle: acadrust::Handle, app: &str, values: Option>, ) { + if values.is_some() && !doc.app_ids.contains(app) { + let mut a = acadrust::tables::AppId::new(app); + a.handle = doc.allocate_handle(); + let _ = doc.app_ids.add(a); + } + let app_handle = doc.app_ids.get(app).map(|a| a.handle.value()); + let Some(entity) = doc.get_entity_mut(handle) else { + return; + }; let common = entity.common_mut(); let mut rebuilt = acadrust::xdata::ExtendedData::new(); for r in common.extended_data.records() { @@ -109,7 +127,13 @@ pub fn set_common_xdata( } rebuilt.add_record(rec); } - rebuilt.raw_dwg_eed = common.extended_data.raw_dwg_eed.clone(); + rebuilt.raw_dwg_eed = common + .extended_data + .raw_dwg_eed + .iter() + .filter(|(h, _)| Some(*h) != app_handle) + .cloned() + .collect(); common.extended_data = rebuilt; }