Repair Part (body.repair, #8371): rebuild refused faces, sew gaps, patch holes

A new undoable feature (`repair`, one pending format file) and command with input and
output schemas. In order:

- A cylinder, cone, sphere or torus face SMLib refuses on its own is rebuilt on a NURBS
  form of its surface laid on arcs from where the face starts
  (`form::surface_on_own_arcs`), with SMLib's own trims: the import's quarter-grid form of
  a face round most of a turn closes on itself, which SmTorus::IsNurbSurfaceTorus does not
  recognise. Geometry unchanged.
- Free edges that pair up within `gap` are sewn by SMLib's stitch; GlueEdgesGeneral gives
  each glued edge its own tolerance (at least twice its gap), the body's and the other
  edges' stay. The result is checked against the input's own SMLib check, the
  tolerance-bound tests (#5, #6, #59, #82) exempt.
- Loops of free edges left are holes: offered as surface.patch, and with patch_holes made
  (flat only when the loop is planar and every face around it is a plane, else G1) and
  sewn in.

The reply reports what changed face by face; nothing to do is refused with why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-28 19:04:13 -04:00
committed by Irakli Kourtis
parent e0c7867b9b
commit 7c8132e2d6
14 changed files with 1074 additions and 11 deletions
+12
View File
@@ -1833,6 +1833,16 @@ pub enum FeatureKind {
file: String,
meshes: Vec<MeshData>,
},
/// Repair Part (#8371): faces SMLib refuses rebuilt on their own surfaces, free edges that
/// pair up within `gap` sewn, and, when `patch_holes`, the loops of free edges left patched
/// and sewn in (`kernel::repair`).
Repair {
body: BodyName,
#[walk(length = "Gap")]
gap: Expr,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
patch_holes: bool,
},
}
/// A fillet corner set back: the vertex at `vertex`, where every edge is rounded, has its corner
@@ -2506,6 +2516,7 @@ impl FeatureKind {
FeatureKind::Import { .. } => "BaseFeature",
FeatureKind::MeshImport { .. } => "MeshFeature",
FeatureKind::MeshConvert { .. } => "MeshConvertFeature",
FeatureKind::Repair { .. } => "RepairFeature",
}
}
/// Default name prefix (`Extrude` → `Extrude1`).
@@ -2589,6 +2600,7 @@ impl FeatureKind {
FeatureKind::Import { .. } => "Import",
FeatureKind::MeshImport { .. } => "Mesh",
FeatureKind::MeshConvert { .. } => "ConvertMesh",
FeatureKind::Repair { .. } => "Repair",
}
}
}
+23
View File
@@ -7019,6 +7019,29 @@ fn eval_feature(
*target = result;
Ok(())
}
FeatureKind::Repair { body, gap, patch_holes } => {
let gap = val(vals, gap, 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}`")))?;
let (repaired, report) = kernel::repair(&source.body, &kernel::RepairOptions { gap, patch_holes: *patch_holes })?;
let repaired = repaired.with_color(source.body.color());
let result = ModelBody::new(source.name.clone(), repaired, source.feature);
let mut left: Vec<String> =
report.refused.iter().map(|r| format!("SMLib still refuses face {} ({:?}): {}", r.face + 1, r.surface, r.why)).collect();
left.extend(
report
.holes
.iter()
.filter(|h| !h.patched)
.map(|h| h.why.clone().unwrap_or_else(|| format!("a hole of {} edges is left open", h.edges.len()))),
);
if !left.is_empty() {
*warning = Some(left.join("; "));
}
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);
@@ -0,0 +1,5 @@
//! A design can hold Repair Part (#8371): a `repair` feature with the `body` it repairs, the
//! widest `gap` between free edges it sews (a length expression) and `patch_holes` (default false,
//! not saved: the loops of free edges left are patched and sewn in). Older designs need no
//! transformation: they have no such feature. The last release refuses a design saved with this
//! (the newer format number).
+117 -3
View File
@@ -14,15 +14,24 @@ use serde_json::{Value, json};
use solidcraft_doc::FeatureKind;
use super::CommandSpec;
use crate::params::{bad, bool_, str_};
use super::features::{add_feature, check_expr};
use crate::params::{bad, bool_, expr, str_};
use crate::schema::browser::{RepairDone, RepairFace, RepairHole, RepairParams, RepairResult, RepairSewn};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const REPAIR_CONTRACT: TypedContract<RepairParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("body.rename", "Rename Body", rename_body).icon("rename").params("body: its name, name: the new name (features that use the body follow)"),
// The import and failed operations offer it for a part the modelling kernel refuses.
planned!("body.repair", "Repair Part", "8371")
CommandSpec::new("body.repair", "Repair Part", crate::checked!("body.repair", repair_body))
.icon("body")
.params("planned: body: its name (a part the modelling kernel refuses: one its import marks as needing repair)"),
.contract(&REPAIR_CONTRACT)
.output(output_schema_for::<RepairResult>)
.params(
"body: its name (a part the modelling kernel refuses, or an open part that should be a solid); gap?: expr (default 0.2 mm, at most 1 mm: free edges that pair up within it are sewn, each sewn edge carrying its own gap as its tolerance); patch_holes?: bool (default false: loops of free edges left are patched, in the plane of the faces around them where those are that plane, else tangent to them, and sewn in; otherwise the reply offers the patches); name? → the feature, and repair: what changed face by face (rebuilt and refused faces, sewn gaps, holes with their surface.patch), faces and largest edge tolerance before and after, closed, modelling, volume. Refused with why when nothing applies",
),
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
.icon("plane")
.params("sketch: id or name; plane: as sketch.create; frame?: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
@@ -806,6 +815,73 @@ pub(crate) fn counted(f: &solidcraft_doc::Feature, i: usize) -> bool {
f.body_names.get(i).is_none_or(|n| n.trim().is_empty()) && (f.shell_piece_order || i < f.body_names.len() || !first)
}
/// Repair Part (#8371): the part's repair is worked out first (refused with why when nothing
/// applies), then made a feature, which works it out again as it evaluates; the reply says what
/// changed, face by face.
fn repair_body(s: &mut Session, p: &Value) -> Result<Value> {
use solidcraft_doc::expr::Kind;
use solidcraft_kernel::{Continuity, RepairOptions, SurfaceKind};
let cmd = "body.repair";
let name = str_(p, "body").filter(|b| !b.trim().is_empty()).ok_or_else(|| bad(cmd, "`body` must name the part to repair"))?.to_string();
let state = s.model.state();
let source = state.body(&name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
if source.body.is_mesh() {
return Err(bad(cmd, "a mesh body has no faces to repair: convert it first"));
}
let gap = expr(p, "gap").unwrap_or_else(|| "0.2 mm".into());
check_expr(s, &gap, Kind::Length, cmd, "gap")?;
let gap_mm = s.doc.eval(&gap, Kind::Length).map_err(|e| bad(cmd, format!("gap: {e}")))?;
let patch_holes = bool_(p, "patch_holes").unwrap_or(false);
let (repaired, report) =
solidcraft_kernel::repair(&source.body, &RepairOptions { gap: gap_mm, patch_holes }).map_err(|e| bad(cmd, e.to_string()))?;
let volume = if report.closed { solidcraft_kernel::measure(&repaired).ok().map(|m| m.volume) } else { None };
let surface = |k: SurfaceKind| format!("{k:?}").to_lowercase();
let face = |face: usize, k: SurfaceKind, why: &str| RepairFace { face, surface: surface(k), why: why.to_string() };
let done = RepairDone {
rebuilt: report.rebuilt.iter().map(|r| face(r.face, r.surface, &r.why)).collect(),
refused: report.refused.iter().map(|r| face(r.face, r.surface, &r.why)).collect(),
sewn: report.sewn.iter().map(|g| RepairSewn { faces: g.faces, gap: g.gap, at: [g.at.x, g.at.y, g.at.z] }).collect(),
holes: report
.holes
.iter()
.map(|h| {
let g = if h.continuity == Continuity::G0 { "g0" } else { "g1" };
let edges: Vec<[f64; 3]> = h.edges.iter().map(|q| [q.x, q.y, q.z]).collect();
RepairHole {
faces: h.faces.clone(),
continuity: g.into(),
patched: h.patched,
why: h.why.clone(),
patch: json!({"command": "surface.patch", "params": {"body": name, "edges": edges, "continuity": g}}),
}
})
.collect(),
faces: report.faces,
largest_edge_tolerance: report.largest_edge_tolerance,
closed: report.closed,
modelling: report.modelling,
volume,
};
let kind = FeatureKind::Repair { body: name.clone().into(), gap, patch_holes };
let v = add_feature(s, p, kind)?;
let field = |k: &str| v.get(k).cloned().unwrap_or(Value::Null);
let list =
|k: &str| v.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
let reply = RepairResult {
feature: field("feature").as_u64().unwrap_or(0),
name: field("name").as_str().unwrap_or_default().to_string(),
bodies: list("bodies"),
created_bodies: list("created_bodies"),
changed_bodies: list("changed_bodies"),
removed_bodies: list("removed_bodies"),
warning: field("warning").as_str().map(str::to_string),
ms: field("ms").as_f64(),
repair: done,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| crate::EngineError::Other(e.to_string()))
}
fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "body.rename";
let old = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must be a body name"))?.to_string();
@@ -1066,6 +1142,44 @@ mod tests {
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
}
#[test]
fn repair_part_patches_a_missing_face_as_one_undoable_feature() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"}));
run(&mut s, "surface.unstitch", json!({"body": "Box", "faces": [[5, 5, 10]]}));
let open = |s: &Session| s.model.state().body("Box").is_some_and(|b| b.body.is_surface());
assert!(open(&s));
// A hole alone: nothing is sewn, and the reason names the hole.
let err = s.execute("body.repair", &json!({"body": "Box"})).unwrap_err().to_string();
assert!(err.contains("a loop of 4 edges"), "{err}");
let features = s.doc.features.len();
let v = run(&mut s, "body.repair", json!({"body": "Box", "patch_holes": true}));
assert_eq!(s.doc.features.len(), features + 1);
let done = &v["repair"];
assert_eq!(done["holes"][0]["patched"], json!(true), "{v}");
assert_eq!(done["holes"][0]["continuity"], json!("g0"), "{v}");
assert_eq!(done["holes"][0]["patch"]["command"], json!("surface.patch"), "{v}");
assert_eq!(done["faces"], json!([5, 6]));
assert_eq!((done["closed"].clone(), done["modelling"].clone()), (json!(true), json!(true)));
assert!((done["volume"].as_f64().unwrap() - 1000.0).abs() < 1e-6, "{v}");
assert_eq!(v["changed_bodies"], json!(["Box"]));
assert!(!open(&s));
// Saved and opened again, the feature repairs the part again.
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!(cold.model.state().body("Box").is_some_and(|b| !b.body.is_surface()));
run(&mut s, "edit.undo", json!({}));
assert!(open(&s));
// A solid with nothing to repair, an unknown body, a gap out of range: refused, nothing added.
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "Solid"}));
for p in
[json!({"body": "Solid"}), json!({"body": "Nope"}), json!({}), json!({"body": "Box", "gap": "5 mm"}), json!({"body": "Box", "gap": "-1"})]
{
let n = s.doc.features.len();
assert!(s.execute("body.repair", &p).is_err(), "{p}");
assert_eq!(s.doc.features.len(), n, "{p}");
}
}
#[test]
fn body_rename_preserves_plastic_rules_and_current_visibility_through_history() {
let mut s = Session::default();
+117
View File
@@ -0,0 +1,117 @@
//! Contracts and result types for the commands in `cmd/browser.rs`.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::types::{FeatureCommon, LengthExpr, feature_common};
/// Repair a part the modelling kernel refuses, or an open part that should be a solid: faces
/// SMLib refuses on their own are rebuilt on their own surfaces (geometry unchanged), free edges
/// that pair up within `gap` are sewn (each sewn edge carries its own gap as its tolerance; the
/// body's and the other edges' stay), and with `patch_holes` the loops of free edges left (missing
/// faces) are patched and sewn in. One undoable feature; refused, with why, when nothing applies.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RepairParams {
/// The part (body name).
pub body: String,
/// The widest gap between two free edges that is sewn, more than 0 and at most 1 mm.
/// Default: 0.2 mm.
#[serde(default)]
pub gap: Option<LengthExpr>,
/// Patch each loop of free edges no pair explains (a missing face) and sew the patch in: the
/// plane of the faces around it where they are all that plane, else a patch tangent to them
/// (G1). Default: false (the reply offers the patches).
#[serde(default)]
pub patch_holes: Option<bool>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// A face rebuilt on its own surface, or one SMLib still refuses.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RepairFace {
/// The face's index in the part's face order before the repair (from 0).
pub face: usize,
/// Its surface: plane, cylinder, cone, sphere, torus or freeform.
pub surface: String,
/// What SMLib said about it.
pub why: String,
}
/// Two free edges sewn into one.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RepairSewn {
/// The faces on either side, by index before the repair.
pub faces: [usize; 2],
/// The widest distance between the two edges, mm.
pub gap: f64,
/// A point halfway along the gap, [x, y, z].
pub at: [f64; 3],
}
/// A loop of free edges no pair explains: a missing face.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RepairHole {
/// The faces around it, by index before the repair.
pub faces: Vec<usize>,
/// g0 (a patch in the plane of the faces around it) or g1 (tangent to curved neighbours).
pub continuity: String,
/// The patch was made and sewn in.
pub patched: bool,
/// Why it was not, when `patch_holes` asked for it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub why: Option<String>,
/// The Patch that fills it: `{command: "surface.patch", params}`.
pub patch: Value,
}
/// What the repair did, face by face.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RepairDone {
pub rebuilt: Vec<RepairFace>,
/// Faces SMLib refuses that could not be rebuilt (the part may still be taken as a whole).
pub refused: Vec<RepairFace>,
pub sewn: Vec<RepairSewn>,
pub holes: Vec<RepairHole>,
/// Faces before and after.
pub faces: [usize; 2],
/// The largest edge tolerance before and after, mm.
pub largest_edge_tolerance: [f64; 2],
/// The part is a closed solid now.
pub closed: bool,
/// The modelling kernel takes the part now.
pub modelling: bool,
/// Its volume, mm³, when it is a closed solid.
pub volume: Option<f64>,
}
/// What `body.repair` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RepairResult {
/// The new feature's id.
pub feature: u64,
/// The new feature's name.
pub name: String,
/// Every body after the feature.
pub bodies: Vec<String>,
/// The bodies the feature made.
pub created_bodies: Vec<String>,
/// Bodies that were there before and now have other geometry.
pub changed_bodies: Vec<String>,
/// Bodies that are gone.
pub removed_bodies: Vec<String>,
/// A warning from the evaluation (a hole left open), or `null`.
pub warning: Option<String>,
/// How long the feature took to evaluate, in milliseconds.
pub ms: Option<f64>,
pub repair: RepairDone,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "browser_tests.rs"]
mod tests;
+28
View File
@@ -0,0 +1,28 @@
use serde_json::{Value, json};
use super::{RepairParams, RepairResult};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<RepairParams>(), &["body", "body_name", "body_names", "gap", "name", "participants", "patch_holes"]);
}
#[test]
fn replies_match_the_output_schema() {
let schema = output_schema_for::<RepairResult>();
let schema = schema.as_value();
let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
assert!(!required.contains(&"param_warnings"));
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"})).unwrap();
s.execute("surface.unstitch", &json!({"body": "Box", "faces": [[5, 5, 10]]})).unwrap();
s.param_check = ParamCheck::Warn;
let r = s.execute("body.repair", &json!({"body": "Box", "gap": "0.1 mm", "patch_holes": true, "name": "Mend"})).unwrap();
assert!(r.get("param_warnings").is_none(), "{r}");
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
let r = s.execute("body.repair", &json!({"body": "Box", "colour": "red"}));
assert!(r.is_err(), "nothing left to repair");
}
+1
View File
@@ -54,6 +54,7 @@ mod coverage_tests;
mod tests;
// The contracts, by command file.
pub mod browser;
pub mod diagnostics;
pub mod features;
pub mod inspect;
+78 -5
View File
@@ -91,6 +91,61 @@ fn params([a0, a1]: [f64; 2], (q0, n): (f64, usize)) -> [f64; 2] {
[param(a0).clamp(q0, end), param(a1).clamp(q0, end)]
}
/// Equal arcs from angle `a0` to `a1` (less than a turn), as few as keep each within a quarter:
/// their knot vector (0 at `a0`, one more at each arc's end) and control points (as
/// [`arcs`]). A form on them is not on the quarter grid: for a face whose angle range needs the
/// four quarters of a turn (or five) without being a turn, which on the grid would be a form that
/// closes on itself (or overlaps itself) around a face that does not.
fn arcs_between(a0: f64, a1: f64) -> Option<(Vec<f64>, Vec<([f64; 2], f64)>)> {
let span = a1 - a0;
if !(a0.is_finite() && span > 0.0 && span < TAU) {
return None;
}
let n = ((span / FRAC_PI_2) - 1e-9).ceil().max(1.0) as usize;
let phi = span / n as f64;
let w = (0.5 * phi).cos();
let at = |a: f64| [a.cos(), a.sin()];
let mut pts = Vec::with_capacity(2 * n + 1);
let mut knots = vec![0.0; 3];
for k in 0..n {
let start = a0 + phi * k as f64;
if k == 0 {
pts.push((at(start), 1.0));
}
let mid = at(start + 0.5 * phi);
pts.push(([mid[0] / w, mid[1] / w], w));
pts.push((at(start + phi), 1.0));
let end = (k + 1) as f64;
knots.extend(if k + 1 < n { vec![end; 2] } else { vec![end; 3] });
}
Some((knots, pts))
}
/// The arcs of a face's angle range `a` and its domain on them: the quarter grid's
/// ([`face_quarters`]), or with `own` and a range short of a turn, arcs of its own from its start
/// ([`arcs_between`], the domain all of them).
fn face_arcs(a: [f64; 2], reach: f64, own: bool) -> Option<((Vec<f64>, Vec<([f64; 2], f64)>), [f64; 2])> {
if own && a[1] - a[0] < TAU * (1.0 - 1e-9) {
// Past each end by `reach` (while that stays short of a turn): the face's boundary, which
// lies off the surface by as much, then stays on the form.
let pad = reach.max(0.0).min(0.25 * (TAU * (1.0 - 1e-9) - (a[1] - a[0])));
let (a0, a1) = (a[0] - pad, a[1] + pad);
let made = arcs_between(a0, a1)?;
let n = made.0.last().copied()?;
let phi = (a1 - a0) / n;
// The form parameter at angle `t`: its arc, plus the arc's own parameter there (as
// `quarter_u`, for an arc of `phi`).
let at = |t: f64| {
let k = ((t - a0) / phi).floor().clamp(0.0, n - 1.0);
let psi = (t - a0 - k * phi).clamp(0.0, phi);
k + 0.5 * (1.0 + (0.5 * psi - 0.25 * phi).tan() / (0.25 * phi).tan())
};
return Some((made, [at(a[0]), at(a[1])]));
}
let (grid, d) = face_quarters(a, reach)?;
Some((arcs(grid.0, grid.1), d))
}
/// Quarter arcs from quarter `q0`, `n` of them: their knot vector (in form parameter) and their
/// control points (as `(cos, sin)` factors on the unit circle's plane) with weights.
fn arcs(q0: f64, n: usize) -> (Vec<f64>, Vec<([f64; 2], f64)>) {
@@ -238,10 +293,29 @@ pub fn tube_turned(s: &Surface, angle: f64) -> Option<Surface> {
/// boundary's points ([`extent`]), which may run past a quarter's end by as much as its edges lie
/// off the surface; the form then covers no quarter for that alone (a torus face of half a tube
/// would otherwise go round the whole tube).
pub fn surface_within(s: &Surface, [u, v]: [[f64; 2]; 2], tolerance: f64) -> Option<(Surface, [[f64; 2]; 2])> {
pub fn surface_within(s: &Surface, domain: [[f64; 2]; 2], tolerance: f64) -> Option<(Surface, [[f64; 2]; 2])> {
surface_on(s, domain, tolerance, false)
}
/// The NURBS form of a cylinder, cone, sphere or torus face over `domain` (as [`surface`]) whose
/// angles run on arcs of the face's own from where it starts, not on its frame's quarter grid,
/// wherever its range is short of a turn: the same surface, exactly, but a form that never
/// closes on itself around a face that does not (a torus face round 250° of its tube needs all
/// four quarters of the grid, a form SMLib does not take for a torus); each angle runs past the
/// face's ends by the angle `tolerance` (mm) makes, so a boundary that far off stays on the
/// form. Its circles' forms then no
/// longer share its parameter, so its pcurves are not lines: Repair Part rebuilds a refused face
/// on it and has SMLib make them.
pub fn surface_on_own_arcs(s: &Surface, domain: [[f64; 2]; 2], tolerance: f64) -> Option<(Surface, [[f64; 2]; 2])> {
match s {
Surface::Cylinder { .. } | Surface::Cone { .. } | Surface::Sphere { .. } | Surface::Torus { .. } => surface_on(s, domain, tolerance, true),
_ => None,
}
}
fn surface_on(s: &Surface, [u, v]: [[f64; 2]; 2], tolerance: f64, own: bool) -> Option<(Surface, [[f64; 2]; 2])> {
let (reach_u, reach_v) = reaches(s, v, tolerance);
let (grid, fu) = face_quarters(u, reach_u)?;
let (u_knots, ring) = arcs(grid.0, grid.1);
let ((u_knots, ring), fu) = face_arcs(u, reach_u, own)?;
// The ring of arcs times a profile `(control, weight)` mapped to (radial, axial) about the
// frame: a rational tensor product, its weights the products.
let net = |f: &Frame, profile: &[([f64; 2], f64)], at: &dyn Fn([f64; 2]) -> (f64, f64)| -> Option<Vec<Vec<[f64; 4]>>> {
@@ -279,8 +353,7 @@ pub fn surface_within(s: &Surface, [u, v]: [[f64; 2]; 2], tolerance: f64) -> Opt
Some((Surface::RationalSpline { u_knots, v_knots, homogeneous }, [fu, params(v, vgrid)]))
}
Surface::Torus { frame, major_radius, minor_radius, tube_start } => {
let (vgrid, fv) = face_quarters(v, reach_v)?;
let (v_knots, tube) = arcs(vgrid.0, vgrid.1);
let ((v_knots, tube), fv) = face_arcs(v, reach_v, own)?;
// The tube's arcs turned on by its start (a rotation of the control points, exact).
let (ts, tc) = tube_start.sin_cos();
let homogeneous = net(frame, &tube, &|[c, s]| (major_radius + minor_radius * (c * tc - s * ts), minor_radius * (s * tc + c * ts)))?;
+42
View File
@@ -909,3 +909,45 @@ fn analytic_partials_are_the_derivatives_of_the_order_below() {
}
}
}
#[test]
fn a_form_on_arcs_of_its_own_is_the_surface_and_does_not_close_around_an_open_face() {
// A torus face round 280° of its tube: on the quarter grid it needs all four quarters, a form
// that closes on itself; on arcs of its own it starts where the face does and stops past
// where it ends.
let torus = Surface::Torus { frame: a_frame(), major_radius: 5., minor_radius: 1.5, tube_start: 0. };
let label = [[0.3, 1.2], [-2.9, 2.0]];
let tube = |s: &Surface| match s {
Surface::RationalSpline { v_knots, .. } => (v_knots.first().copied().unwrap(), v_knots.last().copied().unwrap()),
_ => panic!("not a NURBS: {s:?}"),
};
let (grid, _) = form::surface(&torus, label).unwrap();
assert_eq!(tube(&grid), (-2.0, 2.0), "the grid's form is the whole tube");
let tol = 1e-3;
let (own, domain) = form::surface_on_own_arcs(&torus, label, tol).unwrap();
let (v0, v1) = tube(&own);
assert_eq!((v0, v1), (0.0, 4.0));
let (label_at, own_at) = (eval::SurfaceEval::new(&torus).unwrap(), eval::SurfaceEval::new(&own).unwrap());
let u = domain[0][0];
assert!(own_at.point([u, v0]).unwrap().dist(own_at.point([u, v1]).unwrap()) > 0.1, "the form does not close");
// Every point of the form is on the torus: (√(x² + y²) − R)² + z² = r² in its frame.
for i in 0..=8 {
for j in 0..=12 {
let uv = [domain[0][0] + (domain[0][1] - domain[0][0]) * i as f64 / 8., v0 + (v1 - v0) * j as f64 / 12.];
let p = own_at.point(uv).unwrap() - crate::Vec3::new(1., 2., 3.);
let off = ((p.x.hypot(p.y) - 5.).powi(2) + p.z.powi(2)).sqrt() - 1.5;
assert!(off.abs() < 1e-12, "{uv:?}: {off}");
}
}
// Its domain is the face's: the corners land where the label's do, the form past them by the
// angle the tolerance makes.
for (a, b) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
let (p, q) = (label_at.point([label[0][a], label[1][b]]).unwrap(), own_at.point([domain[0][a], domain[1][b]]).unwrap());
assert!(p.dist(q) < 1e-9, "corner {a}{b}: {p:?} vs {q:?}");
}
assert!(domain[1][0] > v0 && domain[1][1] < v1, "{domain:?}");
// A whole turn stays on the grid (periodic), and a plane has no form.
let (whole, _) = form::surface_on_own_arcs(&torus, [[0.3, 1.2], [-FRAC_PI_2, -FRAC_PI_2 + TAU]], tol).unwrap();
assert_eq!(tube(&whole), (-1.0, 3.0));
assert!(form::surface_on_own_arcs(&Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, label, tol).is_none());
}
+1 -1
View File
@@ -426,7 +426,7 @@ impl Refusal {
}
impl SurfaceKind {
fn of(surface: &solidcraft_geom::brep::Surface) -> SurfaceKind {
pub(crate) fn of(surface: &solidcraft_geom::brep::Surface) -> SurfaceKind {
use solidcraft_geom::brep::Surface as S;
match surface {
S::Plane { .. } | S::PlaneThrough { .. } => SurfaceKind::Plane,
+2
View File
@@ -113,6 +113,7 @@ mod prism_measure;
pub mod profile;
pub mod provenance;
mod record;
mod repair;
#[cfg(test)]
mod seam_faces_tests;
mod sew;
@@ -206,6 +207,7 @@ pub use polybool::faceted_boolean;
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean};
pub use prism_measure::certified_prism_equivalent;
pub use repair::{Hole, RebuiltFace, RefusedFace, RepairOptions, RepairReport, SewnGap, repair};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew, sew_touching};
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
pub use step::step_export;
+588
View File
@@ -0,0 +1,588 @@
//! Repair Part (#8371): a part SMLib refuses, or an open part that should be a solid, made into
//! one SMLib takes, saying what changed face by face. In order:
//!
//! 1. A face SMLib refuses on its own is rebuilt from its own surface, its geometry unchanged: a
//! cylinder, cone, sphere or torus face on a NURBS form SMLib rejects goes as the analytic
//! surface it is, which SMLib parameterises on its own form (the form's control net is the
//! import's construction; the surface it stands for is the label's, exactly).
//! 2. Free edges that pair up within `gap` (a sliver between two faces that should meet) are sewn
//! by SMLib's stitch, which gives each edge it glues its own tolerance from that pair's gap
//! (`SmBrep::GlueEdgesGeneral`: at least twice the gap); the body's tolerance, and every other
//! edge's, stays as it was.
//! 3. A loop of free edges no pair explains is a missing face: Patch on that loop is offered (and
//! made, and sewn in, when asked), planar only where the faces around it are that plane; where
//! they are curved the patch meets them tangent (G1). Extending the neighbours is not done.
//!
//! Nothing is moved: a face rebuilt keeps its surface, a sewn edge keeps its curve (SMLib keeps
//! one of the pair), a patch is a new face. What was done comes back in [`RepairReport`].
use solidcraft_geom::Vec3;
use solidcraft_geom::brep::eval::CurveEval;
use solidcraft_geom::brep::{self as b, Brep};
use crate::body::{Body, SurfaceKind};
use crate::{Continuity, KernelError, Result};
/// Most free edges paired against each other (each against every other near it).
const MAX_FREE_EDGES: usize = 20_000;
/// Most faces sent into SMLib one by one to find the ones it refuses.
const MAX_FACE_PROBES: usize = 20_000;
/// Points along an edge where its distance to its partner is measured.
const GAP_SAMPLES: usize = 16;
/// SMLib's check tests that bound an item's tolerance by the body's (`SmBrep::AssertValid` #5,
/// #6: face; #59: edge, under 1000 times the body's; #82: vertex). An edge sewn across a gap
/// carries that gap; the body's tolerance is not raised to cover it, so these may fail on sewn
/// items, as they already fail on an import's edges that lie off their faces by more.
const TOLERANCE_BOUND_TESTS: [u32; 4] = [5, 6, 59, 82];
/// What a repair may do.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RepairOptions {
/// The widest gap between two free edges that is sewn, mm (> 0).
pub gap: f64,
/// Patch the loops of free edges no pair explains (missing faces), and sew the patches in.
pub patch_holes: bool,
}
/// A face rebuilt so SMLib takes it.
#[derive(Clone, Debug, PartialEq)]
pub struct RebuiltFace {
/// Its index in the body before the repair.
pub face: usize,
pub surface: SurfaceKind,
/// Why SMLib refused it before.
pub why: String,
}
/// A face SMLib refuses that the repair could not rebuild.
#[derive(Clone, Debug, PartialEq)]
pub struct RefusedFace {
pub face: usize,
pub surface: SurfaceKind,
pub why: String,
}
/// Two free edges sewn into one.
#[derive(Clone, Debug, PartialEq)]
pub struct SewnGap {
/// The faces on either side, by index before the repair.
pub faces: [usize; 2],
/// The widest distance between the two edges, mm.
pub gap: f64,
/// A point halfway along the first edge.
pub at: Vec3,
}
/// A loop of free edges no pair explains: a face is missing there.
#[derive(Clone, Debug, PartialEq)]
pub struct Hole {
/// The faces around it, by index before the repair.
pub faces: Vec<usize>,
/// A point halfway along each of its edges: Patch's `edges`.
pub edges: Vec<Vec3>,
/// How a patch meets the faces around it: G0 (the plane they lie in) or G1 (curved neighbours).
pub continuity: Continuity,
/// The patch was made and sewn in.
pub patched: bool,
/// Why it was not, when it was asked for.
pub why: Option<String>,
}
/// What a repair did.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RepairReport {
pub rebuilt: Vec<RebuiltFace>,
pub refused: Vec<RefusedFace>,
pub sewn: Vec<SewnGap>,
pub holes: Vec<Hole>,
/// Faces before and after.
pub faces: [usize; 2],
/// The largest edge tolerance before and after, mm.
pub largest_edge_tolerance: [f64; 2],
/// The result is a closed solid.
pub closed: bool,
/// Whether SMLib takes the result as it is.
pub modelling: bool,
}
impl RepairReport {
/// Whether the repair changed anything.
pub fn changed(&self) -> bool {
!self.rebuilt.is_empty() || !self.sewn.is_empty() || self.holes.iter().any(|h| h.patched)
}
}
fn invalid(what: impl Into<String>) -> KernelError {
KernelError::Invalid(what.into())
}
fn v3(p: [f64; 3]) -> Vec3 {
Vec3::new(p[0], p[1], p[2])
}
/// `body` repaired as the module says, with what was done. Refused (with why) when nothing
/// applies: SMLib takes it and it has no free edges, or its only free edges bound holes and
/// `patch_holes` is off (the report's holes are then in the message).
pub fn repair(body: &Body, opts: &RepairOptions) -> Result<(Body, RepairReport)> {
let _span = solidcraft_profile::span("kernel", "repair");
body.require_brep("repair")?;
if !(opts.gap.is_finite() && opts.gap > 0.0 && opts.gap <= 1.0) {
return Err(invalid("the gap to sew must be more than 0 and at most 1 mm"));
}
let before = body.record();
let mut report =
RepairReport { faces: [before.faces.len(), 0], largest_edge_tolerance: [largest_tolerance(before), 0.0], ..RepairReport::default() };
// 1. Faces SMLib refuses on their own, rebuilt from their surfaces.
let mut record = before.clone();
if body.smlib().is_err() {
rebuild_refused(&mut record, &mut report)?;
}
let mut current = if report.rebuilt.is_empty() { body.clone() } else { Body::from_record(record)?.with_color(body.color()) };
if !report.refused.is_empty() && current.smlib().is_err() {
// SMLib still refuses the part: nothing can be sewn or patched (both are SMLib's). The
// faces rebuilt are kept, and the report names the ones left.
let list: Vec<String> = report.refused.iter().map(|r| format!("face {} ({:?}): {}", r.face + 1, r.surface, r.why)).collect();
if report.rebuilt.is_empty() {
return Err(KernelError::Failed(format!("SMLib refuses faces the repair cannot rebuild: {}", list.join("; "))));
}
let r = current.record();
report.holes = loops_of(r, &free_edges(r)?).iter().map(|l| hole(r, l)).collect();
report.faces[1] = r.faces.len();
report.largest_edge_tolerance[1] = largest_tolerance(r);
report.closed = false;
report.modelling = false;
return Ok((current, report));
}
// 2. and 3. The free edges: pairs within the gap, and loops of the rest.
let free = free_edges(current.record())?;
let pairs = pair_up(current.record(), &free, opts.gap);
let paired: std::collections::HashSet<u32> = pairs.iter().flat_map(|p| [p.edges[0], p.edges[1]]).collect();
let rest: Vec<u32> = free.iter().copied().filter(|e| !paired.contains(e)).collect();
let loops = loops_of(current.record(), &rest);
for l in &loops {
report.holes.push(hole(current.record(), l));
}
for p in &pairs {
report.sewn.push(SewnGap { faces: p.faces, gap: p.gap, at: p.at });
}
if !pairs.is_empty() {
let widest = pairs.iter().map(|p| p.gap).fold(0.0, f64::max);
// SMLib glues the lamina pairs within the stitch tolerance: just past the widest pair, so
// each of them glues and nothing farther apart does.
let tol = (widest * 1.05).max(current.record().tolerance * 2.0);
current = crate::surfaces::stitch_no_worse(&[&current], tol, &TOLERANCE_BOUND_TESTS)?;
}
if opts.patch_holes && !report.holes.is_empty() {
current = patch_holes(current, &mut report.holes)?;
}
if !report.changed() {
let holes: Vec<String> = report
.holes
.iter()
.map(|h| {
format!(
"a loop of {} edges around faces {}",
h.edges.len(),
h.faces.iter().map(|f| (f + 1).to_string()).collect::<Vec<_>>().join(", ")
)
})
.collect();
return Err(invalid(if holes.is_empty() {
"nothing to repair: SMLib takes the part and it has no gaps or holes".to_string()
} else {
format!("nothing sewn: the free edges bound holes ({}); patch them to close the part", holes.join("; "))
}));
}
let after = current.record();
report.faces[1] = after.faces.len();
report.largest_edge_tolerance[1] = largest_tolerance(after);
report.closed = !after.faces.is_empty() && after.edges.iter().all(|e| e.uses.len() >= 2);
report.modelling = current.smlib().is_ok();
Ok((current, report))
}
fn largest_tolerance(r: &Brep) -> f64 {
r.edges.iter().map(|e| e.tolerance).fold(r.tolerance, f64::max)
}
// ---- 1. Faces SMLib refuses -------------------------------------------------------------
/// Whether SMLib takes face `f` of `r` on its own; `Err` with SMLib's reason when it does not.
fn alone(r: &Brep, f: usize) -> Result<std::result::Result<(), String>> {
let one = crate::surfaces::Parts::of(r, &[(f, false)])?.canonical()?;
Ok(solidcraft_smlib::Brep::from_record(&one.brep, true).map(|_| ()).map_err(|e| e.to_string()))
}
/// Every face of `record` SMLib refuses on its own, rebuilt where its surface allows (into
/// `report.rebuilt`), else listed (`report.refused`).
fn rebuild_refused(record: &mut Brep, report: &mut RepairReport) -> Result<()> {
if record.faces.len() > MAX_FACE_PROBES {
return Err(KernelError::TooLarge(format!("a part of {} faces (the repair looks at most {MAX_FACE_PROBES})", record.faces.len())));
}
for f in 0..record.faces.len() {
let Err(why) = alone(record, f)? else { continue };
let Some(face) = record.faces.get(f) else { continue };
let surface = SurfaceKind::of(&face.surface);
match rebuilt_face(record, f) {
Ok(Some(face)) => {
let mut trial = record.clone();
if let Some(slot) = trial.faces.get_mut(f) {
*slot = face;
}
match alone(&trial, f)? {
Ok(()) => {
*record = trial;
report.rebuilt.push(RebuiltFace { face: f, surface, why });
}
Err(still) => {
report.refused.push(RefusedFace { face: f, surface, why: format!("{why}; rebuilt on its own surface, still: {still}") })
}
}
}
Ok(None) => report.refused.push(RefusedFace { face: f, surface, why }),
Err(e) => report.refused.push(RefusedFace { face: f, surface, why: format!("{why}; not rebuilt: {e}") }),
}
}
Ok(())
}
/// Face `f` of `r` rebuilt on its own surface: a cylinder, cone, sphere or torus face on a
/// NURBS form of the surface laid on arcs from where the face starts
/// ([`b::form::surface_on_own_arcs`]) instead of its frame's quarter grid, the same surface
/// exactly, with SMLib's own trims of the face's edges on it (`SmlibTrims`, as the import makes the
/// trims it has no closed form for). The face's edges and loops stay as they were. `None` for a
/// face on another surface.
fn rebuilt_face(r: &Brep, f: usize) -> Result<Option<b::Face>> {
use b::pcurve::{TrimEdge, TrimFace, TrimUse, Trims};
let face = r.faces.get(f).ok_or_else(|| invalid(format!("no face {f}")))?;
if !matches!(face.surface, b::Surface::Cylinder { .. } | b::Surface::Cone { .. } | b::Surface::Sphere { .. } | b::Surface::Torus { .. }) {
return Ok(None);
}
// The face's edges and vertices, numbered here as the trim face takes them.
let mut local: Vec<u32> = Vec::new();
let mut vertices: Vec<u32> = Vec::new();
let mut loops = Vec::with_capacity(face.loops.len());
let mut boundary = Vec::with_capacity(face.loops.len());
for l in &face.loops {
let b::Loop::Edges { edges } = l else { return Ok(None) };
let mut uses = Vec::with_capacity(edges.len());
let mut lb = Vec::with_capacity(edges.len());
for u in edges {
let e = r.edges.get(u.edge as usize).ok_or_else(|| invalid(format!("no edge {}", u.edge)))?;
let k = match local.iter().position(|&x| x == u.edge) {
Some(k) => k,
None => {
local.push(u.edge);
local.len() - 1
}
};
uses.push(TrimUse { edge: u32::try_from(k).map_err(|_| invalid("too many edges"))?, forward: u.forward, pcurve: None });
lb.push((&e.curve, e.interval, u.forward));
}
loops.push(uses);
boundary.push(lb);
}
let mut edges = Vec::with_capacity(local.len());
for &e in &local {
let edge = r.edges.get(e as usize).ok_or_else(|| invalid(format!("no edge {e}")))?;
let mut ends = [0u32; 2];
for (end, v) in ends.iter_mut().zip(edge.vertices) {
let k = match vertices.iter().position(|&x| x == v) {
Some(k) => k,
None => {
vertices.push(v);
vertices.len() - 1
}
};
*end = u32::try_from(k).map_err(|_| invalid("too many vertices"))?;
}
edges.push(TrimEdge { curve: &edge.curve, nurbs: edge.nurbs.as_ref(), interval: edge.interval, vertices: ends, tolerance: edge.tolerance });
}
let points = vertices
.iter()
.map(|&v| r.vertices.get(v as usize).map(|v| v.point).ok_or_else(|| invalid(format!("no vertex {v}"))))
.collect::<Result<Vec<_>>>()?;
let extent = b::form::extent(&face.surface, &boundary).ok_or_else(|| invalid("its boundary does not lie on its surface"))?;
let (form, domain) = b::form::surface_on_own_arcs(
&face.surface,
extent,
local.iter().filter_map(|&e| r.edges.get(e as usize)).map(|e| e.tolerance).fold(r.tolerance, f64::max),
)
.ok_or_else(|| invalid("no NURBS form of its surface over its boundary"))?;
let trim = TrimFace { surface: &face.surface, nurbs: Some(&form), domain, edges, vertices: points, loops, tolerance: r.tolerance };
let pcurves = solidcraft_smlib::pcurves::SmlibTrims.trims(&trim).map_err(KernelError::Failed)?;
let mut out = face.clone();
out.nurbs = Some(form);
out.domain = domain;
let mut it = pcurves.into_iter();
for l in &mut out.loops {
let b::Loop::Edges { edges } = l else { continue };
let pcs = it.next().ok_or_else(|| invalid("pcurves of a loop"))?;
for (u, pc) in edges.iter_mut().zip(pcs) {
u.pcurve = pc;
}
}
Ok(Some(out))
}
// ---- 2. Gaps ----------------------------------------------------------------------------
/// The edges of `r` only one face uses.
fn free_edges(r: &Brep) -> Result<Vec<u32>> {
let free: Vec<u32> =
(0..r.edges.len()).filter(|&e| r.edges.get(e).is_some_and(|e| e.uses.len() == 1)).filter_map(|e| u32::try_from(e).ok()).collect();
if free.len() > MAX_FREE_EDGES {
return Err(KernelError::TooLarge(format!("a part with {} free edges (the repair pairs at most {MAX_FREE_EDGES})", free.len())));
}
Ok(free)
}
/// Two free edges within the gap of each other.
struct Pair {
edges: [u32; 2],
faces: [usize; 2],
gap: f64,
at: Vec3,
}
/// An edge's points, evenly in its parameter (ends included).
fn points(r: &Brep, e: u32, n: usize) -> Vec<Vec3> {
let Some(edge) = r.edges.get(e as usize) else { return Vec::new() };
let Ok(c) = CurveEval::new(edge.parameters()) else { return Vec::new() };
let [t0, t1] = edge.interval;
(0..=n).filter_map(|k| c.point(t0 + (t1 - t0) * k as f64 / n as f64)).collect()
}
/// The widest distance from a point of edge `a` to edge `b` (each of `a`'s points to its nearest
/// point on `b`), `None` where `b` cannot be evaluated.
fn reach(r: &Brep, a: &[Vec3], b: u32) -> Option<f64> {
let edge = r.edges.get(b as usize)?;
let c = CurveEval::new(edge.parameters()).ok()?;
let ends = [c.point(edge.interval[0])?, c.point(edge.interval[1])?];
a.iter()
.map(|p| {
let inside = crate::surfaces::nearest_parameter(&c, edge.interval, *p).map_or(f64::INFINITY, |(_, d)| d);
inside.min(ends[0].dist(*p)).min(ends[1].dist(*p))
})
.try_fold(0.0f64, |m, d| d.is_finite().then_some(m.max(d)))
}
/// The free edges that pair up within `gap`, both ways (each edge's every point near the other),
/// each edge in at most one pair: the closest pairs first. An edge of a face with itself is not
/// a gap.
fn pair_up(r: &Brep, free: &[u32], gap: f64) -> Vec<Pair> {
let samples: Vec<Vec<Vec3>> = free.iter().map(|&e| points(r, e, GAP_SAMPLES)).collect();
let boxes: Vec<Option<(Vec3, Vec3)>> = samples
.iter()
.map(|s| {
let first = *s.first()?;
Some(s.iter().fold((first, first), |(lo, hi), p| (lo.min(*p), hi.max(*p))))
})
.collect();
let face = |e: u32| r.edges.get(e as usize).and_then(|e| e.uses.first()).map(|u| u.face as usize);
let mut candidates = Vec::new();
for i in 0..free.len() {
for j in i + 1..free.len() {
let (Some(&a), Some(&b)) = (free.get(i), free.get(j)) else { continue };
let (Some(Some((alo, ahi))), Some(Some((blo, bhi)))) = (boxes.get(i), boxes.get(j)) else { continue };
let apart = (blo.x - ahi.x).max(alo.x - bhi.x).max(blo.y - ahi.y).max(alo.y - bhi.y).max(blo.z - ahi.z).max(alo.z - bhi.z);
if apart > gap || face(a) == face(b) {
continue;
}
let (Some(sa), Some(sb)) = (samples.get(i), samples.get(j)) else { continue };
let Some(d) = reach(r, sa, b).zip(reach(r, sb, a)).map(|(x, y)| x.max(y)) else { continue };
if d <= gap {
candidates.push((d, a, b));
}
}
}
candidates.sort_by(|x, y| x.0.total_cmp(&y.0));
let mut taken = std::collections::HashSet::new();
let mut out = Vec::new();
for (d, a, b) in candidates {
if taken.contains(&a) || taken.contains(&b) {
continue;
}
let (Some(fa), Some(fb)) = (face(a), face(b)) else { continue };
taken.insert(a);
taken.insert(b);
let at = points(r, a, 2).get(1).copied().unwrap_or(Vec3::ZERO);
out.push(Pair { edges: [a, b], faces: [fa, fb], gap: d, at });
}
out
}
// ---- 3. Holes ---------------------------------------------------------------------------
/// The edges `rest` of `r` in chains joined at shared vertices: each a loop around a hole (or an
/// open run where the boundary does not close).
fn loops_of(r: &Brep, rest: &[u32]) -> Vec<Vec<u32>> {
let mut by_vertex: std::collections::HashMap<u32, Vec<u32>> = std::collections::HashMap::new();
for &e in rest {
if let Some(edge) = r.edges.get(e as usize) {
for v in edge.vertices {
by_vertex.entry(v).or_default().push(e);
}
}
}
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for &start in rest {
if !seen.insert(start) {
continue;
}
let mut chain = vec![start];
let mut stack = vec![start];
while let Some(e) = stack.pop() {
let Some(edge) = r.edges.get(e as usize) else { continue };
for v in edge.vertices {
for &n in by_vertex.get(&v).into_iter().flatten() {
if seen.insert(n) {
chain.push(n);
stack.push(n);
}
}
}
}
out.push(chain);
}
out
}
/// The hole a loop of free edges bounds, and how a patch would meet the faces around it: flat
/// (the loop's plane) when the loop is planar and every face around it is a plane, else tangent
/// to them.
fn hole(r: &Brep, edges: &[u32]) -> Hole {
let mut faces: Vec<usize> = edges.iter().filter_map(|&e| r.edges.get(e as usize)?.uses.first().map(|u| u.face as usize)).collect();
faces.sort_unstable();
faces.dedup();
let at: Vec<Vec3> = edges.iter().filter_map(|&e| points(r, e, 2).get(1).copied()).collect();
let along: Vec<Vec3> = edges.iter().flat_map(|&e| points(r, e, 8)).collect();
let tol = edges.iter().filter_map(|&e| r.edges.get(e as usize)).map(|e| e.tolerance).fold(r.tolerance, f64::max);
let planes = faces.iter().all(|&f| r.faces.get(f).is_some_and(|face| plane_of(&face.surface).is_some()));
let flat = planes && plane_through(&along).is_some_and(|(o, n)| along.iter().all(|p| (*p - o).dot(n).abs() <= tol));
Hole { faces, edges: at, continuity: if flat { Continuity::G0 } else { Continuity::G1 }, patched: false, why: None }
}
/// The plane of a closed polygon's points (Newell's normal, through their centroid); `None`
/// where they enclose no area.
fn plane_through(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
let n = pts.len();
if n < 3 {
return None;
}
let c = pts.iter().fold(Vec3::ZERO, |s, p| s + *p) * (1.0 / n as f64);
let normal = (0..n).filter_map(|i| Some((pts.get(i)?, pts.get((i + 1) % n)?))).fold(Vec3::ZERO, |s, (p, q)| s + (*p - c).cross(*q - c));
Some((c, normal.normalized()?))
}
/// A plane surface's point and unit normal.
fn plane_of(s: &b::Surface) -> Option<(Vec3, Vec3)> {
let (o, u, v) = match s {
b::Surface::Plane { origin, u_axis, v_axis } => (v3(*origin), v3(*u_axis), v3(*v_axis)),
b::Surface::PlaneThrough { origin, u_point, v_point } => (v3(*origin), v3(*u_point) - v3(*origin), v3(*v_point) - v3(*origin)),
_ => return None,
};
Some((o, u.cross(v).normalized()?))
}
/// `body` with each hole patched (Patch on its loop, at its continuity) and the patches sewn in
/// along the hole's own edges; a hole whose patch fails keeps why.
fn patch_holes(body: Body, holes: &mut [Hole]) -> Result<Body> {
let mut patches = Vec::new();
let mut tol = body.record().tolerance;
for h in holes.iter_mut() {
let spec = crate::PatchSpec { continuity: h.continuity, ..crate::PatchSpec::default() };
match crate::patch_edges_with(&body, &h.edges, &spec) {
Ok(p) => {
tol = tol.max(largest_tolerance(p.record()));
patches.push(p);
h.patched = true;
}
Err(e) => h.why = Some(e.to_string()),
}
}
if patches.is_empty() {
return Ok(body);
}
let mut all: Vec<&Body> = vec![&body];
all.extend(patches.iter());
// A patch's edges are the hole's own: they meet within those edges' tolerances.
crate::surfaces::stitch_no_worse(&all, tol * 2.0, &TOLERANCE_BOUND_TESTS)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{box_solid, copy_faces, measure, stitch, transform};
/// The six faces of a 10 × 20 × 30 box, each its own surface body: bottom, top, then the sides.
fn box_faces() -> Vec<Body> {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
[[5.0, 10.0, 0.0], [5.0, 10.0, 30.0], [0.0, 10.0, 15.0], [10.0, 10.0, 15.0], [5.0, 0.0, 15.0], [5.0, 20.0, 15.0]]
.iter()
.map(|p| copy_faces(&b, &[v3(*p)]).unwrap())
.collect()
}
/// The box without its top: an open part with a hole the four sides and their plane bound.
fn open_box(faces: &[Body]) -> Body {
stitch(&[&faces[0], &faces[2], &faces[3], &faces[4], &faces[5]], 0.0).unwrap()
}
#[test]
fn a_gap_between_faces_is_sewn_each_sewn_edge_carrying_its_gap() {
let faces = box_faces();
let open = open_box(&faces);
// The lid 0.05 mm above the walls: one part of six faces whose top edges pair up 0.05 apart.
let lid = transform(&faces[1], Vec3::new(0.0, 0.0, 0.05), Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), 0.0).unwrap();
let part = stitch(&[&open, &lid], 1e-6).unwrap();
assert_eq!(part.record().edges.iter().filter(|e| e.uses.len() == 1).count(), 8, "the gap's edges are free");
let opts = RepairOptions { gap: 0.1, patch_holes: false };
let (fixed, report) = repair(&part, &opts).unwrap();
assert_eq!(report.sewn.len(), 4, "{report:?}");
assert!(report.sewn.iter().all(|g| (g.gap - 0.05).abs() < 1e-6), "{report:?}");
assert!(report.holes.is_empty() && report.rebuilt.is_empty());
assert!(report.closed && report.modelling, "{report:?}");
assert_eq!(report.faces, [6, 6]);
let volume = measure(&fixed).unwrap().volume;
assert!((6000.0..=6010.0).contains(&volume), "{volume}");
// Only the four sewn edges carry the gap; the body's tolerance and every other edge's
// stay SMLib's own.
let r = fixed.record();
assert_eq!(r.tolerance, part.record().tolerance);
let loose: Vec<f64> = r.edges.iter().map(|e| e.tolerance).filter(|t| *t > 0.01).collect();
assert_eq!(loose.len(), 4, "{loose:?}");
assert!(loose.iter().all(|t| *t >= 0.05 && *t <= 0.25), "{loose:?}");
// A gap wider than asked for is not sewn: the free edges then bound holes.
let err = repair(&part, &RepairOptions { gap: 0.01, patch_holes: false }).unwrap_err().to_string();
assert!(err.contains("nothing sewn"), "{err}");
}
#[test]
fn a_hole_is_offered_then_patched_flat_in_the_plane_around_it() {
let open = open_box(&box_faces());
let opts = RepairOptions { gap: 0.2, patch_holes: false };
let err = repair(&open, &opts).unwrap_err().to_string();
assert!(err.contains("a loop of 4 edges"), "{err}");
// Planar walls around a planar loop: the patch is the loop's plane.
let (fixed, report) = repair(&open, &RepairOptions { patch_holes: true, ..opts }).unwrap();
let [hole] = report.holes.as_slice() else { panic!("{report:?}") };
assert_eq!((hole.faces.len(), hole.edges.len(), hole.continuity, hole.patched), (4, 4, Continuity::G0, true), "{hole:?}");
assert!(report.closed && report.modelling, "{report:?}");
assert_eq!(report.faces, [5, 6]);
assert!((measure(&fixed).unwrap().volume - 6000.0).abs() < 1e-6);
}
#[test]
fn a_part_with_nothing_to_repair_is_refused_with_why() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let err = repair(&b, &RepairOptions { gap: 0.2, patch_holes: true }).unwrap_err().to_string();
assert!(err.contains("nothing to repair"), "{err}");
for gap in [0.0, -1.0, 2.0, f64::NAN, f64::INFINITY] {
assert!(repair(&b, &RepairOptions { gap, patch_holes: false }).is_err(), "{gap}");
}
}
}
+47 -2
View File
@@ -699,7 +699,7 @@ const SAMPLES: usize = 64;
/// The parameter of the curve's point nearest `p` inside `interval`, and the distance: the
/// nearest of evenly spaced samples, then a golden-section search between its neighbours.
fn nearest_parameter(c: &CurveEval, [a, b]: [f64; 2], p: Vec3) -> Option<(f64, f64)> {
pub(crate) fn nearest_parameter(c: &CurveEval, [a, b]: [f64; 2], p: Vec3) -> Option<(f64, f64)> {
let dist = |t: f64| c.point(t).map_or(f64::INFINITY, |q| q.dist(p));
let at = |k: usize| a + (b - a) * k as f64 / SAMPLES as f64;
let best = (0..=SAMPLES).min_by(|&i, &j| dist(at(i)).total_cmp(&dist(at(j))))?;
@@ -738,6 +738,28 @@ pub fn stitch(bodies: &[&Body], tol: f64) -> Result<Body> {
Ok(body.with_checked_history(h, &record::counts(bodies)))
}
/// [`stitch`] at `tol` exactly, taking a result whose SMLib check fails no more tests than the
/// bodies' own records do, leaving out the tests `exempt` (by SMLib's test number): Repair Part
/// sews parts SMLib's check already finds fault with (an import's edges off their faces), and
/// an edge sewn across a gap carries that gap as its own tolerance.
pub(crate) fn stitch_no_worse(bodies: &[&Body], tol: f64, exempt: &[u32]) -> Result<Body> {
if bodies.is_empty() {
return Err(invalid("nothing to stitch"));
}
for b in bodies {
b.require_brep("stitch")?;
}
if !(tol.is_finite() && tol > 0.0) {
return Err(invalid("a stitch tolerance must be positive"));
}
let records: Vec<&Brep> = bodies.iter().map(|b| b.record()).collect();
let (parts, offsets) = Parts::joined(&records)?;
let c = without_t_junctions(parts.canonical()?, &offsets, tol)?;
let (body, h) = imp::stitched_no_worse(&c.brep, tol, exempt)?;
let h = h.and_then(|h| carried(&h, &c, &offsets, bodies.len(), |operand, face| FaceOrigin::Face { operand, face }));
Ok(body.with_checked_history(h, &record::counts(bodies)))
}
/// A surface body thickened into a solid: offset along the faces' normals by `thickness`
/// (negative: against them), or by half each way when `symmetric`, by SMLib's thicken
/// ([`crate::offset_backend::thicken`]).
@@ -1248,11 +1270,34 @@ mod imp {
/// and edges by their index in `r`); none where SMLib's import of `r` does not number it as
/// `r` does.
pub(super) fn stitched(r: &Brep, tol: f64) -> Result<(Body, Option<History>)> {
stitched_with(r, tol, |_, sewn| checked(sewn, "stitch").map(|_| ()))
}
/// [`stitched`], taking a result whose check fails no more tests outside `exempt` than the
/// input's does.
pub(super) fn stitched_no_worse(r: &Brep, tol: f64, exempt: &[u32]) -> Result<(Body, Option<History>)> {
stitched_with(r, tol, |input, sewn| {
let counted = |b: &SmBrep| -> Result<usize> {
let report = b.check(CheckLevel::All).map_err(sm("stitch"))?;
Ok(report.failed_tests().into_iter().filter(|t| !exempt.contains(t)).count())
};
let (was, now) = (counted(input)?, counted(sewn)?);
if now > was {
let check = sewn.check(CheckLevel::All).map_err(sm("stitch"))?;
return Err(failed(format!("SMLib stitch: SMLib's check finds the sewn part worse ({now} failed tests, {was} before): {check}")));
}
Ok(())
})
}
/// The record `r` sewn at `tol`, the result taken when `accept` (given SMLib's input and
/// result) does.
fn stitched_with(r: &Brep, tol: f64, accept: impl Fn(&SmBrep, &SmBrep) -> Result<()>) -> Result<(Body, Option<History>)> {
let brep = SmBrep::from_record(r, true).map_err(sm("stitch"))?;
let (back, order) = brep.to_native_ordered().map_err(|e| failed(format!("SMLib stitch: {e}")))?;
let tagged = brep.tagged(0).map_err(sm("stitch"))?;
let (sewn, _closed) = tagged.stitch(tol).map_err(sm("stitch"))?;
checked(&sewn, "stitch")?;
accept(&brep, &sewn)?;
let (record, kept) = sewn.to_native_ordered().map_err(|e| failed(format!("SMLib stitch: {e}")))?;
let h = if same_numbering(r, &back) { sewn.history().ok().and_then(|raw| raw.history(&[order], &kept).ok()) } else { None };
Ok((Body::from_smlib_record(record)?, h))
+13
View File
@@ -28,6 +28,19 @@ impl CheckReport {
pub fn is_valid(&self) -> bool {
self.failures == 0
}
/// The number of each failed test, line by line (`SmBrep::Test #82 …` is 82); a line without
/// one is left out.
pub fn failed_tests(&self) -> Vec<u32> {
self.text
.lines()
.filter_map(|l| {
let rest = l.split_once("Test #")?.1;
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
digits.parse().ok()
})
.collect()
}
}
impl std::fmt::Display for CheckReport {