Implement solid shell workflow and properties

This commit is contained in:
ramox81 committed 2026-09-08 19:37:35 +03:00
1 parent febedad916
commit be06a756da
16 files changed
+780 -52

No files matched your search

Generated
+2 -2
View File
@@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.5.4"
source = "git+https://[email protected]/HakanSeven12/cadcodec.git?rev=5b56571a190e7a17c8f12d36390d2938b0fb72f7#5b56571a190e7a17c8f12d36390d2938b0fb72f7"
source = "git+https://[email protected]/HakanSeven12/cadcodec.git?rev=ffc58a9ee64b2ee818d7cf1e4d8b27a26becbf01#ffc58a9ee64b2ee818d7cf1e4d8b27a26becbf01"
dependencies = [
"ahash 0.8.12",
"anyhow",
@@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4486076cb003a5909e3383900c596f92ef599192#4486076cb003a5909e3383900c596f92ef599192"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=e0f43546929b6af0e436e710bdfc932161d483a3#e0f43546929b6af0e436e710bdfc932161d483a3"
dependencies = [
"acadrust",
"cavalier_contours",
+3 -3
View File
@@ -31,8 +31,8 @@ glam = { version = "0.33", features = ["bytemuck"] }
rfd = "0.17"
clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4486076cb003a5909e3383900c596f92ef599192", features = ["acis", "offset"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "ffc58a9ee64b2ee818d7cf1e4d8b27a26becbf01", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "e0f43546929b6af0e436e710bdfc932161d483a3", features = ["acis", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
@@ -74,7 +74,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
[patch."https://github.com/HakanSeven12/cadcodec.git"]
acadrust = { git = "https://[email protected]/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7" }
acadrust = { git = "https://[email protected]/HakanSeven12/cadcodec.git", rev = "ffc58a9ee64b2ee818d7cf1e4d8b27a26becbf01" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+2 -2
View File
@@ -14,8 +14,8 @@ bincode = "1.3"
thiserror = "1"
# Host adapters convert ObjectId to acadrust handles; kernel handles stay local.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"], optional = true }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4486076cb003a5909e3383900c596f92ef599192", features = ["acis", "offset"], optional = true }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "ffc58a9ee64b2ee818d7cf1e4d8b27a26becbf01", features = ["serde"], optional = true }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "e0f43546929b6af0e436e710bdfc932161d483a3", features = ["acis", "offset"], optional = true }
ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] }
[build-dependencies]
+21
View File
@@ -2926,6 +2926,14 @@ impl OpenCADStudio {
self.command_line.push_info(&prompt);
}
}
CmdResult::ReportError(msg) => {
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.command_line.push_error(&msg);
if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) {
self.command_line.push_info(&prompt);
}
}
CmdResult::ReportMeasurementAndDeselect(msg) => {
self.tabs[i].snap_result = None;
self.tabs[i].scene.deselect_all();
@@ -4115,6 +4123,19 @@ impl OpenCADStudio {
return task;
}
CmdResult::SolidShell {
handle,
actions,
distance,
} => {
let task = self.solid_shell(handle, &actions, distance);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
return task;
}
CmdResult::SolidSubtract {
bases,
cutters,
+24
View File
@@ -971,6 +971,30 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"SHELL" | "SOLIDEDIT" => {
use crate::modules::model::shell_cmd::ShellCommand;
let selected = self.tabs[i]
.scene
.selected_handles_in_order()
.into_iter()
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
.filter(|handle| {
matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(acadrust::EntityType::Solid3D(_))
)
})
.collect::<Vec<_>>();
let target = (selected.len() == 1).then_some(selected[0]);
let new_cmd = if cmd == "SHELL" {
ShellCommand::direct(target)
} else {
ShellCommand::solid_edit(target)
};
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
// ── Solid booleans ─────────────────────────────────────────────
"UNION" => {
use crate::modules::model::boolean_cmd::BoolOp;
+1 -1
View File
@@ -982,7 +982,7 @@ impl OpenCADStudio {
"area":planar.curve.is_closed().then(|| planar.curve.enclosed_area().abs())
})
});
out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))}));
out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid,"moment_of_inertia":m.metrics.moment_of_inertia,"principal_directions":m.metrics.principal_directions,"principal_moments":m.metrics.principal_moments,"product_of_inertia":m.metrics.product_of_inertia,"radii_of_gyration":m.metrics.radii_of_gyration}))}));
}
json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out})
}
+102 -1
View File
@@ -9,7 +9,7 @@ use acadrust::{
};
use cadkernel::brep::Body;
use iced::Task;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use super::Message;
use crate::modules::model::boolean_cmd::BoolOp;
@@ -646,6 +646,107 @@ impl super::OpenCADStudio {
Task::none()
}
pub(super) fn solid_shell(
&mut self,
handle: Handle,
actions: &[crate::command::ShellFaceAction],
distance: f64,
) -> Task<Message> {
use crate::command::ShellFaceAction;
let i = self.active_tab;
if self.reject_locked_edit(i, handle) {
return Task::none();
}
if !matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(EntityType::Solid3D(_))
) {
self.command_line
.push_error(crate::t!("Select a 3D solid.").as_ref());
return Task::none();
}
if !distance.is_finite() || distance.abs() <= f64::EPSILON {
self.command_line
.push_error(crate::t!("The shell offset distance must be nonzero.").as_ref());
return Task::none();
}
self.tabs[i].scene.restore_solid_models(&[handle]);
let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else {
self.command_line
.push_error(crate::t!("The solid geometry could not be restored.").as_ref());
return Task::none();
};
let all_faces = body.face_keys().collect::<Vec<_>>();
let mut removed = HashSet::new();
for action in actions {
match *action {
ShellFaceAction::Remove(point) => {
let Some(face) = solid_model::nearest_face(&body, point.to_array()) else {
self.command_line
.push_error(crate::t!("A selected face could not be resolved.").as_ref());
return Task::none();
};
removed.insert(face);
}
ShellFaceAction::Add(point) => {
let Some(face) = solid_model::nearest_face(&body, point.to_array()) else {
self.command_line
.push_error(crate::t!("A selected face could not be resolved.").as_ref());
return Task::none();
};
removed.remove(&face);
}
ShellFaceAction::RemoveAll => removed.extend(all_faces.iter().copied()),
ShellFaceAction::AddAll => removed.clear(),
}
}
let removed = removed.into_iter().collect::<Vec<_>>();
let result = match cadkernel::brep::shell(&body, &removed, distance) {
Ok(result) => result,
Err(cadkernel::brep::ShellError::InvalidDistance) => {
self.command_line
.push_error(crate::t!("The shell offset distance must be nonzero.").as_ref());
return Task::none();
}
Err(cadkernel::brep::ShellError::UnsupportedSolid) => {
self.command_line.push_error(
crate::t!("SHELL supports rectangular boxes, circular cylinders, and spheres.")
.as_ref(),
);
return Task::none();
}
Err(cadkernel::brep::ShellError::UnknownFace) => {
self.command_line
.push_error(crate::t!("A selected face does not belong to the solid.").as_ref());
return Task::none();
}
Err(cadkernel::brep::ShellError::NoMaterial) => {
self.command_line.push_error(
crate::t!("The offset removes all material. Use a smaller distance or add a face back.")
.as_ref(),
);
return Task::none();
}
Err(cadkernel::brep::ShellError::Kernel(_)) => {
self.command_line.push_error(
crate::t!("The shell could not be constructed from the selected solid and distance.")
.as_ref(),
);
return Task::none();
}
};
if self.replace_solid_body(handle, result, "SHELL") {
self.command_line
.push_output(crate::t!("SHELL: solid updated.").as_ref());
}
Task::none()
}
/// Intersect compatible selected solids, Regions, and coplanar Surfaces.
pub(super) fn solid_intersect(&mut self) -> Task<Message> {
let i = self.active_tab;
+77 -34
View File
@@ -636,7 +636,7 @@ impl OpenCADStudio {
}
}
if !compact_solid && matches!(
if matches!(
entity,
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Region(_)
@@ -654,38 +654,44 @@ impl OpenCADStudio {
{
use crate::entities::common::ro_prop;
let metrics = mesh.metrics;
let mut props = 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(
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",
format!(
"{:.6}, {:.6}, {:.6}",
metrics.centroid[0],
metrics.centroid[1],
metrics.centroid[2]
triple(metrics.centroid),
)]
} else {
vec![
ro_prop(
t!("Vertices").as_ref(),
"mesh_vertices",
metrics.vertices.to_string(),
),
),
ro_prop(
t!("Tessellation").as_ref(),
"mesh_complete",
if mesh.complete { "Complete" } else { "Partial" },
),
];
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,
@@ -694,14 +700,50 @@ impl OpenCADStudio {
| acadrust::EntityType::Body(_)
)
{
props.insert(
3,
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(),
@@ -3093,7 +3135,8 @@ fn retain_compact_solid_sections(
| "transparency"
| "hyperlink"
| "material"
) || crate::scene::model::solid_history::is_specialized_property(property.field)
) || property.field.starts_with("mesh_")
|| crate::scene::model::solid_history::is_specialized_property(property.field)
});
});
sections.retain(|section| !section.props.is_empty());
+17
View File
@@ -1195,6 +1195,15 @@ pub struct LoftOptions {
pub align_direction: bool,
}
/// One face-selection edit made while collecting a solid shell operation.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ShellFaceAction {
Remove(DVec3),
Add(DVec3),
RemoveAll,
AddAll,
}
impl Default for LoftOptions {
fn default() -> Self {
Self {
@@ -1374,6 +1383,8 @@ pub enum CmdResult {
Measurement(String),
/// Print a measurement result and keep the command active.
ReportMeasurement(String),
/// Print an input error and keep the command active.
ReportError(String),
/// Print a measurement result, clear the current selection, and keep the command active.
ReportMeasurementAndDeselect(String),
/// Clear the current selection and keep the command active at its updated step.
@@ -1554,6 +1565,12 @@ pub enum CmdResult {
value: f64,
fillet: bool,
},
/// Offset a solid and optionally open selected faces.
SolidShell {
handle: Handle,
actions: Vec<ShellFaceAction>,
distance: f64,
},
SolidSubtract {
bases: Vec<Handle>,
cutters: Vec<Handle>,
+2
View File
@@ -5,6 +5,7 @@ pub mod cylinder_cmd;
pub mod edge_cmd;
pub mod polysolid_cmd;
pub mod primitive_cmd;
pub mod shell_cmd;
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
@@ -90,6 +91,7 @@ impl CadModule for ModelModule {
tools: vec![
RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)),
RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)),
RibbonItem::LargeTool(tool("SHELL", "Shell", PRESSPULL_ICON)),
],
},
]
+265
View File
@@ -0,0 +1,265 @@
use acadrust::Handle;
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, ShellFaceAction};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ShellStep {
SolidEditOption,
BodyOption,
SelectSolid,
RemoveFaces,
AddFaces,
Distance,
}
pub struct ShellCommand {
entry_name: &'static str,
step: ShellStep,
target: Option<Handle>,
actions: Vec<ShellFaceAction>,
default_distance: f64,
}
impl ShellCommand {
pub fn direct(target: Option<Handle>) -> Self {
Self {
entry_name: "SHELL",
step: if target.is_some() {
ShellStep::RemoveFaces
} else {
ShellStep::SelectSolid
},
target,
actions: Vec::new(),
default_distance: 1.0,
}
}
pub fn solid_edit(target: Option<Handle>) -> Self {
Self {
entry_name: "SOLIDEDIT",
step: ShellStep::SolidEditOption,
target,
actions: Vec::new(),
default_distance: 1.0,
}
}
fn finish(&mut self, distance: f64) -> CmdResult {
let Some(handle) = self.target else {
return CmdResult::Cancel;
};
CmdResult::SolidShell {
handle,
actions: std::mem::take(&mut self.actions),
distance,
}
}
fn undo_face_action(&mut self) -> CmdResult {
self.actions.pop();
CmdResult::NeedPoint
}
fn face_options(add: bool) -> Vec<CmdOption> {
if add {
vec![
CmdOption::new("Undo", "UNDO"),
CmdOption::new("Remove", "REMOVE"),
CmdOption::new("ALL", "ALL"),
]
} else {
vec![
CmdOption::new("Undo", "UNDO"),
CmdOption::new("Add", "ADD"),
CmdOption::new("ALL", "ALL"),
]
}
}
}
impl CadCommand for ShellCommand {
fn name(&self) -> &'static str {
self.entry_name
}
fn prompt(&self) -> String {
match self.step {
ShellStep::SolidEditOption => {
crate::t!("Enter a solid editing option [Body/eXit]:").into_owned()
}
ShellStep::BodyOption => {
crate::t!("Enter a body editing option [Shell/eXit]:").into_owned()
}
ShellStep::SelectSolid => crate::t!("Select a 3D solid:").into_owned(),
ShellStep::RemoveFaces => crate::t!(
"Select faces to remove [Undo/Add/ALL] <done>:"
)
.into_owned(),
ShellStep::AddFaces => crate::t!(
"Select faces to add [Undo/Remove/ALL] <done>:"
)
.into_owned(),
ShellStep::Distance => crate::tf!(
"Specify the shell offset distance <{:.3}>:",
self.default_distance
)
.into_owned(),
}
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
ShellStep::SolidEditOption => vec![
CmdOption::new("Body", "BODY"),
CmdOption::new("eXit", "EXIT"),
],
ShellStep::BodyOption => vec![
CmdOption::new("Shell", "SHELL"),
CmdOption::new("eXit", "EXIT"),
],
ShellStep::RemoveFaces => Self::face_options(false),
ShellStep::AddFaces => Self::face_options(true),
_ => Vec::new(),
}
}
fn needs_entity_pick(&self) -> bool {
matches!(
self.step,
ShellStep::SelectSolid | ShellStep::RemoveFaces | ShellStep::AddFaces
)
}
fn entity_pick_includes_fills(&self) -> bool {
true
}
fn entity_pick_uses_surface_point(&self) -> bool {
true
}
fn entity_pick_highlights_hover(&self) -> bool {
true
}
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
match self.step {
ShellStep::SelectSolid => {
self.target = Some(handle);
self.step = ShellStep::RemoveFaces;
}
ShellStep::RemoveFaces if self.target == Some(handle) => {
self.actions.push(ShellFaceAction::Remove(point));
}
ShellStep::AddFaces if self.target == Some(handle) => {
self.actions.push(ShellFaceAction::Add(point));
}
_ => {}
}
CmdResult::NeedPoint
}
fn on_point(&mut self, _point: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let value = text.trim();
let keyword = value.to_ascii_uppercase();
let result = match self.step {
ShellStep::SolidEditOption => match keyword.as_str() {
"B" | "BODY" => {
self.step = ShellStep::BodyOption;
CmdResult::NeedPoint
}
"X" | "EXIT" => CmdResult::Cancel,
_ => return Some(CmdResult::NeedPoint),
},
ShellStep::BodyOption => match keyword.as_str() {
"S" | "SHELL" => {
self.step = if self.target.is_some() {
ShellStep::RemoveFaces
} else {
ShellStep::SelectSolid
};
CmdResult::NeedPoint
}
"X" | "EXIT" => CmdResult::Cancel,
_ => return Some(CmdResult::NeedPoint),
},
ShellStep::RemoveFaces => match keyword.as_str() {
"U" | "UNDO" => return Some(self.undo_face_action()),
"A" | "ADD" => {
self.step = ShellStep::AddFaces;
CmdResult::NeedPoint
}
"ALL" => {
self.actions.push(ShellFaceAction::RemoveAll);
CmdResult::NeedPoint
}
_ => return Some(CmdResult::NeedPoint),
},
ShellStep::AddFaces => match keyword.as_str() {
"U" | "UNDO" => return Some(self.undo_face_action()),
"R" | "REMOVE" => {
self.step = ShellStep::RemoveFaces;
CmdResult::NeedPoint
}
"ALL" => {
self.actions.push(ShellFaceAction::AddAll);
CmdResult::NeedPoint
}
_ => return Some(CmdResult::NeedPoint),
},
ShellStep::Distance => {
let Ok(distance) = value.parse::<f64>() else {
return Some(CmdResult::ReportError(
crate::t!("A numeric shell offset distance is required.").into_owned(),
));
};
if !distance.is_finite() || distance.abs() <= f64::EPSILON {
return Some(CmdResult::ReportError(
crate::t!("Value must be nonzero.").into_owned(),
));
}
self.default_distance = distance;
self.finish(distance)
}
ShellStep::SelectSolid => return None,
};
Some(result)
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
ShellStep::RemoveFaces | ShellStep::AddFaces => {
self.step = ShellStep::Distance;
CmdResult::NeedPoint
}
ShellStep::Distance => self.finish(self.default_distance),
_ => CmdResult::Cancel,
}
}
fn on_undo_step(&mut self) -> Option<CmdResult> {
matches!(self.step, ShellStep::RemoveFaces | ShellStep::AddFaces)
.then(|| self.undo_face_action())
}
fn input_kind(&self) -> InputKind {
if matches!(self.step, ShellStep::SelectSolid) {
InputKind::Point
} else {
InputKind::SingleToken
}
}
}
inventory::submit!(crate::command::CommandRegistration {
names: &["SHELL", "SOLIDEDIT"]
});
+5
View File
@@ -177,6 +177,11 @@ pub fn tessellate_sat(
color,
None,
));
if let [single] = bodies.as_slice() {
if let Some(properties) = brep::analytic_mass_properties(&single.0) {
set.apply_mass_properties(properties);
}
}
set.curved_gens = curved_gens;
for point in edges {
let high = [point[0] as f32, point[1] as f32, point[2] as f32];
+162 -1
View File
@@ -55,6 +55,42 @@ pub struct MeshMetrics {
pub surface_area: f64,
pub volume: f64,
pub centroid: [f64; 3],
pub moment_of_inertia: [f64; 3],
pub principal_directions: [f64; 9],
pub principal_moments: [f64; 3],
pub product_of_inertia: [f64; 3],
pub radii_of_gyration: [f64; 3],
}
impl MeshMetrics {
pub fn translate(&mut self, delta: [f64; 3]) {
let old = self.centroid;
let new = [old[0] + delta[0], old[1] + delta[1], old[2] + delta[2]];
if self.volume > 1e-18 {
self.moment_of_inertia[0] += self.volume
* (new[1] * new[1] + new[2] * new[2]
- old[1] * old[1]
- old[2] * old[2]);
self.moment_of_inertia[1] += self.volume
* (new[0] * new[0] + new[2] * new[2]
- old[0] * old[0]
- old[2] * old[2]);
self.moment_of_inertia[2] += self.volume
* (new[0] * new[0] + new[1] * new[1]
- old[0] * old[0]
- old[1] * old[1]);
self.product_of_inertia[0] -=
self.volume * (new[0] * new[1] - old[0] * old[1]);
self.product_of_inertia[1] -=
self.volume * (new[1] * new[2] - old[1] * old[2]);
self.product_of_inertia[2] -=
self.volume * (new[2] * new[0] - old[2] * old[0]);
self.radii_of_gyration = self
.moment_of_inertia
.map(|moment| (moment.max(0.0) / self.volume).sqrt());
}
self.centroid = new;
}
}
#[derive(Clone, Debug)]
@@ -160,6 +196,8 @@ fn compute_mesh_metrics(lods: &[MeshModel]) -> MeshMetrics {
let mut area_centroid_numerator = [0.0; 3];
let mut signed_volume = 0.0;
let mut centroid_numerator = [0.0; 3];
let mut squared_integrals = [0.0; 3];
let mut product_integrals = [0.0; 3];
for triangle in mesh.indices.chunks_exact(3) {
let a = point(triangle[0]);
let b = point(triangle[1]);
@@ -187,6 +225,25 @@ fn compute_mesh_metrics(lods: &[MeshModel]) -> MeshMetrics {
signed_volume += tetra;
for axis in 0..3 {
centroid_numerator[axis] += tetra * (a[axis] + b[axis] + c[axis]) / 4.0;
squared_integrals[axis] += tetra
* (a[axis] * a[axis]
+ b[axis] * b[axis]
+ c[axis] * c[axis]
+ a[axis] * b[axis]
+ a[axis] * c[axis]
+ b[axis] * c[axis])
/ 10.0;
}
for (index, (first, second)) in [(0usize, 1usize), (1, 2), (2, 0)]
.into_iter()
.enumerate()
{
let diagonal = a[first] * a[second]
+ b[first] * b[second]
+ c[first] * c[second];
let all = (a[first] + b[first] + c[first])
* (a[second] + b[second] + c[second]);
product_integrals[index] += tetra * (diagonal + all) / 20.0;
}
}
let centroid = if signed_volume.abs() > 1e-18 {
@@ -204,16 +261,120 @@ fn compute_mesh_metrics(lods: &[MeshModel]) -> MeshMetrics {
} else {
[0.0; 3]
};
let orientation = if signed_volume < 0.0 { -1.0 } else { 1.0 };
let volume = signed_volume.abs();
let square = squared_integrals.map(|value| value * orientation);
let products = product_integrals.map(|value| -value * orientation);
let moment_of_inertia = [
square[1] + square[2],
square[0] + square[2],
square[0] + square[1],
];
let central = [
[
moment_of_inertia[0] - volume * (centroid[1].powi(2) + centroid[2].powi(2)),
products[0] + volume * centroid[0] * centroid[1],
products[2] + volume * centroid[0] * centroid[2],
],
[
products[0] + volume * centroid[0] * centroid[1],
moment_of_inertia[1] - volume * (centroid[0].powi(2) + centroid[2].powi(2)),
products[1] + volume * centroid[1] * centroid[2],
],
[
products[2] + volume * centroid[0] * centroid[2],
products[1] + volume * centroid[1] * centroid[2],
moment_of_inertia[2] - volume * (centroid[0].powi(2) + centroid[1].powi(2)),
],
];
let (principal_moments, principal_directions) = principal_axes(central);
let radii_of_gyration = if volume > 1e-18 {
moment_of_inertia.map(|moment| (moment.max(0.0) / volume).sqrt())
} else {
[0.0; 3]
};
MeshMetrics {
vertices: mesh.verts.len(),
triangles: mesh.indices.len() / 3,
surface_area: area,
volume: signed_volume.abs(),
volume,
centroid,
moment_of_inertia,
principal_directions,
principal_moments,
product_of_inertia: products,
radii_of_gyration,
}
}
fn principal_axes(mut matrix: [[f64; 3]; 3]) -> ([f64; 3], [f64; 9]) {
let mut vectors = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
for _ in 0..24 {
let (p, q) = [(0usize, 1usize), (0, 2), (1, 2)]
.into_iter()
.max_by(|&(ap, aq), &(bp, bq)| {
matrix[ap][aq].abs().total_cmp(&matrix[bp][bq].abs())
})
.unwrap_or((0, 1));
if matrix[p][q].abs() <= 1e-12 {
break;
}
let angle = 0.5 * (2.0 * matrix[p][q]).atan2(matrix[q][q] - matrix[p][p]);
let (sine, cosine) = angle.sin_cos();
for row in 0..3 {
let mp = matrix[row][p];
let mq = matrix[row][q];
matrix[row][p] = cosine * mp - sine * mq;
matrix[row][q] = sine * mp + cosine * mq;
}
for column in 0..3 {
let mp = matrix[p][column];
let mq = matrix[q][column];
matrix[p][column] = cosine * mp - sine * mq;
matrix[q][column] = sine * mp + cosine * mq;
}
for row in 0..3 {
let vp = vectors[row][p];
let vq = vectors[row][q];
vectors[row][p] = cosine * vp - sine * vq;
vectors[row][q] = sine * vp + cosine * vq;
}
}
let mut axes = (0..3)
.map(|axis| {
(
matrix[axis][axis].max(0.0),
[vectors[0][axis], vectors[1][axis], vectors[2][axis]],
)
})
.collect::<Vec<_>>();
axes.sort_by(|left, right| left.0.total_cmp(&right.0));
let moments = [axes[0].0, axes[1].0, axes[2].0];
let scale = moments[2].abs().max(1.0);
if (moments[2] - moments[0]).abs() <= scale * 1e-10 {
return (
moments,
[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
);
}
let directions = [
axes[0].1[0], axes[0].1[1], axes[0].1[2], axes[1].1[0], axes[1].1[1],
axes[1].1[2], axes[2].1[0], axes[2].1[1], axes[2].1[2],
];
(moments, directions)
}
impl MeshLodSet {
pub fn apply_mass_properties(&mut self, properties: cadkernel::brep::MassProperties) {
self.metrics.volume = properties.volume;
self.metrics.centroid = properties.centroid;
self.metrics.moment_of_inertia = properties.moment_of_inertia;
self.metrics.principal_directions = properties.principal_directions;
self.metrics.principal_moments = properties.principal_moments;
self.metrics.product_of_inertia = properties.product_of_inertia;
self.metrics.radii_of_gyration = properties.radii_of_gyration;
}
/// Build a set from its LODs, computing the 3D AABB.
pub fn from_lods(lods: Vec<MeshModel>) -> Self {
let (world_aabb, z_aabb) = compute_mesh_aabb(&lods);
+79 -4
View File
@@ -996,9 +996,44 @@ fn brep_properties(
history_flags(document, handle).unwrap_or((false, false, 1));
let (show_history, show_history_editable) =
displayed_history_state(object_show_history, show_history_mode);
vec![PropSection {
title: t!("Solid History").into_owned(),
props: vec![
let mut geometry = vec![Property {
label: t!("Solid type").into_owned(),
field: "solid_history_type",
value: PropValue::ReadOnly(t!("3D Solid").into_owned()),
}];
if let Some(position) = document
.solid_history_operation(handle)
.and_then(|operation| match operation {
SolidHistoryOperation::Brep(value) => world_point(value.base.transform, [0.0; 3]),
_ => None,
})
{
geometry.extend([
crate::entities::common::edit_prop(
t!("Position X").as_ref(),
PROP_POSITION_X,
position.x,
),
crate::entities::common::edit_prop(
t!("Position Y").as_ref(),
PROP_POSITION_Y,
position.y,
),
crate::entities::common::edit_prop(
t!("Position Z").as_ref(),
PROP_POSITION_Z,
position.z,
),
]);
}
vec![
PropSection {
title: t!("Geometry").into_owned(),
props: geometry,
},
PropSection {
title: t!("Solid History").into_owned(),
props: vec![
Property {
label: t!("History").into_owned(),
field: PROP_HISTORY,
@@ -1020,7 +1055,8 @@ fn brep_properties(
},
},
],
}]
},
]
}
fn cylinder_properties(
@@ -2186,11 +2222,50 @@ fn set_pyramid_sides(value: &mut SolidHistoryPyramid, sides: i32) -> bool {
true
}
fn apply_brep_position_property(
value: &mut SolidHistoryBrep,
field: &str,
text: &str,
) -> Option<bool> {
let axis = match field {
PROP_POSITION_X => 0,
PROP_POSITION_Y => 1,
PROP_POSITION_Z => 2,
_ => return None,
};
let Some(target) = crate::entities::common::parse_length(text) else {
return Some(false);
};
if !target.is_finite() {
return Some(false);
}
let Some(current) = matrix(value.base.transform) else {
return Some(false);
};
let position = current.transform_point3(glam::DVec3::ZERO);
let mut next = position;
next[axis] = target;
if next == position {
return Some(true);
}
let updated = glam::DMat4::from_translation(next - position) * current;
if !updated.is_finite() || updated.determinant().abs() <= 1e-12 {
return Some(false);
}
value.base.transform = updated.to_cols_array();
Some(true)
}
pub fn apply_primitive_property(
operation: &mut SolidHistoryOperation,
field: &str,
value: &str,
) -> bool {
if let SolidHistoryOperation::Brep(brep_value) = operation {
if let Some(applied) = apply_brep_position_property(brep_value, field, value) {
return applied;
}
}
if let SolidHistoryOperation::Box(rectangular_value)
| SolidHistoryOperation::Wedge(rectangular_value) = operation
{
+17 -1
View File
@@ -337,6 +337,18 @@ pub fn nearest_planar_face(body: &Body, pick: [f64; 3]) -> Option<FaceKey> {
matches!(body.surfaces.get(node.surface)?, Surface::Plane(_)).then_some(face)
}
/// Face nearest a world-space surface pick, including curved surfaces.
pub fn nearest_face(body: &Body, pick: [f64; 3]) -> Option<FaceKey> {
body.face_keys()
.filter_map(|key| {
let face = body.faces.get(key)?;
let surface = body.surfaces.get(face.surface)?;
Some((key, surface.distance_to(pick).abs()))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(key, _)| key)
}
/// Outward normal of a planar face.
pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> {
let face = body.faces.get(face)?;
@@ -475,7 +487,11 @@ pub fn display_from_solid(
)
})
.collect();
Some((mesh_from_tessellation(tessellation, color)?, wires, center))
let mut mesh = mesh_from_tessellation(tessellation, color)?;
if let Some(properties) = cadkernel::brep::analytic_mass_properties(body) {
mesh.apply_mass_properties(properties);
}
Some((mesh, wires, center))
}
/// The middle of a body, for a caller needing a point to turn or scale about.
+1 -3
View File
@@ -1235,9 +1235,7 @@ impl Scene {
generator.source = source;
}
}
set.metrics.centroid[0] += delta[0];
set.metrics.centroid[1] += delta[1];
set.metrics.centroid[2] += delta[2];
set.metrics.translate(delta);
set.recompute_aabb();
}