Merge pull request #2282 from Karl-VonRichter/difftest-expect-refused-manifold

difftest: expect.refused and expect.manifold for SolidCraft cases (prep for #2278)
This commit is contained in:
bherbruck
2026-11-02 00:03:05 -05:00
committed by GitHub
7 changed files with 1316 additions and 8 deletions
+12 -1
View File
@@ -807,7 +807,18 @@ nice -n 10 cargo run -p difftest -- --solidcraft-cases '3526-*' # ids (<f
void is a region of its own), `volume` (`value`, absolute `tol`, the `formula` it came
from), `removed` (the same with the earlier body `from`: its volume minus `result`'s,
for a result without a closed-form volume of its own), `cylinder_faces_max`,
`sheet_corner` (signed end distances and flat-pattern invariants).
`sheet_corner` (signed end distances and flat-pattern invariants), `manifold`
(`result` must, or must not, be a closed manifold solid, `SmBrep::IsManifoldSolid`, so a
blend that leaves an open body cannot pass on its volume alone), and `refused`
(`step`, `error`: the kernel must refuse; the case passes when that step fails with an
error containing `error`, fails when every step completes, and takes no other
expectation). `refused` is the opposite of `refused_by`: there the refusal is a known
defect and defers the case, here it is the right answer (a blend too large for its
faces, for example `199-*/round_past_the_end_face_is_refused`). `refused.fit_size`
(`value`, `tol`, `kind` `radius` (default), `scale` or `chord`, `source`) also requires the
largest size that builds, which a refusal reports with `fillet_gn_section_generator`
(`SolidError::FilletRefused`, WR-696): a refusal without a size fails it, and a refusal
with one is still an `ERROR` in a case that expects none.
* **Verdicts**: `PASS`, `FAIL`, `UNCHECKED` (no expectations were supplied, or no check
failed but one cannot be measured
yet: `cylinder_faces_max` until solidcore has a cylinder surface kind), `ERROR` (a step
@@ -30,6 +30,7 @@ fn summary(run: &Run) -> String {
}
Run::Deferred(d) => format!("deferred {}: {}", d.blocked_by, d.symbol),
Run::Error(e) => format!("error {e}"),
Run::Refused { message, fit } => format!("refused {message} (fit {})", fit.size),
}
}
+426 -4
View File
@@ -24,6 +24,7 @@ use serde::Deserialize;
use serde_json::{json, Value};
use solidcore::blend::fillet_executive::api as fillet_api;
use solidcore::blend::fillet_executive::FitKind;
use solidcore::boolean::api_brep as boolean;
use solidcore::curve::curve::Curve;
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
@@ -35,6 +36,7 @@ use solidcore::mass::integrator::properties::{brep_volume, PropertyAttributes};
use solidcore::primitive::api_curves as curves;
use solidcore::primitive::api_primitives as primitives;
use solidcore::primitive::primitive_creation::SweepOptions;
use solidcore::query::api_queries::brep_is_manifold_solid;
use solidcore::surface::surface::p2::SurfaceKind;
use solidcore::surface::surface::Surface;
use solidcore::sweep::api_brep as sweeps;
@@ -495,6 +497,49 @@ pub struct Expect {
/// Sheet-corner geometry and flat-pattern invariants from SolidCraft #3170.
#[serde(default)]
pub sheet_corner: Option<SheetCornerExpect>,
/// The kernel must refuse: the case passes when step `step` fails with an error
/// containing `error`, and fails when every step completes. Nothing else is checked
/// then (there is no `result`). Unlike the case's `refused_by` (a known defect that
/// defers the case), the refusal is the correct answer here.
#[serde(default)]
pub refused: Option<RefusedExpect>,
/// Whether `result` must be a closed manifold solid (`SmBrep::IsManifoldSolid`): a
/// blend that leaves an open or non-manifold body must not pass on its volume alone.
#[serde(default)]
pub manifold: Option<bool>,
}
/// A refusal a case expects: the step that fails and text its error contains.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RefusedExpect {
/// The `id` of the step that must fail.
pub step: String,
/// Text the step's error contains. Empty or whitespace-only text is invalid.
pub error: String,
/// The largest size that builds, which the refusal must report
/// (`SolidError::FilletRefused`'s `fit`; needs `fillet_gn_section_generator`).
#[serde(default)]
pub fit_size: Option<FitSizeExpect>,
}
/// A refusal's expected largest size that builds.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FitSizeExpect {
/// The size.
pub value: f64,
/// Absolute tolerance.
pub tol: f64,
/// What it sizes (default `radius`).
#[serde(default = "radius_kind")]
pub kind: FitKindName,
/// Where the value comes from (SolidCraft's test, the fork, a closed form).
pub source: String,
}
fn radius_kind() -> FitKindName {
FitKindName::Radius
}
/// A valid folded solid, one connected non-overlapping flat pattern whose area
@@ -864,6 +909,31 @@ pub fn validate(case: &Case) -> Result<(), String> {
));
}
}
if let Some(r) = &case.expect.refused {
if r.error.trim().is_empty() {
return Err("refused needs the text its error contains".into());
}
if let Some(f) = &r.fit_size {
if !(f.value.is_finite() && f.value >= 0.0 && f.tol.is_finite() && f.tol > 0.0) {
return Err("refused fit_size value must be finite and >= 0, tol positive".into());
}
}
if !case.steps.iter().any(|s| s.id() == r.step) {
return Err(format!("refused names `{}`, which no step writes", r.step));
}
let e = &case.expect;
if e.lumps.is_some()
|| e.volume.is_some()
|| e.removed.is_some()
|| e.cylinder_faces_max.is_some()
|| e.sheet_corner.is_some()
|| e.manifold.is_some()
{
return Err(
"a refused case has no result to check: drop its other expectations".into(),
);
}
}
if let Some(e) = &case.expect.sheet_corner {
if !e.end_face_distances.iter().all(|x| x.is_finite())
|| !e.distance_tol.is_finite()
@@ -902,6 +972,8 @@ pub struct Measured {
pub cylinder_faces: usize,
/// The volume of the case's `removed.from` body minus `volume`, when it asks for it.
pub removed: Option<f64>,
/// `SmBrep::IsManifoldSolid`: a closed solid with every edge on two faces.
pub manifold: bool,
}
/// How a case's run ended.
@@ -913,6 +985,54 @@ pub enum Run {
Deferred(Deferral),
/// A step returned an error other than `Unported`.
Error(String),
/// A fillet or chamfer step was refused as too large, with the largest size that
/// builds it (`SolidError::FilletRefused`, opt-in `fillet_gn_section_generator`).
/// Scored as an `ERROR`, except by a case that expects the refusal.
Refused {
/// The error, prefixed with the step as for [`Run::Error`].
message: String,
/// The largest size that builds the blend.
fit: Fit,
},
}
/// A refusal's largest size that builds (`FitSize` without its edge).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Fit {
/// The size: `> 0`, or `0` when no size builds.
pub size: f64,
/// What it sizes.
pub kind: FitKindName,
}
/// What a [`Fit`] sizes (`FitKind`), as a case names it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FitKindName {
/// A constant ball's radius (a round's radius, an equal chamfer's solver radius).
Radius,
/// The factor on two unequal offsets.
Scale,
/// A chord width.
Chord,
}
impl FitKindName {
fn of(kind: FitKind) -> Self {
match kind {
FitKind::Radius => Self::Radius,
FitKind::Scale => Self::Scale,
FitKind::Chord => Self::Chord,
}
}
fn as_str(self) -> &'static str {
match self {
Self::Radius => "radius",
Self::Scale => "scale",
Self::Chord => "chord",
}
}
}
fn p3(p: P3) -> Point3 {
@@ -926,6 +1046,13 @@ fn solid(e: &SolidError) -> Run {
blocked_by: (*unit).to_string(),
symbol: (*upstream).to_string(),
}),
SolidError::FilletRefused { fit, .. } => Run::Refused {
message: e.to_string(),
fit: Fit {
size: fit.size,
kind: FitKindName::of(fit.kind),
},
},
_ => Run::Error(e.to_string()),
}
}
@@ -1554,9 +1681,11 @@ fn measure(k: &Kernel, b: &mut Brep) -> Result<Measured, SolidError> {
}
}
}
let manifold = brep_is_manifold_solid(b)?;
Ok(Measured {
volume,
lumps,
manifold,
topology: [
b.face_list().len(),
b.edge_list().len(),
@@ -1667,6 +1796,19 @@ pub fn run_rust_on(mut k: Kernel, case: &Case) -> Run {
None => Run::Error(msg),
};
}
Err(Run::Refused { message, fit }) => {
let refused = case.refused_by.as_ref().filter(|r| {
!r.error.trim().is_empty() && r.step == step.id() && message.contains(&r.error)
});
let message = format!("step {} (`{}`): {message}", n + 1, step.id());
return match refused {
Some(r) => Run::Deferred(Deferral {
blocked_by: r.issue.clone(),
symbol: message,
}),
None => Run::Refused { message, fit },
};
}
Err(stop) => return stop,
}
}
@@ -1694,7 +1836,8 @@ pub fn run_rust_on(mut k: Kernel, case: &Case) -> Run {
/// One expectation checked.
#[derive(Debug, Clone, PartialEq)]
pub struct Check {
/// `lumps`, `volume`, `removed`, `cylinder_faces_max`, `sheet_corner`.
/// `lumps`, `volume`, `removed`, `cylinder_faces_max`, `manifold`, `sheet_corner`,
/// `refused`, `fit_size`.
pub name: &'static str,
/// `pass`, `fail` or `unchecked`.
pub status: &'static str,
@@ -1753,6 +1896,13 @@ pub fn checks(e: &Expect, m: &Measured) -> Vec<Check> {
detail: format!("at most {n}, got {}", m.cylinder_faces),
});
}
if let Some(want) = e.manifold {
out.push(Check {
name: "manifold",
status: pass(m.manifold == want),
detail: format!("expected {want}, got {}", m.manifold),
});
}
if e.sheet_corner.is_some() {
out.push(Check {
name: "sheet_corner",
@@ -1766,9 +1916,12 @@ pub fn checks(e: &Expect, m: &Measured) -> Vec<Check> {
/// A run's verdict and its checks.
/// A completed run without expectations is `UNCHECKED`, not a checked pass.
pub fn verdict(e: &Expect, run: &Run) -> (&'static str, Vec<Check>) {
if let Some(r) = &e.refused {
return refusal_verdict(r, run);
}
match run {
Run::Deferred(_) => ("DEFERRED", Vec::new()),
Run::Error(_) => ("ERROR", Vec::new()),
Run::Error(_) | Run::Refused { .. } => ("ERROR", Vec::new()),
Run::Done(m) => {
let c = checks(e, m);
let v = if c.iter().any(|c| c.status == "fail") {
@@ -1783,6 +1936,73 @@ pub fn verdict(e: &Expect, run: &Run) -> (&'static str, Vec<Check>) {
}
}
/// The verdict of a case that expects a refusal: `PASS` when step `r.step` failed with an
/// error containing `r.error`, `FAIL` when every step completed or a step failed some
/// other way, `DEFERRED` when unported code stopped it first.
fn refusal_verdict(r: &RefusedExpect, run: &Run) -> (&'static str, Vec<Check>) {
let check = |ok: bool, detail: String| Check {
name: "refused",
status: if ok { "pass" } else { "fail" },
detail,
};
match run {
Run::Deferred(_) => ("DEFERRED", Vec::new()),
Run::Error(msg) | Run::Refused { message: msg, .. } => {
// `run_rust_on` names the failing step as "step N (`id`): ...".
let ok = msg.contains(&format!("(`{}`): ", r.step))
&& !r.error.trim().is_empty()
&& msg.contains(&r.error);
let detail = format!(
"expected `{}` to fail with `{}`, got: {msg}",
r.step, r.error
);
let mut checks = vec![check(ok, detail)];
if let Some(want) = &r.fit_size {
let got = match run {
Run::Refused { fit, .. } => Some(*fit),
_ => None,
};
let fit_ok = got.is_some_and(|g| {
g.kind == want.kind && (g.size - want.value).abs() <= want.tol
});
checks.push(Check {
name: "fit_size",
status: if fit_ok { "pass" } else { "fail" },
detail: match got {
Some(g) => format!(
"expected {} {} +- {}, got {} {} (off by {})",
want.kind.as_str(),
want.value,
want.tol,
g.kind.as_str(),
g.size,
(g.size - want.value).abs()
),
None => format!(
"expected {} {} +- {}, the refusal reports no size",
want.kind.as_str(),
want.value,
want.tol
),
},
});
}
let all = checks.iter().all(|c| c.status == "pass");
(if all { "PASS" } else { "FAIL" }, checks)
}
Run::Done(_) => (
"FAIL",
vec![check(
false,
format!(
"expected `{}` to be refused, but every step completed",
r.step
),
)],
),
}
}
/// Options of a `--solidcraft-cases` run.
#[derive(Debug, Clone, Default)]
pub struct CasesOptions {
@@ -1804,8 +2024,27 @@ fn run_entry(run: &Run, verdict: &str, checks: &[Check]) -> Value {
json!({"blocked_by": d.blocked_by, "symbol": d.symbol}),
);
}
Run::Error(msg) => {
Run::Error(msg) | Run::Refused { message: msg, .. } => {
e.insert("error".into(), json!(msg));
if let Run::Refused { fit, .. } = run {
e.insert(
"fit".into(),
json!({"size": crate::json::num(fit.size), "kind": fit.kind.as_str()}),
);
}
if !checks.is_empty() {
e.insert(
"checks".into(),
Value::Array(
checks
.iter()
.map(
|c| json!({"name": c.name, "status": c.status, "detail": c.detail}),
)
.collect(),
),
);
}
}
Run::Done(m) => {
e.insert(
@@ -1818,6 +2057,7 @@ fn run_entry(run: &Run, verdict: &str, checks: &[Check]) -> Value {
"vertices": m.topology[2],
"cylinder_faces": m.cylinder_faces,
"removed": m.removed.map(crate::json::num),
"manifold": m.manifold,
}),
);
e.insert(
@@ -1868,6 +2108,18 @@ pub fn run_cases(root: &Path, opts: &CasesOptions) -> ExitCode {
format!("{}: {}", d.blocked_by, d.symbol)
}
Run::Error(msg) => msg.clone(),
Run::Refused { message, fit } => {
let fit_checks: Vec<String> = c
.iter()
.filter(|c| c.name == "fit_size")
.map(|c| format!("fit_size {}: {}", c.status, c.detail))
.collect();
if fit_checks.is_empty() {
format!("{message} [fit {} {}]", fit.kind.as_str(), fit.size)
} else {
format!("{message}; {}", fit_checks.join("; "))
}
}
Run::Done(m) => {
let [f, e, v] = m.topology;
let checked = c
@@ -2050,7 +2302,9 @@ mod tests {
e.lumps.is_some()
|| e.volume.is_some()
|| e.removed.is_some()
|| e.sheet_corner.is_some(),
|| e.sheet_corner.is_some()
|| e.refused.is_some()
|| e.manifold.is_some(),
"{}: nothing to check",
l.id
);
@@ -2295,6 +2549,167 @@ mod tests {
}
}
#[test]
fn refused_expectation_passes_only_on_that_steps_refusal() {
let e: Expect = serde_json::from_value(json!({
"refused": {"step": "result", "error": "status 1001"}
}))
.unwrap();
let r = e.refused.as_ref().unwrap();
let refused = Run::Error("step 2 (`result`): blend::x: upstream status 1001".into());
assert_eq!(verdict(&e, &refused).0, "PASS");
// another step, or another error, is not the refusal asked for
let other_step = Run::Error("step 1 (`body`): upstream status 1001".into());
assert_eq!(verdict(&e, &other_step).0, "FAIL");
let other_error = Run::Error("step 2 (`result`): no body `x`".into());
assert_eq!(verdict(&e, &other_error).0, "FAIL");
// completing is the failure a refused case guards against
let built = Run::Done(Measured {
volume: 0.0,
lumps: 0,
topology: [7, 20, 14],
cylinder_faces: 0,
removed: None,
manifold: false,
});
let (v, c) = verdict(&e, &built);
assert_eq!(v, "FAIL");
assert!(c[0].detail.contains("every step completed"), "{c:?}");
let deferred = Run::Deferred(Deferral {
blocked_by: "unit".into(),
symbol: "Sm".into(),
});
assert_eq!(verdict(&e, &deferred).0, "DEFERRED");
assert_eq!(r.step, "result");
}
#[test]
fn refused_cases_take_no_result_expectations() {
let case = |expect: Value| -> Case {
serde_json::from_value(json!({
"name": "c",
"steps": [{"op": "box", "id": "result", "origin": [0.0, 0.0, 0.0],
"size": [1.0, 1.0, 1.0]}],
"expect": expect
}))
.unwrap()
};
assert!(validate(&case(
json!({"refused": {"step": "result", "error": "1001"}})
))
.is_ok());
assert!(validate(&case(json!({"refused": {"step": "result", "error": " "}}))).is_err());
assert!(
validate(&case(json!({"refused": {"step": "result", "error": "1001",
"fit_size": {"value": -1.0, "tol": 1e-6, "source": "s"}}})))
.is_err()
);
assert!(validate(&case(json!({"refused": {"step": "nope", "error": "1001"}}))).is_err());
assert!(validate(&case(
json!({"lumps": 1, "refused": {"step": "result", "error": "1001"}})
))
.is_err());
}
#[test]
fn manifold_expectation_checks_the_result() {
let e: Expect = serde_json::from_value(json!({"lumps": 1, "manifold": true})).unwrap();
let m = |manifold: bool| {
Run::Done(Measured {
volume: 1.0,
lumps: 1,
topology: [6, 12, 8],
cylinder_faces: 0,
removed: None,
manifold,
})
};
assert_eq!(verdict(&e, &m(true)).0, "PASS");
let (v, c) = verdict(&e, &m(false));
assert_eq!(v, "FAIL");
assert!(
c.iter().any(|c| c.name == "manifold" && c.status == "fail"),
"{c:?}"
);
}
#[test]
fn a_round_taller_than_its_wall_is_refused() {
// SolidCraft #190: an r 13 disc 2 high cannot take a round of 2.001 on its top
// circle; the pinned kernel refuses it, and the refused case passes on that.
let case: Case = serde_json::from_value(json!({
"name": "disc-round-too-tall",
"steps": [
{"op": "cylinder", "id": "disc", "origin": [0.0, 0.0, 0.0], "radius": 13.0,
"height": 2.0},
{"op": "fillet", "id": "result", "body": "disc", "edges": [[0.0, 13.0, 2.0]],
"radius": 2.001}
],
"expect": {"refused": {"step": "result", "error": "status 1001"}}
}))
.unwrap();
validate(&case).unwrap();
let run = run_rust(&case);
assert_eq!(verdict(&case.expect, &run).0, "PASS", "{run:?}");
}
#[test]
fn refused_fit_size_is_checked_against_the_refusal() {
let e: Expect = serde_json::from_value(json!({
"refused": {"step": "result", "error": "status 1001",
"fit_size": {"value": 3.0, "tol": 1e-6, "source": "s"}}
}))
.unwrap();
let refused = |size: f64, kind: FitKindName| Run::Refused {
message: "step 2 (`result`): x: upstream status 1001: refused".into(),
fit: Fit { size, kind },
};
assert_eq!(verdict(&e, &refused(3.0, FitKindName::Radius)).0, "PASS");
let (v, c) = verdict(&e, &refused(2.5, FitKindName::Radius));
assert_eq!(v, "FAIL");
assert!(
c.iter().any(|c| c.name == "fit_size" && c.status == "fail"),
"{c:?}"
);
assert_eq!(verdict(&e, &refused(3.0, FitKindName::Scale)).0, "FAIL");
// a refusal without a size does not meet a fit_size expectation
let plain = Run::Error("step 2 (`result`): x: upstream status 1001".into());
let (v, c) = verdict(&e, &plain);
assert_eq!(v, "FAIL");
assert!(
c.iter().any(|c| c.detail.contains("reports no size")),
"{c:?}"
);
// and a refusal with a size is still an ERROR where no refusal is expected
let none: Expect = serde_json::from_value(json!({"lumps": 1})).unwrap();
assert_eq!(
verdict(&none, &refused(3.0, FitKindName::Radius)).0,
"ERROR"
);
}
#[test]
fn a_round_past_the_end_face_reports_its_fit() {
// SolidCraft #199: r 3.5 on a 14 x 6 bar's vertical edges; with the generic
// blend's checks the refusal reports the largest radius that builds, 3.
let case: Case = serde_json::from_value(json!({
"name": "bar-round-too-large",
"fixes": ["fillet_refuse_overlapping_bands", "fillet_gn_section_generator"],
"steps": [
{"op": "box", "id": "bar", "origin": [14.0, 0.0, 0.0], "size": [14.0, 6.0, 10.0]},
{"op": "fillet", "id": "result", "body": "bar",
"edges": [[14.0, 0.0, 5.0], [28.0, 0.0, 5.0], [14.0, 6.0, 5.0], [28.0, 6.0, 5.0]],
"radius": 3.5}
],
"expect": {"refused": {"step": "result", "error": "refused",
"fit_size": {"value": 3.0, "tol": 1e-6, "source": "half the end face"}}}
}))
.unwrap();
validate(&case).unwrap();
let run = run_rust(&case);
assert_eq!(verdict(&case.expect, &run).0, "PASS", "{run:?}");
}
#[test]
fn scoring() {
let e = Expect {
@@ -2306,6 +2721,8 @@ mod tests {
}),
cylinder_faces_max: None,
sheet_corner: None,
refused: None,
manifold: None,
removed: None,
};
let ok = Measured {
@@ -2314,6 +2731,7 @@ mod tests {
topology: [4, 4, 4],
cylinder_faces: 0,
removed: None,
manifold: true,
};
assert_eq!(verdict(&e, &Run::Done(ok)).0, "PASS");
// The bug: the whole intersect came out empty.
@@ -2323,6 +2741,7 @@ mod tests {
topology: [0, 0, 0],
cylinder_faces: 0,
removed: None,
manifold: true,
};
let (v, c) = verdict(&e, &Run::Done(empty));
assert_eq!(v, "FAIL");
@@ -2361,6 +2780,7 @@ mod tests {
topology: [6, 12, 8],
cylinder_faces: 0,
removed: None,
manifold: true,
});
let (v, c) = verdict(&e, &run);
assert!(c.is_empty());
@@ -2442,6 +2862,7 @@ mod tests {
topology: [1, 0, 0],
cylinder_faces: 0,
removed: None,
manifold: true,
})
)
.0,
@@ -2571,6 +2992,7 @@ mod tests {
topology: [6, 12, 8],
cylinder_faces: 0,
removed,
manifold: true,
};
assert_eq!(verdict(&e, &Run::Done(m(Some(10.5)))).0, "PASS");
assert_eq!(verdict(&e, &Run::Done(m(Some(12.0)))).0, "FAIL");
@@ -2,7 +2,7 @@
"issue": "https://github.com/tangency-labs/solidcraft/issues/1080",
"pr": "https://github.com/tangency-labs/solidcraft/pull/1124",
"test": "crates/kernel/src/issue_1080_tests.rs: chamfer_into_a_pocket_is_refused_with_the_size_that_fits, fillet_into_a_pocket_is_refused_and_one_that_fits_builds, chamfers_crossing_each_other_are_refused, chamfers_meeting_at_a_corner_still_build",
"summary": "On SolidCraft's earlier kernel, fillets and chamfers too large for the faces beside their edge (running into a pocket, or two blends crossing on a narrow face) were built through instead of refused (#1080). The fix refuses them with the largest size that fits; these cases keep the sizes that do fit building, with closed-form volumes: chamfers 2 and 4 and a round 3 on the edge beside a pocket 4 mm in, both top edges of a 4 mm rib at 1.5 (they would meet at 2), and the four top edges of a cube at 5, which mitre at the corners and must not be refused for overlapping there. Two more cases take the test's too-large sizes into the pocket (chamfer 6, round 5) with the cliff overflow (`fillet_refuse_rails_leaving_faces`, `fillet_cliff_overflow`, tangency-labs/solidcore#960): built as if the faces went on, removing exactly what lies in the body. The runner cannot require a refusal, so the rib's crossing chamfer and round of 3 are not ported, nor its SolidCraft-only volume guard; its #1668 and concentric-round tests are already 1668-round-both-sides-of-wall.json and tangency-labs/solidcore#683.",
"summary": "On SolidCraft's earlier kernel, fillets and chamfers too large for the faces beside their edge (running into a pocket, or two blends crossing on a narrow face) were built through instead of refused (#1080). The fix refuses them with the largest size that fits; these cases keep the sizes that do fit building, with closed-form volumes: chamfers 2 and 4 and a round 3 on the edge beside a pocket 4 mm in, both top edges of a 4 mm rib at 1.5 (they would meet at 2), and the four top edges of a cube at 5, which mitre at the corners and must not be refused for overlapping there. Two more cases take the test's too-large sizes into the pocket (chamfer 6, round 5) with the cliff overflow (`fillet_refuse_rails_leaving_faces`, `fillet_cliff_overflow`, tangency-labs/solidcore#960): built as if the faces went on, removing exactly what lies in the body. The runner cannot require a refusal, so the rib's crossing chamfer and round of 3 are not ported, nor its SolidCraft-only volume guard; its #1668 and concentric-round tests are already 1668-round-both-sides-of-wall.json and tangency-labs/solidcore#683. The rib's crossing chamfer and round of 3 must be refused, reporting the largest size that builds, 2 (expect.refused with fit_size; `fillet_gn_section_generator`, `fillet_refuse_overlapping_bands`).",
"cases": [
{
"name": "chamfer_beside_pocket_2",
@@ -528,6 +528,112 @@
"formula": "(1 - pi/4)*5^2*20 - 10*(5 - (sqrt(24)/2 + 12.5*asin(1/5))) (the round's corner less its part over the pocket, x 35..36, y 5..15)"
}
}
},
{
"name": "rib_chamfers_3_cross_and_are_refused",
"note": "Both top edges of the 4 mm rib, chamfer 3: past 2 the two blends' rails cross on the top face. With the generic blend's checks they are refused with the largest size that builds, 2 (WR-696, tangency-labs/solidcore#2278). With the pinned defaults they return success with a non-manifold body and the rib's volume gone (WR-460).",
"fixes": [
"fillet_refuse_overlapping_bands",
"fillet_gn_section_generator"
],
"steps": [
{
"op": "box",
"id": "rib",
"origin": [
0,
0,
0
],
"size": [
40,
4,
20
]
},
{
"op": "chamfer",
"id": "result",
"body": "rib",
"edges": [
[
20,
0,
20
],
[
20,
4,
20
]
],
"distance": 3.0
}
],
"expect": {
"refused": {
"step": "result",
"error": "refused; the largest size that builds",
"fit_size": {
"value": 2.0,
"tol": 1e-06,
"source": "SolidCraft refuses it: 'the largest size that fits is 2 mm', half the 4 mm rib (chamfers_crossing_each_other_are_refused)"
}
}
}
},
{
"name": "rib_rounds_3_cross_and_are_refused",
"note": "Both top edges of the 4 mm rib, fillet 3: past 2 the two blends' rails cross on the top face. With the generic blend's checks they are refused with the largest size that builds, 2 (WR-696, tangency-labs/solidcore#2278). With the pinned defaults they return success with a non-manifold body and the rib's volume gone (WR-460).",
"fixes": [
"fillet_refuse_overlapping_bands",
"fillet_gn_section_generator"
],
"steps": [
{
"op": "box",
"id": "rib",
"origin": [
0,
0,
0
],
"size": [
40,
4,
20
]
},
{
"op": "fillet",
"id": "result",
"body": "rib",
"edges": [
[
20,
0,
20
],
[
20,
4,
20
]
],
"radius": 3.0
}
],
"expect": {
"refused": {
"step": "result",
"error": "refused; the largest size that builds",
"fit_size": {
"value": 2.0,
"tol": 1e-06,
"source": "SolidCraft refuses it: 'the largest size that fits is 2 mm', half the 4 mm rib (chamfers_crossing_each_other_are_refused)"
}
}
}
}
]
}
@@ -2,7 +2,7 @@
"issue": "https://github.com/tangency-labs/solidcraft/issues/1668",
"pr": "https://github.com/tangency-labs/solidcraft/pull/1720",
"test": "crates/kernel/src/issue_1080_tests.rs: rounds_on_both_sides_of_a_wall_build_as_one_fillet",
"summary": "A 104 x 64 x 42 block less a 100 x 60 x 41 cavity open at the bottom (2 mm walls and roof), as in the test (the issue's 100 x 60 x 40 box shelled outward 2 mm). R5 on the outer top edge (y = 32, z = 42, length 104) together with R5 on the concave inner roof/wall edge below it (y = 30, z = 40, length 100) in one fillet was refused as too large (largest 4 mm), although the two built one after the other; #1720 made the fit check count the material the inner round adds. The outer round removes 25(1-pi/4) mm^2 along 104 mm, the inner one adds it along 100 mm: the cavity corner (30, 40) lies 3*sqrt(2) = 4.24 mm from the outer round's ball centre (27, 37), inside its radius, so the removed region stays in material and misses the added one. #1720's test also refused R5 on the outer edge alone; SolidCraft PR #2852 later let it build (it leaves the inner corner 0.76 mm inside the round and reaches it only past 2*sqrt(2)/(sqrt(2)-1) = 6.83 mm), and the test on main builds it. The R7 refusals (outer edge alone, and both edges) are not expressible in the runner. Tolerances are 0.1 % of the outer round's volume. Volumes are closed form.",
"summary": "A 104 x 64 x 42 block less a 100 x 60 x 41 cavity open at the bottom (2 mm walls and roof), as in the test (the issue's 100 x 60 x 40 box shelled outward 2 mm). R5 on the outer top edge (y = 32, z = 42, length 104) together with R5 on the concave inner roof/wall edge below it (y = 30, z = 40, length 100) in one fillet was refused as too large (largest 4 mm), although the two built one after the other; #1720 made the fit check count the material the inner round adds. The outer round removes 25(1-pi/4) mm^2 along 104 mm, the inner one adds it along 100 mm: the cavity corner (30, 40) lies 3*sqrt(2) = 4.24 mm from the outer round's ball centre (27, 37), inside its radius, so the removed region stays in material and misses the added one. #1720's test also refused R5 on the outer edge alone; SolidCraft PR #2852 later let it build (it leaves the inner corner 0.76 mm inside the round and reaches it only past 2*sqrt(2)/(sqrt(2)-1) = 6.83 mm), and the test on main builds it. R7 on the outer edge alone breaks through the wall (#2299, tangency-labs/solidcore#2311): with fillet_refuse_rounds_breaking_through it is refused with its fit, and the fit builds. R7 on both edges is not covered. Tolerances are 0.1 % of the outer round's volume. Volumes are closed form.",
"cases": [
{
"name": "shelled_block",
@@ -178,6 +178,140 @@
"formula": "104*64*42 - 100*60*40 - 25*(1-pi/4)*104 + 25*(1-pi/4)*100 (tol: 0.1 % of the outer round)"
}
}
},
{
"name": "round_7_outer_edge_refused_with_its_fit",
"note": "#2299: R7 on the outer top edge alone. The ball centre (25, 35) is sqrt(2)*(7-2) = 7.07 mm from the inner corner (30, 40), so the round breaks through the wall. With the default flags the pinned port returns a self-intersecting body with the whole section removed (WR-707); fillet_refuse_rounds_breaking_through refuses it and reports the largest radius that stays inside the wall, less the fit margin.",
"steps": [
{
"op": "box",
"id": "outer",
"origin": [
-52,
-32,
0
],
"size": [
104,
64,
42
]
},
{
"op": "box",
"id": "cavity",
"origin": [
-50,
-30,
-1
],
"size": [
100,
60,
41
]
},
{
"op": "difference",
"id": "body",
"a": "outer",
"b": "cavity"
},
{
"op": "fillet",
"id": "result",
"body": "body",
"edges": [
[
0,
32,
42
]
],
"radius": 7
}
],
"expect": {
"refused": {
"step": "result",
"error": "refused; the largest size that builds",
"fit_size": {
"value": 6.818427124746189,
"tol": 0.0001,
"source": "2*sqrt(2)/(sqrt(2)-1) - 0.01: the radius at which the round reaches the inner corner (30, 40), less the 0.01 fit margin (tangency-labs/solidcore#2311)"
}
}
},
"fixes": [
"fillet_refuse_rounds_breaking_through",
"fillet_gn_section_generator"
]
},
{
"name": "round_at_the_fit_builds",
"note": "The fit #2299's refusal reports, 2*sqrt(2)/(sqrt(2)-1) - 0.01, builds on the outer edge: the round stays inside the wall.",
"steps": [
{
"op": "box",
"id": "outer",
"origin": [
-52,
-32,
0
],
"size": [
104,
64,
42
]
},
{
"op": "box",
"id": "cavity",
"origin": [
-50,
-30,
-1
],
"size": [
100,
60,
41
]
},
{
"op": "difference",
"id": "body",
"a": "outer",
"b": "cavity"
},
{
"op": "fillet",
"id": "result",
"body": "body",
"edges": [
[
0,
32,
42
]
],
"radius": 6.818427124746189
}
],
"expect": {
"lumps": 1,
"manifold": true,
"volume": {
"value": 38514.387535910304,
"tol": 1.0376124640896986,
"formula": "104*64*42 - 100*60*40 - r^2*(1-pi/4)*104, r = 2*sqrt(2)/(sqrt(2)-1) - 0.01 (tol: 0.1 % of the round)"
}
},
"fixes": [
"fillet_refuse_rounds_breaking_through",
"fillet_gn_section_generator"
]
}
]
}
@@ -2,7 +2,7 @@
"issue": "https://github.com/tangency-labs/solidcraft/issues/199",
"pr": "https://github.com/tangency-labs/solidcraft/pull/317",
"test": "crates/kernel/src/tests.rs: fillet_consuming_a_side_face_makes_a_full_round",
"summary": "A round that uses up a whole side face must make a full round, not be refused (#199). A 14 x 6 x 10 bar has its four vertical edges rounded r 3: both 6 mm end faces are used up, each end becomes a half cylinder. With one end only, two rounds meet on that end face. A 6 x 6 x 10 post rounded r 3 on all four vertical edges is a cylinder of radius 3. Closed-form volumes; SolidCraft checked 2e-4 relative on a mesh, these check 1e-4 mm^3. The test's past-the-limit refusals are not ported.",
"summary": "A round that uses up a whole side face must make a full round, not be refused (#199). A 14 x 6 x 10 bar has its four vertical edges rounded r 3: both 6 mm end faces are used up, each end becomes a half cylinder. With one end only, two rounds meet on that end face. A 6 x 6 x 10 post rounded r 3 on all four vertical edges is a cylinder of radius 3. Closed-form volumes; SolidCraft checked 2e-4 relative on a mesh, these check 1e-4 mm^3. The test's past-the-limit sizes (3.001, 3.5) must be refused (expect.refused); 3.5 needs fillet_refuse_crossing_rails, without which the pinned SMLib returns an empty, non-manifold body. With the generic blend's checks (`fillet_gn_section_generator`, `fillet_refuse_overlapping_bands`) the refusals report the largest radius that builds, 3 (expect.refused.fit_size).",
"cases": [
{
"name": "both_ends_full_round",
@@ -217,6 +217,243 @@
"formula": "pi*3^2*10"
}
}
},
{
"name": "round_past_the_end_face_is_refused",
"note": "r 3.001 on the four vertical edges: past the 6 mm end faces, which have nothing left to use up. SolidCraft refuses it as larger than the neighbouring faces; so does solidcore, with the default flags and with every flag.",
"steps": [
{
"op": "box",
"id": "bar",
"origin": [
14,
0,
0
],
"size": [
14,
6,
10
]
},
{
"op": "fillet",
"id": "result",
"body": "bar",
"edges": [
[
14,
0,
5
],
[
28,
0,
5
],
[
14,
6,
5
],
[
28,
6,
5
]
],
"radius": 3.001
}
],
"expect": {
"refused": {
"step": "result",
"error": "status 1001"
}
}
},
{
"name": "round_well_past_the_end_face_is_refused",
"note": "r 3.5 on the four vertical edges. With the default flags the pinned SMLib returns success with a non-manifold body and all of the bar's volume gone (WR-460); fillet_refuse_crossing_rails refuses it.",
"fixes": [
"fillet_refuse_crossing_rails"
],
"steps": [
{
"op": "box",
"id": "bar",
"origin": [
14,
0,
0
],
"size": [
14,
6,
10
]
},
{
"op": "fillet",
"id": "result",
"body": "bar",
"edges": [
[
14,
0,
5
],
[
28,
0,
5
],
[
14,
6,
5
],
[
28,
6,
5
]
],
"radius": 3.5
}
],
"expect": {
"refused": {
"step": "result",
"error": "status 1001"
}
}
},
{
"name": "round_3_001_refused_with_its_fit",
"note": "r 3.001 on the four vertical edges with the generic blend's fold and room checks: refused, and the largest radius that builds, 3 (half the 6 mm end face), comes back on the refusal (WR-696, tangency-labs/solidcore#2278).",
"fixes": [
"fillet_refuse_overlapping_bands",
"fillet_gn_section_generator"
],
"steps": [
{
"op": "box",
"id": "bar",
"origin": [
14,
0,
0
],
"size": [
14,
6,
10
]
},
{
"op": "fillet",
"id": "result",
"body": "bar",
"edges": [
[
14,
0,
5
],
[
28,
0,
5
],
[
14,
6,
5
],
[
28,
6,
5
]
],
"radius": 3.001
}
],
"expect": {
"refused": {
"step": "result",
"error": "refused; the largest size that builds",
"fit_size": {
"value": 3.0,
"tol": 1e-06,
"source": "SolidCraft's fit reduces it to 3, half the 6 mm end face (fillet_consuming_a_side_face_makes_a_full_round)"
}
}
}
},
{
"name": "round_3_5_refused_with_its_fit",
"note": "r 3.5 on the four vertical edges with the generic blend's fold and room checks: refused, and the largest radius that builds, 3 (half the 6 mm end face), comes back on the refusal (WR-696, tangency-labs/solidcore#2278).",
"fixes": [
"fillet_refuse_overlapping_bands",
"fillet_gn_section_generator"
],
"steps": [
{
"op": "box",
"id": "bar",
"origin": [
14,
0,
0
],
"size": [
14,
6,
10
]
},
{
"op": "fillet",
"id": "result",
"body": "bar",
"edges": [
[
14,
0,
5
],
[
28,
0,
5
],
[
14,
6,
5
],
[
28,
6,
5
]
],
"radius": 3.5
}
],
"expect": {
"refused": {
"step": "result",
"error": "refused; the largest size that builds",
"fit_size": {
"value": 3.0,
"tol": 1e-06,
"source": "SolidCraft's fit reduces it to 3, half the 6 mm end face (fillet_consuming_a_side_face_makes_a_full_round)"
}
}
}
}
]
}
@@ -0,0 +1,397 @@
{
"issue": "https://github.com/tangency-labs/solidcraft/issues/871",
"pr": "https://github.com/tangency-labs/solidcraft/pull/949",
"test": "crates/kernel/src/issue_871_tests.rs: overlapping_rounds_are_refused, rounds_that_fit_are_closed",
"summary": "A fillet that opened holes in the model must be refused, not accepted (#871). The part is a plate with a narrower block on top and a window through both; every edge of both end faces is rounded. At r 1 the rounds on the window's edges and on the block's sides overlap across the 1.26 mm wall between them: the pinned SMLib returns success with a non-manifold body and all of the part's volume gone (WR-460), fillet_refuse_crossing_rails refuses it. At r 0.5 they fit: one closed manifold solid (its volume has no closed form here).",
"cases": [
{
"name": "overlapping_rounds_are_refused",
"note": "r 1 on the 24 end-face edges: the rounds overlap across the 1.26 mm wall.",
"fixes": [
"fillet_refuse_crossing_rails"
],
"steps": [
{
"op": "box",
"id": "base",
"origin": [
9.5,
8,
0
],
"size": [
14,
21.5,
10
]
},
{
"op": "box",
"id": "top",
"origin": [
9.5,
12.74437365538526,
10
],
"size": [
14,
9.9483859782841,
10
]
},
{
"op": "union",
"id": "joined",
"a": "base",
"b": "top"
},
{
"op": "box",
"id": "window",
"origin": [
9.5,
14,
7
],
"size": [
14,
5.2,
7.1
]
},
{
"op": "difference",
"id": "part",
"a": "joined",
"b": "window"
},
{
"op": "fillet",
"id": "result",
"body": "part",
"edges": [
[
23.5,
12.74437365538526,
15.0
],
[
23.5,
10.37218682769263,
10.0
],
[
23.5,
8.0,
5.0
],
[
23.5,
18.75,
0.0
],
[
23.5,
29.5,
5.0
],
[
23.5,
26.09637981683468,
10.0
],
[
23.5,
22.69275963366936,
15.0
],
[
23.5,
17.71856664452731,
20.0
],
[
23.5,
16.6,
7.0
],
[
23.5,
14.0,
10.55
],
[
23.5,
16.6,
14.100000000000001
],
[
23.5,
19.200000000000003,
10.55
],
[
9.5,
12.74437365538526,
15.0
],
[
9.5,
10.37218682769263,
10.0
],
[
9.5,
8.0,
5.0
],
[
9.5,
18.75,
0.0
],
[
9.5,
29.5,
5.0
],
[
9.5,
26.09637981683468,
10.0
],
[
9.5,
22.69275963366936,
15.0
],
[
9.5,
17.71856664452731,
20.0
],
[
9.5,
16.6,
7.0
],
[
9.5,
14.0,
10.55
],
[
9.5,
16.6,
14.100000000000001
],
[
9.5,
19.200000000000003,
10.55
]
],
"radius": 1.0
}
],
"expect": {
"refused": {
"step": "result",
"error": "status 1001"
}
}
},
{
"name": "rounds_that_fit_are_closed",
"note": "r 0.5, below half the wall: one closed manifold solid.",
"steps": [
{
"op": "box",
"id": "base",
"origin": [
9.5,
8,
0
],
"size": [
14,
21.5,
10
]
},
{
"op": "box",
"id": "top",
"origin": [
9.5,
12.74437365538526,
10
],
"size": [
14,
9.9483859782841,
10
]
},
{
"op": "union",
"id": "joined",
"a": "base",
"b": "top"
},
{
"op": "box",
"id": "window",
"origin": [
9.5,
14,
7
],
"size": [
14,
5.2,
7.1
]
},
{
"op": "difference",
"id": "part",
"a": "joined",
"b": "window"
},
{
"op": "fillet",
"id": "result",
"body": "part",
"edges": [
[
23.5,
12.74437365538526,
15.0
],
[
23.5,
10.37218682769263,
10.0
],
[
23.5,
8.0,
5.0
],
[
23.5,
18.75,
0.0
],
[
23.5,
29.5,
5.0
],
[
23.5,
26.09637981683468,
10.0
],
[
23.5,
22.69275963366936,
15.0
],
[
23.5,
17.71856664452731,
20.0
],
[
23.5,
16.6,
7.0
],
[
23.5,
14.0,
10.55
],
[
23.5,
16.6,
14.100000000000001
],
[
23.5,
19.200000000000003,
10.55
],
[
9.5,
12.74437365538526,
15.0
],
[
9.5,
10.37218682769263,
10.0
],
[
9.5,
8.0,
5.0
],
[
9.5,
18.75,
0.0
],
[
9.5,
29.5,
5.0
],
[
9.5,
26.09637981683468,
10.0
],
[
9.5,
22.69275963366936,
15.0
],
[
9.5,
17.71856664452731,
20.0
],
[
9.5,
16.6,
7.0
],
[
9.5,
14.0,
10.55
],
[
9.5,
16.6,
14.100000000000001
],
[
9.5,
19.200000000000003,
10.55
]
],
"radius": 0.5
}
],
"expect": {
"lumps": 1,
"manifold": true
}
}
]
}