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),
|
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
|
||||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
|
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
|
||||||
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
|
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
|
||||||
need B-rep bodies);
|
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
|
||||||
|
as parametric solids with planar and circular cylindrical faces;
|
||||||
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
|
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
|
||||||
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
|
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
|
||||||
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
|||||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
|
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
|
||||||
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
|
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||||
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
|
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
|
||||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ impl FeatureKind {
|
|||||||
| FeatureKind::Thicken { .. }
|
| FeatureKind::Thicken { .. }
|
||||||
| FeatureKind::Import { .. }
|
| FeatureKind::Import { .. }
|
||||||
| FeatureKind::MeshImport { .. }
|
| FeatureKind::MeshImport { .. }
|
||||||
|
| FeatureKind::MeshConvert { .. }
|
||||||
| FeatureKind::BodyTransfer { .. } => {}
|
| FeatureKind::BodyTransfer { .. } => {}
|
||||||
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
|
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
|
||||||
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
|
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
|
||||||
|
|||||||
@@ -356,9 +356,11 @@ impl crate::Feature {
|
|||||||
mapped_name(n, bodies);
|
mapped_name(n, bodies);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => {
|
K::Split { body, .. }
|
||||||
mapped_name(body, bodies)
|
| K::SheetConvert { body, .. }
|
||||||
}
|
| K::SurfaceExtend { body, .. }
|
||||||
|
| K::SurfaceTrim { body, .. }
|
||||||
|
| K::MeshConvert { body, .. } => mapped_name(body, bodies),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
match &mut self.kind {
|
match &mut self.kind {
|
||||||
|
|||||||
@@ -883,8 +883,12 @@ pub enum FeatureKind {
|
|||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
components: Vec<solvecraft_kernel::StepNode>,
|
components: Vec<solvecraft_kernel::StepNode>,
|
||||||
},
|
},
|
||||||
/// A non-parametric base feature: mesh bodies imported from a 3MF or STL file (kept in the
|
/// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces.
|
||||||
/// design). Mesh bodies render, measure, move and export; solid features cannot use them.
|
MeshConvert {
|
||||||
|
body: String,
|
||||||
|
tolerance: String,
|
||||||
|
},
|
||||||
|
/// Mesh bodies imported from a 3MF or STL file, embedded in the design.
|
||||||
MeshImport {
|
MeshImport {
|
||||||
/// Source file name (display only).
|
/// Source file name (display only).
|
||||||
file: String,
|
file: String,
|
||||||
@@ -1162,6 +1166,7 @@ impl FeatureKind {
|
|||||||
| K::Move { .. }
|
| K::Move { .. }
|
||||||
| K::Import { .. }
|
| K::Import { .. }
|
||||||
| K::MeshImport { .. }
|
| K::MeshImport { .. }
|
||||||
|
| K::MeshConvert { .. }
|
||||||
| K::BodyTransfer { .. } => Vec::new(),
|
| K::BodyTransfer { .. } => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1247,6 +1252,7 @@ impl FeatureKind {
|
|||||||
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
|
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
|
||||||
FeatureKind::Import { .. } => "BaseFeature",
|
FeatureKind::Import { .. } => "BaseFeature",
|
||||||
FeatureKind::MeshImport { .. } => "MeshFeature",
|
FeatureKind::MeshImport { .. } => "MeshFeature",
|
||||||
|
FeatureKind::MeshConvert { .. } => "MeshConvertFeature",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Default name prefix (`Extrude` → `Extrude1`).
|
/// Default name prefix (`Extrude` → `Extrude1`).
|
||||||
@@ -1312,6 +1318,7 @@ impl FeatureKind {
|
|||||||
FeatureKind::SurfaceExtend { .. } => "Extend",
|
FeatureKind::SurfaceExtend { .. } => "Extend",
|
||||||
FeatureKind::Import { .. } => "Import",
|
FeatureKind::Import { .. } => "Import",
|
||||||
FeatureKind::MeshImport { .. } => "Mesh",
|
FeatureKind::MeshImport { .. } => "Mesh",
|
||||||
|
FeatureKind::MeshConvert { .. } => "ConvertMesh",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Every expression the feature uses.
|
/// Every expression the feature uses.
|
||||||
@@ -1520,6 +1527,7 @@ impl FeatureKind {
|
|||||||
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
|
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
|
||||||
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
|
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
|
||||||
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
|
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
|
||||||
|
FeatureKind::MeshConvert { tolerance, .. } => v.push(tolerance),
|
||||||
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
|
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
|
||||||
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
|
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
|
||||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
|
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
|
||||||
|
|||||||
@@ -3299,6 +3299,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
FeatureKind::MeshConvert { body, tolerance } => {
|
||||||
|
let tolerance = val(vals, tolerance, Kind::Length)?;
|
||||||
|
let index = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||||
|
let source = st.bodies.get(index).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||||
|
if doc.model_body_component(source) != f.component {
|
||||||
|
return Err(DocError::Invalid("activate the mesh body's component before converting it".into()));
|
||||||
|
}
|
||||||
|
let converted = kernel::convert_prismatic_mesh(&source.body, tolerance)?;
|
||||||
|
let converted = converted.with_color(source.body.color());
|
||||||
|
let result = ModelBody::new(source.name.clone(), converted, f.id);
|
||||||
|
let target = st.bodies.get_mut(index).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||||
|
*target = result;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
FeatureKind::MeshImport { meshes, .. } => {
|
FeatureKind::MeshImport { meshes, .. } => {
|
||||||
for (k, (name, b)) in mesh_bodies(meshes)?.into_iter().enumerate() {
|
for (k, (name, b)) in mesh_bodies(meshes)?.into_iter().enumerate() {
|
||||||
let name = copy_body_name(st, f, k, &name);
|
let name = copy_body_name(st, f, k, &name);
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ impl FeatureKind {
|
|||||||
}
|
}
|
||||||
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
|
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
|
||||||
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
|
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
|
||||||
|
FeatureKind::MeshConvert { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
|
||||||
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
|
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
|
||||||
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
|
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
|
||||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
|
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
|
||||||
|
|||||||
@@ -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;
|
mod inspect;
|
||||||
pub(crate) mod joints;
|
pub(crate) mod joints;
|
||||||
mod measure_sel;
|
mod measure_sel;
|
||||||
|
mod mesh;
|
||||||
pub(crate) mod motion;
|
pub(crate) mod motion;
|
||||||
mod parts;
|
mod parts;
|
||||||
mod section;
|
mod section;
|
||||||
@@ -157,6 +158,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
|||||||
v.extend(sketch_project::COMMANDS.iter());
|
v.extend(sketch_project::COMMANDS.iter());
|
||||||
v.extend(features::COMMANDS.iter());
|
v.extend(features::COMMANDS.iter());
|
||||||
v.extend(features_more::COMMANDS.iter());
|
v.extend(features_more::COMMANDS.iter());
|
||||||
|
v.extend(mesh::COMMANDS.iter());
|
||||||
v.extend(construct::COMMANDS.iter());
|
v.extend(construct::COMMANDS.iter());
|
||||||
v.extend(sheet::COMMANDS.iter());
|
v.extend(sheet::COMMANDS.iter());
|
||||||
v.extend(plastic::COMMANDS.iter());
|
v.extend(plastic::COMMANDS.iter());
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use serde_json::{Value, json};
|
|||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
|
mod mesh_convert;
|
||||||
|
|
||||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||||
match s.execute(id, &p) {
|
match s.execute(id, &p) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
|
|||||||
@@ -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 lip;
|
||||||
mod loopblend;
|
mod loopblend;
|
||||||
mod measure;
|
mod measure;
|
||||||
|
mod mesh_convert;
|
||||||
mod meshbody;
|
mod meshbody;
|
||||||
mod offset;
|
mod offset;
|
||||||
mod ops;
|
mod ops;
|
||||||
@@ -69,6 +70,7 @@ pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
|
|||||||
pub use iges_out::iges_export_bodies;
|
pub use iges_out::iges_export_bodies;
|
||||||
pub use lip::raise_band;
|
pub use lip::raise_band;
|
||||||
pub use measure::{BodyMeasure, measure};
|
pub use measure::{BodyMeasure, measure};
|
||||||
|
pub use mesh_convert::convert_prismatic_mesh;
|
||||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||||
pub use offset::with_same_surface;
|
pub use offset::with_same_surface;
|
||||||
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix};
|
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix};
|
||||||
|
|||||||
@@ -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 {
|
pub enum Surf {
|
||||||
Patch,
|
Patch,
|
||||||
Stitch { tolerance: String },
|
Stitch { tolerance: String },
|
||||||
|
ConvertMesh { tolerance: String },
|
||||||
Thicken { thickness: String, symmetric: bool, operation: usize },
|
Thicken { thickness: String, symmetric: bool, operation: usize },
|
||||||
Extend { distance: String },
|
Extend { distance: String },
|
||||||
Trim,
|
Trim,
|
||||||
@@ -27,6 +28,7 @@ impl Surf {
|
|||||||
match self {
|
match self {
|
||||||
Surf::Patch => "PATCH",
|
Surf::Patch => "PATCH",
|
||||||
Surf::Stitch { .. } => "STITCH",
|
Surf::Stitch { .. } => "STITCH",
|
||||||
|
Surf::ConvertMesh { .. } => "CONVERT MESH",
|
||||||
Surf::Thicken { .. } => "THICKEN",
|
Surf::Thicken { .. } => "THICKEN",
|
||||||
Surf::Extend { .. } => "EXTEND",
|
Surf::Extend { .. } => "EXTEND",
|
||||||
Surf::Trim => "TRIM",
|
Surf::Trim => "TRIM",
|
||||||
@@ -48,6 +50,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
|
|||||||
match k {
|
match k {
|
||||||
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
|
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
|
||||||
Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
|
Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
|
||||||
|
Surf::ConvertMesh { .. } => vec![SelInput::new("Mesh body", BODIES, false)],
|
||||||
Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
|
Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
|
||||||
Surf::Extend { .. } => vec![SelInput::new("Edges", EDGES, true)],
|
Surf::Extend { .. } => vec![SelInput::new("Edges", EDGES, true)],
|
||||||
Surf::Trim => vec![
|
Surf::Trim => vec![
|
||||||
@@ -64,6 +67,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
|||||||
let k = match id {
|
let k = match id {
|
||||||
"surface.patch" => Surf::Patch,
|
"surface.patch" => Surf::Patch,
|
||||||
"surface.stitch" => Surf::Stitch { tolerance: String::new() },
|
"surface.stitch" => Surf::Stitch { tolerance: String::new() },
|
||||||
|
"mesh.convert" => Surf::ConvertMesh { tolerance: "0.05 mm".into() },
|
||||||
"surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 },
|
"surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 },
|
||||||
"surface.extend" => Surf::Extend { distance: "10 mm".into() },
|
"surface.extend" => Surf::Extend { distance: "10 mm".into() },
|
||||||
"surface.trim" => Surf::Trim,
|
"surface.trim" => Surf::Trim,
|
||||||
@@ -78,7 +82,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
|||||||
pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||||
let mut enter = false;
|
let mut enter = false;
|
||||||
match k {
|
match k {
|
||||||
Surf::Stitch { tolerance } => {
|
Surf::Stitch { tolerance } | Surf::ConvertMesh { tolerance } => {
|
||||||
row_label(ui, "Tolerance");
|
row_label(ui, "Tolerance");
|
||||||
let r = ui.add(egui::TextEdit::singleline(tolerance).hint_text("tight"));
|
let r = ui.add(egui::TextEdit::singleline(tolerance).hint_text("tight"));
|
||||||
crate::params_dialog::complete(ui, &r, tolerance);
|
crate::params_dialog::complete(ui, &r, tolerance);
|
||||||
@@ -159,6 +163,11 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
|||||||
}
|
}
|
||||||
("surface.stitch", p)
|
("surface.stitch", p)
|
||||||
}
|
}
|
||||||
|
Surf::ConvertMesh { tolerance } => {
|
||||||
|
need(0, "a closed mesh body")?;
|
||||||
|
let body = bodies(inputs, 0).into_iter().next().ok_or("select a mesh body")?;
|
||||||
|
("mesh.convert", json!({"body":body,"tolerance":tolerance}))
|
||||||
|
}
|
||||||
Surf::Thicken { thickness, symmetric, operation } => {
|
Surf::Thicken { thickness, symmetric, operation } => {
|
||||||
need(0, "the surfaces to thicken")?;
|
need(0, "the surfaces to thicken")?;
|
||||||
(
|
(
|
||||||
@@ -235,6 +244,9 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelI
|
|||||||
Surf::Stitch { tolerance: if tolerance.trim() == "0" { String::new() } else { tolerance.clone() } },
|
Surf::Stitch { tolerance: if tolerance.trim() == "0" { String::new() } else { tolerance.clone() } },
|
||||||
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
|
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
|
||||||
),
|
),
|
||||||
|
FeatureKind::MeshConvert { body, tolerance } => {
|
||||||
|
(Surf::ConvertMesh { tolerance: tolerance.clone() }, vec![vec![Sel::Body { name: body.clone() }]])
|
||||||
|
}
|
||||||
FeatureKind::Thicken { bodies, thickness, symmetric, operation, .. } => (
|
FeatureKind::Thicken { bodies, thickness, symmetric, operation, .. } => (
|
||||||
Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) },
|
Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) },
|
||||||
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
|
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ fn icon_of(k: &FeatureKind) -> &'static str {
|
|||||||
FeatureKind::Draft { .. } => "draft",
|
FeatureKind::Draft { .. } => "draft",
|
||||||
FeatureKind::Split { .. } => "split",
|
FeatureKind::Split { .. } => "split",
|
||||||
FeatureKind::Move { .. } => "move",
|
FeatureKind::Move { .. } => "move",
|
||||||
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => "import",
|
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::MeshConvert { .. } => "import",
|
||||||
other => crate::workspace::feature_icon(other).unwrap_or("feature"),
|
other => crate::workspace::feature_icon(other).unwrap_or("feature"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
inserting one adds them to the current design. Later features build on them. Mesh bodies render,
|
||||||
measure, move and export, but solid features need B-rep bodies.
|
measure, move and export, but solid features need B-rep bodies.
|
||||||
|
|
||||||
|
For closed mechanical meshes with a constant cross-section, use **Mesh → Modify →
|
||||||
|
Convert Mesh**. Select the mesh body and set the fitting tolerance. The conversion
|
||||||
|
rebuilds planar faces and circular through-walls, replacing the mesh with a solid
|
||||||
|
that accepts solid modeling operations. The imported triangles remain embedded
|
||||||
|
upstream: changing their scale, undoing the conversion or reopening the design
|
||||||
|
rebuilds the result through the timeline. Double-click the conversion to edit its
|
||||||
|
tolerance or source body.
|
||||||
|
|
||||||
|
The tolerance bounds circular reconstruction against the original chords. Coarse
|
||||||
|
polygons remain polygons when rounding would exceed it. Open/nonmanifold meshes,
|
||||||
|
tapers, stepped parts and organic scans are refused by this first reconstruction
|
||||||
|
method; it does not silently substitute a faceted solid. Conversion currently
|
||||||
|
accepts at most 50,000 triangles and a bounded number of boundary segments.
|
||||||
|
|
||||||
Saves are atomic: a crash at any moment leaves either the old file or the new one, and the
|
Saves are atomic: a crash at any moment leaves either the old file or the new one, and the
|
||||||
replaced version is kept as `<file>.bak`. Unsaved changes are autosaved to a recovery folder,
|
replaced version is kept as `<file>.bak`. Unsaved changes are autosaved to a recovery folder,
|
||||||
and the next launch offers to recover them. Older design files are upgraded on open, and files
|
and the next launch offers to recover them. Older design files are upgraded on open, and files
|
||||||
|
|||||||
+3
-3
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolidCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth.
|
Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolidCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth.
|
||||||
|
|
||||||
**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 335 / 541 commands live (62%).** SOLID + SKETCH: 188 / 291 (65%). Registered commands: 311.
|
**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 335 / 541 commands live (62%).** SOLID + SKETCH: 188 / 291 (65%). Registered commands: 314.
|
||||||
|
|
||||||
Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 630 / 1175 (54%).
|
Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 631 / 1175 (54%).
|
||||||
|
|
||||||
| Tab | Panel | Live | Total | % |
|
| Tab | Panel | Live | Total | % |
|
||||||
|---|---|---:|---:|---:|
|
|---|---|---:|---:|---:|
|
||||||
@@ -64,7 +64,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
|
|||||||
| SURFACE | POSITION | 2 | 2 | 100% |
|
| SURFACE | POSITION | 2 | 2 | 100% |
|
||||||
| MESH | CREATE | 2 | 5 | 40% |
|
| MESH | CREATE | 2 | 5 | 40% |
|
||||||
| MESH | PREPARE | 0 | 4 | 0% |
|
| MESH | PREPARE | 0 | 4 | 0% |
|
||||||
| MESH | MODIFY | 6 | 30 | 20% |
|
| MESH | MODIFY | 7 | 30 | 23% |
|
||||||
| MESH | ASSEMBLE | 12 | 21 | 57% |
|
| MESH | ASSEMBLE | 12 | 21 | 57% |
|
||||||
| MESH | CONFIGURE | 3 | 3 | 100% |
|
| MESH | CONFIGURE | 3 | 3 | 100% |
|
||||||
| MESH | CONSTRUCT | 19 | 20 | 95% |
|
| MESH | CONSTRUCT | 19 | 20 | 95% |
|
||||||
|
|||||||
@@ -181,3 +181,4 @@ FusionSnapFitCommand plastic.snap_fit
|
|||||||
FusionLipCommand plastic.lip
|
FusionLipCommand plastic.lip
|
||||||
FusionRestCommand plastic.rest
|
FusionRestCommand plastic.rest
|
||||||
ConstructionPlanePerpendicularToPlaneCommand construct.plane.perpendicular
|
ConstructionPlanePerpendicularToPlaneCommand construct.plane.perpendicular
|
||||||
|
ParaMeshConvertCommand mesh.convert
|
||||||
|
Reference in new issue
Block a user