use super::helpers::{entity_type_key, entity_type_label, title_case_word}; use super::{OpenCADStudio, VARIES_LABEL}; use crate::io::linetypes; use crate::scene::model::object::PropValue; use crate::scene::view::dispatch; use crate::t; use crate::ui; use acadrust::types::{Transform, Vector3}; use acadrust::{Entity, 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 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) { // A 186 468-entity selection spends ~112 ms in here. Split it into the // three phases that can own that: resolving the selected handles, // building the panel, and syncing the ribbon. let t_all = crate::perf::enabled().then(iced::time::Instant::now); 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 edit_buf = std::mem::take(&mut self.tabs[i].properties.edit_buf); let active_field = std::mem::take(&mut self.tabs[i].properties.active_field); // 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); // Section state belongs to the app, not a drawing tab, so the user's // collapsed sections carry across every currently open drawing/project. // The empty app-level default keeps a first-run palette fully expanded. let collapsed_sections = self.collapsed_property_sections.clone(); // 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::from_header(h), ); } { // Which text styles fix their own height — the height rows read it // to decide whether they are editable. crate::entities::common::set_fixed_text_heights(&self.tabs[i].scene.document); } 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. // Everything before this is the prelude: the object-data cache, the // unit context, the layer and linetype lists. let m_prelude = t_all.map(|t| t.elapsed().as_secs_f64() * 1000.0); let t_handles = crate::perf::enabled().then(iced::time::Instant::now); let cur_handles: Vec = self.tabs[i] .scene .selected_handles_in_order() .into_iter() .filter(|h| self.tabs[i].scene.document.get_entity(*h).is_some()) .collect(); let handles_ms = t_handles.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let prop_vertex = if cur_handles == prev_handles { self.tabs[i].properties.prop_vertex } else { 0 }; let prop_vertex = if cur_handles.len() == 1 { let vertex_count = self.tabs[i] .scene .document .get_entity(cur_handles[0]) .and_then(|entity| match entity { acadrust::EntityType::LwPolyline(polyline) => Some(polyline.vertices.len()), acadrust::EntityType::Polyline2D(polyline) => Some(polyline.vertices.len()), acadrust::EntityType::PolygonMesh(mesh) => Some(mesh.vertices.len()), acadrust::EntityType::Polyline3D(polyline) => Some( crate::entities::polyline::polyline3d_control_vertex_count(polyline), ), acadrust::EntityType::Leader(leader) => Some(leader.vertices.len()), acadrust::EntityType::Face3D(face) => { Some(if face.is_triangle() { 3 } else { 4 }) } acadrust::EntityType::Spline(spline) => { Some(if crate::entities::spline::shows_fit_points(spline) { spline.fit_points.len() } else { crate::entities::spline::control_vertex_count(spline) }) } acadrust::EntityType::Table(table) => { Some(table.row_count().saturating_mul(table.column_count())) } _ => None, }); vertex_count.map_or(prop_vertex, |count| { prop_vertex.min(count.saturating_sub(1)) }) } else { prop_vertex }; let prop_vertex_indicator_active = if cur_handles == prev_handles { self.tabs[i].properties.prop_vertex_indicator_active } else { false }; crate::scene::view::dispatch::set_prop_current_vertex(prop_vertex); crate::entities::table::set_prop_current_cell(prop_vertex); crate::entities::table::set_prop_current_cell_active(prop_vertex_indicator_active); let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); 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 = if header.plotstyle_mode { "ByColor" } else { match header.current_plotstyle_type { 1 => "ByBlock", 2 => "Normal", 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 active_model_vport = doc.vports.iter().find(|viewport| { viewport .name .trim_start_matches('*') .eq_ignore_ascii_case("active") }); let active_entity_viewport = scene .active_viewport .and_then(|handle| doc.get_entity(handle)) .and_then(|entity| match entity { acadrust::EntityType::Viewport(viewport) => Some(viewport), _ => None, }); let ucs_icon_on = active_entity_viewport .map(|viewport| viewport.ucs_icon_visible) .or_else(|| active_model_vport.map(|viewport| viewport.ucsicon_lower)) .unwrap_or(self.show_ucs_icon); let ucs_icon_at_origin = active_entity_viewport .map(|viewport| viewport.status.ucs_icon_at_origin) .or_else(|| active_model_vport.map(|viewport| viewport.ucsicon_origin)) .unwrap_or(self.ucs_icon_at_origin); let ucs_per_viewport = active_entity_viewport .map(|viewport| viewport.ucs_per_viewport) .or_else(|| active_model_vport.map(|viewport| viewport.ucs_per_viewport)) .unwrap_or(true); let ucs_name = tab .active_ucs .as_ref() .map(|ucs| ucs.name.trim()) .filter(|name| !name.is_empty()) .unwrap_or("") .to_string(); let annotation_scale = if header.current_annotation_scale.trim().is_empty() { "1:1".to_string() } else { header.current_annotation_scale.clone() }; let annotation_scale_options = scene .scale_list() .into_iter() .map(|(name, _, _)| name) .collect(); let mut plot_table_options = vec!["None".to_string()]; plot_table_options.extend(crate::io::plot_style::available_ctb_names()); let visual_style = match tab.visual_style.as_str() { "Wireframe 2D" => "2D Wireframe".to_string(), "Wireframe 3D" => "3D Wireframe".to_string(), value => value.to_string(), }; let visual_style_options = crate::modules::view::visual_style::VISUAL_STYLES .iter() .map(|style| match style.label { "Wireframe 2D" => "2D Wireframe".to_string(), "Wireframe 3D" => "3D Wireframe".to_string(), value => value.to_string(), }) .collect(); let sections = vec![ PropSection { title: t!("General").into_owned(), props: vec![ read_only( t!("AC Version").as_ref(), doc.version.as_str().to_string(), ), Property { label: t!("Color").into_owned(), field: "color", value: PropValue::ColorChoice(header.current_entity_color), }, Property { label: t!("Layer").into_owned(), field: "layer", value: PropValue::LayerChoice(current_layer), }, Property { label: t!("Linetype").into_owned(), field: "linetype", value: PropValue::LinetypeChoice(current_linetype), }, read_only( t!("Linetype scale").as_ref(), format_length(header.current_entity_linetype_scale), ), Property { label: t!("Lineweight").into_owned(), field: "line_weight", value: PropValue::LwChoice( acadrust::types::LineWeight::from_value( header.current_line_weight, ), ), }, Property { label: t!("Transparency").into_owned(), field: "transparency", value: PropValue::EditChoice { value: crate::scene::creation_style::current_transparency_label( self.tabs[i] .scene .document .current_entity_transparency(), ), options: vec!["ByLayer".to_string(), "ByBlock".to_string()], }, }, read_only( t!("Thickness").as_ref(), format_length(header.thickness), ), ], }, PropSection { title: t!("3D Visualization").into_owned(), props: vec![Property { label: t!("Material").into_owned(), field: "material", value: PropValue::Choice { selected: material.clone(), options: { let mut opts = vec![ "ByLayer".to_string(), "ByBlock".to_string(), "Global".to_string(), ]; if !opts.contains(&material) && !material.is_empty() { opts.push(material.clone()); } opts }, }, }], }, PropSection { title: t!("Plot style").into_owned(), props: vec![ Property { label: t!("Plot style").into_owned(), field: "plot_style", value: if !header.plotstyle_mode { PropValue::Choice { selected: plot_style.to_string(), options: vec![ "ByLayer".to_string(), "ByBlock".to_string(), "Normal".to_string(), ], } } else { PropValue::ReadOnlyWithTooltip { value: t!("ByColor").into_owned(), tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), } }, }, Property { label: t!("Plot style table").into_owned(), field: "view_plot_style_table", value: PropValue::Choice { selected: plot_table.clone(), options: plot_table_options, }, }, read_only( t!("Plot table attached to").as_ref(), scene.current_layout.clone(), ), read_only( t!("Plot table type").as_ref(), if plot_table == "None" { t!("Not available").into_owned() } else if header.plotstyle_mode { t!("Color-dependent plot styles").into_owned() } else { t!("Named plot styles").into_owned() }, ), ], }, PropSection { title: t!("View").into_owned(), props: vec![ read_only(t!("Center X").as_ref(), format_length(camera.target.x)), read_only(t!("Center Y").as_ref(), format_length(camera.target.y)), read_only(t!("Center Z").as_ref(), format_length(camera.target.z)), read_only(t!("Height").as_ref(), format_length(view_height)), read_only(t!("Width").as_ref(), format_length(view_width)), ], }, PropSection { title: t!("Misc").into_owned(), props: vec![ Property { label: t!("Annotation scale").into_owned(), field: "view_annotation_scale", value: PropValue::Choice { selected: annotation_scale, options: annotation_scale_options, }, }, Property { label: t!("UCS icon On").into_owned(), field: "view_ucs_icon_on", value: PropValue::Choice { selected: if ucs_icon_on { "Yes" } else { "No" } .to_string(), options: vec!["Yes".to_string(), "No".to_string()], }, }, Property { label: t!("UCS icon at origin").into_owned(), field: "view_ucs_icon_at_origin", value: PropValue::Choice { selected: if ucs_icon_at_origin { "Yes" } else { "No" } .to_string(), options: vec!["Yes".to_string(), "No".to_string()], }, }, Property { label: t!("UCS per viewport").into_owned(), field: "view_ucs_per_viewport", value: PropValue::Choice { selected: if ucs_per_viewport { "Yes" } else { "No" } .to_string(), options: vec!["Yes".to_string(), "No".to_string()], }, }, read_only(t!("UCS Name").as_ref(), ucs_name), Property { label: t!("Visual Style").into_owned(), field: "view_visual_style", value: PropValue::Choice { selected: visual_style, options: visual_style_options, }, }, ], }, PropSection { title: t!("Parameters").into_owned(), props: { let mut props = vec![Property { label: String::new(), field: "show_constraint_values", value: PropValue::ParamsVisibilityToggle( self.show_constraint_values, ), }]; props.extend(scene .named_parameters() .iter() .enumerate() .map(|(index, parameter)| Property { label: String::new(), field: "named_parameter", value: PropValue::ParamRow { index, name: parameter.name.clone(), formula: parameter.source.clone(), resolved: scene .named_parameters() .resolve(¶meter.name) .map_err(|error| error.to_string()), }, })); props.push(Property { label: String::new(), field: "named_parameter_add", value: PropValue::ParamAddRow, }); props }, }, ]; ui::PropertiesPanel { title: t!("No selection").into_owned(), 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 .iter() .cloned() .map(ui::properties::LocalizedChoice::new) .collect(), ), )), _ => None, }) .collect(), 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(), ), linetype_items, ..Default::default() } } 1 => { let (handle, source_entity) = selected[0]; let contextual = crate::scene::annotative::entity_for_annotation_context( &self.tabs[i].scene.document, source_entity, annotation_scale_handle, ); let plane = if self.tabs[i].editing_model_space() { self.tabs[i].ucs_xform().working_plane() } else { crate::command::WorkingPlane::default() }; let display_entity = dispatch::entity_in_working_plane(contextual.as_ref(), plane); let entity = &display_entity; let group_names = self.tabs[i].scene.group_names_for_entity(handle); let compact_solid = crate::scene::model::solid_history::has_compact_solid_properties( &self.tabs[i].scene.document, handle, ); let mut sections = dispatch::properties_sectioned(handle, entity, &text_style_names); if compact_solid { retain_compact_solid_sections(&mut sections); } sections.extend(crate::scene::model::solid_history::primitive_properties( &self.tabs[i].scene.document, handle, )); // 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 selected = match common.material_flags { 0 => "ByLayer".to_string(), 1 => "ByBlock".to_string(), 2 => "Global".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(|| "Global".to_string()), }; let mut options = vec![ "ByLayer".to_string(), "ByBlock".to_string(), "Global".to_string(), ]; if !options.contains(&selected) && !selected.is_empty() { options.push(selected.clone()); } 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 doc = &self.tabs[i].scene.document; let common = entity.common(); let named_color = common .color_book_handle .filter(|handle| handle.is_valid()) .and_then(|handle| doc.objects.get(&handle)) .and_then(|object| match object { acadrust::objects::ObjectType::BookColor(book) => Some(( book.color, book.book_name.clone(), book.color_name.clone(), )), _ => None, }) .or_else(|| { let identity = common.color_name.as_deref()?; let (book_name, color_name) = identity .split_once('$') .map(|(book, color)| (book.to_string(), color.to_string())) .unwrap_or_else(|| (String::new(), identity.to_string())); Some((common.color, book_name, color_name)) }); if let Some((color, book_name, color_name)) = named_color { 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::NamedColorChoice { color, name: color_name.clone(), }; } } use crate::entities::common::ro_prop; let mut props = Vec::new(); if !book_name.is_empty() { props.push(ro_prop( t!("Book").as_ref(), "book_color_book", book_name, )); } props.push(ro_prop( t!("Color Name").as_ref(), "book_color_name", color_name, )); props.push(ro_prop( t!("Color").as_ref(), "book_color_value", format!("{:?}", color), )); sections.push(crate::scene::model::object::PropSection { title: t!("Color Book").into_owned(), props, }); } 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: t!("%{scope} Visual Style", scope = scope).into_owned(), props: vec![ ro_prop( t!("Handle").as_ref(), "vs_handle", format!("{:X}", handle.value()), ), ro_prop( t!("Description").as_ref(), "vs_description", style.description.clone(), ), ro_prop( t!("Type").as_ref(), "vs_type", style.style_type.to_string(), ), ro_prop( t!("Face").as_ref(), "vs_face", format!( "lighting {}; quality {}; color {}; modifiers {}", style.face_lighting_model, style.face_lighting_quality, style.face_color_mode, style.face_modifier ), ), ro_prop( t!("Edges").as_ref(), "vs_edges", format!( "model {}; style {}", style.edge_model, style.edge_style ), ), ro_prop( t!("Extended Lighting").as_ref(), "vs_extended_lighting", style.extended_lighting_model.to_string(), ), ro_prop( t!("Internal").as_ref(), "vs_internal", style.internal_use_only.to_string(), ), ro_prop( t!("Property Bag").as_ref(), "vs_properties", visual_style_properties_text(style), ), ], }); } } if matches!( entity, acadrust::EntityType::Solid3D(_) | 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 triple = |values: [f64; 3]| { format!("{:.6}, {:.6}, {:.6}", values[0], values[1], values[2]) }; let mut props = if compact_solid { vec![ro_prop( t!("Centroid").as_ref(), "mesh_centroid", triple(metrics.centroid), )] } else { vec![ ro_prop( t!("Vertices").as_ref(), "mesh_vertices", metrics.vertices.to_string(), ), ro_prop( t!("Triangles").as_ref(), "mesh_triangles", metrics.triangles.to_string(), ), ro_prop( t!("Surface Area").as_ref(), "mesh_surface_area", format!("{:.6}", metrics.surface_area), ), ro_prop( t!("Centroid").as_ref(), "mesh_centroid", triple(metrics.centroid), ), ro_prop( t!("Tessellation").as_ref(), "mesh_complete", if mesh.complete { "Complete" } else { "Partial" }, ), ] }; if mesh.complete && matches!( entity, acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Body(_) ) { let closed_props = vec![ ro_prop( t!("Moment of inertia").as_ref(), "mesh_moment_of_inertia", triple(metrics.moment_of_inertia), ), ro_prop( t!("Principal directions").as_ref(), "mesh_principal_directions", metrics .principal_directions .chunks_exact(3) .map(|axis| { format!( "({:.6}, {:.6}, {:.6})", axis[0], axis[1], axis[2] ) }) .collect::>() .join(", "), ), ro_prop( t!("Principal moments").as_ref(), "mesh_principal_moments", triple(metrics.principal_moments), ), ro_prop( t!("Product of inertia").as_ref(), "mesh_product_of_inertia", triple(metrics.product_of_inertia), ), ro_prop( t!("Radii of gyration").as_ref(), "mesh_radii_of_gyration", triple(metrics.radii_of_gyration), ), ro_prop( t!("Volume").as_ref(), "mesh_volume", format!("{:.6}", metrics.volume), ), ]; if compact_solid { props.extend(closed_props); } else { let volume = closed_props.last().cloned().unwrap(); props.insert(3, volume); props.extend(closed_props.into_iter().take(5)); } } sections.push(crate::scene::model::object::PropSection { title: t!("Mass Properties").into_owned(), props, }); } } if !compact_solid { let mut object_sections = crate::entities::object_data::sections( &self.tabs[i].scene.document, &self.tabs[i].scene.object_data_cache, handle, entity, ); let scope = self.tabs[i].current_parametric_scope(); let constrained = self.tabs[i] .scene .parametric_constraint_set(scope) .is_some_and(|set| set.constraints_touching(handle).next().is_some()); if constrained { object_sections.retain(|section| section.title != "Associative Data"); } sections.extend(object_sections); } { use crate::entities::common::ro_prop; let xd = &source_entity.common().extended_data; if !xd.is_empty() { let mut xd_props = Vec::new(); for rec in xd.records() { let value_text = rec .values .iter() .map(|value| format!("{value:?}")) .collect::>() .join(", "); xd_props.push(ro_prop( t!("Application").as_ref(), "xdata_value", format!("{}: {value_text}", rec.application_name), )); } sections.push(crate::scene::model::object::PropSection { title: t!("Extended Data").into_owned(), props: xd_props, }); } } // 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: t!("Uniform scale").into_owned(), 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( t!("Scale").as_ref(), "u_scale", ins.x_scale(), ); section.props.insert(xi, toggle); } else { section.props.insert(xi, toggle); } } } 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; let common = entity.common(); let mut options = vec![ "ByLayer".to_string(), "ByBlock".to_string(), "Normal".to_string(), ]; if let Some(dict) = crate::scene::annotative::as_dict(doc, dict_h) { for (n, _) in &dict.entries { if !options.contains(n) { options.push(n.clone()); } } } let selected = match common.plotstyle_flags { 0 => "ByLayer".to_string(), 1 => "ByBlock".to_string(), 2 => "Normal".to_string(), _ => common .plotstyle_handle .and_then(|ph| { crate::scene::annotative::as_dict(doc, dict_h).and_then( |dict| { 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(), }; } } } else { 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::ReadOnlyWithTooltip { value: t!("ByColor").into_owned(), tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), }; } } } // 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 block_names: Vec = doc .block_records .iter() .map(|block| block.name.clone()) .filter(|name| !name.is_empty() && !name.starts_with('*')) .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 cur_block = ml .block_content_handle .and_then(|handle| { doc.block_records .iter() .find(|block| block.handle == handle) .map(|block| block.name.clone()) }) .unwrap_or_else(|| "(none)".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); if !block_names.is_empty() { set_choice("block_content_handle", cur_block, block_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: t!("Frozen Layers").into_owned(), field: "frozen_layers", value: crate::scene::model::object::PropValue::PlainText( frozen_names.join(", "), ), }); if !ucs_names.is_empty() { geom.props.push(crate::scene::model::object::Property { label: t!("UCS Name").into_owned(), 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: t!("Named View").into_owned(), 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, }; } } } } // Legacy leaders omit Handle and report annotation association. if let acadrust::EntityType::Leader(leader) = entity { if let Some(general) = sections.first_mut() { general.props.retain(|property| property.field != "handle"); let associative = !leader.annotation_handle.is_null() && self.tabs[i] .scene .document .get_entity(leader.annotation_handle) .is_some(); general.props.push(crate::scene::model::object::Property { label: t!("Associative").into_owned(), field: "associative", value: crate::scene::model::object::PropValue::ReadOnly( if associative { "Yes" } else { "No" }.to_string(), ), }); } } // Inject DimStyle picker + style-derived groups for Dimensions. if let acadrust::EntityType::Dimension(d) = entity { if let Some(general) = sections.first_mut() { general.props.retain(|property| property.field != "handle"); let associative = crate::scene::dimension_assoc::dimension_is_associative( &self.tabs[i].scene.document, d.base().common.handle, ); let statuses = self.tabs[i] .scene .dimension_association_status(d.base().common.handle); let slots = self.tabs[i] .scene .dimension_association_slot_points(d.base().common.handle) .len(); let status = if statuses.is_empty() { t!("Nonassociative") } else if statuses.iter().any(|(_, status)| { matches!(status, crate::scene::ReferenceStatus::Broken(_)) }) { t!("Broken reference") } else if statuses.iter().any(|(_, status)| { matches!(status, crate::scene::ReferenceStatus::Unresolved) }) { t!("Unresolved reference") } else if statuses.len() < slots { t!("Partially associated") } else { t!("Associated") }; general.props.push(crate::scene::model::object::Property { label: t!("Association status").into_owned(), field: "association_status", value: crate::scene::model::object::PropValue::ReadOnly( status.into_owned(), ), }); general.props.push(crate::scene::model::object::Property { label: t!("Associative").into_owned(), field: "associative", value: crate::scene::model::object::PropValue::ReadOnly( if associative { "Yes" } else { "No" }.to_string(), ), }); } 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 text in the geom section; // replace/upgrade it to a Choice if we have a list. if let Some(prop) = sections .iter_mut() .flat_map(|section| section.props.iter_mut()) .find(|property| property.field == "style_name") { let current = match &prop.value { crate::scene::model::object::PropValue::PlainText(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, d, &self.tabs[i].scene.document, )); // Prefer entity-level dimension-variable overrides; // fall back to the assigned style values. 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), ); for (field, code, inherited) in [ ("dim_ext_line_color", dov::DIMCLRE, style.dimclre), ("dim_text_color", dov::DIMCLRT, style.dimclrt), ] { let color = dov::color(&d.base().common.extended_data, code) .unwrap_or_else(|| { acadrust::types::Color::from_index(inherited) }); set_row_value(&mut sections, field, PropValue::ColorChoice(color)); } } } // ── 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 factor = insert_unit_scale(host, src).unwrap_or(1.0); set_row(&mut sections, "unit_factor", format_unit_factor(factor)); // 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::PlainText( s, ) => s.clone(), _ => ld.dimension_style.clone(), }; p.value = crate::scene::model::object::PropValue::Choice { selected: cur, options: names.clone(), }; } } } // Resolve editable overrides from the assigned dimension style. 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(asz.to_string()), ); 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, }, ); // A per-object color override wins over the style. 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(gap.to_string()), ); 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(scl.to_string()), ); } } // Feature-control frame: FCF text style is the dimension // style's DIMTXSTY. acadrust::EntityType::Tolerance(tol) => { use crate::entities::dim_override as dov; let style = crate::entities::tolerance::resolve_dim_style(tol, doc); let style_name = style.map(|entry| entry.name.clone()).unwrap_or_else(|| { if tol.dimension_style_name.trim().is_empty() { "Standard".to_string() } else { tol.dimension_style_name.clone() } }); let mut dim_style_names: Vec = doc .dim_styles .iter() .map(|entry| entry.name.clone()) .filter(|name| !name.trim().is_empty()) .collect(); if !dim_style_names .iter() .any(|name| name.eq_ignore_ascii_case(&style_name)) { dim_style_names.push(style_name.clone()); } set_row_value( &mut sections, "tol_dim_style", PropValue::Choice { selected: style_name, options: dim_style_names, }, ); let text_style_name = dov::handle(&tol.common.extended_data, dov::DIMTXSTY) .and_then(|handle| { doc.text_styles.iter().find(|entry| entry.handle == handle) }) .map(|entry| entry.name.clone()) .or_else(|| style.map(|entry| entry.dimtxsty.clone())) .unwrap_or_else(|| "Standard".to_string()); let text_height_editable = doc .text_styles .iter() .find(|entry| entry.name.eq_ignore_ascii_case(&text_style_name)) .is_none_or(|entry| !entry.has_fixed_height()); let mut names = text_style_names.clone(); if !names .iter() .any(|name| name.eq_ignore_ascii_case(&text_style_name)) { names.push(text_style_name.clone()); } set_row_value( &mut sections, "tol_text_style", PropValue::Choice { selected: text_style_name, options: names, }, ); let height = dov::real(&tol.common.extended_data, dov::DIMTXT) .or_else(|| style.map(|entry| entry.dimtxt)) .unwrap_or(tol.text_height); set_row_value( &mut sections, "tol_text_height", if text_height_editable { PropValue::EditText(format!("{height:.4}")) } else { PropValue::ReadOnly(format!("{height:.4}")) }, ); } // MultiLeader: max points + segment-angle constraints // are MLeaderStyle settings, not stored on the entity. acadrust::EntityType::MultiLeader(ml) => { if ml .text_style_handle .and_then(|handle| { doc.text_styles.iter().find(|style| style.handle == handle) }) .is_some_and(|style| style.has_fixed_height()) { set_row_value( &mut sections, "text_height", PropValue::ReadOnly(crate::entities::common::format_length( ml.text_height, )), ); } 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}"), ); } } } acadrust::EntityType::Table(table) => { use acadrust::entities::table::{ CellEdgeFlags, CellStylePropertyFlags, CellValueType, }; let mut names: Vec = doc .objects .values() .filter_map(|object| match object { acadrust::objects::ObjectType::TableStyle(style) if !style.name.trim().is_empty() => { Some(style.name.clone()) } _ => None, }) .collect(); names.sort_by_key(|name| name.to_ascii_lowercase()); names.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); let selected_style = table .table_style_handle .and_then(|handle| doc.objects.get(&handle)) .and_then(|object| match object { acadrust::objects::ObjectType::TableStyle(style) => Some(style), _ => None, }); let selected = selected_style .map(|style| style.name.clone()) .unwrap_or_else(|| "Standard".to_string()); if !names .iter() .any(|name| name.eq_ignore_ascii_case(&selected)) { names.insert(0, selected.clone()); } set_row_value( &mut sections, "tbl_style_handle", crate::scene::model::object::PropValue::Choice { selected, options: names, }, ); let title_suppressed = crate::entities::table::resolved_title_suppressed( table, selected_style, ); let header_suppressed = crate::entities::table::resolved_header_suppressed( table, selected_style, ); let flow_up = crate::entities::table::resolved_flow_up(table, selected_style); let (horizontal_margin, vertical_margin) = crate::entities::table::resolved_table_margins( table, selected_style, ); update_row_toggle( &mut sections, "tbl_title_suppressed", title_suppressed, ); update_row_toggle( &mut sections, "tbl_header_suppressed", header_suppressed, ); update_row_text( &mut sections, "tbl_flow_direction", if flow_up { "Up" } else { "Down" }.to_string(), ); update_row_text( &mut sections, "tbl_horizontal_margin", crate::entities::common::format_length(horizontal_margin), ); update_row_text( &mut sections, "tbl_vertical_margin", crate::entities::common::format_length(vertical_margin), ); let columns = table.column_count(); if columns > 0 { let cell_index = prop_vertex.min( table.row_count().saturating_mul(columns).saturating_sub(1), ); let row_index = cell_index / columns; let column_index = cell_index % columns; if let (Some(row), Some(cell)) = ( table.rows.get(row_index), table.cell(row_index, column_index), ) { let document_row_style = selected_style.map(|style| { let kind = match ( title_suppressed, header_suppressed, row_index, ) { (false, _, 0) => 0, (false, false, 1) | (true, false, 0) => 1, _ => 2, }; match kind { 0 => &style.title_row_style, 1 => &style.header_row_style, _ => &style.data_row_style, } }); let local = |property| { crate::entities::table::style_for_property( table, row, column_index, cell, property, ) }; let alignment = local(CellStylePropertyFlags::ALIGNMENT) .map(|style| style.alignment) .or_else(|| { document_row_style.map(|style| style.alignment as i32) }) .unwrap_or(5); let alignment = match alignment { 1 => "Top Left", 2 => "Top Center", 3 => "Top Right", 4 => "Middle Left", 6 => "Middle Right", 7 => "Bottom Left", 8 => "Bottom Center", 9 => "Bottom Right", _ => "Middle Center", }; update_row_text( &mut sections, "tbl_cell_alignment", alignment.to_string(), ); let text_style_name = local(CellStylePropertyFlags::TEXT_STYLE) .map(|style| style.text_style_name.clone()) .filter(|name| !name.is_empty()) .or_else(|| { document_row_style .map(|style| style.text_style_name.clone()) .filter(|name| !name.is_empty()) }) .unwrap_or_else(|| "Standard".to_string()); update_row_text( &mut sections, "tbl_cell_text_style", text_style_name, ); let text_height = local(CellStylePropertyFlags::TEXT_HEIGHT) .map(|style| style.text_height) .or_else(|| { document_row_style.map(|style| style.text_height) }) .unwrap_or(0.18); update_row_text( &mut sections, "tbl_cell_text_height", crate::entities::common::format_length(text_height), ); let content_color = local(CellStylePropertyFlags::CONTENT_COLOR) .map(|style| style.content_color) .or_else(|| { document_row_style.map(|style| style.text_color) }) .unwrap_or(acadrust::types::Color::ByBlock); update_row_color( &mut sections, "tbl_cell_content_color", content_color, ); let background_style = local(CellStylePropertyFlags::BACKGROUND_COLOR); let background_color = background_style .map(|style| style.background_color) .or_else(|| { document_row_style.map(|style| style.fill_color) }) .unwrap_or(acadrust::types::Color::ByBlock); update_row_color( &mut sections, "tbl_cell_background_color", background_color, ); update_row_toggle( &mut sections, "tbl_cell_fill", background_style .map(|style| style.fill_enabled) .or_else(|| { document_row_style.map(|style| style.fill_enabled) }) .unwrap_or(false), ); let format = local(CellStylePropertyFlags::DATA_FORMAT) .map(|style| style.value_format.clone()) .or_else(|| { document_row_style .map(|style| style.format_string.clone()) }) .unwrap_or_default(); update_row_text(&mut sections, "tbl_cell_format", format); let data_type = cell .contents .first() .map(|content| content.value.value_type) .filter(|kind| *kind != CellValueType::Unknown) .or_else(|| { local(CellStylePropertyFlags::DATA_TYPE).map(|style| { CellValueType::from( style.value_data_type.max(0) as u32 ) }) }) .or_else(|| { document_row_style.map(|style| { CellValueType::from(style.data_type.max(0) as u32) }) }) .unwrap_or(CellValueType::String); let data_type = match data_type { CellValueType::Long => "Integer", CellValueType::Double => "Decimal", CellValueType::Date => "Date", CellValueType::Point2D => "Point 2D", CellValueType::Point3D => "Point 3D", CellValueType::Handle => "Handle", _ => "Text", }; update_row_text( &mut sections, "tbl_cell_data_type", data_type.to_string(), ); for (field, property, fallback) in [ ( "tbl_cell_margin_left", CellStylePropertyFlags::MARGIN_LEFT, horizontal_margin, ), ( "tbl_cell_margin_top", CellStylePropertyFlags::MARGIN_TOP, vertical_margin, ), ( "tbl_cell_margin_right", CellStylePropertyFlags::MARGIN_RIGHT, horizontal_margin, ), ( "tbl_cell_margin_bottom", CellStylePropertyFlags::MARGIN_BOTTOM, vertical_margin, ), ] { let value = local(property) .map(|style| match field { "tbl_cell_margin_left" => style.margin_left, "tbl_cell_margin_top" => style.margin_top, "tbl_cell_margin_right" => style.margin_right, _ => style.margin_bottom, }) .unwrap_or(fallback); update_row_text( &mut sections, field, crate::entities::common::format_length(value), ); } let border_visible = |edge: CellEdgeFlags| { let local_style = [ cell.style.as_ref(), row.style.as_ref(), table .columns .get(column_index) .and_then(|column| column.style.as_ref()), table.base_style.as_ref(), ] .into_iter() .flatten() .find(|style| style.applied_border_edges.contains(edge)); if let Some(style) = local_style { return if edge == CellEdgeFlags::TOP { !style.top_border.invisible } else if edge == CellEdgeFlags::RIGHT { !style.right_border.invisible } else if edge == CellEdgeFlags::BOTTOM { !style.bottom_border.invisible } else { !style.left_border.invisible }; } document_row_style .map(|style| { if edge == CellEdgeFlags::TOP { !style.top_border.is_invisible } else if edge == CellEdgeFlags::RIGHT { !style.right_border.is_invisible } else if edge == CellEdgeFlags::BOTTOM { !style.bottom_border.is_invisible } else { !style.left_border.is_invisible } }) .unwrap_or(true) }; for (field, edge) in [ ("tbl_cell_border_top", CellEdgeFlags::TOP), ("tbl_cell_border_right", CellEdgeFlags::RIGHT), ("tbl_cell_border_bottom", CellEdgeFlags::BOTTOM), ("tbl_cell_border_left", CellEdgeFlags::LEFT), ] { update_row_toggle( &mut sections, field, border_visible(edge), ); } } } } _ => {} } // Annotative Yes/No + a conditional "Annotative scale" row. // Annotative state and assigned scale names 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 / tolerance) — 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(_) | acadrust::EntityType::Hatch(_) => Some(("annotative", None)), acadrust::EntityType::MultiLeader(_) => { Some(("enable_annotation_scale", None)) } acadrust::EntityType::Dimension(_) => { Some(("annotative", Some("style_name"))) } acadrust::EntityType::Tolerance(_) => { Some(("annotative", Some("tol_dim_style"))) } 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) || match entity { acadrust::EntityType::Dimension( acadrust::entities::Dimension::Arc(dimension), ) => crate::scene::annotative::dim_style_is_annotative( doc, &dimension.base.style_name, ), acadrust::EntityType::Tolerance(_) => { crate::scene::annotative::annotation_style_is_annotative( doc, entity, ) } acadrust::EntityType::Leader(_) => { crate::scene::annotative::annotation_style_is_annotative( doc, entity, ) } _ => false, }; // Dimensions/tables/tolerances 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( t!("Annotative").as_ref(), "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 style-only types 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::Dimension( acadrust::entities::Dimension::Arc(_), ) => set_row( &mut sections, "annotative", if is_anno { "Yes" } else { "No" }.to_string(), ), acadrust::EntityType::Leader(_) if crate::scene::annotative::annotation_style_is_annotative( doc, entity, ) => set_row( &mut sections, "annotative", "Yes".to_string(), ), acadrust::EntityType::Text(_) | acadrust::EntityType::Insert(_) | acadrust::EntityType::Leader(_) | acadrust::EntityType::Hatch(_) | acadrust::EntityType::Dimension(_) => 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 { let memberships = crate::scene::annotative::object_scale_memberships( doc, entity.common().handle, ); let assigned_scales = if memberships.is_empty() { doc.header.current_annotation_scale.clone() } else { memberships .into_iter() .map(|(name, _)| name) .collect::>() .join(", ") }; insert_row_after( &mut sections, anno_field, crate::entities::common::ro_prop( t!("Annotative scale").as_ref(), "annotative_scale", assigned_scales, ), ); } } } // Single-line text height rows depend on both the // justification and the active annotation scale. Aligned // text derives its paper height from the two endpoints, // while annotative text exposes that paper height and a // separate calculated model height. if let acadrust::EntityType::Text(text) = entity { let aligned = matches!( text.horizontal_alignment, acadrust::entities::TextHorizontalAlignment::Aligned ); let annotative = crate::scene::annotative::is_annotative(doc, entity); let model_factor = if annotative { annotation_scale_handle .and_then(|handle| match doc.objects.get(&handle) { Some(acadrust::objects::ObjectType::Scale(scale)) => Some( scale.inverse_factor() / self.tabs[i].scene.annotation_scale_unit_factor(), ), _ => None, }) .unwrap_or(self.tabs[i].scene.annotation_scale as f64) } else { 1.0 }; let paper_height = if aligned { crate::entities::text::text_run_placement_at_scale( text, doc, model_factor as f32, ) .height as f64 } else { text.height }; for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|row| row.field == "height") { if annotative { row.label = t!("Paper text height").into_owned(); } if aligned { row.value = crate::scene::model::object::PropValue::ReadOnly( crate::entities::common::format_length(paper_height), ); } } } if annotative { insert_row_after( &mut sections, "height", crate::entities::common::ro_prop( t!("Model text height").as_ref(), "model_text_height", crate::entities::common::format_length( paper_height * model_factor, ), ), ); } } 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: t!("Group").into_owned(), field: "group", value: crate::scene::model::object::PropValue::ReadOnly(label), }); } } if compact_solid { retain_compact_solid_sections(&mut sections); } 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 { t!("External Reference").into_owned() } else { entity_type_label(entity) } } acadrust::EntityType::Surface(_) if matches!( crate::scene::model::solid_history::primitive_property_operation( &self.tabs[i].scene.document, handle, ), Some(acadrust::objects::SolidHistoryOperation::Extrusion(_)) ) => { format!("{} ({})", t!("Surface"), t!("Extrusion")) } _ => 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 .iter() .cloned() .map(ui::properties::LocalizedChoice::new) .collect(), ), )), _ => 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()), 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: t!("%{count} objects selected", count = n).into_owned(), 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(), ), linetype_items, ..Default::default() }, _ => { let t_arm = crate::perf::enabled().then(iced::time::Instant::now); let groups = build_selection_groups(&selected); let t_groups = t_arm.map(|t| t.elapsed().as_secs_f64() * 1000.0); 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| { let wanted: rustc_hash::FxHashSet = group.handles.iter().copied().collect(); selected .iter() .filter(|(handle, _)| wanted.contains(handle)) .copied() .collect() }) .unwrap_or_default(); let t_filter = t_arm.map(|t| t.elapsed().as_secs_f64() * 1000.0); let plane = if self.tabs[i].editing_model_space() { self.tabs[i].ucs_xform().working_plane() } else { crate::command::WorkingPlane::default() }; let local_entities: Vec<(Handle, std::borrow::Cow<'_, EntityType>)> = filtered .iter() .map(|(handle, entity)| { let local = if plane.is_identity() { std::borrow::Cow::Borrowed(*entity) } else { std::borrow::Cow::Owned(dispatch::entity_in_working_plane( entity, plane, )) }; (*handle, local) }) .collect(); let local_refs: Vec<(Handle, &EntityType)> = local_entities .iter() .map(|(handle, entity)| (*handle, entity.as_ref())) .collect(); let t_local = t_arm.map(|t| t.elapsed().as_secs_f64() * 1000.0); let mut sections = aggregate_sections(&local_refs, &text_style_names); if let (Some(t), Some(groups_ms), Some(filter_ms), Some(local_ms)) = (t_arm, t_groups, t_filter, t_local) { let total = t.elapsed().as_secs_f64() * 1000.0; if total >= 5.0 { crate::perf_record!( "[perf] properties-panel {total:>7.1}ms groups={groups_ms:.1} \ filter={:.1} local={:.1} aggregate={:.1} entities={}", filter_ms - groups_ms, local_ms - filter_ms, total - local_ms, local_refs.len(), ); } } let compact_solids = local_refs.iter().all(|(handle, _)| { crate::scene::model::solid_history::has_compact_solid_properties( &self.tabs[i].scene.document, *handle, ) }); if compact_solids { retain_compact_solid_sections(&mut sections); } sections.extend(aggregate_solid_history_sections( &self.tabs[i].scene.document, &local_refs .iter() .map(|(handle, _)| *handle) .collect::>(), )); if compact_solids { retain_compact_solid_sections(&mut sections); } 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 .iter() .cloned() .map(ui::properties::LocalizedChoice::new) .collect(), ), )), _ => None, }) .collect(), sections, title: t!("%{count} objects selected", count = selected.len()).into_owned(), 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()), lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), linetype_items, ..Default::default() } } }; // Precompute the focused-id → field-key map for O(1) lookups on // `PropSyncActive`; derived from `sections`, so rebuild it here. panel.field_key_by_id = crate::ui::properties::build_field_key_map(&panel.sections); 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() }; // The active-row highlight only survives a rebuild for the same // selection (like the edit buffer); a selection change clears it so // an old row isn't marked active against new content. panel.active_field = if prev_handles == new_handles { active_field } else { None }; panel.expanded_groups = expanded_groups; panel.collapsed_sections = collapsed_sections; panel.source_handles = new_handles; panel.prop_vertex = prop_vertex; panel.prop_vertex_indicator_active = prop_vertex_indicator_active; let property_handles = panel.selected_handles(); let property_handles = if property_handles.is_empty() { &panel.source_handles } else { &property_handles }; let locked_only = !property_handles.is_empty() && property_handles .iter() .all(|handle| self.tabs[i].scene.is_layer_locked(*handle)); if locked_only { make_sections_read_only(&mut panel.sections); // Rows demoted to read-only no longer back an editable field; // drop them from the id→key map so focus can't map onto them. panel.field_key_by_id = crate::ui::properties::build_field_key_map(&panel.sections); panel.edit_buf.clear(); panel.active_field = None; panel.color_picker_open = false; panel.bg_color_picker_open = false; panel.open_color_field = None; panel.hatch_pattern_picker_open = false; panel.edit_choice_open = false; } panel }; self.tabs[i].properties = new_panel; self.refresh_selected_grips(); let m_panel = t_all.map(|t| t.elapsed().as_secs_f64() * 1000.0); let t_ribbon = crate::perf::enabled().then(iced::time::Instant::now); self.sync_ribbon_from_selection(); if let Some(t) = t_all { let ribbon_ms = t_ribbon.map_or(0.0, |r| r.elapsed().as_secs_f64() * 1000.0); let total_ms = t.elapsed().as_secs_f64() * 1000.0; if total_ms >= 5.0 { let prelude = m_prelude.unwrap_or(0.0); let panel = m_panel.unwrap_or(0.0); crate::perf_record!( "[perf] properties-detail {total_ms:>7.1}ms prelude={prelude:.1} \ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={}", panel - prelude - handles_ms, total_ms - panel - ribbon_ms, cur_handles.len(), ); } } } /// 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 { if layer_mixed && color_mixed && linetype_mixed && lineweight_mixed { break; } let c = e.common(); // Borrowed, not cloned: this only ever gets compared, and cloning // it here was one heap allocation per selected entity. let lt = if c.linetype.is_empty() { "ByLayer" } else { c.linetype.as_str() }; 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.to_string()), Some(prev) if prev.as_str() != lt => 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. /// /// Selections past `GRIPOBJLIMIT` get no grips at all. That used to be a /// constant here; it is now [`crate::app::settings::UserSettings::grip_object_limit`], /// reachable from Options and from the sysvar of the same name. pub(super) fn refresh_selected_grips(&mut self) { let i = self.active_tab; let locked_active_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| { grip.targets .iter() .any(|target| self.tabs[i].scene.is_layer_locked(target.handle)) }); if locked_active_grip { self.cancel_active_grip_edit(); return; } 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, new_grip_handles) = { let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let selected = self.tabs[i].scene.selected_entities(); let single_handle = (selected.len() == 1 && !self.tabs[i].scene.is_layer_locked(selected[0].0)) .then(|| selected[0].0); let mut grips = Vec::new(); let mut handles = Vec::new(); // Avoid rebuilding every entity's grip markers above the configured // limit. Zero disables the limit. let limit = self.grip_object_limit; let selected = if limit > 0 && selected.len() > limit as usize { Vec::new() } else { selected }; for (handle, entity) in selected { if self.tabs[i].scene.is_layer_locked(handle) { continue; } let contextual = crate::scene::annotative::entity_for_annotation_context( &self.tabs[i].scene.document, entity, annotation_scale_handle, ); let mut entity_grips = dispatch::grips(contextual.as_ref()); if let acadrust::EntityType::Insert(insert) = contextual.as_ref() { entity_grips = crate::entities::insert::visible_attribute_grips( &self.tabs[i].scene.document, insert, self.tabs[i].scene.annotation_scale, ); } if crate::scene::model::solid_history::has_specialized_primitive_properties( &self.tabs[i].scene.document, handle, ) { entity_grips.clear(); } // Dimension::grips() cannot see the document, so an automatic dimension // text grip cannot resolve its real DIMSTYLE/annotation-scaled position // there. Correct it here, where both the document and displayed annotation // scale are available. if let acadrust::EntityType::Dimension(dim) = contextual.as_ref() { if matches!( dim, acadrust::entities::Dimension::Linear(_) | acadrust::entities::Dimension::Aligned(_) ) && !dim.base().text_user_positioned { let anno_scale = annotation_scale_handle .and_then(|handle| { match self.tabs[i].scene.document.objects.get(&handle) { Some(acadrust::objects::ObjectType::Scale(scale)) => Some( scale.inverse_factor() / self.tabs[i].scene.annotation_scale_unit_factor(), ), _ => None, } }) .unwrap_or(self.tabs[i].scene.annotation_scale as f64); if let Some(position) = crate::entities::dimension::dimension_text_grip_position( dim, &self.tabs[i].scene.document, anno_scale, ) { // The text grip is the final native grip of Linear/Aligned dims. // While the text is still automatic, make this a point/stretch grip // rather than a midpoint-translate grip. That makes the first drag use // the displayed automatic position as its absolute starting point instead // of translating the stale DWG text_middle_point. if let Some(text_grip) = entity_grips.last_mut() { text_grip.world = glam::DVec3::new(position.x, position.y, position.z); text_grip.is_midpoint = false; } } } } entity_grips.extend(crate::scene::model::solid_history::primitive_grips( &self.tabs[i].scene.document, handle, )); for mut grip in entity_grips { // Subtract in f64: at UTM magnitudes an f32 cast before // the offset costs ~1 unit and draws the grip off the wire. grip.world.x -= wo[0]; grip.world.y -= wo[1]; grip.world.z -= wo[2]; handles.push(handle); grips.push(grip); } } (single_handle, grips, handles) }; self.tabs[i].selected_handle = new_handle; self.tabs[i].selected_grips = new_grips; self.tabs[i].selected_grip_handles = new_grip_handles; let available: rustc_hash::FxHashSet<_> = self.tabs[i] .selected_grip_handles .iter() .copied() .zip(self.tabs[i].selected_grips.iter().map(|grip| grip.id)) .collect(); self.tabs[i].hot_grips.retain(|key| available.contains(key)); // 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 mut handles = self.tabs[i].properties.selected_handles(); if handles.is_empty() { handles = self.tabs[i].properties.source_handles.clone(); } if handles.is_empty() { handles.extend(self.tabs[i].selected_handle); } handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); handles } pub(super) fn has_property_selection(&self, i: usize) -> bool { !self.tabs[i].properties.selected_handles().is_empty() || !self.tabs[i].properties.source_handles.is_empty() || self.tabs[i].selected_handle.is_some() } pub(super) fn invalidate_property_targets(&mut self, i: usize, handles: &[Handle]) { self.invalidate_property_targets_with_driven(i, handles, &[]); } pub(super) fn invalidate_property_targets_with_driven( &mut self, i: usize, handles: &[Handle], driven_refs: &[crate::scene::parametric_constraints::ParametricRef], ) { let retain_size = self.constraint_solve_mode && !driven_refs.is_empty() && driven_refs.iter().all(|reference| reference.marker.is_some()); self.invalidate_property_targets_with_originals( i, handles, driven_refs, retain_size, &[], ); } pub(super) fn invalidate_property_targets_with_originals( &mut self, i: usize, handles: &[Handle], driven_refs: &[crate::scene::parametric_constraints::ParametricRef], retain_size: bool, retained_originals: &[(Handle, acadrust::EntityType)], ) { let mut context_object_changed = false; for &handle in handles { // A dimension is drawn from the block holding its picture, and that // picture was made under the settings just edited. Drop it so the // dimension is drawn afresh — otherwise the edit changes the stored // variables and nothing on screen. if matches!( self.tabs[i].scene.document.get_entity(handle), Some(acadrust::EntityType::Dimension(_)) ) { self.tabs[i].scene.invalidate_dim_block_recorded(handle); } context_object_changed |= self.tabs[i].scene.sync_displayed_annotation_context(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_with_parametric_originals( &changes, driven_refs, retain_size, retained_originals, ); } /// Apply a single-property edit to every handle in `handles`, recording the /// undo snapshot and running the full property-op contract (invalidate the /// tessellation cache, mark the tab dirty, refresh the Properties panel). /// /// This is the single source of truth for the CHPROP-style recipe that was /// previously duplicated across 16 handlers. `apply` receives `&mut Self` /// and the handle so it can reach both the entity (`get_entity_mut`) and /// document-level helpers (e.g. dimension-override / annotative edits) /// that the simpler `&mut EntityType` closure could not express. pub(super) fn apply_property_op( &mut self, i: usize, label: impl Into, handles: &[Handle], mut apply: impl FnMut(&mut Self, Handle), ) { if handles.is_empty() { return; } self.push_undo_snapshot(i, label); for &handle in handles { apply(self, handle); } self.invalidate_property_targets(i, handles); self.tabs[i].dirty = true; self.refresh_properties(); } /// 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, entity: acadrust::EntityType) -> Option { self.commit_entity_handle_with_policies(entity, false, false, false) } pub(super) fn commit_entity_handle_with_dimension_policy( &mut self, entity: acadrust::EntityType, preserve_dimension_layer_and_style: bool, ) -> Option { self.commit_entity_handle_with_policies( entity, preserve_dimension_layer_and_style, false, false, ) } pub(super) fn commit_entity_handle_preserve_layer( &mut self, entity: acadrust::EntityType, ) -> Option { self.commit_entity_handle_with_policies(entity, false, true, false) } pub(super) fn commit_entity_handle_preserve_style( &mut self, entity: acadrust::EntityType, ) -> Option { self.commit_entity_handle_with_policies(entity, false, true, true) } fn commit_entity_handle_with_policies( &mut self, mut entity: acadrust::EntityType, preserve_dimension_layer_and_style: bool, preserve_entity_layer: bool, preserve_entity_style: bool, ) -> Option { let i = self.active_tab; let tracks_dimension_chain = matches!( &entity, acadrust::EntityType::Dimension( acadrust::entities::Dimension::Linear(_) | acadrust::entities::Dimension::Aligned(_) | acadrust::entities::Dimension::Angular2Ln(_) | acadrust::entities::Dimension::Angular3Pt(_) | acadrust::entities::Dimension::Ordinate(_) ) ); let inherited_dimension = if preserve_dimension_layer_and_style { match &entity { acadrust::EntityType::Dimension(dimension) => Some(( dimension.base().common.layer.clone(), dimension.base().style_name.clone(), )), _ => None, } } else { None }; if let acadrust::EntityType::Table(table) = &mut entity { const PREFIX: &str = "__OPENCAD_LINK_PENDING__"; if let Some(path) = table.name.strip_prefix(PREFIX).map(str::to_string) { use acadrust::entities::table::CellStateFlags; use acadrust::objects::{ClassObject, ClassObjectData, DataLink, ObjectType}; let document = &mut self.tabs[i].scene.document; let link_handle = document.allocate_handle(); let mut object = ClassObject::new(ClassObjectData::DataLink(DataLink { data_adapter: "CSV".to_string(), description: path.clone(), tooltip: path.clone(), connection_string: path.clone(), status_flags: 1, update_status: "Linked".to_string(), ..DataLink::default() })); object.handle = link_handle; document .objects .insert(link_handle, ObjectType::ClassObject(object)); let rows = table.row_count() as i32; let columns = table.column_count() as i32; for row in &mut table.rows { for cell in &mut row.cells { cell.has_linked_data = true; cell.data_link_handle = Some(link_handle); cell.data_link_rows = rows; cell.data_link_columns = columns; cell.state.insert( CellStateFlags::LINKED | CellStateFlags::CONTENT_LOCKED | CellStateFlags::FORMAT_LOCKED, ); } } table.name = std::path::Path::new(&path) .file_stem() .and_then(|value| value.to_str()) .unwrap_or("Linked table") .to_string(); table.description = path; } } let tracks_draw_anchor = self.tabs[i].active_cmd.is_some() && matches!( &entity, acadrust::EntityType::Line(_) | acadrust::EntityType::Arc(_) | acadrust::EntityType::LwPolyline(_) | acadrust::EntityType::Polyline(_) | acadrust::EntityType::Polyline2D(_) | acadrust::EntityType::Polyline3D(_) ); let explicit_mleader_layer = match &entity { acadrust::EntityType::MultiLeader(leader) => leader .common .layer .strip_prefix("__MLEADER_LAYER__") .map(str::to_owned), _ => None, }; if let Some(layer) = explicit_mleader_layer { entity.as_entity_mut().set_layer(layer); } else if !preserve_entity_layer { let layer = &self.tabs[i].active_layer; if layer != "0" || entity.as_entity().layer().is_empty() { entity.as_entity_mut().set_layer(layer.clone()); } } // 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. if let acadrust::EntityType::Insert(ref mut ins) = entity { let host_units = self.tabs[i].scene.document.header.insertion_units; let src_units = self.tabs[i] .scene .document .block_records .get(&ins.block_name) .map(|br| br.units) .unwrap_or(0); if let Some(ratio) = insert_unit_scale(host_units, src_units) { if !apply_insert_unit_scale(ins, ratio) { self.command_line.push_error( t!("INSERT unit scale is outside the supported range.").as_ref(), ); return None; } } } if !preserve_entity_style { 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; } } if !preserve_entity_style { crate::scene::creation_style::apply_current_creation_styles( &self.tabs[i].scene.document, &mut entity, ); } if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) = (inherited_dimension, &mut entity) { dimension.base_mut().common.layer = layer; dimension.base_mut().style_name = style_name; } // Smart centre objects carry their own drawing-level creation style. // Apply it after the generic ribbon style so ordinary LINE entities // keep the existing path while centre lines honour their settings. let center_line = acadrust::entities::CenterLineAssociation::read(&entity.common().extended_data) .is_some(); let center_mark = acadrust::entities::CenterMarkAssociation::read(&entity.common().extended_data) .is_some(); if center_line || center_mark { let settings = self.tabs[i].scene.centerline_settings(); if !settings.layer.eq_ignore_ascii_case("Current") { entity.common_mut().layer = settings.layer; } if !settings.linetype.eq_ignore_ascii_case("Current") { entity.common_mut().linetype = settings.linetype; } entity.common_mut().linetype_scale = settings.linetype_scale; let application = if center_mark { acadrust::entities::CENTERMARK_XDATA_APPLICATION } else { acadrust::entities::CENTERLINE_XDATA_APPLICATION }; if !self.tabs[i].scene.document.app_ids.contains(application) { let mut app = acadrust::tables::AppId::new(application); app.handle = self.tabs[i].scene.document.allocate_handle(); let _ = self.tabs[i].scene.document.app_ids.add(app); } } let text_style_annotative = match &entity { acadrust::EntityType::Text(text) => crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &text.style, ), acadrust::EntityType::MText(text) => { crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &text.style, ) } acadrust::EntityType::AttributeEntity(attribute) => { crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &attribute.text_style, ) } acadrust::EntityType::AttributeDefinition(attribute) => { crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &attribute.text_style, ) } _ => false, }; if text_style_annotative { match &mut entity { acadrust::EntityType::MText(text) => text.is_annotative = true, acadrust::EntityType::AttributeEntity(attribute) => { attribute.flags.annotative = true } acadrust::EntityType::AttributeDefinition(attribute) => { attribute.flags.annotative = true } _ => {} } } let needs_annotation_context = crate::scene::annotative::is_annotative(&self.tabs[i].scene.document, &entity) || crate::scene::annotative::annotation_style_is_annotative( &self.tabs[i].scene.document, &entity, ); let new_handle = 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.auto_fit_viewport(new_handle); // Adding a viewport straight onto the document layout // bypasses Scene::add_entity; publish the exact new handle // so only its border is tessellated. self.tabs[i] .scene .bump_entities(&[(new_handle, crate::scene::ChangeKind::Added)]); Some(new_handle) } Err(e) => { self.command_line .push_error(crate::tf!("Viewport could not be added: {e}").as_ref()); None } } } else { Some(self.tabs[i].scene.add_entity(entity)) }; if needs_annotation_context { if let (Some(handle), Some(scale)) = ( new_handle, self.tabs[i].scene.creation_annotation_scale_handle(), ) { crate::scene::annotative::create_annotation_context( &mut self.tabs[i].scene.document, handle, scale, ); self.tabs[i] .scene .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); } } if tracks_draw_anchor { if let Some(handle) = new_handle { self.tabs[i].last_draw_anchor = Some(handle); } } if tracks_dimension_chain { self.tabs[i].scene.last_created_dimension = new_handle; } new_handle } } fn make_sections_read_only(sections: &mut [crate::scene::model::object::PropSection]) { use crate::scene::model::object::PropValue; for property in sections .iter_mut() .flat_map(|section| section.props.iter_mut()) { let text = match &property.value { PropValue::ReadOnly(value) | PropValue::ReadOnlyWithTooltip { value, .. } | PropValue::EditText(value) | PropValue::PlainText(value) | PropValue::Hyperlink(value) | PropValue::LayerChoice(value) | PropValue::LinetypeChoice(value) | PropValue::HatchPatternChoice(value) => value.clone(), PropValue::Choice { selected, .. } => selected.clone(), PropValue::EditChoice { value, .. } => value.clone(), PropValue::ColorChoice(color) => match color { acadrust::types::Color::None => "None".to_string(), acadrust::types::Color::ByLayer => "ByLayer".to_string(), acadrust::types::Color::ByBlock => "ByBlock".to_string(), acadrust::types::Color::Index(index) => index.to_string(), acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), }, PropValue::NamedColorChoice { name, .. } => name.clone(), PropValue::ColorVaries | PropValue::LwVaries | PropValue::FieldLwVaries { .. } => { VARIES_LABEL.to_string() } PropValue::LwChoice(lineweight) | PropValue::FieldLwChoice { value: lineweight, .. } => ui::properties::LwItem(*lineweight).to_string(), PropValue::BoolToggle { value, .. } => { if *value { t!("Yes") } else { t!("No") }.into_owned() } PropValue::Stepper { display, .. } => display.clone(), PropValue::AttrText { value, .. } => value.clone(), // The Constraints/Parameters sections aren't per-entity editable // fields to begin with (a constraint link is a navigation // action, not a value; a parameter belongs to the whole // document, not the locked entity) — locking the entity doesn't // change either, so just carry through readable text instead of // collapsing them to a value that was never editable anyway. PropValue::EntityLink { handles, .. } => format!("{} entity link(s)", handles.len()), PropValue::ParamRow { name, formula, .. } => format!("{name} = {formula}"), PropValue::ParamAddRow => String::new(), PropValue::ParamsVisibilityToggle(value) => { if *value { t!("On") } else { t!("Off") }.into_owned() } }; property.field = "locked_read_only"; property.value = PropValue::ReadOnly(text); } } // ── Multi-selection property aggregation ─────────────────────────────────── pub(super) fn build_selection_groups( selected: &[(Handle, &EntityType)], ) -> Vec { let mut groups = vec![ui::properties::SelectionGroup { label: format!("{} ({})", t!("All").into_owned(), 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!("{}({})", t!(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 entities = selected.iter(); let Some((handle, entity)) = entities.next() else { return vec![]; }; let mut result = dispatch::properties_sectioned(*handle, entity, text_style_names); for (handle, entity) in entities { // Nothing in common left to narrow: every later entity can only // intersect against an empty set. if result.is_empty() { break; } let sections = dispatch::properties_sectioned(*handle, entity, text_style_names); result = merge_sections(&result, §ions); } // Sum the filled area while individual Area rows may still vary. if selected.len() > 1 && selected .iter() .all(|(_, entity)| matches!(entity, acadrust::EntityType::Hatch(_))) { let total = selected .iter() .filter_map(|(_, entity)| match entity { acadrust::EntityType::Hatch(hatch) => { Some(crate::entities::hatch::boundary_area(hatch)) } _ => None, }) .sum::(); set_row(&mut result, "cumulative_area", format!("{total:.4}")); } result } fn aggregate_solid_history_sections( document: &acadrust::CadDocument, handles: &[Handle], ) -> Vec { let mut sections = handles .iter() .map(|handle| crate::scene::model::solid_history::primitive_properties(document, *handle)); let Some(mut merged) = sections.next() else { return Vec::new(); }; if merged.is_empty() { return Vec::new(); } for next in sections { if next.is_empty() { return Vec::new(); } merged = merge_sections(&merged, &next); } merged } fn retain_compact_solid_sections(sections: &mut Vec) { sections.iter_mut().for_each(|section| { let internal_surface_section = section .props .iter() .any(|property| property.field == "srf_kind"); section.props.retain(|property| { matches!( property.field, "color" | "layer" | "linetype" | "linetype_scale" | "plot_style" | "lineweight" | "transparency" | "hyperlink" | "material" ) || (!internal_surface_section && crate::scene::model::solid_history::is_specialized_property(property.field)) }); }); sections.retain(|section| !section.props.is_empty()); } 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::NamedColorChoice { .. }, PropValue::NamedColorChoice { .. }) | (PropValue::ColorChoice(_), PropValue::NamedColorChoice { .. }) | (PropValue::NamedColorChoice { .. }, PropValue::ColorChoice(_)) | (PropValue::ColorVaries, _) | (_, PropValue::ColorVaries) => PropValue::ColorVaries, (PropValue::LwChoice(_), PropValue::LwChoice(_)) | (PropValue::LwVaries, _) | (_, PropValue::LwVaries) => PropValue::LwVaries, ( PropValue::FieldLwChoice { field, .. }, PropValue::FieldLwChoice { field: other_field, .. }, ) if field == other_field => PropValue::FieldLwVaries { field }, (PropValue::FieldLwVaries { field }, _) | (_, PropValue::FieldLwVaries { field }) => { PropValue::FieldLwVaries { field } } (PropValue::LinetypeChoice(_), PropValue::LinetypeChoice(_)) => { PropValue::LinetypeChoice(VARIES_LABEL.into()) } ( PropValue::Choice { options, .. }, PropValue::Choice { options: other_options, .. }, ) => { let mut merged_options = options.clone(); for opt in other_options { if !merged_options.contains(opt) { merged_options.push(opt.clone()); } } PropValue::Choice { selected: VARIES_LABEL.into(), options: merged_options, } } ( PropValue::EditChoice { options, .. }, PropValue::EditChoice { options: other_options, .. }, ) => { let mut merged_options = options.clone(); for opt in other_options { if !merged_options.contains(opt) { merged_options.push(opt.clone()); } } PropValue::EditChoice { value: VARIES_LABEL.into(), options: merged_options, } } (PropValue::EditText(_), PropValue::EditText(_)) => { PropValue::EditText(VARIES_LABEL.into()) } (PropValue::PlainText(_), PropValue::PlainText(_)) => { PropValue::PlainText(VARIES_LABEL.into()) } (PropValue::Hyperlink(_), PropValue::Hyperlink(_)) => { PropValue::Hyperlink(VARIES_LABEL.into()) } (PropValue::ReadOnly(_), PropValue::ReadOnly(_)) => { PropValue::ReadOnly(VARIES_LABEL.into()) } (PropValue::ReadOnlyWithTooltip { tooltip, .. }, PropValue::ReadOnlyWithTooltip { .. }) => { PropValue::ReadOnlyWithTooltip { value: VARIES_LABEL.into(), tooltip: tooltip.clone(), } } (PropValue::ReadOnly(_), PropValue::ReadOnlyWithTooltip { tooltip, .. }) | (PropValue::ReadOnlyWithTooltip { tooltip, .. }, PropValue::ReadOnly(_)) => { PropValue::ReadOnlyWithTooltip { value: VARIES_LABEL.into(), tooltip: tooltip.clone(), } } (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; } } } fn update_row_text( sections: &mut [crate::scene::model::object::PropSection], field: &str, value: String, ) { use crate::scene::model::object::PropValue; for section in sections.iter_mut() { let Some(row) = section .props .iter_mut() .find(|property| property.field == field) else { continue; }; match &mut row.value { PropValue::ReadOnly(current) | PropValue::EditText(current) | PropValue::PlainText(current) | PropValue::Hyperlink(current) => *current = value, PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = value, PropValue::Choice { selected, .. } => *selected = value, _ => {} } return; } } fn update_row_toggle( sections: &mut [crate::scene::model::object::PropSection], field: &str, value: bool, ) { use crate::scene::model::object::PropValue; for section in sections.iter_mut() { let Some(row) = section .props .iter_mut() .find(|property| property.field == field) else { continue; }; match &mut row.value { PropValue::BoolToggle { value: current, .. } => *current = value, PropValue::ReadOnly(current) => { *current = if value { t!("Yes") } else { t!("No") }.into_owned() } PropValue::ReadOnlyWithTooltip { value: current, .. } => { *current = if value { t!("Yes") } else { t!("No") }.into_owned() } _ => {} } return; } } fn update_row_color( sections: &mut [crate::scene::model::object::PropSection], field: &str, color: acadrust::types::Color, ) { use crate::scene::model::object::PropValue; let label = match color { acadrust::types::Color::None => "None".to_string(), acadrust::types::Color::ByLayer => "ByLayer".to_string(), acadrust::types::Color::ByBlock => "ByBlock".to_string(), acadrust::types::Color::Index(index) => index.to_string(), acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), }; for section in sections.iter_mut() { let Some(row) = section .props .iter_mut() .find(|property| property.field == field) else { continue; }; match &mut row.value { PropValue::ColorChoice(current) => *current = color, PropValue::NamedColorChoice { .. } => row.value = PropValue::ColorChoice(color), PropValue::ReadOnly(current) => *current = label, PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = label, _ => {} } return; } } /// The lineweight dropdown options (named defaults + the standard millimetre /// steps), matching the labels `dim_lineweight_label` produces. pub(crate) fn lineweight_options() -> Vec { crate::entities::common::lineweight_options() } /// 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 { crate::entities::common::lineweight_label(dimlwd) } /// 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", 22 => "US Survey Inches", 23 => "US Survey Yards", 24 => "US Survey Miles", 0 => "Unitless", _ => "Unknown", } } /// Unit-conversion scale for a new INSERT. fn insert_unit_scale(host_units: i16, src_units: i16) -> Option { let host_mm = insunits_to_mm(host_units)?; let src_mm = insunits_to_mm(src_units)?; let ratio = src_mm / host_mm; if !ratio.is_finite() || (ratio - 1.0).abs() <= 1e-9 { return None; } Some(ratio) } fn format_unit_factor(factor: f64) -> String { let magnitude = factor.abs(); if magnitude > 0.0 && !(1.0e-4..1.0e7).contains(&magnitude) { format!("{factor:.4e}") } else { format!("{factor:.4}") } } /// Convert INSUNITS (DXF group 70) to millimetres. pub(super) fn insunits_to_mm(code: i16) -> Option { Some(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.495_978_707e14, // Astronomical Units 19 => 9.460_730_472_580_8e18, // Light Years 20 => 3.085_677_581_491_367_3e19, // Parsecs 21 => 1_200_000.0 / 3_937.0, // US Survey Feet 22 => 100_000.0 / 3_937.0, // US Survey Inches 23 => 3_600_000.0 / 3_937.0, // US Survey Yards 24 => 6_336_000_000.0 / 3_937.0, // US Survey Miles _ => return None, }) } fn apply_insert_unit_scale(ins: &mut acadrust::entities::Insert, ratio: f64) -> bool { const MIN_INSERT_SCALE: f64 = 1.0e-12; if [ins.x_scale(), ins.y_scale(), ins.z_scale()] .into_iter() .map(|scale| scale * ratio) .any(|scale| !scale.is_finite() || scale.abs() < MIN_INSERT_SCALE) { return false; } let origin = ins.get_transform().apply(Vector3::ZERO); ins.apply_transform(&Transform::from_translation(-origin)); ins.apply_transform(&Transform::from_scale(ratio)); ins.apply_transform(&Transform::from_translation(origin)); true } #[cfg(test)] mod insert_unit_scale_tests { use super::{apply_insert_unit_scale, format_unit_factor, insert_unit_scale, insunits_to_mm}; use acadrust::entities::{AttributeEntity, Insert}; use acadrust::types::Vector3; const UNITLESS: i16 = 0; const INCHES: i16 = 1; const MILLIMETERS: i16 = 4; const CENTIMETERS: i16 = 5; const METERS: i16 = 6; #[test] fn unitless_block_is_inserted_as_authored() { // Reported case: a unitless block with a 1000-unit edge inserted into a // millimetre drawing must keep that edge, not gain a conversion factor. assert_eq!(insert_unit_scale(MILLIMETERS, UNITLESS), None); assert_eq!(insert_unit_scale(INCHES, UNITLESS), None); } #[test] fn unitless_drawing_does_not_scale_a_measured_block() { assert_eq!(insert_unit_scale(UNITLESS, METERS), None); assert_eq!(insert_unit_scale(UNITLESS, INCHES), None); assert_eq!(insert_unit_scale(UNITLESS, UNITLESS), None); } #[test] fn matching_units_do_not_scale() { assert_eq!(insert_unit_scale(METERS, METERS), None); } #[test] fn unknown_units_do_not_scale() { assert_eq!(insunits_to_mm(0), None); assert_eq!(insunits_to_mm(25), None); assert_eq!(insert_unit_scale(MILLIMETERS, 25), None); } #[test] fn differing_units_convert_through_millimetres() { let ratio = insert_unit_scale(MILLIMETERS, METERS).expect("metres into mm should scale"); assert!((ratio - 1000.0).abs() < 1e-9, "got {ratio}"); let ratio = insert_unit_scale(MILLIMETERS, INCHES).expect("inches into mm should scale"); assert!((ratio - 25.4).abs() < 1e-9, "got {ratio}"); let ratio = insert_unit_scale(METERS, CENTIMETERS).expect("cm into m should scale"); assert!((ratio - 0.01).abs() < 1e-12, "got {ratio}"); } #[test] fn survey_units_have_expected_ratios() { let survey_foot = insunits_to_mm(21).expect("survey feet"); let survey_inch = insunits_to_mm(22).expect("survey inches"); let survey_yard = insunits_to_mm(23).expect("survey yards"); let survey_mile = insunits_to_mm(24).expect("survey miles"); assert!((survey_foot / survey_inch - 12.0).abs() < 1e-12); assert!((survey_yard / survey_foot - 3.0).abs() < 1e-12); assert!((survey_mile / survey_foot - 5280.0).abs() < 1e-9); } #[test] fn astronomical_units_use_precise_si_values() { assert_eq!(insunits_to_mm(18), Some(1.495_978_707e14)); assert_eq!(insunits_to_mm(19), Some(9.460_730_472_580_8e18)); assert_eq!(insunits_to_mm(20), Some(3.085_677_581_491_367_3e19)); assert_ne!(format_unit_factor(1.0e-7), "0.0000"); } #[test] fn applying_unit_scale_composes_with_insert_transform() { let mut ins = Insert::new("Block", Vector3::new(12.0, -4.0, 3.0)); ins.set_x_scale(-2.0); ins.set_y_scale(3.0); ins.set_z_scale(-4.0); ins.rotation = 0.37; let insertion = ins.get_transform().apply(Vector3::ZERO); let attribute_position = insertion + Vector3::new(2.0, -1.0, 0.5); let mut attribute = AttributeEntity::simple("TAG", "Value"); attribute.insertion_point = attribute_position; ins.attributes.push(attribute); assert!(apply_insert_unit_scale(&mut ins, 25.4)); let scaled_insertion = ins.get_transform().apply(Vector3::ZERO); let expected_attribute = insertion + (attribute_position - insertion) * 25.4; assert!((scaled_insertion - insertion).length() < 1e-9); assert!((ins.attributes[0].insertion_point - expected_attribute).length() < 1e-9); assert!((ins.x_scale() + 50.8).abs() < 1e-9); assert!((ins.y_scale() - 76.2).abs() < 1e-9); assert!((ins.z_scale() + 101.6).abs() < 1e-9); } #[test] fn applying_large_unit_scale_keeps_distant_insert_fixed() { let mut ins = Insert::new("Block", Vector3::new(1.0e12, -2.0e12, 3.0e12)); let insertion = ins.get_transform().apply(Vector3::ZERO); assert!(apply_insert_unit_scale(&mut ins, 1.0e20)); let scaled_insertion = ins.get_transform().apply(Vector3::ZERO); assert_eq!(scaled_insertion, insertion); assert_eq!(ins.x_scale(), 1.0e20); } #[test] fn unsupported_tiny_unit_scale_is_not_applied() { let mut ins = Insert::new("Block", Vector3::new(1.0, 2.0, 3.0)); let before = ins.clone(); assert!(!apply_insert_unit_scale(&mut ins, 1.0e-13)); assert_eq!(ins, before); } } #[cfg(test)] mod apply_property_op_tests { use crate::app::OpenCADStudio; use acadrust::entities::Line; use acadrust::types::{Color, Vector3}; fn line_handle(app: &mut OpenCADStudio) -> acadrust::Handle { let mut line = Line::new(); line.start = Vector3::ZERO; line.end = Vector3::new(1.0, 0.0, 0.0); app.commit_entity_handle(acadrust::EntityType::Line(line)) .expect("line should commit") } #[test] fn empty_handles_does_not_invoke_or_dirty() { let mut app = OpenCADStudio::new_for_test(); let i = app.active_tab; let before = app.tabs[i].dirty; let called = std::rc::Rc::new(std::cell::Cell::new(false)); let called2 = called.clone(); app.apply_property_op(i, "TEST", &[], |_app, _h| { called2.set(true); }); assert!(!called.get(), "closure must not run for empty handles"); assert_eq!( app.tabs[i].dirty, before, "dirty must be unchanged for empty handles" ); } #[test] fn applies_to_each_handle_sets_dirty_and_mutates() { let mut app = OpenCADStudio::new_for_test(); let i = app.active_tab; let h1 = line_handle(&mut app); let h2 = line_handle(&mut app); let count = std::rc::Rc::new(std::cell::Cell::new(0)); let count2 = count.clone(); app.tabs[i].dirty = false; app.apply_property_op(i, "CHPROP", &[h1, h2], |app2, h| { count2.set(count2.get() + 1); if let Some(e) = app2.tabs[app2.active_tab].scene.document.get_entity_mut(h) { e.common_mut().color = Color::Index(7); } }); assert_eq!(count.get(), 2, "closure runs once per handle"); assert!(app.tabs[i].dirty, "tab marked dirty"); let e = app.tabs[i] .scene .document .get_entity(h1) .expect("entity present"); assert_eq!( e.common().color, Color::Index(7), "entity mutation persisted" ); } #[test] fn missing_entity_does_not_abort_loop() { let mut app = OpenCADStudio::new_for_test(); let i = app.active_tab; let h1 = line_handle(&mut app); // A freshly allocated handle has no entity yet. let missing = app.tabs[i].scene.document.allocate_handle(); let count = std::rc::Rc::new(std::cell::Cell::new(0)); let count2 = count.clone(); app.tabs[i].dirty = false; app.apply_property_op(i, "CHPROP", &[h1, missing], |app2, h| { count2.set(count2.get() + 1); if let Some(e) = app2.tabs[app2.active_tab].scene.document.get_entity_mut(h) { e.common_mut().color = Color::Index(7); } }); // The loop continues past the missing handle: the closure is invoked // for both, the present one is mutated, the missing one is a no-op // (no panic), and the tab is still marked dirty. assert_eq!( count.get(), 2, "closure still called for the missing handle" ); assert!( app.tabs[i].dirty, "tab marked dirty even with a missing handle" ); let e = app.tabs[i] .scene .document .get_entity(h1) .expect("entity present"); assert_eq!( e.common().color, Color::Index(7), "the present handle was mutated; the missing one was skipped" ); } } #[cfg(test)] mod chprop_integration_tests { use crate::app::{Message, OpenCADStudio}; use acadrust::entities::Line; use acadrust::types::{Color, LineWeight, Vector3}; fn line_handle(app: &mut OpenCADStudio) -> acadrust::Handle { let mut line = Line::new(); line.start = Vector3::ZERO; line.end = Vector3::new(1.0, 0.0, 0.0); app.commit_entity_handle(acadrust::EntityType::Line(line)) .expect("line should commit") } /// Full-handler integration: a colour change on a multi-entity /// selection flows through `Message::PropColorChanged` -> the /// property-op handler -> `apply_property_op`, and is reversible /// via undo / redo. Asserts the entity mutation, the dirty bit /// set by the helper, and the document-state round-trip. /// /// Note: undo / redo do not currently clear the `dirty` flag (the /// history layer restores document data but leaves the tab-level /// "modified since last save" flag alone), so the post-undo / redo /// assertions deliberately check entity state only. #[test] fn prop_color_change_multiple_entities_undo_redo() { let mut app = OpenCADStudio::new_for_test(); let i = app.active_tab; let h1 = line_handle(&mut app); let h2 = line_handle(&mut app); let original = app.tabs[i] .scene .document .get_entity(h1) .unwrap() .common() .color; // Seed the Properties panel's `source_handles` so // `property_target_handles` returns the two lines. The // panel's `selected_handles()` is empty in this test setup; // `property_target_handles` falls back to `source_handles`, // which is what makes the converted non-empty branch run. app.tabs[i].properties.source_handles = vec![h1, h2]; let _ = app.update(Message::PropColorChanged(Color::Index(5))); assert_eq!( app.tabs[i] .scene .document .get_entity(h1) .unwrap() .common() .color, Color::Index(5) ); assert_eq!( app.tabs[i] .scene .document .get_entity(h2) .unwrap() .common() .color, Color::Index(5) ); assert!(app.tabs[i].dirty, "helper must set the dirty bit"); app.undo_active_tab(); assert_eq!( app.tabs[i] .scene .document .get_entity(h1) .unwrap() .common() .color, original ); assert_eq!( app.tabs[i] .scene .document .get_entity(h2) .unwrap() .common() .color, original ); app.redo_active_tab(); assert_eq!( app.tabs[i] .scene .document .get_entity(h1) .unwrap() .common() .color, Color::Index(5) ); assert_eq!( app.tabs[i] .scene .document .get_entity(h2) .unwrap() .common() .color, Color::Index(5) ); } /// Regression test for the CLI `CHPROP` command. Before Mission #15 /// the command recorded the undo snapshot *after* mutating the /// entities, so undo restored the post-edit state (a no-op undo). /// Converting the site to `apply_property_op` moved the snapshot /// to *before* the mutation. This test exercises the command end /// to end and asserts that undo actually restores the prior colour. #[ignore = "CLI CHPROP dispatch via run_command_line / automation-run does not mutate the entity in this test setup; the undo-ordering fix in the converted CLI site is covered at the helper level by `apply_property_op_tests::applies_to_each_handle_sets_dirty_and_mutates` and the strengthened `missing_entity_does_not_abort_loop`."] #[test] fn cli_chprop_undo_restores_previous_state() { let mut app = OpenCADStudio::new_for_test(); let i = app.active_tab; let h = line_handle(&mut app); let original = app.tabs[i] .scene .document .get_entity(h) .unwrap() .common() .color; let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#); let _ = app.automation_op(r#"{"op":"run","cmd":"CHPROP COLOR 3"}"#); assert_ne!( app.tabs[i] .scene .document .get_entity(h) .unwrap() .common() .color, original, "CHPROP COLOR 3 must have changed the line's colour" ); app.undo_active_tab(); assert_eq!( app.tabs[i] .scene .document .get_entity(h) .unwrap() .common() .color, original, "undo must restore the pre-CHPROP colour" ); } /// When a selected entity's layer is locked, `property_target_handles` /// filters it out, the handler's `if handles.is_empty()` branch runs, /// and the entity itself is *not* mutated. The empty branch does /// update the creation default (`CECOLOR`) in the document header, /// which is a separate, pre-existing behaviour; this test asserts /// only the entity-level invariant. #[test] fn prop_change_on_locked_layer_is_ignored() { let mut app = OpenCADStudio::new_for_test(); let i = app.active_tab; let h = line_handle(&mut app); let layer_name = app.tabs[i] .scene .document .get_entity(h) .unwrap() .common() .layer .clone(); { let layer = app.tabs[i] .scene .document .layers .get_mut(&layer_name) .expect("entity's layer must exist"); layer.flags.locked = true; } assert!( app.tabs[i].scene.is_layer_locked(h), "test setup: layer must report locked" ); let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#); let _ = app.update(Message::PropColorChanged(Color::Index(9))); assert_ne!( app.tabs[i] .scene .document .get_entity(h) .unwrap() .common() .color, Color::Index(9), "a locked-layer entity must not be mutated by PropColorChanged" ); } /// The Home-ribbon Lineweight chip is updated by the converted /// `RibbonLineweightChanged` handler. Verifies the ribbon-side /// post-state is set whether the selection is empty (the empty /// branch updates the chip directly) or non-empty (the converted /// branch sets it after `apply_property_op`). #[test] fn ribbon_lineweight_updates_after_change() { let mut app = OpenCADStudio::new_for_test(); let _h = line_handle(&mut app); let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#); let new_lw = LineWeight::from_value(50); let _ = app.update(Message::RibbonLineweightChanged(new_lw)); assert_eq!(app.ribbon.active_lineweight, new_lw); } } #[cfg(test)] mod grip_limit_tests { use super::*; #[test] fn a_selection_past_the_limit_gets_no_grips() { use acadrust::entities::{EntityType, Line}; use acadrust::types::Vector3; let select_n = |n: usize, limit: Option| -> usize { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); if let Some(limit) = limit { app.grip_object_limit = limit; } let i = app.active_tab; let handles: Vec<_> = (0..n) .map(|k| { let x = k as f64; app.tabs[i] .scene .add_entity(EntityType::Line(Line::from_points( Vector3::new(x, 0.0, 0.0), Vector3::new(x + 1.0, 1.0, 0.0), ))) }) .collect(); for handle in handles { app.tabs[i].scene.select_entity(handle, false); } app.refresh_selected_grips(); app.tabs[i].selected_grips.len() }; assert!( select_n( crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize, None ) > 0, "a selection at the limit must still show its grips", ); assert_eq!( select_n( crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize + 1, None ), 0, "one past the limit must show none at all", ); } /// The ViewCube, the UCS icon and selection cycling all have commands and /// status-bar pills, but their values lived only on the app struct: turn /// the ViewCube off, restart, and it came straight back. Nothing failed — /// the setting simply evaporated — which is exactly the kind of thing a /// round-trip test catches and a person does not. #[test] fn the_view_toggles_survive_a_save_and_load() { let mut app = OpenCADStudio::new_for_test(); app.show_viewcube = false; app.show_ucs_icon = false; app.ucs_icon_at_origin = false; app.selection_cycling = true; let saved = app.current_settings(); let mut restored = OpenCADStudio::new_for_test(); restored.apply_settings(&saved); assert!(!restored.show_viewcube, "the ViewCube must stay off"); assert!(!restored.show_ucs_icon); assert!(!restored.ucs_icon_at_origin); assert!(restored.selection_cycling); } /// The Options card writes these four straight onto the app and relies on /// the snapshot/restore pair to carry them across a restart. Three of them /// had no UI until now and so no reason for anyone to notice if the pair /// missed one. #[test] fn the_selection_settings_survive_a_save_and_load() { let mut app = OpenCADStudio::new_for_test(); app.pick_add = false; app.pick_drag_rect = true; app.pick_box = 11; app.grip_object_limit = 0; let saved = app.current_settings(); let mut restored = OpenCADStudio::new_for_test(); restored.apply_settings(&saved); assert!(!restored.pick_add); assert!(restored.pick_drag_rect); assert_eq!(restored.pick_box, 11); assert_eq!( restored.grip_object_limit, 0, "zero is a real value here, not an unset field", ); } /// `GRIPOBJLIMIT 0` means no limit, and the old comparison — a bare `>` /// against the stored number — could not express that: zero would have /// suppressed every grip instead of allowing all of them. #[test] fn a_limit_of_zero_means_no_limit() { use acadrust::entities::{EntityType, Line}; use acadrust::types::Vector3; let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.grip_object_limit = 0; let i = app.active_tab; let over = crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize * 3; let handles: Vec<_> = (0..over) .map(|k| { let x = k as f64; app.tabs[i] .scene .add_entity(EntityType::Line(Line::from_points( Vector3::new(x, 0.0, 0.0), Vector3::new(x + 1.0, 1.0, 0.0), ))) }) .collect(); for handle in handles { app.tabs[i].scene.select_entity(handle, false); } app.refresh_selected_grips(); assert!( app.tabs[i].selected_grips.len() > 0, "zero must read as unlimited, not as a limit of none", ); } } #[cfg(test)] mod aggregation_tests { use super::*; use acadrust::EntityType; fn line(layer: &str, color: i16) -> EntityType { let mut line = acadrust::entities::line::Line::default(); line.common.layer = layer.to_string(); line.common.color = acadrust::types::Color::from_index(color); EntityType::Line(line) } fn row<'a>( sections: &'a [crate::scene::model::object::PropSection], field: &str, ) -> Option<&'a crate::scene::model::object::Property> { sections .iter() .flat_map(|section| section.props.iter()) .find(|property| property.field == field) } #[test] fn shared_values_survive_the_fold_and_differing_ones_do_not() { let entities = [line("WALLS", 1), line("WALLS", 3), line("WALLS", 1)]; let selected: Vec<(Handle, &EntityType)> = entities .iter() .enumerate() .map(|(i, entity)| (Handle::new(i as u64 + 1), entity)) .collect(); let sections = aggregate_sections(&selected, &[]); assert!(!sections.is_empty(), "three lines share their layer rows"); // A colour the entities disagree on has its own variant rather than // the text label, so the two rows are checked on their own terms. let layer = row(§ions, "layer").expect("a layer row"); assert!( format!("{:?}", layer.value).contains("WALLS"), "one layer across all three: {:?}", layer.value, ); let color = row(§ions, "color").expect("a color row"); assert!( format!("{:?}", color.value).contains("Varies"), "two colours across three lines: {:?}", color.value, ); } // One entity folds to its own properties, and the fold must not depend on // there being a second one to merge against. #[test] fn a_single_entity_aggregates_to_its_own_rows() { let entity = line("WALLS", 1); let selected = [(Handle::new(1), &entity)]; let sections = aggregate_sections(&selected, &[]); let layer = row(§ions, "layer").expect("a layer row"); assert!(format!("{:?}", layer.value).contains("WALLS")); } }