fix(blocks): enforce constant attributes in ATTEDIT and restore ATTDEFs on explode

Enforce constant attribute flag in ATTEDIT dialog by presenting constant attributes as disabled and ignoring modifications. When exploding an insert, restore block-definition attribute definitions as transformed ATTDEF entities while dropping instance attribute values.
This commit is contained in:
Karim Jerbi committed 2026-10-13 11:10:11 +01:00
1 parent df22824fbe
commit 59bb6faabd
5 files changed
+393 -33

No files matched your search

+172
View File
@@ -1347,3 +1347,175 @@ fn locked_purge_skips_a_block_with_an_open_bedit_session() {
assert!(record_exists(&app, &name));
let _ = app.dispatch_command("BEDIT_DISCARD");
}
#[test]
fn locked_attedit_attribute_flags_cases() {
use crate::ui::window::attribute_editor::AttrRow;
use codec::entities::{AttributeDefinition, AttributeEntity};
use codec::tables::BlockRecord;
// Verify flag behavior in ATTEDIT conversion and apply:
// 1. Normal attribute: editable, modifies value
let mut normal_entity =
AttributeEntity::new("NORM_TAG".to_string(), "original_norm".to_string());
let normal_row = AttrRow {
tag: "NORM_TAG".to_string(),
prompt: "Enter norm:".to_string(),
value: "edited_norm".to_string(),
text_style: "STANDARD".to_string(),
height: "2.5".to_string(),
rotation: "0".to_string(),
width_factor: "1".to_string(),
oblique: "0".to_string(),
h_align: codec::entities::HorizontalAlignment::Left,
v_align: codec::entities::VerticalAlignment::Baseline,
backwards: false,
upside_down: false,
layer: "0".to_string(),
color: codec::types::Color::ByLayer,
linetype: "ByLayer".to_string(),
line_weight: codec::types::LineWeight::ByLayer,
is_constant: false,
};
assert!(!normal_row.is_constant);
// 2. Constant attribute: is_constant is true, apply ignores value edit
let mut const_entity =
AttributeEntity::new("CONST_TAG".to_string(), "original_const".to_string());
const_entity.flags.constant = true;
let mut const_row = normal_row.clone();
const_row.tag = "CONST_TAG".to_string();
const_row.value = "attempted_edit".to_string();
const_row.is_constant = true;
// 3. Invisible attribute: editable in ATTEDIT (flags.invisible is true, but is_constant is false)
let mut invis_entity =
AttributeEntity::new("INVIS_TAG".to_string(), "original_invis".to_string());
invis_entity.flags.invisible = true;
let mut invis_row = normal_row.clone();
invis_row.tag = "INVIS_TAG".to_string();
invis_row.value = "edited_invis".to_string();
invis_row.is_constant = false;
// 4. Preset attribute: editable in ATTEDIT (preset only affects initial insertion prompt)
let mut preset_entity =
AttributeEntity::new("PRESET_TAG".to_string(), "original_preset".to_string());
preset_entity.flags.preset = true;
let mut preset_row = normal_row.clone();
preset_row.tag = "PRESET_TAG".to_string();
preset_row.value = "edited_preset".to_string();
preset_row.is_constant = false;
// Verify metadata mapping from CadDocument
let mut doc = codec::CadDocument::new();
let next = doc.next_handle();
let br_h = Handle::new(next);
let mut br = BlockRecord::new("FLAG_BLOCK");
br.handle = br_h;
br.block_entity_handle = Handle::new(next + 1);
br.block_end_handle = Handle::new(next + 2);
let mut ad_norm = AttributeDefinition::new(
"NORM_TAG".to_string(),
"Enter norm:".to_string(),
"def".to_string(),
);
let mut ad_const = AttributeDefinition::new(
"CONST_TAG".to_string(),
"Enter const:".to_string(),
"def".to_string(),
);
ad_const.flags.constant = true;
let mut ad_invis = AttributeDefinition::new(
"INVIS_TAG".to_string(),
"Enter invis:".to_string(),
"def".to_string(),
);
ad_invis.flags.invisible = true;
let mut ad_preset = AttributeDefinition::new(
"PRESET_TAG".to_string(),
"Enter preset:".to_string(),
"def".to_string(),
);
ad_preset.flags.preset = true;
let h1 = Handle::new(next + 3);
let h2 = Handle::new(next + 4);
let h3 = Handle::new(next + 5);
let h4 = Handle::new(next + 6);
ad_norm.common.handle = h1;
ad_const.common.handle = h2;
ad_invis.common.handle = h3;
ad_preset.common.handle = h4;
br.entity_handles = vec![h1, h2, h3, h4];
doc.block_records.add(br).unwrap();
doc.add_entity(EntityType::AttributeDefinition(ad_norm))
.unwrap();
doc.add_entity(EntityType::AttributeDefinition(ad_const))
.unwrap();
doc.add_entity(EntityType::AttributeDefinition(ad_invis))
.unwrap();
doc.add_entity(EntityType::AttributeDefinition(ad_preset))
.unwrap();
let meta = crate::app::update::command::block_attr_meta(&doc, "FLAG_BLOCK");
assert!(!meta.get("NORM_TAG").unwrap().constant);
assert!(meta.get("CONST_TAG").unwrap().constant);
assert!(!meta.get("INVIS_TAG").unwrap().constant);
assert!(!meta.get("PRESET_TAG").unwrap().constant);
// Test conversion from AttributeEntity to AttrRow
let r_norm = crate::app::update::command::attr_row_from_entity(&normal_entity, &meta);
assert!(!r_norm.is_constant);
assert_eq!(r_norm.prompt, "Enter norm:");
let r_const = crate::app::update::command::attr_row_from_entity(&const_entity, &meta);
assert!(r_const.is_constant);
assert_eq!(r_const.prompt, "Enter const:");
let r_invis = crate::app::update::command::attr_row_from_entity(&invis_entity, &meta);
assert!(!r_invis.is_constant);
assert_eq!(r_invis.prompt, "Enter invis:");
let r_preset = crate::app::update::command::attr_row_from_entity(&preset_entity, &meta);
assert!(!r_preset.is_constant);
assert_eq!(r_preset.prompt, "Enter preset:");
// Test apply_attr_row behavior on constant vs non-constant
// Normal: applies
let mut edited_norm_row = r_norm.clone();
edited_norm_row.value = "edited_norm".to_string();
assert!(crate::app::update::command::apply_attr_row(
&mut normal_entity,
&edited_norm_row
));
assert_eq!(normal_entity.get_value(), "edited_norm");
// Constant: ignores value change!
let mut edited_const_row = r_const.clone();
edited_const_row.value = "attempted_edit".to_string();
assert!(!crate::app::update::command::apply_attr_row(
&mut const_entity,
&edited_const_row
));
assert_eq!(const_entity.get_value(), "original_const");
// Invisible: applies
let mut edited_invis_row = r_invis.clone();
edited_invis_row.value = "edited_invis".to_string();
assert!(crate::app::update::command::apply_attr_row(
&mut invis_entity,
&edited_invis_row
));
assert_eq!(invis_entity.get_value(), "edited_invis");
// Preset: applies
let mut edited_preset_row = r_preset.clone();
edited_preset_row.value = "edited_preset".to_string();
assert!(crate::app::update::command::apply_attr_row(
&mut preset_entity,
&edited_preset_row
));
assert_eq!(preset_entity.get_value(), "edited_preset");
}
+40 -26
View File
@@ -4265,11 +4265,11 @@ impl OpenCADStudio {
Some(codec::EntityType::Insert(ins)) if !ins.attributes.is_empty() => {
// The prompt text lives on the block's ATTDEFs, not on the
// attribute instances — map tag → prompt from the definition.
let prompts = block_attr_prompts(doc, &ins.block_name);
let meta = block_attr_meta(doc, &ins.block_name);
let rows = ins
.attributes
.iter()
.map(|a| attr_row_from_entity(a, &prompts))
.map(|a| attr_row_from_entity(a, &meta))
.collect::<Vec<_>>();
Ok((ins.block_name.clone(), rows))
}
@@ -4363,16 +4363,47 @@ impl OpenCADStudio {
}
}
pub(crate) struct BlockAttrMeta {
pub(crate) prompt: String,
pub(crate) constant: bool,
}
/// Map each attribute tag to the metadata (prompt text, constant flag) declared on
/// the block's matching ATTDEF. Tags with no definition are absent.
pub(crate) fn block_attr_meta(
doc: &codec::CadDocument,
block_name: &str,
) -> rustc_hash::FxHashMap<String, BlockAttrMeta> {
let mut map = rustc_hash::FxHashMap::default();
if let Some(br) = doc.block_records.get(block_name) {
for &eh in &br.entity_handles {
if let Some(codec::EntityType::AttributeDefinition(ad)) = doc.get_entity(eh) {
map.insert(
ad.tag.clone(),
BlockAttrMeta {
prompt: ad.prompt.clone(),
constant: ad.flags.constant,
},
);
}
}
}
map
}
/// Build the editor's working copy of one attribute. Angles are shown in
/// degrees; numeric fields become strings so the user can type freely.
fn attr_row_from_entity(
pub(crate) fn attr_row_from_entity(
a: &codec::entities::AttributeEntity,
prompts: &rustc_hash::FxHashMap<String, String>,
meta: &rustc_hash::FxHashMap<String, BlockAttrMeta>,
) -> AttrRow {
let fmt = |v: f64| format!("{v}");
let ad_meta = meta.get(&a.tag);
let prompt = ad_meta.map(|m| m.prompt.clone()).unwrap_or_default();
let is_constant = a.flags.constant || ad_meta.map_or(false, |m| m.constant);
AttrRow {
tag: a.tag.clone(),
prompt: prompts.get(&a.tag).cloned().unwrap_or_default(),
prompt,
value: a.get_value().to_string(),
text_style: a.text_style.clone(),
height: fmt(a.height),
@@ -4387,15 +4418,16 @@ fn attr_row_from_entity(
color: a.common.color,
linetype: a.common.linetype.clone(),
line_weight: a.common.line_weight,
is_constant,
}
}
/// Write one edited row back onto its attribute; returns whether anything
/// changed. Numeric fields are parsed (angles from degrees); an unparseable
/// field is left as-is.
fn apply_attr_row(a: &mut codec::entities::AttributeEntity, row: &AttrRow) -> bool {
/// field is left as-is. Constant attributes ignore edits to their value.
pub(crate) fn apply_attr_row(a: &mut codec::entities::AttributeEntity, row: &AttrRow) -> bool {
let mut ch = false;
if a.get_value() != row.value {
if !row.is_constant && a.get_value() != row.value {
a.set_value(row.value.clone());
ch = true;
}
@@ -4466,24 +4498,6 @@ fn apply_attr_row(a: &mut codec::entities::AttributeEntity, row: &AttrRow) -> bo
ch
}
/// Map each attribute tag to the prompt text declared on the block's matching
/// ATTDEF. Attribute instances (ATTRIB) carry only tag + value; the prompt is
/// defined once on the block definition. Tags with no definition are absent.
fn block_attr_prompts(
doc: &codec::CadDocument,
block_name: &str,
) -> rustc_hash::FxHashMap<String, String> {
let mut map = rustc_hash::FxHashMap::default();
if let Some(br) = doc.block_records.get(block_name) {
for &eh in &br.entity_handles {
if let Some(codec::EntityType::AttributeDefinition(ad)) = doc.get_entity(eh) {
map.insert(ad.tag.clone(), ad.prompt.clone());
}
}
}
map
}
#[cfg(test)]
mod layer_rename_tests {
use crate::app::OpenCADStudio;
+33 -2
View File
@@ -115,18 +115,49 @@ pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec<Entity
// contents, not on the insert.
let base = crate::scene::render_graph::block_base_point(document, &ins.block_name);
let to_block = Transform::from_translation(Vector3::new(-base.x, -base.y, -base.z));
let mut attdefs: Vec<EntityType> = Vec::new();
let entities: Vec<EntityType> = document
.entities_in_block(&ins.block_name)
.map(|entity| {
let mut shifted = entity.clone();
Entity::apply_transform(shifted.as_entity_mut(), &to_block);
if let EntityType::AttributeDefinition(_) = &shifted {
attdefs.push(shifted.clone());
}
shifted
})
.collect();
ins.explode(&entities)
let mut pieces: Vec<EntityType> = ins
.explode(&entities)
.into_iter()
.map(normalize_insert_entity)
.collect()
.collect();
// Exploding an INSERT drops attribute values (ATTRIBs)
// and the attribute definitions (ATTDEF tags) reappear as ATTDEF entities.
// The codec's `ins.explode` skips `AttributeDefinition` entities, so we
// transform and append them here.
if !attdefs.is_empty() {
let insert_xform = ins.get_transform();
for ad_ent in &attdefs {
let mut exploded_ad = ad_ent.clone();
Entity::apply_transform(exploded_ad.as_entity_mut(), &insert_xform);
// Resolve common properties (layer "0", ByBlock color / line weight)
let common = exploded_ad.common_mut();
if common.layer == "0" {
common.layer = ins.common.layer.clone();
}
if common.color == codec::types::Color::ByBlock {
common.color = ins.common.color;
}
if common.line_weight == codec::types::LineWeight::ByBlock {
common.line_weight = ins.common.line_weight;
}
pieces.push(exploded_ad);
}
}
pieces
}
}
EntityType::MLine(ml) => explode_mline(ml),
+118 -1
View File
@@ -679,7 +679,7 @@ fn locked_explode_places_geometry_at_convention_positions() {
let mut line_start = None;
let mut line_end = None;
let mut circle_center = None;
for piece in pieces {
for piece in &pieces {
match piece {
EntityType::Line(line) => {
if line_end.is_none() {
@@ -710,6 +710,20 @@ fn locked_explode_places_geometry_at_convention_positions() {
1e-6,
"exploded circle center",
);
// Exploding an INSERT drops attribute values and the
// attribute definitions (tags) reappear as ATTDEF entities.
let attdef_piece = pieces.iter().find_map(|p| match p {
EntityType::AttributeDefinition(ad) => Some(ad.clone()),
_ => None,
});
let ad = attdef_piece.expect("the exploded ATTDEF");
assert_eq!(ad.tag, "TAG1");
assert_close(
ad.insertion_point,
EXPECTED_ATTR_POINT,
1e-6,
"exploded attdef insertion point",
);
// Same numbers as the render path: EXPLODE and the renderer agree.
let (render_start, render_end) = instance_line_world(&scene, BP_TEST);
assert_close(render_end, EXPECTED_LINE_END, 1e-6, "render path: line end");
@@ -1036,3 +1050,106 @@ fn locked_nested_reference_explode_matches_the_render_path() {
"render path: inner line end",
);
}
#[test]
fn locked_nested_insert_with_attributes_exploded_one_level() {
// When an outer block containing an inner INSERT and attributes is exploded one level,
// the outer block's attributes drop their values and its ATTDEFs reappear, while
// the inner INSERT remains an intact INSERT with its attributes preserved.
const INNER: &str = "BP_ATTR_INNER";
const OUTER: &str = "BP_ATTR_OUTER";
let mut scene = Scene::new();
// INNER block: base (0,0,0), has a LINE and an ATTDEF
let inner_ad = AttributeDefinition {
insertion_point: v(1.0, 1.0, 0.0),
height: 1.0,
..AttributeDefinition::new(
"INNER_TAG".to_string(),
"Inner prompt".to_string(),
"Inner val".to_string(),
)
};
scene.define_block_raw(
INNER,
v(0.0, 0.0, 0.0),
vec![
EntityType::Line(Line::from_points(v(0.0, 0.0, 0.0), v(5.0, 0.0, 0.0))),
EntityType::AttributeDefinition(inner_ad),
],
);
// OUTER block: base (10.0, 5.0, 0.0), has a nested INSERT of INNER (with an attribute instance) and an ATTDEF
let mut inner_ins = Insert::new(INNER.to_string(), v(5.0, 3.0, 0.0));
let mut inner_attr =
AttributeEntity::new("INNER_TAG".to_string(), "Actual inner value".to_string());
inner_attr.insertion_point = v(6.0, 4.0, 0.0);
inner_ins.attributes.push(inner_attr);
let outer_ad = AttributeDefinition {
insertion_point: v(12.0, 8.0, 0.0),
height: 1.0,
..AttributeDefinition::new(
"OUTER_TAG".to_string(),
"Outer prompt".to_string(),
"Outer val".to_string(),
)
};
scene.define_block_raw(
OUTER,
v(10.0, 5.0, 0.0),
vec![
EntityType::Insert(inner_ins),
EntityType::AttributeDefinition(outer_ad),
],
);
// Model space: instance of OUTER at (100, 100), 30°, scale 2
let mut outer_instance = Insert::new(OUTER.to_string(), v(100.0, 100.0, 0.0));
outer_instance.set_x_scale(2.0);
outer_instance.set_y_scale(2.0);
outer_instance.set_z_scale(2.0);
outer_instance.rotation = DEG30;
let mut outer_attr =
AttributeEntity::new("OUTER_TAG".to_string(), "Actual outer value".to_string());
outer_attr.insertion_point = v(100.464102, 107.196152, 0.0);
outer_instance.attributes.push(outer_attr);
// Explode outer instance one level
let pieces = crate::modules::draw::modify::explode::explode_entity(
&EntityType::Insert(outer_instance),
&scene.document,
);
// Expected pieces: the nested inner INSERT (with its attribute still intact) and the outer ATTDEF
let nested_insert = pieces
.iter()
.find_map(|p| match p {
EntityType::Insert(ins) if ins.block_name == INNER => Some(ins.clone()),
_ => None,
})
.expect("nested inner INSERT among pieces");
assert_eq!(nested_insert.attributes.len(), 1);
assert_eq!(nested_insert.attributes[0].tag, "INNER_TAG");
assert_eq!(
nested_insert.attributes[0].get_value(),
"Actual inner value"
);
let outer_attdef = pieces
.iter()
.find_map(|p| match p {
EntityType::AttributeDefinition(ad) if ad.tag == "OUTER_TAG" => Some(ad.clone()),
_ => None,
})
.expect("reappeared outer ATTDEF among pieces");
assert_close(
outer_attdef.insertion_point,
EXPECTED_ATTR_POINT,
1e-6,
"outer attdef insertion point",
);
}
+30 -4
View File
@@ -59,6 +59,8 @@ pub struct AttrRow {
pub color: AcadColor,
pub linetype: String,
pub line_weight: LineWeight,
/// Constant attributes cannot be edited.
pub is_constant: bool,
}
/// Combined justification options (horizontal × vertical), matching the
@@ -215,7 +217,24 @@ fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
}
}
fn disabled_field_style(theme: &Theme, _status: text_input::Status) -> text_input::Style {
let palette = theme.palette();
text_input::Style {
background: Background::Color(palette.background.weak.color),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
icon: palette.background.base.text.scale_alpha(0.38),
placeholder: palette.background.base.text.scale_alpha(0.38),
value: palette.background.base.text.scale_alpha(0.48),
selection: palette.primary.base.color.scale_alpha(0.2),
}
}
/// One `label : widget` row with a fixed-width label column.
fn field_row<'a>(
label: Cow<'static, str>,
widget: Element<'a, Message>,
@@ -469,14 +488,21 @@ fn attribute_tab<'a>(
}
let sel = selected.min(rows.len() - 1);
let value_box = text_input("", rows[sel].value.as_str())
.on_input(move |v| Message::AttrEditorInput { idx: sel, value: v })
.on_submit(Message::AttrEditorApply)
.style(field_style)
let is_constant = rows[sel].is_constant;
let mut value_box = text_input("", rows[sel].value.as_str())
.size(13)
.padding([4, 6])
.width(width);
if is_constant {
value_box = value_box.style(disabled_field_style);
} else {
value_box = value_box
.on_input(move |v| Message::AttrEditorInput { idx: sel, value: v })
.on_submit(Message::AttrEditorApply)
.style(field_style);
}
column![
head,
scrollable(list).width(width).height(height),