Compare commits

...

1 Commits

Author SHA1 Message Date
Brennen Herbruck 492c750122 solidcore: the pipe's bend check is refusal-only; restore the cases' own inputs and bounds (#15038)
path_bends_within decided at 16 samples per knot span whether a pipe folds, and
its false let the pipe through. It is now bend_found_within, returning the
parameter of the bend it found: a found bend refuses the pipe, none found proves
nothing and the pipe is built unchecked as upstream builds it (§7.6). The
refusals it makes are unchanged. A certified lower bound on the radius of
curvature per span is noted as open work.

#14640 changed general_path_sweep's ledger case to pass: the balance wire's
spline fitted open instead of closed, the pipe 0.5 instead of 0.75. The case is
restored as written, in its own test marked known failing: the closed fit bends
with radius 0.041, so the 0.75 pipe is refused. The ledger test keeps every
other case and leaves path-spline to that test.

issue_3093's volumes were compared by an overlap of enclosures of any width
after #13680, where the case had compared numbers to 1e-9 relative. Each volume
is now enclosed to a quarter of 1e-9 of itself and every point of one enclosure
must be within 1e-9 of every point of the other. Done that way, the enclosure
proves the cap case's pinned 757.499499825162 (the estimator's answer at
accuracy 1e-6) 3.0e-9 too large, so the case pins the certified volume,
757.499497557, with its provenance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:50:28 -05:00
3 changed files with 98 additions and 59 deletions
+32 -28
View File
@@ -303,8 +303,8 @@ pub fn create_draft_sweep(
/// # Errors
///
/// `InvalidInput` for no path curve, a radius `<= 0` (a NaN radius passes, as
/// upstream), or a radius that reaches the centre of a bend in the path
/// ([`path_bends_within`]; not upstream, which builds that pipe with its wall folded
/// upstream), or a radius found to reach the centre of a bend in the path
/// ([`bend_found_within`]; not upstream, which builds that pipe with its wall folded
/// through itself, WR-774); [`SolidError::Unported`] otherwise
/// (`SmBrep::SmBrep`, until the B-rep constructor is ported).
pub fn create_pipe_sweep(
@@ -322,7 +322,9 @@ pub fn create_pipe_sweep(
}
let path = bspline_of(path_curve, OP_PIPE)?;
if path_bends_within(&path, pipe_radius) {
// Refusal only: a bend found refuses the pipe; none found proves nothing, and the pipe
// is built unchecked, as upstream builds it (definitions-and-certificates.md §7.6).
if bend_found_within(&path, pipe_radius).is_some() {
return Err(SolidError::invalid_input(
OP_PIPE,
"the pipe radius reaches the centre of a bend in the path, so the pipe's wall \
@@ -348,45 +350,46 @@ pub fn create_pipe_sweep(
})
}
/// The samples per knot span, both span ends included, at which [`path_bends_within`]
/// measures a pipe path's curvature. A span is one polynomial or rational piece, whose
/// The samples per knot span, both span ends included, at which [`bend_found_within`]
/// looks for a bend tighter than a pipe. A span is one polynomial or rational piece, whose
/// curvature rises and falls only a few times, so 16 samples find a bend well inside the
/// pipe at a cost that is small beside the sweep itself. A bend tighter than the pipe that
/// lies between two samples is missed, and that pipe is built as before.
/// lies between two samples is not found, and that pipe is built as before.
const PIPE_BEND_SAMPLES_PER_SPAN: u32 = 16;
/// Whether `path` bends somewhere with a radius of at most `radius`, that is, with
/// curvature at least `1 / radius`. A pipe of that radius folds its wall through itself on
/// the inside of such a bend: the circles of neighbouring sections cross there, so the
/// swept surface is singular (Maekawa, Patrikalakis, Sakkalis and Yu, "Analysis and
/// applications of pipe surfaces", CAGD 15(5), 1998).
/// A parameter at which `path` bends with a radius of at most `radius`, that is, with
/// curvature at least `1 / radius`, if one is found. A pipe of that radius folds its wall
/// through itself on the inside of such a bend: the circles of neighbouring sections cross
/// there, so the swept surface is singular (Maekawa, Patrikalakis, Sakkalis and Yu,
/// "Analysis and applications of pipe surfaces", CAGD 15(5), 1998).
///
/// Measured at [`PIPE_BEND_SAMPLES_PER_SPAN`] samples per knot span. A `true` is proved by
/// the sample that found the bend; a `false` only says that no sample found one. A sample
/// whose curvature cannot be computed (a zero-length tangent) proves nothing and is
/// skipped. A pipe that crosses itself between distant parts of its path is not found
/// here.
fn path_bends_within(path: &BSplineCurve, radius: f64) -> bool {
let Some(nurb) = path.nurb.as_ref() else {
return false;
};
/// **Refusal only.** The path's curvature is evaluated at [`PIPE_BEND_SAMPLES_PER_SPAN`]
/// samples per knot span. A `Some` is proved by the sample that found the bend, so it may
/// refuse a pipe. A `None` proves nothing: it is not a certificate that the path bends no
/// tighter than `radius` anywhere (definitions-and-certificates.md §7.6, "a check at sample
/// points proves nothing"), and no caller may accept a construction on it. A certified
/// check, a lower bound on the radius of curvature over each knot span enclosed on the
/// Bernstein form of the curvature's numerator and denominator, is open work (#15038).
/// A sample whose curvature cannot be computed (a zero-length tangent) proves nothing and
/// is skipped. A pipe that crosses itself between distant parts of its path is not looked
/// for here.
fn bend_found_within(path: &BSplineCurve, radius: f64) -> Option<f64> {
let nurb = path.nurb.as_ref()?;
// A clamped curve's spans run between knots[degree] and knots[n + 1].
let Some(span_knots) = nurb.knots().get(nurb.degree()..=nurb.n() + 1) else {
return false;
};
let span_knots = nurb.knots().get(nurb.degree()..=nurb.n() + 1)?;
let reaches = |u: f64, side: Side| {
nurb.eval_curvature(u, side, false)
.is_ok_and(|c| c.kappa * radius >= 1.0)
};
span_knots.windows(2).any(|ends| {
span_knots.windows(2).find_map(|ends| {
let &[start, end] = ends else {
return false;
return None;
};
// A repeated knot spans nothing.
if end <= start {
return false;
return None;
}
(0..=PIPE_BEND_SAMPLES_PER_SPAN).any(|i| {
(0..=PIPE_BEND_SAMPLES_PER_SPAN).find_map(|i| {
let at = f64::from(i) / f64::from(PIPE_BEND_SAMPLES_PER_SPAN);
// At its end knot a span is evaluated from its own side, not the next span's.
let side = if i == PIPE_BEND_SAMPLES_PER_SPAN {
@@ -394,7 +397,8 @@ fn path_bends_within(path: &BSplineCurve, radius: f64) -> bool {
} else {
Side::Left
};
reaches(start + at * (end - start), side)
let u = start + at * (end - start);
reaches(u, side).then_some(u)
})
})
}
+30 -24
View File
@@ -106,28 +106,13 @@ const FIT: &[(f64, f64, f64)] = &[
/// The pipe's radius: the wire's 1.5 mm circle.
const RADIUS: f64 = 0.75;
/// The pipe's radius along [`spline_path`]. The fitted spline overshoots where a straight
/// meets a round near the wire's far end, bending there with radius about 0.564 (sampled at
/// (168.51, -37.90, 50.71)). A pipe of [`RADIUS`] would fold its wall through itself in that
/// bend, and `create_pipe_sweep` refuses it (#13766), so this pipe stays inside the bend.
const SPLINE_RADIUS: f64 = 0.5;
/// The balance wire's points fitted open, as the wire runs. Fitted closed (C1 between its
/// two ends, though the wire's ends are apart), the spline bends with radius about 0.041
/// near its start and 0.045 near its end, which no pipe of the wire's size fits.
/// The balance wire's points fitted by a cubic spline closed (C1 between its two ends), as
/// the case was written.
fn spline_path() -> Curve {
let pts: Vec<Point3> = FIT.iter().map(|&(x, y, z)| Point3::new(x, y, z)).collect();
Curve::from(
BSplineCurve::interpolate_points(
&pts,
None,
3,
None,
None,
false,
ParamMethod::ChordLength,
)
.unwrap(),
BSplineCurve::interpolate_points(&pts, None, 3, None, None, true, ParamMethod::ChordLength)
.unwrap(),
)
}
@@ -657,10 +642,6 @@ fn issue_2572_every_path_wall_is_a_definition_or_in_the_ledger() {
"path-b-spline",
create_pipe_sweep(&mut k, RADIUS, Some(&arc_path()), false).unwrap(),
),
(
"path-spline",
create_pipe_sweep(&mut k, SPLINE_RADIUS, Some(&spline_path()), false).unwrap(),
),
(
"path-b-spline",
create_pipe_sweep(&mut k, RADIUS, Some(&wire), false).unwrap(),
@@ -730,13 +711,38 @@ fn issue_2572_every_path_wall_is_a_definition_or_in_the_ledger() {
unlisted.is_empty(),
"bare wall faces of kinds the ledger does not list: {unlisted:?}"
);
let fixed: Vec<&String> = ledger.keys().filter(|kd| !bare.contains(*kd)).collect();
// `path-spline` is the known-failing case's (`issue_2572_the_balance_wire_spline_pipe_is_in_the_ledger`).
let fixed: Vec<&String> = ledger
.keys()
.filter(|kd| *kd != "path-spline" && !bare.contains(*kd))
.collect();
assert!(
fixed.is_empty(),
"listed kinds no case makes bare any more (fixed? delete their ledger files): {fixed:?}"
);
}
/// The ledger's `path-spline` kind: the wire's pipe along its points fitted by a spline.
/// Known failing since #14640: that spline, fitted closed (C1 between the wire's two ends,
/// though they are apart), bends with radius about 0.041 near its start and 0.045 near its
/// end, so `create_pipe_sweep` refuses the wire's 0.75 pipe as folding through itself. The
/// input is the case's own and is not changed to pass (#15038): the case needs a path the
/// wire's pipe fits, and until it has one this test stays listed as failing.
#[test]
#[ignore = "known failing: the closed spline fit bends with radius 0.041 < 0.75 and the pipe is refused (#14640, #15038)"]
fn issue_2572_the_balance_wire_spline_pipe_is_in_the_ledger() {
let mut k = Kernel::default();
assert!(path_ledger().contains_key("path-spline"));
let b = create_pipe_sweep(&mut k, RADIUS, Some(&spline_path()), false).unwrap();
let bare = walls(&b)
.iter()
.any(|s| s.procedural_definition().is_none() && !matches!(s.kind(), SurfaceKind::Plane(_)));
assert!(
bare,
"the listed kind no longer appears (fixed? delete its ledger file)"
);
}
/// The definition's partials at the ends of the net's parameter across the sweep are those
/// of its points, one-sided: the held parameter keeps the end segment's slope.
#[test]
@@ -23,7 +23,7 @@
use solidcore::blend::feature_executive::{FeatureExecutive, RebuildBrepType};
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::geom::object::ObjectRef;
use solidcore::mass::enclosure::VolumeSign;
use solidcore::mass::enclosure::{enclose_volume, VolumeSign};
use solidcore::nurbs::nurbs_srf::decompose_srf;
use solidcore::surface::extend::Continuity;
use solidcore::surface::side::BoxSide;
@@ -143,13 +143,16 @@ fn the_extrusion_defeatures_to_the_same_solid_at_any_reach() {
assert!(s.manifold, "reach {dist:?}");
assert!(s.check.lines().is_empty(), "reach {dist:?}: {:?}", s.check);
assert_eq!(bp1::faces(&b, None).unwrap().len(), 13, "reach {dist:?}");
Some(positive(s.volume))
positive(s.volume);
Some(narrow(&k, &mut b))
};
let Some(v) = base(None) else { return };
// the same solid at every reach: the volume enclosures meet
// the same solid at every reach: every volume in one enclosure is within `REL` of
// every volume in the other
for d in [10.0, 15.0, 19.999_999_999, 20.0, 25.0] {
let w = base(Some(d)).unwrap();
assert!(w.0 <= v.1 && v.0 <= w.1, "reach {d}: {w:?} against {v:?}");
let apart = (w.1 - v.0).max(v.1 - w.0);
assert!(apart <= REL * v.0, "reach {d}: {w:?} against {v:?}");
}
}
@@ -190,11 +193,37 @@ fn a_feature_whose_cap_cannot_be_built_is_kept_and_named() {
let s = validation::soundness(&mut k, &mut b).unwrap();
assert!(s.manifold);
assert!(s.check.lines().is_empty(), "{:?}", s.check);
let (lo, hi) = positive(s.volume);
positive(s.volume);
// every volume in the enclosure is within `REL` of the body's
let (lo, hi) = narrow(&k, &mut b);
let apart = (hi - VOLUME).max(VOLUME - lo);
assert!(apart <= REL * lo, "[{lo}, {hi}]");
}
/// The volume of the body `a_feature_whose_cap_cannot_be_built_is_kept_and_named` makes: the
/// middle of its certified enclosure `[757.499_497_550_496_5, 757.499_497_564_175]` (width
/// 1.4e-8). The case first pinned `757.499_499_825_162`, the volume estimator's answer at
/// accuracy 1e-6 (`brep_volume_default`, still its answer on main); the enclosure proves that
/// 3.0e-9 of the volume too large, past the case's own relative bound [`REL`] (#15038).
const VOLUME: f64 = 757.499_497_557;
/// How far apart, relative to the volume, two volumes of the same solid may be: the bound
/// the case compared its volumes with before the volume became an enclosure (#15038).
const REL: f64 = 1e-9;
/// The body's volume enclosed to a quarter of [`REL`] of itself, as `(lo, hi)`: narrow
/// enough that two enclosures of one solid are within `REL` of each other end to end, so the
/// comparison is the relative bound on numbers, proved, and not an overlap of enclosures of
/// any width.
fn narrow(k: &Kernel, b: &mut Brep) -> (f64, f64) {
let rough = enclose_volume(k, b, 1.0).unwrap();
assert!(rough.lo > 0.0, "{rough:?}");
let e = enclose_volume(k, b, 0.25 * REL * rough.lo).unwrap();
assert!(
lo <= 757.499_499_825_162 && 757.499_499_825_162 <= hi,
"[{lo}, {hi}]"
e.width() <= 0.25 * REL * rough.lo,
"not enclosed narrowly enough: {e:?}"
);
(e.lo, e.hi)
}
/// A volume proved positive, as its enclosure's ends.