use super::helpers::{entity_type_key, entity_type_label, title_case_word}; use super::{OpenCADStudio, VARIES_LABEL}; use crate::io::linetypes; use crate::scene::view::dispatch; use crate::ui; use acadrust::{EntityType, Handle}; /// Above this many selected objects the Properties panel skips per-entity /// property aggregation (which is O(n) per row, plus an O(n²) group filter) and /// shows a count-only summary instead. Bulk edits still go through the ribbon. const MAX_PROP_AGGREGATE: usize = 2_000; fn material_color_text(color: &acadrust::objects::MaterialColor) -> String { let rgb = color .rgb .map(|value| format!("#{:06X}", value as u32 & 0x00ff_ffff)) .unwrap_or_else(|| "Object".to_string()); format!("{rgb}, factor {:.3}", color.factor) } fn material_map_text(map: &acadrust::objects::MaterialMap) -> String { let source = match map.source { 1 => { if map.file_name.trim().is_empty() { "File".to_string() } else { map.file_name.clone() } } 2 => format!( "Procedural {}", map.texture.as_ref().map_or(0, |texture| texture.mode) ), _ => "None".to_string(), }; format!( "{source}; blend {:.3}; projection {}; tiling {}; auto {}", map.blend_factor, map.projection, map.tiling, map.auto_transform ) } fn visual_style_properties_text(style: &acadrust::objects::VisualStyle) -> String { style .properties .iter() .enumerate() .map(|(index, property)| { format!( "{}: {:?} (enabled {})", index, property.value, property.enabled ) }) .collect::>() .join("\n") } impl OpenCADStudio { /// Rebuild the PropertiesPanel from the current entity selection. /// Preserves UI state (open pickers, edit buffer) across refreshes. pub(super) fn refresh_properties(&mut self) { let i = self.active_tab; if !crate::entities::object_data::cache_is_prepared( &self.tabs[i].scene.object_data_cache, ) { self.tabs[i].scene.object_data_cache = crate::entities::object_data::build_cache(&self.tabs[i].scene.document); } // Note: the color-picker dropdown is intentionally NOT carried over — a // rebuild means the selection (or a property) changed, so the dropdown // closes, matching the deselect / reselect / click-away expectation. let color_palette_open = self.tabs[i].properties.color_palette_open; let edit_buf = std::mem::take(&mut self.tabs[i].properties.edit_buf); // Expanded coordinate groups persist across rebuilds AND selection // changes — it's a per-user view preference, not per-entity state. let expanded_groups = std::mem::take(&mut self.tabs[i].properties.expanded_groups); // Which entities the previous panel was built for — an uncommitted // edit buffer only survives a rebuild for the *same* selection. let prev_handles = std::mem::take(&mut self.tabs[i].properties.source_handles); let selected_group = self.tabs[i].properties.selected_group.clone(); // Seed the per-thread unit context from the document header so the // entity property builders (which only see f64 values) can format // lengths/angles per LUNITS / LUPREC / AUNITS / AUPREC. { let h = &self.tabs[i].scene.document.header; crate::entities::common::set_unit_context(crate::entities::common::UnitContext { lunits: h.linear_unit_format, luprec: h.linear_unit_precision, aunits: h.angular_unit_format, auprec: h.angular_unit_precision, }); } let layer_names: Vec = self.tabs[i] .scene .document .layers .iter() .map(|l| l.name.clone()) .collect(); let linetype_items: Vec = self.tabs[i] .scene .document .line_types .iter() .map(|lt| { let name = if lt.name.is_empty() { "ByLayer".to_string() } else { lt.name.clone() }; let art = linetypes::extract_pattern(<.description); ui::properties::LinetypeItem { name, art } }) .collect(); let text_style_names: Vec = self.tabs[i] .scene .document .text_styles .iter() .map(|style| style.name.trim().to_string()) .filter(|name| !name.is_empty()) .collect(); // Current-Vertex focus survives only while the same object stays // selected; a changed selection resets to the first vertex. Seed the // per-thread focus so the polyline property builder / editor targets it. let cur_handles: Vec = self.tabs[i] .scene .selected_entities() .iter() .map(|(h, _)| *h) .collect(); let prop_vertex = if cur_handles == prev_handles { self.tabs[i].properties.prop_vertex } else { 0 }; crate::scene::view::dispatch::set_prop_current_vertex(prop_vertex); let new_panel = { let selected = self.tabs[i].scene.selected_entities(); let mut panel = match selected.len() { 0 => { use crate::scene::model::object::{PropSection, PropValue, Property}; let tab = &self.tabs[i]; let scene = &tab.scene; let doc = &scene.document; let header = &doc.header; let camera = scene.camera.borrow(); let (viewport_width, viewport_height) = scene.selection.borrow().vp_size; let aspect = if viewport_height > 0.0 { viewport_width as f64 / viewport_height as f64 } else { 1.0 }; let view_height = camera.ortho_size() as f64 * 2.0; let view_width = view_height * aspect; let format_length = crate::entities::common::format_length; let read_only = |label: &str, value: String| Property { label: label.to_string(), field: "drawing_property", value: PropValue::ReadOnly(value), }; let current_layer = if header.current_layer_name.is_empty() { tab.active_layer.clone() } else { header.current_layer_name.clone() }; let current_linetype = if !header.current_linetype_name.is_empty() { header.current_linetype_name.clone() } else if !header.current_linetype_handle.is_null() { doc.line_types .iter() .find(|line_type| { line_type.handle == header.current_linetype_handle }) .map(|line_type| line_type.name.clone()) .unwrap_or_else(|| "ByLayer".to_string()) } else { "ByLayer".to_string() }; let material = if header.current_material_handle.is_null() { "ByLayer".to_string() } else { doc.objects .get(&header.current_material_handle) .and_then(|object| match object { acadrust::objects::ObjectType::Material(material) => { Some(material.name.clone()) } _ => None, }) .unwrap_or_else(|| "ByLayer".to_string()) }; let plot_style = match header.current_plotstyle_type { 1 => "ByBlock", 2 => "ByColor", 3 => "ByObject", _ => "ByLayer", }; let layout_plot_table = doc.objects.values().find_map(|object| { let acadrust::objects::ObjectType::Layout(layout) = object else { return None; }; (layout.name == scene.current_layout && !layout.plot_style_sheet.trim().is_empty()) .then(|| layout.plot_style_sheet.clone()) }); let plot_table = layout_plot_table .or_else(|| { (!header.stylesheet.trim().is_empty()) .then(|| header.stylesheet.clone()) }) .unwrap_or_else(|| "None".to_string()); let ucs_per_viewport = scene .active_viewport .and_then(|handle| doc.get_entity(handle)) .and_then(|entity| match entity { acadrust::EntityType::Viewport(viewport) => { Some(viewport.ucs_per_viewport) } _ => None, }) .unwrap_or(false); let ucs_name = tab .active_ucs .as_ref() .map(|ucs| ucs.name.trim()) .filter(|name| !name.is_empty()) .unwrap_or("World") .to_string(); let annotation_scale = if header.current_annotation_scale.trim().is_empty() { "1:1".to_string() } else { header.current_annotation_scale.clone() }; let sections = vec![ PropSection { title: "General".to_string(), props: vec![ read_only("AC Version", doc.version.as_str().to_string()), Property { label: "Color".to_string(), field: "color", value: PropValue::ColorChoice(header.current_entity_color), }, Property { label: "Layer".to_string(), field: "layer", value: PropValue::LayerChoice(current_layer), }, Property { label: "Linetype".to_string(), field: "linetype", value: PropValue::LinetypeChoice(current_linetype), }, read_only( "Linetype scale", format_length(header.current_entity_linetype_scale), ), Property { label: "Lineweight".to_string(), field: "line_weight", value: PropValue::LwChoice( acadrust::types::LineWeight::from_value( header.current_line_weight, ), ), }, read_only("Transparency", "ByLayer".to_string()), read_only("Thickness", format_length(header.thickness)), ], }, PropSection { title: "3D Visualization".to_string(), props: vec![read_only("Material", material)], }, PropSection { title: "Plot style".to_string(), props: vec![ read_only("Plot style", plot_style.to_string()), read_only("Plot style table", plot_table.clone()), read_only( "Plot table attached to", if plot_table == "None" { "None".to_string() } else { scene.current_layout.clone() }, ), read_only( "Plot table type", if header.plotstyle_mode { "Named plot styles".to_string() } else { "Color-dependent plot styles".to_string() }, ), ], }, PropSection { title: "View".to_string(), props: vec![ read_only("Center X", format_length(camera.target.x)), read_only("Center Y", format_length(camera.target.y)), read_only("Center Z", format_length(camera.target.z)), read_only("Height", format_length(view_height)), read_only("Width", format_length(view_width)), ], }, PropSection { title: "Misc".to_string(), props: vec![ read_only("Annotation scale", annotation_scale), read_only( "UCS icon On", if self.show_ucs_icon { "Yes" } else { "No" }.to_string(), ), read_only( "UCS icon at origin", if self.ucs_icon_at_origin { "Yes" } else { "No" } .to_string(), ), read_only( "UCS per viewport", if ucs_per_viewport { "Yes" } else { "No" }.to_string(), ), read_only("UCS Name", ucs_name), read_only("Visual Style", tab.visual_style.clone()), ], }, ]; ui::PropertiesPanel { title: "No selection".to_string(), sections, layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), linetype_combo: iced::widget::combo_box::State::new( linetype_items.clone(), ), lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), hatch_pattern_combo: iced::widget::combo_box::State::new( crate::scene::model::hatch_patterns::names(), ), linetype_items, ..Default::default() } } 1 => { let (handle, source_entity) = selected[0]; let contextual = crate::scene::annotative::entity_for_active_context( &self.tabs[i].scene.document, source_entity, ); let entity = contextual.as_ref(); let group_names = self.tabs[i].scene.group_names_for_entity(handle); let mut sections = dispatch::properties_sectioned(handle, entity, &text_style_names); // Turn the Material row into an editable picker: the source // options (ByLayer / ByBlock) plus every named material the // drawing defines. A custom flag (3) shows the stored handle's // material name as the selection. { let doc = &self.tabs[i].scene.document; let common = entity.common(); let mat_names: Vec = doc .objects .iter() .filter_map(|(_, o)| match o { acadrust::objects::ObjectType::Material(m) => Some(m.name.clone()), _ => None, }) .collect(); let selected = match common.material_flags { 0 => "ByLayer".to_string(), 1 => "ByBlock".to_string(), _ => common .material_handle .and_then(|mh| { doc.objects.iter().find_map(|(h, o)| match o { acadrust::objects::ObjectType::Material(m) if *h == mh => { Some(m.name.clone()) } _ => None, }) }) .unwrap_or_else(|| "ByLayer".to_string()), }; let mut options = vec!["ByLayer".to_string(), "ByBlock".to_string()]; options.extend(mat_names); for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == "material") { row.value = crate::scene::model::object::PropValue::Choice { selected: selected.clone(), options: options.clone(), }; } } let effective_handle = match common.material_flags { 3 => common.material_handle, 0 => doc .layers .get(&common.layer) .map(|layer| layer.material) .filter(|handle| handle.is_valid()), _ => None, }; if let Some(acadrust::objects::ObjectType::Material(material)) = effective_handle.and_then(|handle| doc.objects.get(&handle)) { use crate::entities::common::ro_prop; sections.push(crate::scene::model::object::PropSection { title: "Material Details".to_string(), props: vec![ ro_prop("Name", "mat_name", material.name.clone()), ro_prop( "Description", "mat_description", material.description.clone(), ), ro_prop( "Ambient", "mat_ambient", material_color_text(&material.ambient_color), ), ro_prop( "Diffuse", "mat_diffuse", material_color_text(&material.diffuse_color), ), ro_prop( "Specular", "mat_specular", material_color_text(&material.specular_color), ), ro_prop( "Gloss", "mat_gloss", format!("{:.3}", material.specular_gloss_factor), ), ro_prop( "Opacity", "mat_opacity", format!("{:.3}", material.opacity_percent), ), ro_prop( "Reflectivity", "mat_reflectivity", format!("{:.3}", material.reflectivity), ), ro_prop( "Translucence", "mat_translucence", format!("{:.3}", material.translucence), ), ro_prop( "Refraction", "mat_refraction", format!("{:.3}", material.refraction_index), ), ro_prop( "Self Illumination", "mat_self_illumination", format!("{:.3}", material.self_illumination), ), ro_prop( "Luminance", "mat_luminance", format!( "{:.3} (mode {})", material.luminance, material.luminance_mode ), ), ro_prop( "Diffuse Map", "mat_diffuse_map", material_map_text(&material.diffuse_map), ), ro_prop( "Specular Map", "mat_specular_map", material_map_text(&material.specular_map), ), ro_prop( "Reflection Map", "mat_reflection_map", material_map_text(&material.reflection_map), ), ro_prop( "Opacity Map", "mat_opacity_map", material_map_text(&material.opacity_map), ), ro_prop( "Bump Map", "mat_bump_map", material_map_text(&material.bump_map), ), ro_prop( "Refraction Map", "mat_refraction_map", material_map_text(&material.refraction_map), ), ro_prop( "Normal Map", "mat_normal_map", material_map_text(&material.normal_map), ), ro_prop( "Render Flags", "mat_render_flags", format!( "illumination {}; channels {}; mode {}; two-sided {}", material.illumination_model, material.channel_flags, material.mode, material.two_sided_material ), ), ro_prop( "Advanced", "mat_advanced", format!( "normal {:.3}; bump {:.3}; reflect {:.3}; transmit {:.3}", material.normal_map_strength, material.indirect_bump_scale, material.reflectance_scale, material.transmittance_scale ), ), ], }); } } { let doc = &self.tabs[i].scene.document; let common = entity.common(); if let Some(acadrust::objects::ObjectType::BookColor(book)) = common .color_book_handle .filter(|handle| handle.is_valid()) .and_then(|handle| doc.objects.get(&handle)) { for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|row| row.field == "color") { row.value = crate::scene::model::object::PropValue::ColorChoice( book.color, ); } } use crate::entities::common::ro_prop; sections.push(crate::scene::model::object::PropSection { title: "Color Book".to_string(), props: vec![ ro_prop("Book", "book_color_book", book.book_name.clone()), ro_prop( "Color Name", "book_color_name", book.color_name.clone(), ), ro_prop( "Color", "book_color_value", format!("{:?}", book.color), ), ], }); } let style_handles = [ ("Full", common.full_visual_style_handle), ("Face", common.face_visual_style_handle), ("Edge", common.edge_visual_style_handle), ]; for (scope, handle) in style_handles { let Some((handle, style)) = handle .filter(|handle| handle.is_valid()) .and_then(|handle| { doc.objects.get(&handle).and_then(|object| match object { acadrust::objects::ObjectType::VisualStyle(style) => { Some((handle, style)) } _ => None, }) }) else { continue; }; use crate::entities::common::ro_prop; sections.push(crate::scene::model::object::PropSection { title: format!("{scope} Visual Style"), props: vec![ ro_prop( "Handle", "vs_handle", format!("{:X}", handle.value()), ), ro_prop( "Description", "vs_description", style.description.clone(), ), ro_prop( "Type", "vs_type", style.style_type.to_string(), ), ro_prop( "Face", "vs_face", format!( "lighting {}; quality {}; color {}; modifiers {}", style.face_lighting_model, style.face_lighting_quality, style.face_color_mode, style.face_modifier ), ), ro_prop( "Edges", "vs_edges", format!( "model {}; style {}", style.edge_model, style.edge_style ), ), ro_prop( "Extended Lighting", "vs_extended_lighting", style.extended_lighting_model.to_string(), ), ro_prop( "Internal", "vs_internal", style.internal_use_only.to_string(), ), ro_prop( "Property Bag", "vs_properties", visual_style_properties_text(style), ), ], }); } } if matches!( entity, acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Region(_) | acadrust::EntityType::Body(_) | acadrust::EntityType::Surface(_) | acadrust::EntityType::Mesh(_) | acadrust::EntityType::PolygonMesh(_) | acadrust::EntityType::PolyfaceMesh(_) ) { if let Some(mesh) = self.tabs[i] .scene .meshes .get(&handle) .or_else(|| self.tabs[i].scene.block_meshes.get(&handle)) { use crate::entities::common::ro_prop; let metrics = mesh.metrics; let mut props = vec![ ro_prop( "Vertices", "mesh_vertices", metrics.vertices.to_string(), ), ro_prop( "Triangles", "mesh_triangles", metrics.triangles.to_string(), ), ro_prop( "Surface Area", "mesh_surface_area", format!("{:.6}", metrics.surface_area), ), ro_prop( "Centroid", "mesh_centroid", format!( "{:.6}, {:.6}, {:.6}", metrics.centroid[0], metrics.centroid[1], metrics.centroid[2] ), ), ro_prop( "Tessellation", "mesh_complete", if mesh.complete { "Complete" } else { "Partial" }, ), ]; if mesh.complete && matches!( entity, acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Region(_) | acadrust::EntityType::Body(_) ) { props.insert( 3, ro_prop( "Volume", "mesh_volume", format!("{:.6}", metrics.volume), ), ); } sections.push(crate::scene::model::object::PropSection { title: "Mass Properties".to_string(), props, }); } } sections.extend(crate::entities::object_data::sections( &self.tabs[i].scene.document, &self.tabs[i].scene.object_data_cache, handle, entity, )); // Uniform-scale checkbox for block references (#427): // while the three scale factors are equal (and the user // hasn't opted into per-axis editing) the panel shows one // Scale row plus a checked "Uniform scale" box; unchecking // expands the familiar Scale X/Y/Z rows. if let acadrust::EntityType::Insert(ins) = entity { let eq = (ins.x_scale() - ins.y_scale()).abs() < 1e-12 && (ins.x_scale() - ins.z_scale()).abs() < 1e-12; let uniform = eq && !self.props_asym_scale.contains(&handle.value()); for section in sections.iter_mut() { let Some(xi) = section.props.iter().position(|p| p.field == "x_scale") else { continue; }; let toggle = crate::scene::model::object::Property { label: "Uniform scale".into(), field: "ins_uniform", value: crate::scene::model::object::PropValue::BoolToggle { field: "ins_uniform", value: uniform, }, }; if uniform { section .props .retain(|p| p.field != "y_scale" && p.field != "z_scale"); let xi = section .props .iter() .position(|p| p.field == "x_scale") .unwrap_or(xi.min(section.props.len())); section.props[xi] = crate::entities::common::edit_prop( "Scale", "u_scale", ins.x_scale(), ); section.props.insert(xi, toggle); } else { section.props.insert(xi, toggle); } } } // In named plot-style mode (PSTYLEMODE=1) the Plot style row // picks a named style from the drawing's plot style table. In // the color-dependent mode it stays read-only (the object's // color drives the plot style), so it is left untouched. if self.tabs[i].scene.document.header.plotstyle_mode { let doc = &self.tabs[i].scene.document; let dict_h = doc.header.acad_plotstylename_dict_handle; if let Some(dict) = crate::scene::annotative::as_dict(doc, dict_h) { let common = entity.common(); let mut options = vec!["ByLayer".to_string(), "ByBlock".to_string()]; options.extend(dict.entries.iter().map(|(n, _)| n.clone())); let selected = match common.plotstyle_flags { 0 => "ByLayer".to_string(), 1 => "ByBlock".to_string(), _ => common .plotstyle_handle .and_then(|ph| { dict.entries .iter() .find(|(_, h)| *h == ph) .map(|(n, _)| n.clone()) }) .unwrap_or_else(|| "ByLayer".to_string()), }; for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == "plot_style") { row.value = crate::scene::model::object::PropValue::Choice { selected: selected.clone(), options: options.clone(), }; } } } } // Turn the MLEADER handle-backed rows (multileader style, text // style, arrowhead block, leader linetype) into editable name // pickers. The current handle resolves to a display name; the // options list every candidate the drawing offers. Applying a // pick resolves the name back to a handle in the update loop. if let acadrust::EntityType::MultiLeader(ml) = entity { let doc = &self.tabs[i].scene.document; // Option lists. let mleader_styles: Vec = doc .objects .iter() .filter_map(|(_, o)| match o { acadrust::objects::ObjectType::MultiLeaderStyle(s) => { Some(s.name.clone()) } _ => None, }) .collect(); // A leader with no linetype handle draws ByBlock; expose that // as the first option so the default is selectable. let ltype_names: Vec = std::iter::once("ByBlock".to_string()) .chain( doc.line_types .iter() .map(|l| l.name.clone()) .filter(|n| !n.is_empty()), ) .collect(); // Arrowheads are blocks; the closed-filled default has no // block, so seed the list with it and add the arrowhead // blocks (leading underscore) present in the drawing. let arrow_names: Vec = std::iter::once("Closed filled".to_string()) .chain( doc.block_records .iter() .filter(|b| b.name.starts_with('_')) .map(|b| b.name.clone()), ) .collect(); let tstyle_names = text_style_names.clone(); // Currently selected names. let cur_style = ml .style_handle .and_then(|h| { doc.objects.iter().find_map(|(oh, o)| match o { acadrust::objects::ObjectType::MultiLeaderStyle(s) if *oh == h => { Some(s.name.clone()) } _ => None, }) }) .unwrap_or_else(|| "Standard".to_string()); let cur_tstyle = ml .text_style_handle .and_then(|h| { doc.text_styles.iter().find(|s| s.handle == h).map(|s| s.name.clone()) }) .unwrap_or_else(|| "Standard".to_string()); let cur_arrow = ml .arrowhead_handle .and_then(|h| { doc.block_records.iter().find(|b| b.handle == h).map(|b| b.name.clone()) }) .unwrap_or_else(|| "Closed filled".to_string()); let cur_ltype = ml .line_type_handle .and_then(|h| { doc.line_types.iter().find(|l| l.handle == h).map(|l| l.name.clone()) }) .unwrap_or_else(|| "ByBlock".to_string()); let mut set_choice = |field: &str, selected: String, options: Vec| { for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == field) { row.value = crate::scene::model::object::PropValue::Choice { selected: selected.clone(), options: options.clone(), }; } } }; set_choice("mleader_style", cur_style, mleader_styles); set_choice("text_style_handle", cur_tstyle, tstyle_names); set_choice("arrowhead_handle", cur_arrow, arrow_names); set_choice("line_type_handle", cur_ltype, ltype_names); } // Inject viewport-only properties that require doc access. if let acadrust::EntityType::Viewport(vp) = entity { let frozen_names: Vec = vp .frozen_layers .iter() .filter_map(|&h| { self.tabs[i] .scene .document .layers .iter() .find(|l| l.handle == h) .map(|l| l.name.clone()) }) .collect(); // Collect available UCS names for the name picker. let ucs_names: Vec = self.tabs[i] .scene .document .ucss .iter() .map(|u| u.name.clone()) .filter(|n| !n.is_empty()) .collect(); // Current UCS name (resolved from vp.ucs_handle). let current_ucs = self.tabs[i] .scene .document .ucss .iter() .find(|u| u.handle == vp.ucs_handle) .map(|u| u.name.clone()) .unwrap_or_default(); // Collect available named view names. let view_names: Vec = self.tabs[i] .scene .document .views .iter() .map(|v| v.name.clone()) .filter(|n| !n.is_empty()) .collect(); if let Some(geom) = sections.last_mut() { geom.props.push(crate::scene::model::object::Property { label: "Frozen Layers".to_string(), field: "frozen_layers", value: crate::scene::model::object::PropValue::EditText( frozen_names.join(", "), ), }); if !ucs_names.is_empty() { geom.props.push(crate::scene::model::object::Property { label: "UCS Name".to_string(), field: "vp_ucs_name", value: crate::scene::model::object::PropValue::Choice { selected: current_ucs, options: ucs_names, }, }); } if !view_names.is_empty() { geom.props.push(crate::scene::model::object::Property { label: "Named View".to_string(), field: "vp_named_view", value: crate::scene::model::object::PropValue::Choice { selected: String::new(), options: view_names, }, }); } } // Drive the viewport scale picker from the drawing's // own scale list instead of a built-in set. let file_scales = self.tabs[i].scene.scale_list(); if !file_scales.is_empty() { let eff = crate::scene::vp_effective_scale( vp.custom_scale, vp.view_height, vp.height, ); let selected = file_scales .iter() .find(|(_, _, f)| (f - eff).abs() < 0.001 * f.max(0.001)) .map(|(n, _, _)| n.clone()) .unwrap_or_default(); let options: Vec = file_scales.iter().map(|(n, _, _)| n.clone()).collect(); if let Some(geom) = sections.last_mut() { if let Some(prop) = geom.props.iter_mut().find(|p| p.field == "vscale_std") { prop.value = crate::scene::model::object::PropValue::Choice { selected, options, }; } } } } // Inject DimStyle picker + style-derived groups for Dimensions. if let acadrust::EntityType::Dimension(d) = entity { let dim_style_names: Vec = self.tabs[i] .scene .document .dim_styles .iter() .map(|s| s.name.clone()) .filter(|n| !n.is_empty()) .collect(); if !dim_style_names.is_empty() { // Current style is already shown as EditText in the geom section; // replace/upgrade it to a Choice if we have a list. if let Some(geom) = sections.last_mut() { // Find and replace the style_name EditText with a Choice. if let Some(prop) = geom.props.iter_mut().find(|p| p.field == "style_name") { let current = match &prop.value { crate::scene::model::object::PropValue::EditText(s) => s.clone(), _ => String::new(), }; prop.value = crate::scene::model::object::PropValue::Choice { selected: current, options: dim_style_names, }; } } } // Append the resolved dimension style's groups // (Lines & Arrows, Text, Fit, Units, Tolerances). let style_name = d.base().style_name.clone(); if let Some(style) = self.tabs[i].scene.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")) }) { sections.extend(crate::entities::dimension::style_sections(style)); // The style groups are a read-only mirror, but the // dim-line colour is an editable per-object override: // prefer the ACAD_DSTYLE code-176 (ACI) override, else // the style's DIMCLRD. use crate::entities::dim_override as dov; use crate::scene::model::object::PropValue; let dim_c = dov::color(&d.base().common.extended_data, dov::DIMCLRD) .unwrap_or_else(|| acadrust::types::Color::from_index(style.dimclrd)); set_row_value( &mut sections, "dim_line_color", PropValue::ColorChoice(dim_c), ); } } // ── Doc-dependent property rows ────────────────────────── // Rows whose value lives on another object (a block record, // an underlay definition, a dimension / multileader style) // are left empty by the entity builders and resolved here, // where the document is reachable. let doc = &self.tabs[i].scene.document; match entity { // Block reference: the referenced block's units and the // unit-scale factor against the drawing's INSUNITS. acadrust::EntityType::Insert(ins) => { let host = doc.header.insertion_units; let src = doc .block_records .get(&ins.block_name) .map(|br| br.units) .unwrap_or(0); set_row(&mut sections, "block_unit", insunits_name(src).to_string()); let host_mm = if host == 0 { 1.0 } else { insunits_to_mm(host) }; let src_mm = if src == 0 { 1.0 } else { insunits_to_mm(src) }; let factor = if host_mm.abs() > 1e-12 { src_mm / host_mm } else { 1.0 }; set_row(&mut sections, "unit_factor", format!("{factor:.4}")); // Name row: editable for regular blocks — pick an // existing definition to re-point this reference, or // type a new name to rename the definition (every // insert of it follows). Anonymous (*) and // xref(-dependent) blocks keep the read-only row. let regular = |br: &acadrust::tables::BlockRecord| { !br.is_anonymous() && !br.flags.is_xref && !br.name.contains('|') }; let editable = doc .block_records .get(&ins.block_name) .map(®ular) .unwrap_or(false); if editable { let mut options: Vec = doc .block_records .iter() .filter(|br| regular(br)) .map(|br| br.name.clone()) .collect(); options.sort_by(|a, b| { a.to_lowercase().cmp(&b.to_lowercase()) }); set_row_value( &mut sections, "block", crate::scene::model::object::PropValue::EditChoice { value: ins.block_name.clone(), options, }, ); } } // Underlay: name + path from the referenced definition. acadrust::EntityType::Underlay(ul) => { if let Some((name, path)) = doc.objects.iter().find_map(|(h, o)| match o { acadrust::objects::ObjectType::UnderlayDefinition(def) if *h == ul.definition_handle => { let nm = if !def.name.is_empty() { def.name.clone() } else { def.page_name.clone() }; Some((nm, def.file_path.clone())) } _ => None, }) { set_row(&mut sections, "ul_name", name); set_row(&mut sections, "ul_path", path); } } // Leader: text style / vertical text placement / overall // scale come from its dimension style. acadrust::EntityType::Leader(ld) => { // Dim style row → dropdown of the drawing's dim styles. let names: Vec = doc .dim_styles .iter() .map(|s| s.name.clone()) .filter(|n| !n.is_empty()) .collect(); if !names.is_empty() { for section in sections.iter_mut() { if let Some(p) = section .props .iter_mut() .find(|p| p.field == "dimension_style") { let cur = match &p.value { crate::scene::model::object::PropValue::EditText(s) => { s.clone() } _ => ld.dimension_style.clone(), }; p.value = crate::scene::model::object::PropValue::Choice { selected: cur, options: names.clone(), }; } } } // 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) { 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", PropValue::Choice { selected: arrow_label, options: arrow_opts, }, ); 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: a per-object ACAD_DSTYLE // override (code 176, an ACI index) wins over the // style's DIMCLRD. Editable — the picked colour is // written back as that override so it round-trips. let dim_c = dov::color(xd, dov::DIMCLRD) .unwrap_or_else(|| acadrust::types::Color::from_index(ds.dimclrd)); set_row_value( &mut sections, "dim_line_color", PropValue::ColorChoice(dim_c), ); 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", PropValue::Choice { selected: dimtad_label(tad).to_string(), options: tad_options(), }, ); let scl = dov::real(xd, dov::DIMSCALE).unwrap_or(ds.dimscale); set_row_value( &mut sections, "dim_scale_overall", PropValue::EditText(format!("{scl:.4}")), ); } } // Feature-control frame: FCF text style is the dimension // style's DIMTXSTY. acadrust::EntityType::Tolerance(tol) => { if let Some(ds) = find_dim_style(doc, &tol.dimension_style_name) { if !ds.dimtxsty.is_empty() { set_row(&mut sections, "tol_text_style", ds.dimtxsty.clone()); } } } // MultiLeader: max points + segment-angle constraints // are MLeaderStyle settings, not stored on the entity. acadrust::EntityType::MultiLeader(ml) => { if let Some(sh) = ml.style_handle { if let Some((mx, a1, a2)) = doc.objects.iter().find_map(|(h, o)| match o { acadrust::objects::ObjectType::MultiLeaderStyle(s) if *h == sh => { Some(( s.max_leader_points, s.first_segment_angle, s.second_segment_angle, )) } _ => None, }) { set_row(&mut sections, "max_leader_points", mx.to_string()); set_row( &mut sections, "first_segment_angle", format!("{a1:.4}"), ); set_row( &mut sections, "second_segment_angle", format!("{a2:.4}"), ); } } } _ => {} } // Annotative Yes/No + a conditional "Annotative scale" row. // Read-only: annotative state comes from the entity's style // (or its own flag) and the assigned annotation-scale name(s) // are walked from the entity's extension dictionary — both // need the document, so they are resolved here. { // Which entities show an Annotative row, the field it uses, // and — for those that don't already carry the row // (dimension / table) — the existing field to insert it // after. MLeader uses its editable toggle field. let anno: Option<(&str, Option<&str>)> = match entity { acadrust::EntityType::Text(_) | acadrust::EntityType::MText(_) | acadrust::EntityType::Insert(_) | acadrust::EntityType::Leader(_) => Some(("annotative", None)), acadrust::EntityType::MultiLeader(_) => { Some(("enable_annotation_scale", None)) } acadrust::EntityType::Dimension(_) => { Some(("annotative", Some("style_name"))) } acadrust::EntityType::Table(_) => { Some(("annotative", Some("tbl_style_handle"))) } _ => None, }; if let Some((anno_field, insert_after)) = anno { let is_anno = crate::scene::annotative::is_annotative(doc, entity); // Dimensions/tables carry no Annotative row yet — add one // right after their style row. if let Some(anchor) = insert_after { insert_row_after( &mut sections, anchor, crate::entities::common::ro_prop( "Annotative", "annotative", "No", ), ); } // Objects that carry a per-object annotation context // (MTEXT via its native flag, single-line TEXT via the // context alone) get an editable toggle: turning it on // synthesizes a real per-scale representation. The // remaining types are style-driven and stay read-only. if anno_field == "annotative" { match entity { acadrust::EntityType::MText(t) => set_row_value( &mut sections, "annotative", crate::scene::model::object::PropValue::BoolToggle { field: "is_annotative", value: t.is_annotative, }, ), acadrust::EntityType::Text(_) | acadrust::EntityType::Insert(_) => set_row_value( &mut sections, "annotative", crate::scene::model::object::PropValue::BoolToggle { field: "annotative_ctx", value: is_anno, }, ), _ => set_row( &mut sections, "annotative", if is_anno { "Yes" } else { "No" }.to_string(), ), } } if is_anno { // The applied annotation scale follows the current // annotation scale (CANNOSCALE / the status-bar // scale pill), not a per-object stored value. insert_row_after( &mut sections, anno_field, crate::entities::common::ro_prop( "Annotative scale", "annotative_scale", doc.header.current_annotation_scale.clone(), ), ); } } } if !group_names.is_empty() { let label = group_names.join(", "); if let Some(general) = sections.first_mut() { general.props.push(crate::scene::model::object::Property { label: "Group".to_string(), field: "group", value: crate::scene::model::object::PropValue::ReadOnly(label), }); } } let title = match entity { acadrust::EntityType::Insert(ins) => { let is_xref = self.tabs[i] .scene .document .block_records .iter() .find(|br| br.name == ins.block_name) .map(|br| br.flags.is_xref || br.flags.is_xref_overlay) .unwrap_or(false); if is_xref { "External Reference".to_string() } else { entity_type_label(entity) } } _ => entity_type_label(entity), }; ui::PropertiesPanel { choice_combos: sections .iter() .flat_map(|section| section.props.iter()) .filter_map(|prop| match &prop.value { crate::scene::model::object::PropValue::Choice { options, .. } => Some(( prop.field.to_string(), iced::widget::combo_box::State::new(options.clone()), )), _ => None, }) .collect(), sections, title, layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), linetype_combo: iced::widget::combo_box::State::new(linetype_items.clone()), hatch_pattern_combo: iced::widget::combo_box::State::new( crate::scene::model::hatch_patterns::names(), ), lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), linetype_items, ..Default::default() } } // Property aggregation is O(n) per row plus an O(n²) group filter // (`group.handles.contains` scans per entity), stalling the rebuild // for seconds at tens of thousands of objects. Above the cap show a // count-only panel; bulk edits still go through the ribbon. n if n > MAX_PROP_AGGREGATE => ui::PropertiesPanel { title: format!("{} objects selected", n), layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), linetype_combo: iced::widget::combo_box::State::new(linetype_items.clone()), lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), hatch_pattern_combo: iced::widget::combo_box::State::new( crate::scene::model::hatch_patterns::names(), ), linetype_items, ..Default::default() }, _ => { let groups = build_selection_groups(&selected); let active_group = selected_group .and_then(|group| groups.iter().find(|g| g.label == group.label).cloned()) .or_else(|| groups.first().cloned()); let filtered: Vec<(Handle, &EntityType)> = active_group .as_ref() .map(|group| { selected .iter() .filter(|(handle, _)| group.handles.contains(handle)) .copied() .collect() }) .unwrap_or_default(); let sections = aggregate_sections(&filtered, &text_style_names); ui::PropertiesPanel { choice_combos: sections .iter() .flat_map(|section| section.props.iter()) .filter_map(|prop| match &prop.value { crate::scene::model::object::PropValue::Choice { options, .. } => Some(( prop.field.to_string(), iced::widget::combo_box::State::new(options.clone()), )), _ => None, }) .collect(), sections, title: format!("{} objects selected", selected.len()), selection_group_combo: iced::widget::combo_box::State::new(groups.clone()), selection_groups: groups, selected_group: active_group, layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), linetype_combo: iced::widget::combo_box::State::new(linetype_items.clone()), hatch_pattern_combo: iced::widget::combo_box::State::new( crate::scene::model::hatch_patterns::names(), ), lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), linetype_items, ..Default::default() } } }; panel.color_palette_open = color_palette_open; let new_handles: Vec = selected.iter().map(|(h, _)| *h).collect(); // Carry the in-progress edits only when the selection is unchanged // (a commit-triggered rebuild); a genuine selection change starts // with a clean buffer so no stale value leaks onto the new entity. panel.edit_buf = if prev_handles == new_handles { edit_buf } else { Default::default() }; panel.expanded_groups = expanded_groups; panel.source_handles = new_handles; panel.prop_vertex = prop_vertex; panel }; self.tabs[i].properties = new_panel; self.refresh_selected_grips(); self.sync_ribbon_from_selection(); } /// Drive the Home-ribbon Layer / Color / Linetype / Lineweight dropdowns /// from the current entity selection. With no selection the ribbon falls /// back to the active creation defaults (per-tab active_layer + ByLayer). /// Mixed selections keep the prior value (we'd need a UI "*Varies*" /// marker to do better). pub(super) fn sync_ribbon_from_selection(&mut self) { let i = self.active_tab; // The Start (welcome) tab has no document — keep the ribbon's // current-layer chip empty rather than re-seeding it with a default. if self.tabs[i].is_start { self.ribbon.active_layer = String::new(); return; } let selected = self.tabs[i].scene.selected_entities(); if selected.is_empty() { // Creation defaults: prefer the file's saved CECOLOR / CELTYPE / // CELWEIGHT (and current_layer_name); fall back to ByLayer when // those slots are still at their factory default. let header = &self.tabs[i].scene.document.header; let layer = if header.current_layer_name.is_empty() { self.tabs[i].active_layer.clone() } else { header.current_layer_name.clone() }; self.ribbon.active_layer = layer; self.ribbon.active_color = header.current_entity_color; // current_linetype_name may be empty when only the handle was // written; resolve via line_types table in that case. let lt = if !header.current_linetype_name.is_empty() { header.current_linetype_name.clone() } else if !header.current_linetype_handle.is_null() { self.tabs[i] .scene .document .line_types .iter() .find(|lt| lt.handle == header.current_linetype_handle) .map(|lt| lt.name.clone()) .unwrap_or_else(|| "ByLayer".to_string()) } else { "ByLayer".to_string() }; self.ribbon.active_linetype = lt; self.ribbon.active_lineweight = acadrust::types::LineWeight::from_value(header.current_line_weight); return; } let mut layer: Option = None; let mut color: Option = None; let mut linetype: Option = None; let mut lineweight: Option = None; let mut layer_mixed = false; let mut color_mixed = false; let mut linetype_mixed = false; let mut lineweight_mixed = false; for (_h, e) in &selected { let c = e.common(); let lt = if c.linetype.is_empty() { "ByLayer".to_string() } else { c.linetype.clone() }; match &layer { None => layer = Some(c.layer.clone()), Some(prev) if prev != &c.layer => layer_mixed = true, _ => {} } match &color { None => color = Some(c.color), Some(prev) if prev != &c.color => color_mixed = true, _ => {} } match &linetype { None => linetype = Some(lt), Some(prev) if prev != < => linetype_mixed = true, _ => {} } match &lineweight { None => lineweight = Some(c.line_weight), Some(prev) if prev != &c.line_weight => lineweight_mixed = true, _ => {} } } if !layer_mixed { if let Some(l) = layer { self.ribbon.active_layer = l; } } if !color_mixed { if let Some(c) = color { self.ribbon.active_color = c; } } if !linetype_mixed { if let Some(l) = linetype { self.ribbon.active_linetype = l; } } if !lineweight_mixed { if let Some(lw) = lineweight { self.ribbon.active_lineweight = lw; } } } /// Rebuild the cached selected_grips from the current entity selection. pub(super) fn refresh_selected_grips(&mut self) { let i = self.active_tab; let is_paper = self.tabs[i].scene.current_layout != "Model"; // Paper-space entity coordinates are NOT offset by world_offset (same rule // as wire tessellation in wires_for_block). Only subtract in model space. let wo = if is_paper { [0.0f64; 3] } else { [0.0_f64; 3] }; let (new_handle, new_grips) = { let selected = self.tabs[i].scene.selected_entities(); if selected.len() == 1 { let (handle, entity) = selected[0]; let contextual = crate::scene::annotative::entity_for_active_context( &self.tabs[i].scene.document, entity, ); let grips = dispatch::grips(contextual.as_ref()) .into_iter() .map(|mut g| { // Subtract in f64: at UTM magnitudes an f32 cast before // the offset costs ~1 unit and draws the grip off the // wire. g.world.x -= wo[0]; g.world.y -= wo[1]; g.world.z -= wo[2]; g }) .collect(); (Some(handle), grips) } else { (None, vec![]) } }; self.tabs[i].selected_handle = new_handle; self.tabs[i].selected_grips = new_grips; // Append the dynamic-block visibility (lookup) grip, if the lone // selection is a visibility-parametric block reference. self.refresh_visibility_grip(wo); } pub(super) fn property_target_handles(&self, i: usize) -> Vec { let handles = self.tabs[i].properties.selected_handles(); if !handles.is_empty() { handles } else { self.tabs[i].selected_handle.into_iter().collect() } } pub(super) fn invalidate_property_targets(&mut self, i: usize, handles: &[Handle]) { let mut context_object_changed = false; for &handle in handles { context_object_changed |= crate::scene::annotative::sync_active_context_from_entity( &mut self.tabs[i].scene.document, handle, ); // Hatch / SOLID fills render from prebuilt cached models; rebuild // them or pattern edits (scale, background, …) stay invisible // (#415). self.tabs[i].scene.refresh_fill_model(handle); } if context_object_changed { self.tabs[i].scene.poison_undo_recording(); } // Solid (ACIS) meshes bake their colour into the mesh, so a colour / // layer change needs an explicit recolour — re-tessellating wires // alone wouldn't update them. self.tabs[i].scene.recolor_meshes_for_handles(handles); let changes: Vec<_> = handles .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); } /// Add an entity to the correct space (model or paper space layout). pub(super) fn commit_entity(&mut self, entity: acadrust::EntityType) { let _ = self.commit_entity_handle(entity); } /// Like [`commit_entity`] but returns the handle the new entity was given /// (or `None` if it could not be added). Lets callers follow up — e.g. /// open the in-place text editor on a freshly created MultiLeader. pub(super) fn commit_entity_handle( &mut self, mut entity: acadrust::EntityType, ) -> Option { let i = self.active_tab; let layer = &self.tabs[i].active_layer; if layer != "0" || entity.as_entity().layer().is_empty() { entity.as_entity_mut().set_layer(layer.clone()); } // A new dimension adopts the current dimension style (DIMSTYLE), like // AutoCAD — the DIM commands leave the default "Standard" on the entity, // so stamp the header's current style here. ADDSELECTED sets DIMSTYLE to // the template's first, so a cloned dimension keeps its style (#239). if let acadrust::EntityType::Dimension(ref mut d) = entity { let cur = self.tabs[i] .scene .document .header .current_dimstyle_name .clone(); if !cur.trim().is_empty() { d.base_mut().style_name = cur; } } // INSUNITS: when inserting a block whose BlockRecord.units differ // from the host's header.insertion_units, scale the new INSERT so // 1 source-unit equals the matching host length. When either side // is unitless (0) AutoCAD falls back to MEASUREMENT (0 = Imperial / // inches, 1 = Metric / mm); honour the same fallback. if let acadrust::EntityType::Insert(ref mut ins) = entity { let header = &self.tabs[i].scene.document.header; let measurement_fallback = if header.measurement == 1 { 4 } else { 1 }; let host_raw = header.insertion_units; let host_units = if host_raw == 0 { measurement_fallback } else { host_raw }; let src_raw = self.tabs[i] .scene .document .block_records .get(&ins.block_name) .map(|br| br.units) .unwrap_or(0); let src_units = if src_raw == 0 { measurement_fallback } else { src_raw }; if src_units != host_units { let ratio = insunits_to_mm(src_units) / insunits_to_mm(host_units); if ratio.is_finite() && (ratio - 1.0).abs() > 1e-9 { ins.set_x_scale(ratio); ins.set_y_scale(ratio); ins.set_z_scale(ratio); } } } crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color); crate::scene::view::dispatch::apply_common_prop( &mut entity, "linetype", &self.ribbon.active_linetype.clone(), ); crate::scene::view::dispatch::apply_line_weight(&mut entity, self.ribbon.active_lineweight); // CELTSCALE (header.current_entity_linetype_scale): new entities // pick up the document's saved per-entity linetype scale. The user // can override per entity later via the properties panel. let celtscale = self.tabs[i].scene.document.header.current_entity_linetype_scale; if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { entity.common_mut().linetype_scale = celtscale; } // A new dimension inherits the document's current dimension style // (DIMSTYLE) instead of staying at the entity "Standard" default. Only // fill in when still at the default so an explicitly-styled dimension // is preserved. See #92. if let acadrust::EntityType::Dimension(ref mut d) = entity { let cur = self.tabs[i] .scene .document .header .current_dimstyle_name .clone(); let s = d.base().style_name.clone(); if (s.is_empty() || s.eq_ignore_ascii_case("Standard")) && !cur.is_empty() { d.base_mut().style_name = cur; } } // MultiLeader / Table inherit the document's current style (#92). These // styles live in the objects dictionary, so resolve the current style // name to its object handle. Left untouched when the command already // assigned a style or no matching style object exists. match &mut entity { acadrust::EntityType::MultiLeader(ml) if ml.style_handle.is_none() => { let name = self.tabs[i] .scene .document .header .current_mleader_style_name .clone(); if !name.is_empty() { let found = self.tabs[i].scene.document.objects.iter().find_map(|(h, o)| match o { acadrust::objects::ObjectType::MultiLeaderStyle(s) if s.name.eq_ignore_ascii_case(&name) => { Some((*h, s.clone())) } _ => None, }); if let Some((h, s)) = found { ml.style_handle = Some(h); // Inherit the style's settings so a new multileader // reflects the current MLeaderStyle (the renderer reads // these entity fields). See #94. // The entity and style enums are distinct types with // matching discriminants — round-trip through i16. ml.content_type = (s.content_type as i16).into(); ml.path_type = (s.path_type as i16).into(); ml.line_color = s.line_color; ml.line_type_handle = s.line_type_handle; ml.line_weight = s.line_weight; ml.enable_landing = s.enable_landing; ml.enable_dogleg = s.enable_dogleg; ml.dogleg_length = s.landing_distance; ml.arrowhead_handle = s.arrowhead_handle; ml.arrowhead_size = s.arrowhead_size; ml.text_style_handle = s.text_style_handle; ml.text_color = s.text_color; ml.text_frame = s.text_frame; ml.text_height = s.text_height; ml.context.text_height = s.text_height; ml.text_left_attachment = (s.text_left_attachment as i16).into(); ml.text_right_attachment = (s.text_right_attachment as i16).into(); ml.text_top_attachment = (s.text_top_attachment as i16).into(); ml.text_bottom_attachment = (s.text_bottom_attachment as i16).into(); ml.text_attachment_direction = (s.text_attachment_direction as i16).into(); ml.text_alignment = (s.text_alignment as i16).into(); ml.text_angle_type = (s.text_angle_type as i16).into(); ml.block_content_handle = s.block_content_handle; ml.block_content_color = s.block_content_color; ml.block_connection_type = (s.block_content_connection as i16).into(); ml.block_rotation = s.block_content_rotation; ml.block_scale = acadrust::types::Vector3::new( s.block_content_scale_x, s.block_content_scale_y, s.block_content_scale_z, ); ml.scale_factor = s.scale_factor; } } } acadrust::EntityType::Table(t) if t.table_style_handle.is_none() => { let name = self.tabs[i] .scene .document .header .current_table_style_name .clone(); if !name.is_empty() { t.table_style_handle = self.tabs[i].scene.document.objects.iter().find_map(|(h, o)| { match o { acadrust::objects::ObjectType::TableStyle(s) if s.name.eq_ignore_ascii_case(&name) => { Some(*h) } _ => None, } }); } } _ => {} } if matches!(&entity, acadrust::EntityType::Viewport(_)) && self.tabs[i].scene.current_layout != "Model" { // Assign a unique viewport ID (max existing id + 1, min 2). if let acadrust::EntityType::Viewport(ref mut vp) = entity { let layout_block = self.tabs[i].scene.current_layout_block_handle_pub(); let max_id = self.tabs[i] .scene .document .entities() .filter_map(|e| { if let acadrust::EntityType::Viewport(v) = e { if v.common.owner_handle == layout_block { Some(v.id) } else { None } } else { None } }) .max() .unwrap_or(1); vp.id = (max_id + 1).max(2); } let layout = self.tabs[i].scene.current_layout.clone(); match self.tabs[i] .scene .document .add_entity_to_layout(entity, &layout) { Ok(new_handle) => { if self.tabs[i].scene.is_recording_undo() { self.tabs[i].scene.record_undo_before(new_handle, None); self.tabs[i].scene.poison_undo_recording(); } self.tabs[i].scene.auto_fit_viewport(new_handle); // Adding a viewport straight onto the document layout // bypasses Scene::add_entity, which is what normally // invalidates the wire-tessellation cache. Without this the // new viewport's border isn't tessellated until the next // zoom/pan forces a rebuild. self.tabs[i].scene.bump_geometry_no_blocks(); Some(new_handle) } Err(e) => { self.command_line .push_error(&format!("Viewport could not be added: {e}")); None } } } else { Some(self.tabs[i].scene.add_entity(entity)) } } } // ── Multi-selection property aggregation ─────────────────────────────────── pub(super) fn build_selection_groups( selected: &[(Handle, &EntityType)], ) -> Vec { let mut groups = vec![ui::properties::SelectionGroup { label: format!("All({})", selected.len()), handles: selected.iter().map(|(handle, _)| *handle).collect(), }]; let mut by_type: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for (handle, entity) in selected { by_type .entry(entity_type_key(entity)) .or_default() .push(*handle); } for (kind, handles) in by_type { groups.push(ui::properties::SelectionGroup { label: format!("{}({})", title_case_word(&kind), handles.len()), handles, }); } groups } pub(super) fn aggregate_sections( selected: &[(Handle, &EntityType)], text_style_names: &[String], ) -> Vec { if selected.is_empty() { return vec![]; } let mut all_sections: Vec> = selected .iter() .map(|(handle, entity)| dispatch::properties_sectioned(*handle, entity, text_style_names)) .collect(); let mut result = all_sections.remove(0); for sections in all_sections { result = merge_sections(&result, §ions); } result } fn merge_sections( left: &[crate::scene::model::object::PropSection], right: &[crate::scene::model::object::PropSection], ) -> Vec { left.iter() .filter_map(|section| { let rhs = right .iter() .find(|candidate| candidate.title == section.title)?; let props: Vec = section .props .iter() .filter_map(|prop| { let other = rhs .props .iter() .find(|candidate| candidate.field == prop.field)?; Some(crate::scene::model::object::Property { label: prop.label.clone(), field: prop.field, value: merge_prop_value(&prop.value, &other.value), }) }) .collect(); if props.is_empty() { None } else { Some(crate::scene::model::object::PropSection { title: section.title.clone(), props, }) } }) .collect() } fn merge_prop_value( left: &crate::scene::model::object::PropValue, right: &crate::scene::model::object::PropValue, ) -> crate::scene::model::object::PropValue { use crate::scene::model::object::PropValue; if left == right { return left.clone(); } match (left, right) { (PropValue::LayerChoice(_), PropValue::LayerChoice(_)) => { PropValue::LayerChoice(VARIES_LABEL.into()) } (PropValue::ColorChoice(_), PropValue::ColorChoice(_)) | (PropValue::ColorVaries, _) | (_, PropValue::ColorVaries) => PropValue::ColorVaries, (PropValue::LwChoice(_), PropValue::LwChoice(_)) | (PropValue::LwVaries, _) | (_, PropValue::LwVaries) => PropValue::LwVaries, (PropValue::LinetypeChoice(_), PropValue::LinetypeChoice(_)) => { PropValue::LinetypeChoice(VARIES_LABEL.into()) } ( PropValue::Choice { options, .. }, PropValue::Choice { options: other_options, .. }, ) if options == other_options => PropValue::Choice { selected: VARIES_LABEL.into(), options: options.clone(), }, ( PropValue::EditChoice { options, .. }, PropValue::EditChoice { options: other_options, .. }, ) if options == other_options => PropValue::EditChoice { value: VARIES_LABEL.into(), options: options.clone(), }, (PropValue::EditText(_), PropValue::EditText(_)) => { PropValue::EditText(VARIES_LABEL.into()) } (PropValue::ReadOnly(_), PropValue::ReadOnly(_)) => { PropValue::ReadOnly(VARIES_LABEL.into()) } (PropValue::HatchPatternChoice(_), PropValue::HatchPatternChoice(_)) => { PropValue::HatchPatternChoice(VARIES_LABEL.into()) } ( PropValue::BoolToggle { field, .. }, PropValue::BoolToggle { field: other_field, .. }, ) if field == other_field => PropValue::ReadOnly(VARIES_LABEL.into()), _ => left.clone(), } } /// Set the first property row matching `field` (across all sections) to a /// read-only `value`. No-op when the field is absent. Used to fill the /// doc-dependent placeholder rows the entity builders leave empty. fn set_row( sections: &mut [crate::scene::model::object::PropSection], field: &str, value: String, ) { for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == field) { row.value = crate::scene::model::object::PropValue::ReadOnly(value); return; } } } /// 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>( doc: &'a acadrust::CadDocument, name: &str, ) -> Option<&'a acadrust::tables::DimStyle> { doc.dim_styles.iter().find(|s| { s.name.eq_ignore_ascii_case(name) || (name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) }) } /// Insert `row` immediately after the first property whose field matches. fn insert_row_after( sections: &mut [crate::scene::model::object::PropSection], field: &str, row: crate::scene::model::object::Property, ) { for section in sections.iter_mut() { if let Some(idx) = section.props.iter().position(|p| p.field == field) { section.props.insert(idx + 1, row); return; } } } /// Vertical text placement (DIMTAD) label. fn dimtad_label(dimtad: i16) -> &'static str { match dimtad { 1 => "Above", 2 => "Outside", 3 => "JIS", 4 => "Below", _ => "Centered", } } /// 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). 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", "CLOSED" => "Closed", "CLOSEDBLANK" => "Closed blank", "DOT" => "Dot", "DOTSMALL" => "Dot small", "DOTBLANK" => "Dot blank", "SMALLDOTBLANK" => "Dot small blank", "ORIGIN" => "Origin indicator", "ORIGIN2" => "Origin indicator 2", "OPEN" => "Open", "OPEN90" => "Right angle", "OPEN30" => "Open 30", "NONE" => "None", "OBLIQUE" => "Oblique", "ARCHTICK" => "Architectural tick", "BOXBLANK" => "Box", "BOXFILLED" => "Box filled", "DATUMBLANK" => "Datum triangle", "DATUMFILLED" => "Datum triangle filled", "INTEGRAL" => "Integral", _ => return name.to_string(), }; label.to_string() } /// The leader's arrowhead label, resolved from the dim style's DIMLDRBLK block. fn leader_arrow_label( doc: &acadrust::CadDocument, ds: &acadrust::tables::DimStyle, arrow_enabled: bool, ) -> String { if !arrow_enabled { return "None".to_string(); } if ds.dimldrblk.is_null() { return "Closed filled".to_string(); } doc.block_records .iter() .find(|b| b.handle == ds.dimldrblk) .map(|b| arrowhead_label(&b.name)) .unwrap_or_else(|| "Closed filled".to_string()) } /// DIMLWD lineweight enum → label. fn dim_lineweight_label(dimlwd: i16) -> String { match dimlwd { -1 => "ByLayer".to_string(), -2 => "ByBlock".to_string(), -3 => "Default".to_string(), v if v >= 0 => format!("{:.2} mm", v as f64 / 100.0), _ => "Default".to_string(), } } /// Human-readable INSUNITS name (DXF group 70 unit codes). fn insunits_name(code: i16) -> &'static str { match code { 1 => "Inches", 2 => "Feet", 3 => "Miles", 4 => "Millimeters", 5 => "Centimeters", 6 => "Meters", 7 => "Kilometers", 8 => "Microinches", 9 => "Mils", 10 => "Yards", 11 => "Angstroms", 12 => "Nanometers", 13 => "Microns", 14 => "Decimeters", 15 => "Decameters", 16 => "Hectometers", 17 => "Gigameters", 18 => "Astronomical Units", 19 => "Light Years", 20 => "Parsecs", 21 => "US Survey Feet", _ => "Unitless", } } /// Convert INSUNITS (DXF group 70) to millimetres. /// 0 = unitless / unknown: returns 1.0 so the caller treats it as "do not scale". fn insunits_to_mm(code: i16) -> f64 { match code { 1 => 25.4, // Inches 2 => 304.8, // Feet 3 => 1_609_344.0, // Miles 4 => 1.0, // Millimeters 5 => 10.0, // Centimeters 6 => 1_000.0, // Meters 7 => 1_000_000.0, // Kilometers 8 => 0.000_025_4, // Microinches 9 => 0.025_4, // Mils 10 => 914.4, // Yards 11 => 1.0e-7, // Angstroms 12 => 1.0e-6, // Nanometers 13 => 0.001, // Microns 14 => 100.0, // Decimeters 15 => 10_000.0, // Decameters 16 => 100_000.0, // Hectometers 17 => 1.0e12, // Gigameters 18 => 1.496e14, // Astronomical Units 19 => 9.461e18, // Light Years 20 => 3.086e19, // Parsecs 21 => 304.800_609_6, // US Survey Feet _ => 1.0, } }