Reconstruct constant-section meshes as parametric solids

This commit is contained in:
lillith authored and bherbruck committed 2026-10-12 10:06:42 -04:00
1 parent 417511367c
commit df4c8a2994
19 files changed
+717 -12

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
//! Parametric reconstruction of imported mechanical mesh bodies.
use serde_json::Value;
use solvecraft_doc::FeatureKind;
use solvecraft_doc::expr::Kind;
use super::{
CommandSpec,
features::{add_feature, check_expr},
};
use crate::{
Result, Session,
params::{bad, expr, str_},
};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("mesh.convert", "Convert Mesh", convert)
.at("MESH", "MODIFY")
.icon("import")
.params("body: mesh body name; tolerance?: length expression (default 0.05 mm): reconstruct a closed constant-section mechanical mesh as editable planar and cylindrical faces, with a parametric timeline feature; name?"),
];
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "mesh.convert";
let body = str_(p, "body").filter(|b| !b.trim().is_empty()).ok_or_else(|| bad(cmd, "select a mesh body"))?;
let state = s.model.state();
let source = state.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
if !source.body.is_mesh() {
return Err(bad(cmd, "select a mesh body"));
}
if s.doc.model_body_component(source) != s.active_component {
return Err(bad(cmd, "activate the mesh body's component before converting it"));
}
let tolerance = expr(p, "tolerance").unwrap_or_else(|| "0.05 mm".into());
check_expr(s, &tolerance, Kind::Length, cmd, "tolerance")?;
add_feature(s, p, FeatureKind::MeshConvert { body: body.into(), tolerance })
}
+2
View File
@@ -26,6 +26,7 @@ mod file;
mod inspect;
pub(crate) mod joints;
mod measure_sel;
mod mesh;
pub(crate) mod motion;
mod parts;
mod section;
@@ -157,6 +158,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
+2
View File
@@ -4,6 +4,8 @@ use serde_json::{Value, json};
use crate::*;
mod mesh_convert;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
+93
View File
@@ -0,0 +1,93 @@
use super::*;
use solvecraft_doc::{Document, FeatureKind, MeshData};
use solvecraft_geom::Vec3;
fn imported_box() -> Session {
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
let mesh = body.tessellate(0.01).unwrap();
let mut doc = Document::default();
doc.add_feature(
FeatureKind::MeshImport {
file: "public-safe-box.stl".into(),
meshes: vec![MeshData {
name: "Mesh".into(),
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: Some([0.2, 0.5, 0.8]),
}],
},
Some("Imported mesh"),
)
.unwrap();
Session::new(doc)
}
#[test]
fn mesh_conversion_is_parametric_undoable_and_editable_after_cold_reopen() {
let mut s = imported_box();
run(&mut s, "parameters.add", json!({"name":"size","expression":"1","unit":""}));
run(&mut s, "solid.scale", json!({"bodies":["Mesh"],"factor":"size","name":"Mesh scale"}));
run(&mut s, "mesh.convert", json!({"body":"Mesh","tolerance":"0.01 mm","name":"Reconstructed prism"}));
let check = |s: &Session| {
let state = s.model.state();
let body = &state.body("Mesh").unwrap().body;
assert!(!body.is_mesh());
assert_eq!(body.faces(0.01).unwrap().len(), 6);
assert_eq!(body.color(), Some([0.2, 0.5, 0.8]));
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
};
check(&s);
assert!((volume(&mut s) - 6000.).abs() < 1e-6);
run(&mut s, "edit.undo", json!({}));
assert!(s.model.state().body("Mesh").unwrap().body.is_mesh());
run(&mut s, "edit.redo", json!({}));
check(&s);
run(&mut s, "parameters.change", json!({"name":"size","expression":"2"}));
check(&s);
assert!((volume(&mut s) - 48000.).abs() < 1e-5);
let mut cold = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
check(&cold);
assert!((volume(&mut cold) - 48000.).abs() < 1e-5);
run(&mut cold, "solid.fillet", json!({"body":"Mesh","edges":[[0,20,60]],"radius":2}));
assert!(volume(&mut cold) < 48000.);
assert!(cold.model.results.iter().all(|r| r.error.is_none()));
}
#[test]
fn failed_mesh_conversions_leave_the_design_unchanged() {
let mut s = imported_box();
let before = s.doc.to_json();
for params in [
json!({}),
json!({"body":"Missing"}),
json!({"body":"Mesh","tolerance":0}),
json!({"body":"Mesh","tolerance":"unknown"}),
json!({"body":"Mesh","tolerance":"2 mm"}),
] {
assert!(s.execute("mesh.convert", &params).is_err());
assert_eq!(s.doc.to_json(), before);
assert!(s.model.state().body("Mesh").unwrap().body.is_mesh());
}
}
#[test]
fn exported_binary_stl_with_a_bore_reconstructs_analytic_faces() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Section"}));
run(&mut s, "sketch.line", json!({"points":[[0,0],[40,0],[40,20],[20,20],[20,30],[0,30]],"closed":true}));
run(&mut s, "sketch.circle.center", json!({"center":[10,10],"radius":4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":"Section","profiles":[{"point":[30,10]}],"distance":15,"body_name":"Bracket"}));
let path = std::env::temp_dir().join(format!("solvecraft-mesh-convert-{}.stl", std::process::id()));
run(&mut s, "file.save_mesh", json!({"path":path}));
run(&mut s, "doc.open", json!({"path":path}));
let name = s.model.state().bodies[0].name.clone();
run(&mut s, "mesh.convert", json!({"body":name,"tolerance":"0.05 mm"}));
let state = s.model.state();
let body = &state.bodies[0].body;
assert!(!body.is_mesh());
assert!(body.validity().is_empty());
assert_eq!(solvecraft_kernel::measure(body).unwrap().merged.face_types.get("cylinder"), Some(&1));
assert_eq!(body.faces(0.01).unwrap().len(), 10);
std::fs::remove_file(path).unwrap();
}