Reconstruct constant-section meshes as parametric solids
This commit is contained in:
1 parent
417511367c
commit
df4c8a2994
19 files changed
+717
-12
No files matched your search
@@ -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
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -883,8 +883,12 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
components: Vec<solvecraft_kernel::StepNode>,
|
||||
},
|
||||
/// 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),
|
||||
|
||||
@@ -3299,6 +3299,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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);
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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(¤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<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, ®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());
|
||||
}
|
||||
}
|
||||
@@ -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<SelInput> {
|
||||
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<SelInput>)> {
|
||||
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<SelInput>)> {
|
||||
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<Strin
|
||||
}
|
||||
("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 } => {
|
||||
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() } },
|
||||
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, .. } => (
|
||||
Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) },
|
||||
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 `<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
|
||||
|
||||
+3
-3
@@ -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% |
|
||||
|
||||
@@ -181,3 +181,4 @@ FusionSnapFitCommand plastic.snap_fit
|
||||
FusionLipCommand plastic.lip
|
||||
FusionRestCommand plastic.rest
|
||||
ConstructionPlanePerpendicularToPlaneCommand construct.plane.perpendicular
|
||||
ParaMeshConvertCommand mesh.convert
|
||||
|
Reference in new issue
Block a user