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::{GripDef, PropValue}; use crate::scene::view::dispatch; use crate::t; use crate::ui; use codec::types::{Transform, Vector3}; use codec::{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; /// Cap the total number of selection grips at /// [`crate::app::settings::MAX_SELECTED_GRIPS`], keeping entity order with /// non-midpoint (vertex/stretch) grips first. Below the cap both vecs are /// returned untouched, so existing selections are identical. The parallel /// `handles` vec is truncated in lockstep with `grips`. /// /// `pub` (not private) so the `cargo bench` harness (external crate) measures /// the real function as `ui_grip_budget`. pub fn apply_grip_budget( mut grips: Vec, mut handles: Vec, ) -> (Vec, Vec) { debug_assert_eq!( grips.len(), handles.len(), "grips/handles are parallel vecs built in lockstep" ); if grips.len() > crate::app::settings::MAX_SELECTED_GRIPS { // Vertex budget: one dense polyline can emit ~2 grips/vertex past the // object-count gate above. Keep entity order; non-midpoint (stretch) // grips first, midpoints fill the remainder. let cap = crate::app::settings::MAX_SELECTED_GRIPS; let mut idx: Vec = (0..grips.len()).collect(); idx.sort_by_key(|&j| grips[j].is_midpoint); // false (vertex) first; stable idx.truncate(cap); idx.sort_unstable(); // restore entity order for determinism let keep: rustc_hash::FxHashSet = idx.into_iter().collect(); let mut kept_grips = Vec::with_capacity(cap); let mut kept_handles = Vec::with_capacity(cap); for (j, (grip, handle)) in grips.into_iter().zip(handles.into_iter()).enumerate() { if keep.contains(&j) { kept_grips.push(grip); kept_handles.push(handle); } } grips = kept_grips; handles = kept_handles; } (grips, handles) } fn visual_style_properties_text(style: &codec::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. /// Sections and title of the Properties panel's single-object page for /// `handle` — also what the node graph shows as a node's ports. #[allow(clippy::ptr_arg)] pub(super) fn entity_property_sections( &self, i: usize, handle: Handle, source_entity: &EntityType, text_style_names: &Vec, annotation_scale_handle: Option, prop_vertex: usize, ) -> (Vec, String) { 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 { codec::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 { codec::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 { codec::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, codec::EntityType::Solid3D(_) | codec::EntityType::Body(_) | codec::EntityType::Surface(_) | codec::EntityType::Mesh(_) | codec::EntityType::PolygonMesh(_) | codec::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, codec::EntityType::Solid3D(_) | codec::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 codec::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.tabs[i].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 codec::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 { codec::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. // Filled in below from the current arrowhead. (#1820) let arrow_names: Vec; 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 { codec::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 = crate::entities::arrowheads::label( doc, ml.arrowhead_handle.unwrap_or(codec::Handle::NULL), ); arrow_names = crate::entities::arrowheads::options([cur_arrow.as_str()]); 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 codec::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 codec::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 { t!("Yes") } else { t!("No") }.into_owned(), ), }); } } // Inject DimStyle picker + style-derived groups for Dimensions. if let codec::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 { t!("Yes") } else { t!("No") }.into_owned(), ), }); } 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(|| codec::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(|| codec::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. codec::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: &codec::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, }, ); } if let Some(br) = doc .block_records .get(&ins.block_name) .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) { let base_dir = self.tabs[i] .current_path .as_deref() .and_then(|p| p.parent()) .map(|p| p.to_path_buf()) .unwrap_or_else(|| std::path::PathBuf::from(".")); let overrides = crate::io::xref::layer_overrides(doc, &br.name, &br.xref_path, &base_dir); xref_rows(&mut sections, ins, &br.xref_path, factor, overrides); } // A dynamic block's visibility states, picked like its grip. if let Some(row) = Self::visibility_property(doc, handle) { sections.push(crate::scene::model::object::PropSection { title: t!("Custom").into_owned(), props: vec![row], }); } // An associative array's counts and spacings. if let Some(array) = crate::scene::assoc_array::array_of_insert(doc, handle) { sections.push(crate::scene::assoc_array::property_section(doc, &array)); } } // Underlay: name, page, path and size from the definition. codec::EntityType::Underlay(ul) => { use crate::entities::underlay as und; if let Some(def) = und::definition(ul, doc) { set_row(&mut sections, "ul_name", und::definition_display_name(def)); set_row(&mut sections, "ul_page", und::page_of(def).to_string()); set_row(&mut sections, "ul_path", und::display_path(&def.file_path)); } if let Some((w, h)) = und::shown_size(ul, doc) { set_row_value( &mut sections, "ul_width", crate::scene::model::object::PropValue::EditText(und::plain_number(w)), ); set_row_value( &mut sections, "ul_height", crate::scene::model::object::PropValue::EditText(und::plain_number(h)), ); } } // Point cloud: the saved path from its definition; the scan's // metres against the drawing's units. codec::EntityType::Extended(extended) => { if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data { if let Some(definition) = crate::scene::model::point_cloud::definition(doc, data) { set_row( &mut sections, "ext_pcx_path", definition.source_filename.clone(), ); } set_row(&mut sections, "ext_pcx_unit", t!("Meters").into_owned()); let factor = insert_unit_scale(doc.header.insertion_units, 6).unwrap_or(1.0); set_row( &mut sections, "ext_pcx_unit_factor", format_unit_factor(factor), ); } } // Leader: text style / vertical text placement / overall // scale come from its dimension style. codec::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(|| codec::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. codec::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. codec::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 { codec::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}")); } } } codec::EntityType::Table(table) => { use codec::entities::table::{ CellEdgeFlags, CellStylePropertyFlags, CellValueType, }; let mut names: Vec = doc .objects .values() .filter_map(|object| match object { codec::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 { codec::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 { t!("Up") } else { t!("Down") }.into_owned(), ); 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(codec::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(codec::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 { codec::EntityType::Text(_) | codec::EntityType::MText(_) | codec::EntityType::Insert(_) | codec::EntityType::Leader(_) | codec::EntityType::Hatch(_) => Some(("annotative", None)), codec::EntityType::MultiLeader(_) => Some(("enable_annotation_scale", None)), codec::EntityType::Dimension(_) => Some(("annotative", Some("style_name"))), codec::EntityType::Tolerance(_) => Some(("annotative", Some("tol_dim_style"))), codec::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 { codec::EntityType::Dimension(dimension) if matches!(**dimension, codec::entities::Dimension::Arc(_)) => { crate::scene::annotative::dim_style_is_annotative( doc, &dimension.base().style_name, ) } codec::EntityType::Tolerance(_) => { crate::scene::annotative::annotation_style_is_annotative(doc, entity) } codec::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 { // The reference offers Yes/No; the choice // drives the same per-object toggle. codec::EntityType::MText(t) => set_row_value( &mut sections, "annotative", yes_no_choice(t.is_annotative || is_anno), ), codec::EntityType::Dimension(dimension) if matches!(**dimension, codec::entities::Dimension::Arc(_)) => { set_row( &mut sections, "annotative", if is_anno { t!("Yes") } else { t!("No") }.into_owned(), ) } codec::EntityType::Leader(_) if crate::scene::annotative::annotation_style_is_annotative( doc, entity, ) => { set_row(&mut sections, "annotative", t!("Yes").into_owned()) } codec::EntityType::Text(_) | codec::EntityType::Insert(_) | codec::EntityType::Leader(_) | codec::EntityType::Hatch(_) | codec::EntityType::Dimension(_) => { set_row_value(&mut sections, "annotative", yes_no_choice(is_anno)) } _ => set_row( &mut sections, "annotative", if is_anno { t!("Yes") } else { t!("No") }.into_owned(), ), } } 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 Some(factor) = self.tabs[i].scene.annotative_text_height_factor(entity.common().handle) { // Annotative text stores the model height of its default scale; // the Height row edits the paper height at the displayed scale. let (stored, model_factor, aligned) = match entity { codec::EntityType::Text(text) => { let aligned = matches!( text.horizontal_alignment, codec::entities::TextHorizontalAlignment::Aligned ); let stored = if aligned { crate::entities::text::text_run_placement_at_scale(text, doc, 1.0).height as f64 } else { text.height }; (stored, factor, aligned) } codec::EntityType::MText(text) => (text.height, factor, false), _ => unreachable!("annotative_text_height_factor is text-only"), }; let ratio = crate::scene::annotative::text_context_ratio( doc, entity.common().handle, annotation_scale_handle, ) .unwrap_or(1.0); let paper_height = stored / model_factor; for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|row| row.field == "height") { let editable = !aligned && !matches!(row.value, crate::scene::model::object::PropValue::ReadOnly(_)); *row = crate::entities::common::num_prop( t!("Paper text height").as_ref(), "height", paper_height, editable, ); } } 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(stored * ratio), ), ); } 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 { codec::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) } } codec::EntityType::Surface(_) if matches!( crate::scene::model::solid_history::primitive_property_operation( &self.tabs[i].scene.document, handle, ), Some(codec::objects::SolidHistoryOperation::Extrusion(_)) ) => { format!("{} ({})", t!("Surface"), t!("Extrusion")) } // The reference names an angular dimension by its kind. codec::EntityType::Dimension(dimension) if matches!(**dimension, codec::entities::Dimension::Angular2Ln(_)) => { t!("Angular Dimension").into_owned() } codec::EntityType::Dimension(dimension) if matches!(**dimension, codec::entities::Dimension::Angular3Pt(_)) => { t!("3 Point Angular Dimension").into_owned() } codec::EntityType::Underlay(underlay) => match underlay.underlay_type { codec::entities::UnderlayType::Pdf => t!("PDF Underlay"), codec::entities::UnderlayType::Dwf => t!("DWF Underlay"), codec::entities::UnderlayType::Dgn => t!("DGN Underlay"), } .into_owned(), _ => entity_type_label(entity), }; // A dynamic dimension shows only its constraint and text // rotation, as the reference does. let title = match crate::scene::parametric_constraints::dynamic_dimension_constraint( &self.tabs[i].scene.parametric_constraints, handle, ) { Some((set, constraint)) => { let annotational = self.tabs[i].scene.dimension_is_annotational(handle); sections = dynamic_dimension_sections( &self.tabs[i].scene, handle, set, constraint, sections, ); // An annotational constraint is an ordinary // dimension to the panel; the dynamic form is // named after its constraint. if annotational { title } else { use codec::entities::Dimension; match crate::entities::dimension::as_dimension(entity) { Some(Dimension::Aligned(_)) => t!("Aligned Dimensional Constraint"), Some(Dimension::Angular2Ln(_)) => t!("Angular Dimension (Dynamic)"), Some(Dimension::Angular3Pt(_)) => { t!("3 Point Angular Dimension (Dynamic)") } Some(Dimension::Radius(_)) => t!("Radius Dimensional Constraint"), Some(Dimension::Diameter(_)) => t!("Diameter Dimensional Constraint"), _ => t!("Linear Dimensional Constraint"), } .into_owned() } } None => title, }; // A plugin-declared class identity (API v10) qualifies the title as // "