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

+2 -1
View File
@@ -77,7 +77,8 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ; IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features 3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies); need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature (manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
+1 -1
View File
@@ -49,7 +49,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 | | M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 | | M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 | | M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | | M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
+1
View File
@@ -329,6 +329,7 @@ impl FeatureKind {
| FeatureKind::Thicken { .. } | FeatureKind::Thicken { .. }
| FeatureKind::Import { .. } | FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. } | FeatureKind::MeshImport { .. }
| FeatureKind::MeshConvert { .. }
| FeatureKind::BodyTransfer { .. } => {} | FeatureKind::BodyTransfer { .. } => {}
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt), FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => { FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
+5 -3
View File
@@ -356,9 +356,11 @@ impl crate::Feature {
mapped_name(n, bodies); mapped_name(n, bodies);
} }
} }
K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => { K::Split { body, .. }
mapped_name(body, bodies) | K::SheetConvert { body, .. }
} | K::SurfaceExtend { body, .. }
| K::SurfaceTrim { body, .. }
| K::MeshConvert { body, .. } => mapped_name(body, bodies),
_ => {} _ => {}
} }
match &mut self.kind { match &mut self.kind {
+10 -2
View File
@@ -883,8 +883,12 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
components: Vec<solvecraft_kernel::StepNode>, components: Vec<solvecraft_kernel::StepNode>,
}, },
/// A non-parametric base feature: mesh bodies imported from a 3MF or STL file (kept in the /// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces.
/// design). Mesh bodies render, measure, move and export; solid features cannot use them. MeshConvert {
body: String,
tolerance: String,
},
/// Mesh bodies imported from a 3MF or STL file, embedded in the design.
MeshImport { MeshImport {
/// Source file name (display only). /// Source file name (display only).
file: String, file: String,
@@ -1162,6 +1166,7 @@ impl FeatureKind {
| K::Move { .. } | K::Move { .. }
| K::Import { .. } | K::Import { .. }
| K::MeshImport { .. } | K::MeshImport { .. }
| K::MeshConvert { .. }
| K::BodyTransfer { .. } => Vec::new(), | K::BodyTransfer { .. } => Vec::new(),
} }
} }
@@ -1247,6 +1252,7 @@ impl FeatureKind {
FeatureKind::SurfaceExtend { .. } => "ExtendFeature", FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
FeatureKind::Import { .. } => "BaseFeature", FeatureKind::Import { .. } => "BaseFeature",
FeatureKind::MeshImport { .. } => "MeshFeature", FeatureKind::MeshImport { .. } => "MeshFeature",
FeatureKind::MeshConvert { .. } => "MeshConvertFeature",
} }
} }
/// Default name prefix (`Extrude` → `Extrude1`). /// Default name prefix (`Extrude` → `Extrude1`).
@@ -1312,6 +1318,7 @@ impl FeatureKind {
FeatureKind::SurfaceExtend { .. } => "Extend", FeatureKind::SurfaceExtend { .. } => "Extend",
FeatureKind::Import { .. } => "Import", FeatureKind::Import { .. } => "Import",
FeatureKind::MeshImport { .. } => "Mesh", FeatureKind::MeshImport { .. } => "Mesh",
FeatureKind::MeshConvert { .. } => "ConvertMesh",
} }
} }
/// Every expression the feature uses. /// Every expression the feature uses.
@@ -1520,6 +1527,7 @@ impl FeatureKind {
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v), FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {} FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance), FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
FeatureKind::MeshConvert { tolerance, .. } => v.push(tolerance),
FeatureKind::Thicken { thickness, .. } => v.push(thickness), FeatureKind::Thicken { thickness, .. } => v.push(thickness),
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v), FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance), FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
+14
View File
@@ -3299,6 +3299,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
} }
Ok(()) Ok(())
} }
FeatureKind::MeshConvert { body, tolerance } => {
let tolerance = val(vals, tolerance, Kind::Length)?;
let index = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let source = st.bodies.get(index).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
if doc.model_body_component(source) != f.component {
return Err(DocError::Invalid("activate the mesh body's component before converting it".into()));
}
let converted = kernel::convert_prismatic_mesh(&source.body, tolerance)?;
let converted = converted.with_color(source.body.color());
let result = ModelBody::new(source.name.clone(), converted, f.id);
let target = st.bodies.get_mut(index).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
*target = result;
Ok(())
}
FeatureKind::MeshImport { meshes, .. } => { FeatureKind::MeshImport { meshes, .. } => {
for (k, (name, b)) in mesh_bodies(meshes)?.into_iter().enumerate() { for (k, (name, b)) in mesh_bodies(meshes)?.into_iter().enumerate() {
let name = copy_body_name(st, f, k, &name); let name = copy_body_name(st, f, k, &name);
+1
View File
@@ -259,6 +259,7 @@ impl FeatureKind {
} }
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v), FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)), FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
FeatureKind::MeshConvert { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)), FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v), FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)), FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
+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; mod inspect;
pub(crate) mod joints; pub(crate) mod joints;
mod measure_sel; mod measure_sel;
mod mesh;
pub(crate) mod motion; pub(crate) mod motion;
mod parts; mod parts;
mod section; mod section;
@@ -157,6 +158,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch_project::COMMANDS.iter()); v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter()); v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter()); v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct::COMMANDS.iter()); v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter()); v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter()); v.extend(plastic::COMMANDS.iter());
+2
View File
@@ -4,6 +4,8 @@ use serde_json::{Value, json};
use crate::*; use crate::*;
mod mesh_convert;
fn run(s: &mut Session, id: &str, p: Value) -> Value { fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) { match s.execute(id, &p) {
Ok(v) => v, 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();
}
+2
View File
@@ -35,6 +35,7 @@ mod intersection_curve_tests;
mod lip; mod lip;
mod loopblend; mod loopblend;
mod measure; mod measure;
mod mesh_convert;
mod meshbody; mod meshbody;
mod offset; mod offset;
mod ops; mod ops;
@@ -69,6 +70,7 @@ pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
pub use iges_out::iges_export_bodies; pub use iges_out::iges_export_bodies;
pub use lip::raise_band; pub use lip::raise_band;
pub use measure::{BodyMeasure, measure}; pub use measure::{BodyMeasure, measure};
pub use mesh_convert::convert_prismatic_mesh;
pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use offset::with_same_surface; pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix}; pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix};
+471
View File
@@ -0,0 +1,471 @@
//! Conservative reconstruction of constant-section mechanical mesh parts.
//! Geometry is inferred from closed, manifold input, never from a display mesh.
use std::collections::BTreeMap;
use solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use crate::{Body, KernelError, Result};
const MAX_TRIANGLES: usize = 50_000;
const MAX_BOUNDARY: usize = 8192;
fn invalid(message: &str) -> KernelError {
KernelError::Invalid(format!("mesh conversion: {message}"))
}
/// Reconstruct a constant-section mesh as analytic planes and, where certified
/// within `tolerance` mm, circular cylinders. The source mesh is not modified.
/// Unsupported shapes are refused instead of silently returning a faceted solid.
pub fn convert_prismatic_mesh(body: &Body, tolerance: f64) -> Result<Body> {
crate::guard("convert prismatic mesh", || {
if !tolerance.is_finite() || !(1e-6..=1.).contains(&tolerance) {
return Err(invalid("tolerance must be between 0.000001 and 1 mm"));
}
let input = body.mesh.as_ref().ok_or_else(|| invalid("select a mesh body"))?;
let welded = weld(input, tolerance)?;
let mesh = welded.mesh.as_ref().ok_or_else(|| invalid("empty mesh"))?;
if mesh.triangles.len() > MAX_TRIANGLES {
return Err(invalid("mesh exceeds the 50000-triangle conversion budget"));
}
let mut edges: BTreeMap<(u32, u32), (usize, i32)> = BTreeMap::new();
let mut axes: BTreeMap<(i64, i64, i64), (Vec3, f64)> = BTreeMap::new();
for triangle in &mesh.triangles {
let [a, b, c] = *triangle;
let (Some(pa), Some(pb), Some(pc)) = (mesh.positions.get(a as usize), mesh.positions.get(b as usize), mesh.positions.get(c as usize))
else {
return Err(invalid("triangle refers to a missing vertex"));
};
let cross = (*pb - *pa).cross(*pc - *pa);
let Some(mut n) = cross.normalized() else { return Err(invalid("degenerate triangle")) };
for (a, b) in [(a, b), (b, c), (c, a)] {
let entry = edges.entry((a.min(b), a.max(b))).or_default();
entry.0 += 1;
entry.1 += if a < b { 1 } else { -1 };
}
if n.x < -1e-8 || (n.x.abs() <= 1e-8 && (n.y < -1e-8 || (n.y.abs() <= 1e-8 && n.z < 0.))) {
n = -n;
}
let key = ((n.x * 1e7).round() as i64, (n.y * 1e7).round() as i64, (n.z * 1e7).round() as i64);
let entry = axes.entry(key).or_insert((n, 0.));
entry.1 += cross.len();
}
if edges.values().any(|&(count, balance)| count != 2 || balance != 0) {
return Err(invalid("mesh must be closed and consistently oriented, with two triangles per edge"));
}
let mut axes: Vec<_> = axes.into_values().collect();
axes.sort_by(|a, b| b.1.total_cmp(&a.1));
for (axis, _) in axes.into_iter().take(16) {
if let Some(result) = extrusion(mesh, axis, tolerance)? {
return Ok(result);
}
}
Err(invalid("this mesh is not a supported constant-section prism; no geometry was converted"))
})
}
fn weld(mesh: &crate::meshbody::TriMesh, tolerance: f64) -> Result<Body> {
if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len() > MAX_TRIANGLES * 3 {
return Err(invalid("mesh exceeds the conversion budget"));
}
let bounds = mesh.positions.iter().fold(solvecraft_geom::Aabb3::EMPTY, |mut b, p| {
b.add(*p);
b
});
let epsilon = (bounds.size().len().max(1.) * 1e-7).min(tolerance * 0.01).max(1e-9);
if !epsilon.is_finite() || mesh.positions.iter().any(|p| [p.x, p.y, p.z].iter().any(|v| !v.is_finite() || (v / epsilon).abs() > 1e15)) {
return Err(invalid("mesh coordinates exceed the conversion budget"));
}
let key = |p: Vec3| ((p.x / epsilon).floor() as i64, (p.y / epsilon).floor() as i64, (p.z / epsilon).floor() as i64);
let mut grid = BTreeMap::<_, Vec<u32>>::new();
let mut positions: Vec<Vec3> = Vec::new();
let mut remap = Vec::new();
let mut comparisons = 0usize;
for &p in &mesh.positions {
let k = key(p);
let mut found = None;
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
for &i in grid.get(&(k.0 + dx, k.1 + dy, k.2 + dz)).into_iter().flatten() {
comparisons += 1;
if comparisons > 8_000_000 {
return Err(invalid("vertex welding exceeds its work budget"));
}
if positions.get(i as usize).is_some_and(|q| q.dist(p) <= epsilon) {
found = Some(i);
break;
}
}
}
}
}
let i = match found {
Some(i) => i,
None => {
let i = u32::try_from(positions.len()).map_err(|_| invalid("too many vertices"))?;
positions.push(p);
grid.entry(k).or_default().push(i);
i
}
};
remap.push(i);
}
let triangles = mesh
.triangles
.iter()
.map(|t| {
let (Some(&a), Some(&b), Some(&c)) = (remap.get(t[0] as usize), remap.get(t[1] as usize), remap.get(t[2] as usize)) else {
return Err(invalid("missing triangle vertex"));
};
Ok([a, b, c])
})
.collect::<Result<Vec<_>>>()?;
crate::mesh_body(&positions, &triangles)
}
fn extrusion(mesh: &crate::meshbody::TriMesh, axis: Vec3, tolerance: f64) -> Result<Option<Body>> {
let (lo, hi) = mesh.positions.iter().map(|p| p.dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), h| (lo.min(h), hi.max(h)));
let height = hi - lo;
let bounds = mesh.positions.iter().fold(solvecraft_geom::Aabb3::EMPTY, |mut b, p| {
b.add(*p);
b
});
let epsilon = (bounds.size().len().max(1.) * 1e-7).min(tolerance * 0.01).max(1e-9);
if height <= epsilon {
return Ok(None);
}
let Some(plane) = Plane::from_normal(axis * lo, axis) else { return Ok(None) };
let mut caps = [BTreeMap::<(u32, u32), usize>::new(), BTreeMap::new()];
for triangle in &mesh.triangles {
let [a, b, c] = *triangle;
let (Some(pa), Some(pb), Some(pc)) = (mesh.positions.get(a as usize), mesh.positions.get(b as usize), mesh.positions.get(c as usize)) else {
return Err(invalid("missing triangle vertex"));
};
let hs = [pa.dot(axis), pb.dot(axis), pc.dot(axis)];
let cap = if hs.iter().all(|h| (h - lo).abs() <= epsilon) {
0
} else if hs.iter().all(|h| (h - hi).abs() <= epsilon) {
1
} else {
// STL stores coordinates as f32. Test the projected triangle's
// distance from a line rather than amplifying that rounding into
// an angular error on narrow side triangles.
let [a, b, c] = [plane.to_local(*pa), plane.to_local(*pb), plane.to_local(*pc)];
let length = a.dist(b).max(b.dist(c)).max(c.dist(a));
if (b - a).cross(c - a).abs() > epsilon * 3. * length {
return Ok(None);
}
continue;
};
let Some(edges) = caps.get_mut(cap) else { return Ok(None) };
for (a, b) in [(a, b), (b, c), (c, a)] {
*edges.entry((a.min(b), a.max(b))).or_default() += 1;
}
}
let raw_key = |p: Vec2| ((p.x / epsilon).floor() as i64, (p.y / epsilon).floor() as i64);
let mut positions = BTreeMap::<(i64, i64), Vec2>::new();
let mut projected = Vec::with_capacity(mesh.positions.len());
for p in &mesh.positions {
let p = plane.to_local(*p);
let k = raw_key(p);
let mut canonical = None;
for dx in -1..=1 {
for dy in -1..=1 {
let candidate = (k.0 + dx, k.1 + dy);
if positions.get(&candidate).is_some_and(|q| q.dist(p) <= epsilon) {
canonical = Some(candidate);
}
}
}
let canonical = canonical.unwrap_or(k);
positions.entry(canonical).or_insert(p);
projected.push(canonical);
}
let boundary = |cap: usize| -> Option<BTreeMap<(i64, i64), Vec<(i64, i64)>>> {
let mut adjacency = BTreeMap::<_, Vec<_>>::new();
for (&(a, b), &count) in caps.get(cap)? {
if count == 2 {
continue;
}
if count != 1 {
return None;
}
let (a, b) = (*projected.get(a as usize)?, *projected.get(b as usize)?);
if a == b {
return None;
}
adjacency.entry(a).or_default().push(b);
adjacency.entry(b).or_default().push(a);
}
for neighbours in adjacency.values_mut() {
neighbours.sort();
}
Some(adjacency)
};
let (Some(mut adjacency), Some(other)) = (boundary(0), boundary(1)) else { return Ok(None) };
if adjacency != other || adjacency.is_empty() || adjacency.len() > MAX_BOUNDARY || adjacency.values().any(|n| n.len() != 2) {
return Ok(None);
}
// Every side vertex must lie on an unchanged straight extrusion of the cap.
// This excludes twisted, tapered and stepped meshes before fitting any circles.
let point = |k: (i64, i64)| Vec2::new(k.0 as f64 * epsilon, k.1 as f64 * epsilon);
let boundary_segments: Vec<_> =
adjacency.iter().flat_map(|(&a, bs)| bs.iter().filter(move |&&b| a < b).map(move |&b| (point(a), point(b)))).collect();
if mesh.positions.len().saturating_mul(boundary_segments.len()) > 32_000_000 {
return Err(invalid("boundary certification exceeds its work budget"));
}
for p in &mesh.positions {
let h = p.dot(axis);
if (h - lo).abs() <= epsilon || (h - hi).abs() <= epsilon {
continue;
}
let p = plane.to_local(*p);
if !boundary_segments.iter().any(|&(a, b)| {
solvecraft_geom::Seg2::Line { a, b }.point_at(solvecraft_geom::Seg2::Line { a, b }.closest_param(p)).dist(p) <= epsilon * 3.
}) {
return Ok(None);
}
}
let mut loops = Vec::new();
let mut raw_loops = Vec::new();
while let Some((&start, _)) = adjacency.first_key_value() {
let mut current = start;
let mut previous = None;
let mut chain = Vec::new();
for _ in 0..=MAX_BOUNDARY {
let Some(neighbours) = adjacency.remove(&current) else { return Ok(None) };
chain.push(*positions.get(&current).unwrap_or(&point(current)));
let next = neighbours.into_iter().find(|n| Some(*n) != previous);
let Some(next) = next else { return Ok(None) };
if next == start {
break;
}
previous = Some(current);
current = next;
}
if chain.len() < 3 || current == start {
return Ok(None);
}
raw_loops.push(Loop2::polygon(&chain));
loops.push(fit_loop(&chain, tolerance, epsilon));
}
certify_nesting(&raw_loops, &loops, epsilon, bounds.size().len())?;
// Nest by the original polygon, so smoothing cannot change which loop is a hole.
let mut regions: Vec<Region2> = Vec::new();
for (i, lp) in loops.iter().enumerate() {
let Some(probe) = raw_loops.get(i).and_then(|l| l.segs.first()).map(|s| s.start()) else { return Ok(None) };
let owners: Vec<_> = raw_loops.iter().enumerate().filter(|(j, other)| *j != i && other.contains(probe)).collect();
if owners.len() % 2 == 0 {
regions.push(Region2 { outer: lp.ccw(), holes: Vec::new() });
}
}
for (i, lp) in loops.iter().enumerate() {
let Some(probe) = raw_loops.get(i).and_then(|l| l.segs.first()).map(|s| s.start()) else { return Ok(None) };
let owners = raw_loops.iter().enumerate().filter(|(j, other)| *j != i && other.contains(probe)).count();
if owners % 2 == 1 {
let owner = regions
.iter()
.enumerate()
.filter(|(_, r)| r.outer.contains(probe))
.min_by(|(_, a), (_, b)| a.outer.signed_area().abs().total_cmp(&b.outer.signed_area().abs()))
.map(|(i, _)| i);
let Some(region) = owner.and_then(|i| regions.get_mut(i)) else { return Ok(None) };
region.holes.push(lp.ccw().reversed());
}
}
let body = crate::extrude_planar_regions(&plane, &regions, 0., height)?;
if !body.validity().is_empty() {
return Err(invalid("reconstructed solid did not pass validity checks"));
}
let perimeter: f64 = loops.iter().flat_map(|lp| &lp.segs).map(|s| s.length()).sum();
let original = mesh.to_mesh().measure().volume;
let rebuilt = crate::measure(&body)?.volume;
if !original.is_finite() || original <= 0. || (rebuilt - original).abs() > perimeter * height * tolerance + epsilon * bounds.size().len().powi(2)
{
return Err(invalid("reconstructed volume is inconsistent with the source mesh"));
}
Ok(Some(body))
}
fn certify_nesting(raw: &[Loop2], fitted: &[Loop2], epsilon: f64, size: f64) -> Result<()> {
let mut work = 0usize;
for (i, a) in raw.iter().enumerate() {
for (j, b) in raw.iter().enumerate().skip(i + 1) {
work = work.saturating_add(a.segs.len().saturating_mul(b.segs.len()));
if work > 16_000_000 {
return Err(invalid("loop certification exceeds its work budget"));
}
let contains = |a: &Loop2, b: &Loop2| b.segs.first().is_some_and(|s| a.contains(s.start()));
let (ab, ba) = (contains(a, b), contains(b, a));
if ab && ba {
return Err(invalid("overlapping boundary loops"));
}
for (a, b) in
[(a, b), (fitted.get(i).ok_or_else(|| invalid("missing fitted loop"))?, fitted.get(j).ok_or_else(|| invalid("missing fitted loop"))?)]
{
let region = |l: &Loop2| Region2 { outer: l.ccw(), holes: Vec::new() };
let overlap = crate::planar_regions_boolean(&[region(a)], &[region(b)], crate::BoolOp::Intersect, epsilon.clamp(1e-9, 1.))?;
let area: f64 = overlap.iter().map(Region2::area).sum();
let expected = if ab {
b.signed_area().abs()
} else if ba {
a.signed_area().abs()
} else {
0.
};
if (area - expected).abs() > epsilon * size.max(1.) * 10. {
return Err(invalid("conversion would cross or merge boundary loops"));
}
}
}
}
Ok(())
}
fn fit_loop(points: &[Vec2], tolerance: f64, epsilon: f64) -> Loop2 {
// Fit a complete circular boundary only with enough samples to distinguish it
// from a polygon. Certify the chord deviation, not just the vertex residual.
if points.len() >= 12 {
let (Some(&a), Some(&b), Some(&c)) = (points.first(), points.get(points.len() / 3), points.get(2 * points.len() / 3)) else {
return Loop2::polygon(points);
};
let u = b - a;
let v = c - a;
let d = 2. * u.cross(v);
if d.abs() > epsilon * epsilon {
let center = a + Vec2::new(v.y * u.len2() - u.y * v.len2(), u.x * v.len2() - v.x * u.len2()) / d;
let radius = center.dist(a);
if radius.is_finite() && radius > epsilon && points.iter().all(|p| (center.dist(*p) - radius).abs() <= epsilon * 3.) {
let mut sweep = 0.;
let mut direction = 0f64;
let valid = (0..points.len()).all(|i| {
let (Some(a), Some(b)) = (points.get(i), points.get((i + 1) % points.len())) else { return false };
let signed = ((*a - center).cross(*b - center)).atan2((*a - center).dot(*b - center));
if direction == 0. {
direction = signed.signum();
}
if signed * direction <= 0. {
return false;
}
sweep += signed;
let angle = signed.abs();
angle <= std::f64::consts::PI / 6. + 1e-8 && radius * (1. - (angle * 0.5).cos()) + epsilon * 10. <= tolerance
});
if valid && (sweep.abs() - std::f64::consts::TAU).abs() <= 1e-7 {
return Loop2::circle(center, radius);
}
}
}
}
let mut points = points.to_vec();
let linear_tolerance = epsilon / points.len().max(1) as f64;
loop {
let n = points.len();
if n <= 3 {
break;
}
let remove = (0..n).find(|&i| {
let (Some(a), Some(b), Some(c)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { return false };
let edge = solvecraft_geom::Seg2::Line { a: *a, b: *c };
edge.point_at(edge.closest_param(*b)).dist(*b) <= linear_tolerance
});
let Some(index) = remove else { break };
points.remove(index);
}
Loop2::polygon(&points)
}
#[cfg(test)]
mod tests {
use super::*;
fn mesh_of(body: &Body) -> Body {
let mesh = body.tessellate(0.005).unwrap();
crate::mesh_body(&mesh.positions, &mesh.triangles).unwrap()
}
#[test]
fn reconstructs_planes_holes_and_round_walls_in_arbitrary_orientation() {
let region = Region2 {
outer: Loop2::polygon(&[
Vec2::new(0., 0.),
Vec2::new(40., 0.),
Vec2::new(40., 20.),
Vec2::new(20., 20.),
Vec2::new(20., 30.),
Vec2::new(0., 30.),
]),
holes: vec![Loop2::circle(Vec2::new(10., 10.), 4.).reversed()],
};
let source = crate::extrude_planar_regions(&Plane::XY, &[region], 0., 15.).unwrap();
let source = crate::transform(&source, Vec3::new(31., -17., 9.), Vec3::ZERO, Vec3::new(1., 2., 3.), 0.73).unwrap();
let converted = convert_prismatic_mesh(&mesh_of(&source), 0.05).unwrap();
assert!(!converted.is_mesh());
assert!(converted.validity().is_empty());
let expected = (1000. - std::f64::consts::PI * 16.) * 15.;
let fine_volume = |body: &Body| body.tessellate_with(1e-5, true).unwrap().measure().volume;
assert!((fine_volume(&converted) - expected).abs() < 0.02);
assert!((crate::measure(&converted).unwrap().volume - crate::measure(&source).unwrap().volume).abs() < 1e-7);
assert_eq!(crate::measure(&converted).unwrap().merged.face_types.get("cylinder"), Some(&1));
assert!(converted.faces(0.01).unwrap().len() < 15);
let step = crate::step_export(&[&converted], "SolidCraft regression").unwrap();
let reopened = crate::step_import(&step).unwrap();
assert!((fine_volume(&reopened.bodies[0].body) - expected).abs() < 0.02);
}
#[test]
fn planar_boxes_are_exact_and_coarse_polygons_are_not_rounded() {
let source = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
let result = convert_prismatic_mesh(&mesh_of(&source), 0.01).unwrap();
assert_eq!(result.faces(0.01).unwrap().len(), 6);
assert!((crate::measure(&result).unwrap().volume - 6000.).abs() < 1e-6);
let points: Vec<_> = (0..6)
.map(|i| {
let angle = i as f64 * std::f64::consts::TAU / 6.;
Vec2::new(angle.cos(), angle.sin()) * 10.
})
.collect();
assert_eq!(fit_loop(&points, 1., 1e-6).segs.len(), 6);
let points: Vec<_> = (0..16)
.map(|i| {
let angle = i as f64 * std::f64::consts::TAU / 16.;
Vec2::new(angle.cos(), angle.sin()) * 10.
})
.collect();
assert_eq!(fit_loop(&points, 0.001, 1e-6).segs.len(), 16);
assert_eq!(fit_loop(&points, 0.2, 1e-6).segs.len(), 2);
}
#[test]
fn refuses_open_nonprismatic_and_hostile_inputs() {
let open = crate::mesh_body(&[Vec3::ZERO, Vec3::X, Vec3::Y], &[[0, 1, 2]]).unwrap();
assert!(convert_prismatic_mesh(&open, 0.05).is_err());
let solid = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap();
assert!(convert_prismatic_mesh(&solid, 0.05).is_err());
for tolerance in [f64::NAN, f64::INFINITY, -1., 0., 2.] {
assert!(convert_prismatic_mesh(&mesh_of(&solid), tolerance).is_err());
}
let sphere = crate::sphere(Vec3::ZERO, 10.).unwrap();
assert!(convert_prismatic_mesh(&mesh_of(&sphere), 0.05).is_err());
for coordinate in [f64::NAN, f64::INFINITY, f64::MAX, 1e30] {
let mesh = crate::meshbody::TriMesh { positions: vec![Vec3::new(coordinate, 0., 0.)], triangles: vec![] };
assert!(weld(&mesh, 0.05).is_err());
}
}
#[test]
fn fitting_cannot_open_a_hole_through_a_nearby_wall() {
let outer = Loop2::polygon(&[Vec2::new(-1., -1.), Vec2::new(1., -1.), Vec2::new(1., 1.), Vec2::new(-1., 1.)]);
let points: Vec<_> = (0..12)
.map(|i| {
let angle = (i as f64 + 0.5) * std::f64::consts::TAU / 12.;
Vec2::new(angle.cos(), angle.sin()) * 1.02
})
.collect();
let inner = Loop2::polygon(&points);
let fitted = fit_loop(&points, 0.05, 1e-6);
assert_eq!(fitted.segs.len(), 2);
assert!(certify_nesting(&[outer.clone(), inner], &[outer, fitted], 1e-6, 3.).is_err());
}
}
+13 -1
View File
@@ -16,6 +16,7 @@ use crate::selection::{BODIES, EDGES, FACES, PLANES, PROFILES, SelInput};
pub enum Surf { pub enum Surf {
Patch, Patch,
Stitch { tolerance: String }, Stitch { tolerance: String },
ConvertMesh { tolerance: String },
Thicken { thickness: String, symmetric: bool, operation: usize }, Thicken { thickness: String, symmetric: bool, operation: usize },
Extend { distance: String }, Extend { distance: String },
Trim, Trim,
@@ -27,6 +28,7 @@ impl Surf {
match self { match self {
Surf::Patch => "PATCH", Surf::Patch => "PATCH",
Surf::Stitch { .. } => "STITCH", Surf::Stitch { .. } => "STITCH",
Surf::ConvertMesh { .. } => "CONVERT MESH",
Surf::Thicken { .. } => "THICKEN", Surf::Thicken { .. } => "THICKEN",
Surf::Extend { .. } => "EXTEND", Surf::Extend { .. } => "EXTEND",
Surf::Trim => "TRIM", Surf::Trim => "TRIM",
@@ -48,6 +50,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
match k { match k {
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)], Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)], Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
Surf::ConvertMesh { .. } => vec![SelInput::new("Mesh body", BODIES, false)],
Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)], Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
Surf::Extend { .. } => vec![SelInput::new("Edges", EDGES, true)], Surf::Extend { .. } => vec![SelInput::new("Edges", EDGES, true)],
Surf::Trim => vec![ Surf::Trim => vec![
@@ -64,6 +67,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
let k = match id { let k = match id {
"surface.patch" => Surf::Patch, "surface.patch" => Surf::Patch,
"surface.stitch" => Surf::Stitch { tolerance: String::new() }, "surface.stitch" => Surf::Stitch { tolerance: String::new() },
"mesh.convert" => Surf::ConvertMesh { tolerance: "0.05 mm".into() },
"surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 }, "surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 },
"surface.extend" => Surf::Extend { distance: "10 mm".into() }, "surface.extend" => Surf::Extend { distance: "10 mm".into() },
"surface.trim" => Surf::Trim, "surface.trim" => Surf::Trim,
@@ -78,7 +82,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool { pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
let mut enter = false; let mut enter = false;
match k { match k {
Surf::Stitch { tolerance } => { Surf::Stitch { tolerance } | Surf::ConvertMesh { tolerance } => {
row_label(ui, "Tolerance"); row_label(ui, "Tolerance");
let r = ui.add(egui::TextEdit::singleline(tolerance).hint_text("tight")); let r = ui.add(egui::TextEdit::singleline(tolerance).hint_text("tight"));
crate::params_dialog::complete(ui, &r, tolerance); crate::params_dialog::complete(ui, &r, tolerance);
@@ -159,6 +163,11 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
} }
("surface.stitch", p) ("surface.stitch", p)
} }
Surf::ConvertMesh { tolerance } => {
need(0, "a closed mesh body")?;
let body = bodies(inputs, 0).into_iter().next().ok_or("select a mesh body")?;
("mesh.convert", json!({"body":body,"tolerance":tolerance}))
}
Surf::Thicken { thickness, symmetric, operation } => { Surf::Thicken { thickness, symmetric, operation } => {
need(0, "the surfaces to thicken")?; need(0, "the surfaces to thicken")?;
( (
@@ -235,6 +244,9 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelI
Surf::Stitch { tolerance: if tolerance.trim() == "0" { String::new() } else { tolerance.clone() } }, Surf::Stitch { tolerance: if tolerance.trim() == "0" { String::new() } else { tolerance.clone() } },
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()], vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
), ),
FeatureKind::MeshConvert { body, tolerance } => {
(Surf::ConvertMesh { tolerance: tolerance.clone() }, vec![vec![Sel::Body { name: body.clone() }]])
}
FeatureKind::Thicken { bodies, thickness, symmetric, operation, .. } => ( FeatureKind::Thicken { bodies, thickness, symmetric, operation, .. } => (
Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) }, Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) },
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()], vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
+1 -1
View File
@@ -32,7 +32,7 @@ fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::Draft { .. } => "draft", FeatureKind::Draft { .. } => "draft",
FeatureKind::Split { .. } => "split", FeatureKind::Split { .. } => "split",
FeatureKind::Move { .. } => "move", FeatureKind::Move { .. } => "move",
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => "import", FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::MeshConvert { .. } => "import",
other => crate::workspace::feature_icon(other).unwrap_or("feature"), other => crate::workspace::feature_icon(other).unwrap_or("feature"),
} }
} }
+44
View File
@@ -0,0 +1,44 @@
use serde_json::json;
use solvecraft_engine::doc::{Document, FeatureKind, MeshData};
use solvecraft_engine::geom::Vec3;
use solvecraft_engine::{Sel, Session};
use solvecraft_ui_egui::scenario::Harness;
#[test]
fn convert_mesh_uses_preselection_and_reopens_its_timeline_dialog() {
let body = solvecraft_engine::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: "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: None,
}],
},
None,
)
.unwrap();
let mut h = Harness::new();
h.app.session = Session::new(doc);
h.app.session.selection = vec![Sel::Body { name: "Mesh".into() }];
h.step(&json!({"start":"mesh.convert"})).unwrap();
let dialog = h.app.dialog.as_ref().unwrap();
assert_eq!(dialog.inputs[0].items, vec![Sel::Body { name: "Mesh".into() }]);
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.status);
assert!(!h.app.session.model.state().body("Mesh").unwrap().body.is_mesh());
let feature = h.app.session.doc.features.last().unwrap().id;
let response = h.call("ui.editFeature", json!({"feature":feature}));
assert_eq!(response["ok"], true, "{response}");
let dialog = h.app.dialog.as_ref().unwrap();
assert!(
matches!(&dialog.kind,solvecraft_ui_egui::dialogs::Kind::Surface(solvecraft_ui_egui::dialogs_surface::Surf::ConvertMesh { tolerance }) if tolerance=="0.05 mm")
);
assert!(h.app.session.model.state().body("Mesh").unwrap().body.is_mesh());
h.step(&json!({"key":"Escape"})).unwrap();
assert!(!h.app.session.model.state().body("Mesh").unwrap().body.is_mesh());
}
+14
View File
@@ -13,6 +13,20 @@ Opening a STEP or IGES file creates a design with the file's bodies as a base fe
inserting one adds them to the current design. Later features build on them. Mesh bodies render, inserting one adds them to the current design. Later features build on them. Mesh bodies render,
measure, move and export, but solid features need B-rep bodies. measure, move and export, but solid features need B-rep bodies.
For closed mechanical meshes with a constant cross-section, use **Mesh → Modify →
Convert Mesh**. Select the mesh body and set the fitting tolerance. The conversion
rebuilds planar faces and circular through-walls, replacing the mesh with a solid
that accepts solid modeling operations. The imported triangles remain embedded
upstream: changing their scale, undoing the conversion or reopening the design
rebuilds the result through the timeline. Double-click the conversion to edit its
tolerance or source body.
The tolerance bounds circular reconstruction against the original chords. Coarse
polygons remain polygons when rounding would exceed it. Open/nonmanifold meshes,
tapers, stepped parts and organic scans are refused by this first reconstruction
method; it does not silently substitute a faceted solid. Conversion currently
accepts at most 50,000 triangles and a bounded number of boundary segments.
Saves are atomic: a crash at any moment leaves either the old file or the new one, and the Saves are atomic: a crash at any moment leaves either the old file or the new one, and the
replaced version is kept as `<file>.bak`. Unsaved changes are autosaved to a recovery folder, replaced version is kept as `<file>.bak`. Unsaved changes are autosaved to a recovery folder,
and the next launch offers to recover them. Older design files are upgraded on open, and files and the next launch offers to recover them. Older design files are upgraded on open, and files
+3 -3
View File
@@ -2,9 +2,9 @@
Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolidCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolidCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth.
**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 335 / 541 commands live (62%).** SOLID + SKETCH: 188 / 291 (65%). Registered commands: 311. **In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 335 / 541 commands live (62%).** SOLID + SKETCH: 188 / 291 (65%). Registered commands: 314.
Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 630 / 1175 (54%). Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 631 / 1175 (54%).
| Tab | Panel | Live | Total | % | | Tab | Panel | Live | Total | % |
|---|---|---:|---:|---:| |---|---|---:|---:|---:|
@@ -64,7 +64,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| SURFACE | POSITION | 2 | 2 | 100% | | SURFACE | POSITION | 2 | 2 | 100% |
| MESH | CREATE | 2 | 5 | 40% | | MESH | CREATE | 2 | 5 | 40% |
| MESH | PREPARE | 0 | 4 | 0% | | MESH | PREPARE | 0 | 4 | 0% |
| MESH | MODIFY | 6 | 30 | 20% | | MESH | MODIFY | 7 | 30 | 23% |
| MESH | ASSEMBLE | 12 | 21 | 57% | | MESH | ASSEMBLE | 12 | 21 | 57% |
| MESH | CONFIGURE | 3 | 3 | 100% | | MESH | CONFIGURE | 3 | 3 | 100% |
| MESH | CONSTRUCT | 19 | 20 | 95% | | MESH | CONSTRUCT | 19 | 20 | 95% |
+1
View File
@@ -181,3 +181,4 @@ FusionSnapFitCommand plastic.snap_fit
FusionLipCommand plastic.lip FusionLipCommand plastic.lip
FusionRestCommand plastic.rest FusionRestCommand plastic.rest
ConstructionPlanePerpendicularToPlaneCommand construct.plane.perpendicular ConstructionPlanePerpendicularToPlaneCommand construct.plane.perpendicular
ParaMeshConvertCommand mesh.convert
1 # Fusion catalog command id -> SolveCraft command id, for `cargo xtask parity` only (observation data).
181 FusionLipCommand
182 FusionRestCommand
183 ConstructionPlanePerpendicularToPlaneCommand
184 ParaMeshConvertCommand