diff --git a/Cargo.lock b/Cargo.lock index 902c37361..ffb760230 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=44cde65#44cde6532ea89a5b3169e5eb829cff44e51a3120" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=db2eae87f759f724115d5d7a7b3e7c44f9759b64#db2eae87f759f724115d5d7a7b3e7c44f9759b64" 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=256b7c829b6c1d64cd762568902c035eb6af7c31#256b7c829b6c1d64cd762568902c035eb6af7c31" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=20a4dfa75f5a00a4ce28f07a797134810f4006b0#20a4dfa75f5a00a4ce28f07a797134810f4006b0" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index fe2ac68bd..2e9f434fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "44cde65", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "256b7c829b6c1d64cd762568902c035eb6af7c31", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "20a4dfa75f5a00a4ce28f07a797134810f4006b0", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index a8848b619..6cdfcbb52 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -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 = "44cde65", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "256b7c829b6c1d64cd762568902c035eb6af7c31", features = ["acis", "offset"], optional = true } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "20a4dfa75f5a00a4ce28f07a797134810f4006b0", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 4df55bf53..76f070754 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "44cde65", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "44cde65", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 80b884411..60af2e4c4 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "44cde65", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 7b7b0537c..a802c2941 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "44cde65", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", features = ["serde"] } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 86ba8612e..a5cadff8e 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2929,6 +2929,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(); @@ -4127,6 +4135,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, diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 90eda4823..69dc797b2 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -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::>(); + 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; diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 3f1f58b26..b2ff7ea7e 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -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}) } @@ -1222,6 +1222,92 @@ mod tests { assert!((curve["length"].as_f64().unwrap() - std::f64::consts::TAU * 2.0).abs() < 1e-9); assert!((curve["area"].as_f64().unwrap() - std::f64::consts::PI * 4.0).abs() < 1e-9); } + + #[test] + fn control_shell_roundtrips_with_kernel_mass_properties() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"start","cmd":"BOX"})); + request( + &mut app, + json!({"op":"input","kind":"point","point":[0.0,0.0,0.0]}), + ); + request( + &mut app, + json!({"op":"input","kind":"point","point":[6.0,5.0,0.0]}), + ); + assert_eq!( + request(&mut app, json!({"op":"input","kind":"token","text":"4"}))["status"], + "completed" + ); + let handle = app.tabs[app.active_tab] + .scene + .document + .entities() + .find_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)).then(|| entity.common().handle) + }) + .unwrap(); + let handle_text = format!("{:X}", handle.value()); + + request(&mut app, json!({"op":"select","handles":[handle_text]})); + request(&mut app, json!({"op":"start","cmd":"SHELL"})); + request( + &mut app, + json!({"op":"input","kind":"entity","handle":handle_text,"point":[3.0,2.5,4.0]}), + ); + request(&mut app, json!({"op":"input","kind":"enter"})); + assert_eq!( + request(&mut app, json!({"op":"input","kind":"token","text":"0.5"}))["status"], + "completed" + ); + + let measured = app.control_measure(&json!({"handles":[handle_text]})); + let mesh = &measured["measurements"][0]["mesh"]; + assert!((mesh["volume"].as_f64().unwrap() - 50.0).abs() < 1e-9); + assert_eq!(mesh["principal_moments"].as_array().unwrap().len(), 3); + assert!(matches!( + app.tabs[app.active_tab] + .scene + .document + .solid_history_operation(handle), + Some(acadrust::objects::SolidHistoryOperation::Brep(_)) + )); + + assert_eq!(request(&mut app, json!({"op":"undo"}))["ok"], true); + let restored = app.control_measure(&json!({"handles":[handle_text]})); + assert!( + (restored["measurements"][0]["mesh"]["volume"] + .as_f64() + .unwrap() + - 120.0) + .abs() + < 1e-9 + ); + assert_eq!(request(&mut app, json!({"op":"redo"}))["ok"], true); + + let path = std::env::temp_dir().join(format!("ocs-shell-{}.dwg", session_id())); + assert_eq!( + request(&mut app, json!({"op":"save","path":path}))["ok"], + true + ); + request(&mut app, json!({"op":"new"})); + assert_eq!( + request(&mut app, json!({"op":"open","path":path}))["status"], + "completed" + ); + let reopened = app.control_measure(&json!({"handles":[handle_text]})); + assert!( + (reopened["measurements"][0]["mesh"]["volume"] + .as_f64() + .unwrap() + - 50.0) + .abs() + < 1e-9 + ); + let _ = std::fs::remove_file(path); + } + #[test] fn control_retry_is_idempotent_and_stale_state_rejected() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index b53480273..d767d9005 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -9,7 +9,7 @@ use acadrust::{ }; use cadkernel::brep::{Body, EdgeKey, FaceKey}; use iced::Task; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use super::Message; use crate::modules::model::boolean_cmd::BoolOp; @@ -861,6 +861,143 @@ impl super::OpenCADStudio { Task::none() } + pub(super) fn solid_shell( + &mut self, + handle: Handle, + actions: &[crate::command::ShellFaceAction], + distance: f64, + ) -> Task { + 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::>(); + let mut removed = HashSet::new(); + for action in actions { + match *action { + ShellFaceAction::Remove(point) => { + let Some(face) = cadkernel::brep::shell_face_at_point(&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) = cadkernel::brep::shell_face_at_point(&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::>(); + + 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(); + } + }; + + let Some(source) = self.tabs[i].scene.document.get_entity(handle).cloned() else { + return Task::none(); + }; + let Some(entity) = entity_with_boolean_body(source, &result) else { + self.command_line + .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); + return Task::none(); + }; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(handle, &result) + .filter(|display| display.0.complete) + else { + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref()); + return Task::none(); + }; + + self.push_undo_snapshot(i, "SHELL"); + self.tabs[i].scene.delete_solid_history(handle); + if !self.tabs[i].scene.update_entity(entity) { + self.command_line + .push_error(crate::t!("The solid could not be updated.").as_ref()); + return Task::none(); + } + self.tabs[i] + .scene + .create_solid_history(handle, solid_history::brep_op(&result)); + self.tabs[i] + .scene + .register_prepared_solid_model(handle, result, display); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + 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 { let i = self.active_tab; diff --git a/src/app/properties.rs b/src/app/properties.rs index ad055f125..a72daeaf4 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -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::>() + .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()); diff --git a/src/command.rs b/src/command.rs index 1f636b100..99d092712 100644 --- a/src/command.rs +++ b/src/command.rs @@ -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. @@ -1556,6 +1567,12 @@ pub enum CmdResult { other_value: f64, fillet: bool, }, + /// Offset a solid and optionally open selected faces. + SolidShell { + handle: Handle, + actions: Vec, + distance: f64, + }, SolidSubtract { bases: Vec, cutters: Vec, diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 08211e7cb..546342d00 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -5,6 +5,7 @@ pub mod cylinder_cmd; pub mod edge_cmd; pub mod polysolid_cmd; pub mod primitive_cmd; +pub mod shell_cmd; pub mod slice_cmd; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; @@ -91,6 +92,7 @@ impl CadModule for ModelModule { tools: vec![ RibbonItem::LargeTool(tool("FILLETEDGE", "Fillet Edge", FILLET_ICON)), RibbonItem::LargeTool(tool("CHAMFEREDGE", "Chamfer", CHAMFER_ICON)), + RibbonItem::LargeTool(tool("SHELL", "Shell", PRESSPULL_ICON)), ], }, ] diff --git a/src/modules/model/shell_cmd.rs b/src/modules/model/shell_cmd.rs new file mode 100644 index 000000000..7ceee1ef4 --- /dev/null +++ b/src/modules/model/shell_cmd.rs @@ -0,0 +1,308 @@ +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, + actions: Vec, + default_distance: f64, +} + +impl ShellCommand { + pub fn direct(target: Option) -> 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) -> 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 { + 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] :").into_owned() + } + ShellStep::AddFaces => { + crate::t!("Select faces to add [Undo/Remove/ALL] :").into_owned() + } + ShellStep::Distance => crate::tf!( + "Specify the shell offset distance <{:.3}>:", + self.default_distance + ) + .into_owned(), + } + } + + fn options(&self) -> Vec { + 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 { + 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::() 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 { + 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"] +}); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_shell_collects_face_edits_and_signed_distance() { + let handle = Handle::new(42); + let first = DVec3::new(1.0, 2.0, 3.0); + let second = DVec3::new(4.0, 5.0, 6.0); + let mut command = ShellCommand::direct(Some(handle)); + + command.on_entity_pick(handle, first); + command.on_text_input("ADD"); + command.on_entity_pick(handle, second); + command.on_text_input("UNDO"); + command.on_text_input("ALL"); + assert!(matches!(command.on_enter(), CmdResult::NeedPoint)); + + assert!(matches!( + command.on_text_input("-0.5"), + Some(CmdResult::SolidShell { + handle: result_handle, + actions, + distance: -0.5, + }) if result_handle == handle + && actions == vec![ShellFaceAction::Remove(first), ShellFaceAction::AddAll] + )); + } + + #[test] + fn invalid_distance_keeps_the_distance_step_active() { + let mut command = ShellCommand::direct(Some(Handle::new(7))); + command.on_enter(); + + assert!(matches!( + command.on_text_input("zero"), + Some(CmdResult::ReportError(_)) + )); + assert!(matches!( + command.on_text_input("0.25"), + Some(CmdResult::SolidShell { distance: 0.25, .. }) + )); + } +} diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index 5b74c78c6..c21c69364 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -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]; diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index 628b7c408..f59f91d77 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -55,6 +55,44 @@ 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 { + fn apply_mass_properties(&mut self, properties: cadkernel::brep::MassProperties) { + self.volume = properties.volume; + self.centroid = properties.centroid; + self.moment_of_inertia = properties.moment_of_inertia; + self.principal_directions = properties.principal_directions; + self.principal_moments = properties.principal_moments; + self.product_of_inertia = properties.product_of_inertia; + self.radii_of_gyration = properties.radii_of_gyration; + } + + pub fn translate(&mut self, delta: [f64; 3]) { + if self.volume > 1e-18 { + self.apply_mass_properties( + cadkernel::brep::MassProperties { + volume: self.volume, + centroid: self.centroid, + moment_of_inertia: self.moment_of_inertia, + principal_directions: self.principal_directions, + principal_moments: self.principal_moments, + product_of_inertia: self.product_of_inertia, + radii_of_gyration: self.radii_of_gyration, + } + .translated(delta), + ); + } else { + for axis in 0..3 { + self.centroid[axis] += delta[axis]; + } + } + } } #[derive(Clone, Debug)] @@ -146,74 +184,52 @@ fn compute_mesh_metrics(lods: &[MeshModel]) -> MeshMetrics { let Some(mesh) = lods.iter().find(|mesh| !mesh.indices.is_empty()) else { return MeshMetrics::default(); }; - let point = |index: u32| { - let index = index as usize; - let high = mesh.verts.get(index).copied().unwrap_or([0.0; 3]); - let low = mesh.verts_low.get(index).copied().unwrap_or([0.0; 3]); - [ - high[0] as f64 + low[0] as f64, - high[1] as f64 + low[1] as f64, - high[2] as f64 + low[2] as f64, - ] + let kernel_mesh = cadkernel::brep::Mesh { + positions: mesh + .verts + .iter() + .enumerate() + .map(|(index, high)| { + let low = mesh.verts_low.get(index).copied().unwrap_or([0.0; 3]); + [ + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ] + }) + .collect(), + normals: Vec::new(), + triangles: mesh + .indices + .chunks_exact(3) + .map(|triangle| { + [ + triangle[0] as usize, + triangle[1] as usize, + triangle[2] as usize, + ] + }) + .collect(), }; - let mut area = 0.0; - let mut area_centroid_numerator = [0.0; 3]; - let mut signed_volume = 0.0; - let mut centroid_numerator = [0.0; 3]; - for triangle in mesh.indices.chunks_exact(3) { - let a = point(triangle[0]); - let b = point(triangle[1]); - let c = point(triangle[2]); - let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; - let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; - let cross = [ - ab[1] * ac[2] - ab[2] * ac[1], - ab[2] * ac[0] - ab[0] * ac[2], - ab[0] * ac[1] - ab[1] * ac[0], - ]; - let triangle_area = 0.5 - * (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]) - .sqrt(); - area += triangle_area; - for axis in 0..3 { - area_centroid_numerator[axis] += - triangle_area * (a[axis] + b[axis] + c[axis]) / 3.0; - } - let tetra = ( - a[0] * (b[1] * c[2] - b[2] * c[1]) - - a[1] * (b[0] * c[2] - b[2] * c[0]) - + a[2] * (b[0] * c[1] - b[1] * c[0]) - ) / 6.0; - signed_volume += tetra; - for axis in 0..3 { - centroid_numerator[axis] += tetra * (a[axis] + b[axis] + c[axis]) / 4.0; - } - } - let centroid = if signed_volume.abs() > 1e-18 { - [ - centroid_numerator[0] / signed_volume, - centroid_numerator[1] / signed_volume, - centroid_numerator[2] / signed_volume, - ] - } else if area > 1e-18 { - [ - area_centroid_numerator[0] / area, - area_centroid_numerator[1] / area, - area_centroid_numerator[2] / area, - ] - } else { - [0.0; 3] - }; - MeshMetrics { + let surface = kernel_mesh.surface_properties(); + let mut metrics = MeshMetrics { vertices: mesh.verts.len(), triangles: mesh.indices.len() / 3, - surface_area: area, - volume: signed_volume.abs(), - centroid, + surface_area: surface.map_or(0.0, |value| value.0), + centroid: surface.map_or([0.0; 3], |value| value.1), + ..MeshMetrics::default() + }; + if let Some(properties) = kernel_mesh.inertial_properties() { + metrics.apply_mass_properties(properties); } + metrics } impl MeshLodSet { + pub fn apply_mass_properties(&mut self, properties: cadkernel::brep::MassProperties) { + self.metrics.apply_mass_properties(properties); + } + /// Build a set from its LODs, computing the 3D AABB. pub fn from_lods(lods: Vec) -> Self { let (world_aabb, z_aabb) = compute_mesh_aabb(&lods); diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 334981e0c..1718313ee 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1013,9 +1013,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, @@ -1037,7 +1072,8 @@ fn brep_properties( }, }, ], - }] + }, + ] } fn cylinder_properties( @@ -2203,11 +2239,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 { + 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 { diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index d6e8c6d9a..fedae9365 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -475,7 +475,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. diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 50f839d34..0b9fdc6a3 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1364,9 +1364,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(); }