forked from 1b-agent/opencadstudio-upstream
Constraint delete: a Properties-panel row delete button, viewport
click-to-select on a constraint glyph pill with Delete-key support, and a
right-click "Delete Constraint" menu item — all routed through one
undoable Scene::selected_constraint-aware path.
Fixes two named-parameter bugs found while testing that feature:
- DIMCONSTRAINT: a parameter named like a mode-switch keyword ("r", "d",
"a", "x", "y") could never be referenced, since the keyword check ran
before the parameter-name check.
- PARAMETERS modal Apply rebuilt Scene::named_parameters but never
refreshed the Properties panel, leaving renamed/deleted rows stale
until an unrelated refresh (e.g. a selection change) happened to occur.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
4428 lines
208 KiB
Rust
4428 lines
208 KiB
Rust
use super::helpers::{entity_type_key, entity_type_label, title_case_word};
|
|
use super::{OpenCADStudio, VARIES_LABEL};
|
|
use crate::io::linetypes;
|
|
use crate::scene::view::dispatch;
|
|
use crate::scene::model::object::PropValue;
|
|
use crate::ui;
|
|
use crate::t;
|
|
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::<Vec<_>>()
|
|
.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<String> = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.layers
|
|
.iter()
|
|
.map(|l| l.name.clone())
|
|
.collect();
|
|
let linetype_items: Vec<ui::properties::LinetypeItem> = 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<String> = 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<acadrust::Handle> = 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,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
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::<Vec<_>>()
|
|
.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 {
|
|
sections.extend(crate::entities::object_data::sections(
|
|
&self.tabs[i].scene.document,
|
|
&self.tabs[i].scene.object_data_cache,
|
|
handle,
|
|
entity,
|
|
));
|
|
}
|
|
|
|
{
|
|
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::<Vec<_>>()
|
|
.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<String> = 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<String> = 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<String> = 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<String> = 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<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::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<String> = 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<String> = 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<String> = 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<String> =
|
|
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,
|
|
);
|
|
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<String> = 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<String> = 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<String> = 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<String> =
|
|
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<String> = 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<String> = 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::<Vec<_>>()
|
|
.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);
|
|
}
|
|
// Persistent sketch-constraint list for this entity —
|
|
// the Properties-panel analogue of the viewport's
|
|
// constraint glyph pills: one row per constraint
|
|
// touching `handle`, clickable to select every entity
|
|
// it relates (PropConstraintLinkClick). Omitted
|
|
// entirely when the entity has none — an unconstrained
|
|
// entity (the common case) shouldn't gain empty
|
|
// bookkeeping.
|
|
{
|
|
let scope = self.tabs[i].current_sketch_scope();
|
|
let scene = &self.tabs[i].scene;
|
|
if let Some(set) = scene.sketch_constraint_set(scope) {
|
|
let props: Vec<crate::scene::model::object::Property> = set
|
|
.constraints_touching(handle)
|
|
.map(|c| {
|
|
let conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id);
|
|
let value_suffix = match &c.driving_param {
|
|
Some(crate::scene::named_parameters::DrivingValue::Literal(v)) => {
|
|
format!(" = {v:.2}")
|
|
}
|
|
Some(crate::scene::named_parameters::DrivingValue::Named(name)) => {
|
|
match scene.named_parameters().resolve(name) {
|
|
Ok(v) => format!(" = {name} ({v:.2})"),
|
|
Err(_) => format!(" = {name} (?)"),
|
|
}
|
|
}
|
|
None => String::new(),
|
|
};
|
|
let label = format!("{} {:?}{}", c.kind.glyph_symbol(), c.kind, value_suffix);
|
|
let mut handles: Vec<acadrust::Handle> = c.refs.iter().map(|r| r.entity).collect();
|
|
handles.sort_by_key(|h| h.value());
|
|
handles.dedup();
|
|
crate::scene::model::object::Property {
|
|
label,
|
|
field: "sketch_constraint",
|
|
value: crate::scene::model::object::PropValue::EntityLink { id: c.id, handles, conflicting },
|
|
}
|
|
})
|
|
.collect();
|
|
if !props.is_empty() {
|
|
sections.push(crate::scene::model::object::PropSection {
|
|
title: t!("Constraints").into_owned(),
|
|
props,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
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<Handle> =
|
|
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::<Vec<_>>(),
|
|
));
|
|
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<acadrust::Handle> = 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<String> = None;
|
|
let mut color: Option<acadrust::types::Color> = None;
|
|
let mut linetype: Option<String> = None;
|
|
let mut lineweight: Option<acadrust::types::LineWeight> = 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 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<Handle> {
|
|
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]) {
|
|
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(&changes);
|
|
}
|
|
|
|
/// 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<String>,
|
|
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<Handle> {
|
|
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<Handle> {
|
|
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<Handle> {
|
|
self.commit_entity_handle_with_policies(entity, false, true, false)
|
|
}
|
|
|
|
pub(super) fn commit_entity_handle_preserve_style(
|
|
&mut self,
|
|
entity: acadrust::EntityType,
|
|
) -> Option<Handle> {
|
|
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<Handle> {
|
|
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::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(),
|
|
};
|
|
property.field = "locked_read_only";
|
|
property.value = PropValue::ReadOnly(text);
|
|
}
|
|
}
|
|
|
|
// ── Multi-selection property aggregation ───────────────────────────────────
|
|
|
|
pub(super) fn build_selection_groups(
|
|
selected: &[(Handle, &EntityType)],
|
|
) -> Vec<ui::properties::SelectionGroup> {
|
|
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<String, Vec<Handle>> =
|
|
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<crate::scene::model::object::PropSection> {
|
|
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::<f64>();
|
|
set_row(&mut result, "cumulative_area", format!("{total:.4}"));
|
|
}
|
|
result
|
|
}
|
|
|
|
fn aggregate_solid_history_sections(
|
|
document: &acadrust::CadDocument,
|
|
handles: &[Handle],
|
|
) -> Vec<crate::scene::model::object::PropSection> {
|
|
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<crate::scene::model::object::PropSection>,
|
|
) {
|
|
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<crate::scene::model::object::PropSection> {
|
|
left.iter()
|
|
.filter_map(|section| {
|
|
let rhs = right
|
|
.iter()
|
|
.find(|candidate| candidate.title == section.title)?;
|
|
let props: Vec<crate::scene::model::object::Property> = 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::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) => *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<String> {
|
|
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::<f64>()
|
|
.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<String> {
|
|
["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<f64> {
|
|
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.
|
|
fn insunits_to_mm(code: i16) -> Option<f64> {
|
|
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<i32>| -> 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"));
|
|
}
|
|
}
|