diff --git a/src/app/update/block_ops_tests.rs b/src/app/update/block_ops_tests.rs index 2dc81cacb..260b016b1 100644 --- a/src/app/update/block_ops_tests.rs +++ b/src/app/update/block_ops_tests.rs @@ -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"); +} diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 8688f8c86..c84c6f7f6 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -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::>(); 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 { + 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, + meta: &rustc_hash::FxHashMap, ) -> 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 { - 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; diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index ba11835a4..cf0888e56 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -115,18 +115,49 @@ pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec = Vec::new(); let entities: Vec = 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 = 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), diff --git a/src/scene/block_base_point_tests.rs b/src/scene/block_base_point_tests.rs index d5a501d1f..78cfc0b04 100644 --- a/src/scene/block_base_point_tests.rs +++ b/src/scene/block_base_point_tests.rs @@ -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", + ); +} diff --git a/src/ui/window/attribute_editor.rs b/src/ui/window/attribute_editor.rs index 75cf42dd7..35a9d34a6 100644 --- a/src/ui/window/attribute_editor.rs +++ b/src/ui/window/attribute_editor.rs @@ -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),