From df4c8a2994e913082a136f8eddd5bc8c5e13bcc0 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:50:49 +0200 Subject: [PATCH] Reconstruct constant-section meshes as parametric solids --- README.md | 3 +- ROADMAP.md | 2 +- crates/doc/src/assembly.rs | 1 + crates/doc/src/clipboard.rs | 8 +- crates/doc/src/document.rs | 12 +- crates/doc/src/eval.rs | 14 + crates/doc/src/params.rs | 1 + crates/engine/src/cmd/mesh.rs | 37 ++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/tests.rs | 2 + crates/engine/src/tests/mesh_convert.rs | 93 +++++ crates/kernel/src/lib.rs | 2 + crates/kernel/src/mesh_convert.rs | 471 ++++++++++++++++++++++++ crates/ui-egui/src/dialogs_surface.rs | 14 +- crates/ui-egui/src/timeline.rs | 2 +- crates/ui-egui/tests/mesh_conversion.rs | 44 +++ docs/book/src/guide/files.md | 14 + docs/parity.md | 6 +- xtask/data/fusion-ids.tsv | 1 + 19 files changed, 717 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/cmd/mesh.rs create mode 100644 crates/engine/src/tests/mesh_convert.rs create mode 100644 crates/kernel/src/mesh_convert.rs create mode 100644 crates/ui-egui/tests/mesh_conversion.rs diff --git a/README.md b/README.md index cf3b9340..0ea28d8f 100644 --- a/README.md +++ b/README.md @@ -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), 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 - 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 (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 diff --git a/ROADMAP.md b/ROADMAP.md index be98a932..f8434677 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | | 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 | -| 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 | | 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 | diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index f284d5fa..b8924d45 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -329,6 +329,7 @@ impl FeatureKind { | FeatureKind::Thicken { .. } | FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } + | FeatureKind::MeshConvert { .. } | FeatureKind::BodyTransfer { .. } => {} FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt), FeatureKind::SurfaceTrim { plane, tool, keep, .. } => { diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index e60c2c7c..22837e8c 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -356,9 +356,11 @@ impl crate::Feature { mapped_name(n, bodies); } } - K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => { - mapped_name(body, bodies) - } + K::Split { body, .. } + | K::SheetConvert { body, .. } + | K::SurfaceExtend { body, .. } + | K::SurfaceTrim { body, .. } + | K::MeshConvert { body, .. } => mapped_name(body, bodies), _ => {} } match &mut self.kind { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 41cff42a..d863a052 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -883,8 +883,12 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Vec::is_empty")] components: Vec, }, - /// A non-parametric base feature: mesh bodies imported from a 3MF or STL file (kept in the - /// design). Mesh bodies render, measure, move and export; solid features cannot use them. + /// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces. + MeshConvert { + body: String, + tolerance: String, + }, + /// Mesh bodies imported from a 3MF or STL file, embedded in the design. MeshImport { /// Source file name (display only). file: String, @@ -1162,6 +1166,7 @@ impl FeatureKind { | K::Move { .. } | K::Import { .. } | K::MeshImport { .. } + | K::MeshConvert { .. } | K::BodyTransfer { .. } => Vec::new(), } } @@ -1247,6 +1252,7 @@ impl FeatureKind { FeatureKind::SurfaceExtend { .. } => "ExtendFeature", FeatureKind::Import { .. } => "BaseFeature", FeatureKind::MeshImport { .. } => "MeshFeature", + FeatureKind::MeshConvert { .. } => "MeshConvertFeature", } } /// Default name prefix (`Extrude` → `Extrude1`). @@ -1312,6 +1318,7 @@ impl FeatureKind { FeatureKind::SurfaceExtend { .. } => "Extend", FeatureKind::Import { .. } => "Import", FeatureKind::MeshImport { .. } => "Mesh", + FeatureKind::MeshConvert { .. } => "ConvertMesh", } } /// Every expression the feature uses. @@ -1520,6 +1527,7 @@ impl FeatureKind { FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v), FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {} FeatureKind::Stitch { tolerance, .. } => v.push(tolerance), + FeatureKind::MeshConvert { tolerance, .. } => v.push(tolerance), FeatureKind::Thicken { thickness, .. } => v.push(thickness), FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v), FeatureKind::SurfaceExtend { distance, .. } => v.push(distance), diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index ed6c69d8..fd76266a 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3299,6 +3299,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } 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, .. } => { for (k, (name, b)) in mesh_bodies(meshes)?.into_iter().enumerate() { let name = copy_body_name(st, f, k, &name); diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index c1b54110..f9309c9e 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -259,6 +259,7 @@ impl FeatureKind { } FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v), 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::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v), FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)), diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs new file mode 100644 index 00000000..1e72e889 --- /dev/null +++ b/crates/engine/src/cmd/mesh.rs @@ -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 { + 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 }) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 426389bb..f825d095 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8de28a94..b6ba6ed2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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, diff --git a/crates/engine/src/tests/mesh_convert.rs b/crates/engine/src/tests/mesh_convert.rs new file mode 100644 index 00000000..14193df7 --- /dev/null +++ b/crates/engine/src/tests/mesh_convert.rs @@ -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", ¶ms).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(); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 2fbd0dcd..5a3b45f6 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -35,6 +35,7 @@ mod intersection_curve_tests; mod lip; mod loopblend; mod measure; +mod mesh_convert; mod meshbody; mod offset; 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 lip::raise_band; pub use measure::{BodyMeasure, measure}; +pub use mesh_convert::convert_prismatic_mesh; pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use offset::with_same_surface; pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix}; diff --git a/crates/kernel/src/mesh_convert.rs b/crates/kernel/src/mesh_convert.rs new file mode 100644 index 00000000..5dceb88d --- /dev/null +++ b/crates/kernel/src/mesh_convert.rs @@ -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 { + 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 { + 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>::new(); + let mut positions: Vec = 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::>>()?; + crate::mesh_body(&positions, &triangles) +} + +fn extrusion(mesh: &crate::meshbody::TriMesh, axis: Vec3, tolerance: f64) -> Result> { + 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>> { + 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(¤t) else { return Ok(None) }; + chain.push(*positions.get(¤t).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 = 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, ®ions, 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()); + } +} diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index da832ef3..cfbf7bf1 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -16,6 +16,7 @@ use crate::selection::{BODIES, EDGES, FACES, PLANES, PROFILES, SelInput}; pub enum Surf { Patch, Stitch { tolerance: String }, + ConvertMesh { tolerance: String }, Thicken { thickness: String, symmetric: bool, operation: usize }, Extend { distance: String }, Trim, @@ -27,6 +28,7 @@ impl Surf { match self { Surf::Patch => "PATCH", Surf::Stitch { .. } => "STITCH", + Surf::ConvertMesh { .. } => "CONVERT MESH", Surf::Thicken { .. } => "THICKEN", Surf::Extend { .. } => "EXTEND", Surf::Trim => "TRIM", @@ -48,6 +50,7 @@ fn inputs(k: &Surf) -> Vec { match k { Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, 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::Extend { .. } => vec![SelInput::new("Edges", EDGES, true)], Surf::Trim => vec![ @@ -64,6 +67,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let k = match id { "surface.patch" => Surf::Patch, "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.extend" => Surf::Extend { distance: "10 mm".into() }, "surface.trim" => Surf::Trim, @@ -78,7 +82,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool { let mut enter = false; match k { - Surf::Stitch { tolerance } => { + Surf::Stitch { tolerance } | Surf::ConvertMesh { tolerance } => { row_label(ui, "Tolerance"); let r = ui.add(egui::TextEdit::singleline(tolerance).hint_text("tight")); crate::params_dialog::complete(ui, &r, tolerance); @@ -159,6 +163,11 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { + 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 } => { need(0, "the surfaces to thicken")?; ( @@ -235,6 +244,9 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec { + (Surf::ConvertMesh { tolerance: tolerance.clone() }, vec![vec![Sel::Body { name: body.clone() }]]) + } FeatureKind::Thicken { bodies, thickness, symmetric, operation, .. } => ( Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) }, vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()], diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index fe53568a..59e95a55 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -32,7 +32,7 @@ fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Draft { .. } => "draft", FeatureKind::Split { .. } => "split", FeatureKind::Move { .. } => "move", - FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => "import", + FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::MeshConvert { .. } => "import", other => crate::workspace::feature_icon(other).unwrap_or("feature"), } } diff --git a/crates/ui-egui/tests/mesh_conversion.rs b/crates/ui-egui/tests/mesh_conversion.rs new file mode 100644 index 00000000..5ca95ff9 --- /dev/null +++ b/crates/ui-egui/tests/mesh_conversion.rs @@ -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()); +} diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index e236ae57..de42425d 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -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, 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 replaced version is kept as `.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 diff --git a/docs/parity.md b/docs/parity.md index ed9c52e9..5fcbd494 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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. -**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 | % | |---|---|---:|---:|---:| @@ -64,7 +64,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SURFACE | POSITION | 2 | 2 | 100% | | MESH | CREATE | 2 | 5 | 40% | | MESH | PREPARE | 0 | 4 | 0% | -| MESH | MODIFY | 6 | 30 | 20% | +| MESH | MODIFY | 7 | 30 | 23% | | MESH | ASSEMBLE | 12 | 21 | 57% | | MESH | CONFIGURE | 3 | 3 | 100% | | MESH | CONSTRUCT | 19 | 20 | 95% | diff --git a/xtask/data/fusion-ids.tsv b/xtask/data/fusion-ids.tsv index e61809ad..965acc44 100644 --- a/xtask/data/fusion-ids.tsv +++ b/xtask/data/fusion-ids.tsv @@ -181,3 +181,4 @@ FusionSnapFitCommand plastic.snap_fit FusionLipCommand plastic.lip FusionRestCommand plastic.rest ConstructionPlanePerpendicularToPlaneCommand construct.plane.perpendicular +ParaMeshConvertCommand mesh.convert