feat(properties): editable per-object leader dimension overrides

A legacy leader's Lines & Arrows / Text / Fit rows are now editable
per-object overrides of its dimension style, stored in the standard
ACAD_DSTYLE XDATA record (new src/entities/dim_override.rs codec). Arrow
block, arrow size, dim-line lineweight, text offset, vertical text
position and overall scale each prefer an override over the style; the
renderer honours arrow size / block / overall scale and the dim-line
lineweight, so an edited leader redraws at its new arrow and weight.

XDATA edits go through a new dispatch::set_entity_xdata, which registers
the application in the APPID table (the DWG writer drops records for an
unregistered app) and drops that app's stale verbatim EED block (which
otherwise wins over the structured record on a DWG save), so edits and
clears round-trip. The hyperlink editor moves onto this path too.

Dim-line colour stays read-only: it would live in the leader's
override_color, which the file layer doesn't serialise, so making it
editable would silently lose the pick on save. Picking "Closed filled"
writes an explicit null-handle arrow override rather than clearing, so
it sticks even when the style's arrow differs; an unparseable numeric
entry is ignored instead of wiping the stored override.

Note: the DXF entity writer only emits XDATA for hatches, so these
overrides (and hyperlinks) currently persist on DWG save but not DXF —
an acadrust-side gap to close separately.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-13 00:28:42 +03:00
1 parent 984fa79189
commit 7cf1bf014b
6 files changed
+442 -53

No files matched your search

+156 -13
View File
@@ -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<String> =
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<String> {
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::<f64>()
.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<String> {
["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",
+132 -20
View File
@@ -1316,6 +1316,82 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
}
}
} 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<acadrust::Handle> =
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<Message> {
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::<f64>() {
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,
);
+98
View File
@@ -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<f64> {
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<i16> {
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<Handle> {
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<XDataValue>) {
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);
}
+24 -13
View File
@@ -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,
+1
View File
@@ -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;
+31 -7
View File
@@ -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<Vec<acadrust::xdata::XDataValue>>,
) {
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;
}