Compare commits

...

8 Commits

Author SHA1 Message Date
jyviko 6de14d4add test: the kept-edge check test fails when the face's trimmed box is not used (#2976)
The test raises a 2-cube's top to a Plane::from_normal at z = 3 (net about 1.2e6 wide, face
2 x 2), marks the moved face and checks two kept edges: one through the face at (1, 1)
refuses, one through the plane's net at (50, 50) outside the face is clear. With the box
dropped (every loop's UV span discarded, so the whole surface is tested) the (50, 50) edge
is called crossed and the test fails at its unwrap; with the box it passes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKMptsUdYotbrqYpSda
2026-11-08 16:32:50 -05:00
jyviko 7549156435 fix: a kept edge is proved clear of the faces the operation moved, or the operation is refused (#2976)
The pre-merge gate tested kept edges against each moved surface's whole extension. A
plane's net is about 1.2e6 wide, so it crossed the edges of unrelated solids in the same
file; those edges were re-intersected, which broke the body, and cases that built on main
(topology.brep_local_operation fc0000, fc0025) were refused. The extension is a superset of
the face the merge builds, and faces are trimmed only inside piecewise_merge, so the kept
set cannot depend on trimmed faces before it.

Now the executive keeps edges by the vertex rule, as on main, and the local operation
checks the result: each moved face carries a private mark through the offset (the mark
mechanism of the caller marks), and after the merge every kept edge, over its whole curve,
must be proved clear of the part of each moved face's surface over the box of its trim
curves (surface_distance_lower_bound with the UV box, a ball enclosure of each curve cell,
rounding counted; clearance::all_clear) by more than the summed tolerances. Not proved
clear refuses the operation (Unsoundness::KeptEdgeNotClear), the input put back. It does
not re-intersect the edge. Cell budget raised to 255 cells and 12 halvings: fc0000's
circle passes 0.066 from a face, which six halvings could not separate.

Tests: clearance::only_the_trimmed_region_of_a_surface_is_tested (an edge through a
plane far outside the face box is clear, the whole surface calls it crossed; one inside the
box is not); a_kept_edge_through_a_moved_face_refuses_and_one_beside_it_does_not;
the tilted-top case is refused with the input unchanged. Local goldens (aarch64, read
only, not blessed): topology.brep_local_operation 165 cases, changed=0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKMptsUdYotbrqYpSda
2026-11-08 16:32:50 -05:00
jyviko 6843398d2c fix: clippy on the clearance certificate; the tilted-top test asserts the refusal, cites the certified bound (#2976, #2971)
clearance.rs: backticks in the header doc; a NaN-safe test of the line's length
(clippy::neg_cmp_op_on_partial_ord). local_operation.rs: the executive comment names
the certified clearance bound that keeps an edge. The tilted-top test (z = x + y - 1
over a 2-cube) cut the corner whose vertical edge meets the plane only below the body:
the extended offset does not build that vanishing edge, so the operation refuses with
Unsound and b is unchanged, with and without the gate (3 vs 5 failed edge checks).
The test now asserts that refusal; the regression that fails without the gate is the
keep_unmoved_edges unit test (the old vertex rule keeps all 4 eligible bottom edges,
the gate keeps the 2 far ones).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKMptsUdYotbrqYpSda
2026-11-08 16:32:50 -05:00
aapolipponen 0908520966 test: report kept-edge counts across clearance filtering (#2976) 2026-11-08 16:32:50 -05:00
aapolipponen e806792e31 fix: leave degenerate line parameter maps unproved (#2976) 2026-11-08 16:32:50 -05:00
aapolipponen 229d720a2c fix: compile certified edge clearance tests (#2976) 2026-11-08 16:32:50 -05:00
aapolipponen 35f1c4d7df fix: certify kept edges clear of every moved surface (#2976) 2026-11-08 16:32:50 -05:00
aapolipponen fb98759f24 test: a tilted top must re-intersect crossed unmoved edges (#2976) 2026-11-08 16:32:50 -05:00
5 changed files with 646 additions and 5 deletions
+9
View File
@@ -174,6 +174,11 @@ pub enum Unsoundness {
NotManifoldSolid,
/// The body is a manifold solid whose volume is not positive.
VolumeNotPositive,
/// An edge the operation kept as it was is not proved clear of a face it moved: a
/// certified distance bound between the edge's whole curve and the moved face's
/// trimmed region does not exceed their tolerances (the edge may cross the face, which
/// the body's check cannot see, or the bound is unavailable).
KeptEdgeNotClear,
}
/// How loops of an offset face touch without crossing ([`OffsetFailure::WallsTouch`]).
@@ -449,6 +454,10 @@ impl core::fmt::Display for OffsetFailure {
"the local operation's body is a manifold solid whose volume is not \
positive: refused, the input unchanged",
),
Unsoundness::KeptEdgeNotClear => f.write_str(
"an edge the local operation kept as it was is not proved clear of a face \
it moved: refused, the input unchanged",
),
},
Self::ExactCurveUncut => f.write_str(
"the extended faces beside an edge meet on an exact curve that could not be \
@@ -11,6 +11,7 @@
mod attributes;
mod cavity;
pub(crate) mod clearance;
mod crossing_loops;
mod edgeuse;
mod face;
@@ -0,0 +1,397 @@
//! Not upstream: proving that the edges a local operation kept are clear of the faces it
//! moved.
//!
//! | Rust | Upstream |
//! |------|----------|
//! | [`separated`], [`all_clear`] | none |
//!
//! What is tested (issue #2971's certified bound): a kept edge's whole curve against the
//! trimmed domain of each moved result face, the surface restricted to the box of the
//! face's trim curves in its parameters (`surface_distance_lower_bound`'s `uv_box`), never
//! the surface's whole extension: a plane's net is hundreds of thousands of units wide and
//! crosses edges of solids the face never reaches. A curve interval is enclosed by rounded interval evaluation, then by a ball B(c,r).
//! Distance to a set is 1-Lipschitz: d(C,S) >= d(c,S) - r. The surface distance is the
//! existing certified bound (positive-weight Bernstein hulls, including the definition's
//! deviation and arithmetic rounding), not a projection or a sample. Thus every accepted
//! cell proves all its curve points clear, including between evaluated parameters.
//!
//! Deviations: no recursion; at most 255 curve cells and twelve halvings per branch (a cell is split only while its
//! ball is not proved clear, so the cells gather where the curve passes near the face). A kind
//! without an enclosure, a nonfinite enclosure, or an exhausted budget proves nothing.
//! NURBS degree is at most 18 here: the factorials in `curve_derivs_over` are then exact
//! binary64 integers. Lines are evaluated on their definition, not a rounded fitted form.
use crate::blend::fillet_section::certificate::{curve_derivs_over, NurbsCurve};
use crate::blend::fillet_section::interval::Ivl;
use crate::curve::curve::{Curve, CurveKind};
use crate::curve::line::Line;
use crate::geom::extent1d::Extent1d;
use crate::geom::extent2d::Extent2d;
use crate::kernel::Kernel;
use crate::math::Point3;
use crate::nurbs::math::{NOW, NOZ};
use crate::query::distance_bound::surface_distance_lower_bound;
use crate::surface::surface::Surface;
use crate::{Result, SolidError};
const OP: &str = "offset::offset_executive::clearance";
/// An edge a local operation kept as it was: its curve over its interval, and the
/// tolerance (its own, at least the body's) it must stay clear within.
#[derive(Debug, Clone)]
pub(crate) struct KeptEdge {
pub(crate) curve: Curve,
pub(crate) interval: Extent1d,
pub(crate) tolerance: f64,
}
/// A face a local operation moved, as the result has it: its surface, the box of its trim
/// curves in the surface's parameters (`None` when a trim curve has no box: the whole
/// surface is then tested), and its tolerance (at least the body's).
#[derive(Debug, Clone)]
pub(crate) struct MovedFace {
pub(crate) surface: Surface,
pub(crate) uv_box: Option<Extent2d>,
pub(crate) tolerance: f64,
}
/// Every kept edge is proved clear of every moved face by more than their summed
/// tolerances (rounded up). `false` means some pair is unproved, not necessarily crossing.
///
/// # Errors
/// Cancellation.
pub(crate) fn all_clear(kernel: &Kernel, kept: &[KeptEdge], moved: &[MovedFace]) -> Result<bool> {
for edge in kept {
for face in moved {
let tolerance = (Ivl::from(edge.tolerance) + Ivl::from(face.tolerance)).hi;
if !separated(
kernel,
&edge.curve,
&edge.interval,
&face.surface,
face.uv_box.as_ref(),
tolerance,
)? {
return Ok(false);
}
}
}
Ok(true)
}
const MAX_DEPTH: usize = 12;
const MAX_CELLS: usize = 255;
enum EnclosedCurve<'a> {
Line(&'a Line),
Nurbs(NurbsCurve),
}
impl<'a> EnclosedCurve<'a> {
fn of(curve: &'a Curve) -> Option<Self> {
let base = match curve.kind() {
CurveKind::Line(line) => return Some(Self::Line(line)),
CurveKind::BSpline(c) => c,
// Curve::evaluate_point reads these conics' stored rational forms too.
CurveKind::Ellipse(c) => c.base(),
CurveKind::Parabola(c) => c.base(),
CurveKind::Hyperbola(c) => c.base(),
_ => return None,
};
let net = base.gw_nurb()?;
let (p, n) = (net.degree(), net.cpts().len());
if !(1..=18).contains(&p) || n <= p || net.knots().len() != n + p + 1 {
return None;
}
// Read the stored homogeneous coefficients directly: control_polygon followed
// by NurbsCurve::of would divide and multiply rational coordinates again.
let mut pw = Vec::new();
for q in net.cpts() {
let w = if q.w == NOW { 1.0 } else { q.w };
if !(q.x.is_finite() && q.y.is_finite() && q.z.is_finite() && w.is_finite())
|| q.z == NOZ
|| w <= 0.0
{
return None;
}
pw.extend_from_slice(&[q.x, q.y, q.z, w]);
}
let u = net.knots().to_vec();
if u.iter().any(|x| !x.is_finite()) || u.iter().zip(u.iter().skip(1)).any(|(a, b)| a > b) {
return None;
}
Some(Self::Nurbs(NurbsCurve { p, n, u, pw }))
}
fn over(&self, t: Ivl) -> Result<Option<[Ivl; 3]>> {
match self {
Self::Line(line) => {
// The affine NURBS-to-STEP parameter map, with every subtraction,
// division and multiplication enclosed (Line::evaluate_point).
let mut step = t;
if line.is_bounded() {
let nurb = line.natural_interval();
let domain = line.step_interval();
let length = Ivl::from(nurb.max()) - Ivl::from(nurb.min());
if length.lo.is_nan()
|| length.lo <= 0.0
|| crate::geom::tol::is_zero(nurb.length())
{
return Ok(None);
}
let along = if line.inside_out() {
(Ivl::from(nurb.max()) - t) / length
} else {
(t - Ivl::from(nurb.min())) / length
};
step = Ivl::from(domain.min())
+ along * (Ivl::from(domain.max()) - Ivl::from(domain.min()));
} else if line.inside_out() {
step = Ivl::from(0.0) - step;
}
let p = line.line_point();
let v = line.line_vector();
let s = step * Ivl::from(line.line_scale());
Ok(Some([
Ivl::from(p.x) + s * Ivl::from(v.x),
Ivl::from(p.y) + s * Ivl::from(v.y),
Ivl::from(p.z) + s * Ivl::from(v.z),
]))
}
Self::Nurbs(net) => {
let (Some(&lo), Some(&hi)) = (net.u.get(net.p), net.u.get(net.n)) else {
return Ok(None);
};
if !(t.lo >= lo && t.hi <= hi) {
return Ok(None);
}
let derivatives = curve_derivs_over(net, t, 0, None)?;
Ok(derivatives.first().map(|p| [p.x, p.y, p.z]))
}
}
}
}
/// Every point of `curve` over `interval` is proved farther than `tolerance` from the
/// part of `surface` over `uv_box` (the whole finite image when `None`).
/// `false` means unproved, not necessarily intersecting. No curve or surface samples can
/// make this return true.
///
/// # Errors
/// Cancellation is propagated. An unavailable certificate returns `false`, so the
/// executive takes the ordinary re-intersection path instead of keeping this edge.
pub(super) fn separated(
kernel: &Kernel,
curve: &Curve,
interval: &Extent1d,
surface: &Surface,
uv_box: Option<&Extent2d>,
tolerance: f64,
) -> Result<bool> {
let Some(curve) = EnclosedCurve::of(curve) else {
return Ok(false);
};
let (lo, hi) = (interval.min(), interval.max());
if !(lo.is_finite() && hi.is_finite() && lo < hi && tolerance.is_finite() && tolerance >= 0.0) {
return Ok(false);
}
let mut stack = vec![(Ivl::new(lo, hi), 0_usize)];
let mut cells = 0;
while let Some((t, depth)) = stack.pop() {
kernel.check_cancelled(OP)?;
cells += 1;
if cells > MAX_CELLS {
return Ok(false);
}
let box_ = match curve.over(t) {
Ok(Some(box_)) => box_,
Err(e @ SolidError::Cancelled { .. }) => return Err(e),
_ => return Ok(false),
};
if box_
.iter()
.any(|x| !(x.lo.is_finite() && x.hi.is_finite() && x.lo <= x.hi))
{
return Ok(false);
}
// The centre need not lie on the curve. Its computed binary64 value is exact
// data for both bounds: interval subtraction encloses the ball's radius.
let centre = box_.map(|x| 0.5 * x.lo + 0.5 * x.hi);
let mut radius2 = Ivl::from(0.0);
for (x, c) in box_.into_iter().zip(centre) {
let delta = x - Ivl::from(c);
radius2 = radius2 + delta * delta;
}
let radius = radius2.sqrt().hi;
let want = (Ivl::from(radius) + Ivl::from(tolerance)).hi;
if !want.is_finite() {
return Ok(false);
}
let [cx, cy, cz] = centre;
let lower = match surface_distance_lower_bound(
kernel,
surface,
uv_box,
Point3::new(cx, cy, cz),
want,
) {
Ok(lower) => lower,
Err(e @ SolidError::Cancelled { .. }) => return Err(e),
Err(_) => return Ok(false),
};
if (Ivl::from(lower) - Ivl::from(radius)).lo > tolerance {
continue;
}
if depth >= MAX_DEPTH {
return Ok(false);
}
let mid = 0.5 * t.lo + 0.5 * t.hi;
if !(mid > t.lo && mid < t.hi) {
return Ok(false);
}
stack.push((Ivl::new(mid, t.hi), depth + 1));
stack.push((Ivl::new(t.lo, mid), depth + 1));
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::{Point2, Vec3};
use crate::nurbs::b_spline_curve::p1::{BSplineCurveForm, CanonicalData, KnotType};
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::surface::offset_surface::OffsetSurface;
use crate::surface::plane::Plane;
fn floor() -> Surface {
Surface::from(Plane::from_normal(Vec3::ZERO, Vec3::Z))
}
#[test]
fn a_long_clear_edge_is_proved_after_subdivision() {
let k = Kernel::default();
let interval = Extent1d::from_min_max(0.0, 1.0);
for curve in [
Curve::from(Line::from_points(
Point3::new(-10.0, 0.0, 1.0),
Point3::new(10.0, 0.0, 1.0),
3,
)),
Curve::from(
BSplineCurve::create_line_segment(
3,
Point3::new(-10.0, 0.0, 1.0),
Point3::new(10.0, 0.0, 1.0),
)
.unwrap(),
),
] {
assert!(separated(&k, &curve, &interval, &floor(), None, 0.01).unwrap());
}
}
#[test]
fn a_crossing_contact_or_tolerance_band_is_never_certified_clear() {
let k = Kernel::default();
let interval = Extent1d::from_min_max(0.0, 1.0);
for (a, b, tolerance) in [
(-1.0, 1.0, 0.01),
(0.0, 0.0, 0.01),
(0.005, 0.005, 0.01),
(0.01, 0.01, 0.01),
] {
let curve = Curve::from(Line::from_points(
Point3::new(-1.0, 0.0, a),
Point3::new(1.0, 0.0, b),
3,
));
assert!(!separated(&k, &curve, &interval, &floor(), None, tolerance).unwrap());
}
}
#[test]
fn rational_curve_interiors_and_the_trim_interval_are_bounded() {
let k = Kernel::default();
let curve = Curve::from(
BSplineCurve::create_canonical(&CanonicalData {
dim: 3,
degree: 2,
control_points: &[
Point3::new(-1.0, 0.0, 1.0),
Point3::new(0.0, 0.0, -2.0),
Point3::new(1.0, 0.0, 1.0),
],
form: BSplineCurveForm::Unspecified,
knot_multiplicities: &[3, 3],
knots: &[0.0, 1.0],
knot_type: KnotType::Unspecified,
weights: Some(&[1.0, 2.0, 1.0]),
trim: None,
})
.unwrap(),
);
// The endpoints are above the plane but C(1/2).z = -1: it crosses.
assert!(!separated(
&k,
&curve,
&Extent1d::from_min_max(0.0, 1.0),
&floor(),
None,
0.01,
)
.unwrap());
assert!(separated(
&k,
&curve,
&Extent1d::from_min_max(0.0, 0.03125),
&floor(),
None,
0.01,
)
.unwrap());
}
/// An edge through the plane far outside the face's parameter box is clear of the face
/// (the plane's net is far wider than any face on it), and an edge through the plane
/// inside the box is not. Tested over the whole surface instead, the far edge would
/// be reported crossed.
#[test]
fn only_the_trimmed_region_of_a_surface_is_tested() {
let k = Kernel::default();
let plane = floor();
let vertical_through = |u: f64, v: f64| {
let p = plane.evaluate_point(&k, Point2::new(u, v)).unwrap();
Curve::from(Line::from_points(
Point3::new(p.x, p.y, p.z - 1.0),
Point3::new(p.x, p.y, p.z + 1.0),
3,
))
};
let interval = Extent1d::from_min_max(0.0, 1.0);
let face = Extent2d::from_bounds(-1.0, -1.0, 1.0, 1.0);
let inside = vertical_through(0.25, 0.5);
let outside = vertical_through(500.0, 0.0);
assert!(!separated(&k, &inside, &interval, &plane, Some(&face), 0.01).unwrap());
assert!(separated(&k, &outside, &interval, &plane, Some(&face), 0.01).unwrap());
assert!(!separated(&k, &outside, &interval, &plane, None, 0.01).unwrap());
}
#[test]
fn unavailable_or_nonfinite_bounds_do_not_keep_an_edge() {
let k = Kernel::default();
let curve = Curve::from(Line::from_points(Vec3::ZERO, Vec3::X, 3));
let interval = Extent1d::from_min_max(0.0, 1.0);
let implicit = Surface::from(OffsetSurface::new(&k, 2.0, floor(), true, 0).unwrap());
assert!(!separated(&k, &curve, &interval, &implicit, None, 0.01).unwrap());
assert!(!separated(&k, &curve, &interval, &floor(), None, f64::NAN).unwrap());
assert!(!separated(
&k,
&curve,
&Extent1d::from_min_max(-1.0, f64::INFINITY),
&floor(),
None,
0.01,
)
.unwrap());
}
}
@@ -453,6 +453,31 @@ impl OffsetExecutive {
self.kept_edgeuses.contains(&edgeuse)
}
/// Not upstream: the edges [`Self::keep_unmoved_edges`] kept, as their curves over
/// their intervals with the tolerance each must stay clear within (its own, at least
/// the body's), for the local operation's check that the faces it moved stay clear of
/// them ([`super::clearance::all_clear`]). `original` is the B-rep the executive ran on.
///
/// # Errors
///
/// [`SolidError::NotFound`] for a dangling id; a null pointer for a kept edge without
/// a curve.
pub(crate) fn kept_edge_curves(
&self,
original: &Brep,
) -> Result<Vec<super::clearance::KeptEdge>> {
let mut kept = Vec::new();
for &e in &self.kept_edges {
let curve = edge::curve(original, e)?.ok_or_else(|| SolidError::null_pointer(OP))?;
kept.push(super::clearance::KeptEdge {
curve: curve.clone(),
interval: edge::interval(original, e)?,
tolerance: edge::tolerance(original, e)?.max(original.tolerance()),
});
}
Ok(kept)
}
/// Sets `m_eOffsetOperation`.
pub fn set_operation(&mut self, operation: OffsetOperation) {
self.operation = operation;
@@ -31,12 +31,14 @@ use std::collections::{BTreeMap, BTreeSet};
use crate::error::offset::OffsetFailure;
use crate::error::{EntityRef, Failure, Result, SolidError};
use crate::geom::attribute::ObjectKey;
use crate::geom::attribute::{Attribute, Behavior, ObjectKey};
use crate::geom::axis2_placement::Axis2Placement;
use crate::geom::extent2d::Extent2d;
use crate::kernel::Kernel;
use crate::math::Vec3;
use crate::nurbs::b_spline_surface::p1::SurfParamType;
use crate::nurbs::knots::Side;
use crate::offset::offset_executive::clearance::{self, KeptEdge, MovedFace};
use crate::offset::offset_executive::p1::{OffsetExecutive, OffsetOperation};
use crate::offset::offset_executive::p2::OffsetNotifications;
use crate::query::brep::{calculate_bounding_box, BoundingBoxOptions};
@@ -244,14 +246,27 @@ fn local_operation_on(
// A face's surface is extended as far as its neighbours' new surfaces meet it, and as
// far again as they moved beside it (not upstream, whose margin is five half-radii of
// the sphere bounding the box: `local_extension_reach`). The edges whose faces and
// vertices' faces all keep their surfaces are kept as they are, not intersected again
// (`OffsetExecutive::keeps_edge`).
// vertices' faces all keep their surfaces, and whose whole curve is certified clear of
// every moved surface (a ball enclosure of each curve cell against the surface's
// certified distance bound, rounding counted: `clearance::separated`, #2971), are
// kept as they are, not intersected again (`OffsetExecutive::keeps_edge`).
executive.set_local_surfaces(&all_faces, new_list, &moved);
// the shell faces get copied without being offset: none
executive.set_shell_faces(&[]);
// extend/intersect to fill new convex edge gaps rather than adding a fillet
executive.set_extend_convex_edges(true);
// (not upstream) each moved face carries a private mark through the operation, as a
// caller's mark does (`Behavior::Copy`, found on the result's faces below): the
// faces of the result that lie where the operation moved, with their trimmed regions,
// are what the kept edges must stay clear of (`refuse_kept_edges_not_clear`)
for &face in &moved {
b.add_attribute(
EntityRef::Face(face),
Attribute::long(MOVED_FACE_MARK, 1, Behavior::Copy),
)?;
}
// (not upstream) `b`'s own attributes go to the offset under the original
// identities, as upstream's live on the faces it copies (`SM_NO_COPY` of each
// source face); the caller's store does not keep them afterwards
@@ -270,6 +285,11 @@ fn local_operation_on(
// whose status is then `SM_ERR`) the offset's error is the operation's, with its own
// status and reason, as the reach's and the final gate's refusals are
let offset = executive.do_extended_offset(k, Some(&mut *b), notifications);
// the edges it kept, as they were on `b` (the faces of `b` go with `move_faces`)
let kept = match &offset {
Ok(_) => executive.kept_edge_curves(b),
Err(_) => Ok(Vec::new()),
};
drop(executive);
let new_brep = offset.and_then(|mut new_brep| {
// the offset B-rep's objects carry what the offset gave them
@@ -286,6 +306,7 @@ fn local_operation_on(
.retain_rekeyed(&|key| (!seeded.contains(&key)).then_some(key))?;
}
let mut new_brep = new_brep?;
let kept = kept?;
// remove all faces from this Brep, then move all faces of the new Brep into it
// (`CopyFaces` with a merge, which also grows this Brep's tolerance to the new one's);
@@ -298,9 +319,83 @@ fn local_operation_on(
// (not upstream: the port's cached topology fields, true again for the caller)
b.refresh_cached_topology();
moved?;
refuse_kept_edges_not_clear(k, b, &kept)?;
refuse_unsound(k, b, &input)
}
/// The id of the private mark the moved faces carry through a local operation (below the
/// application range, which starts above 10000).
const MOVED_FACE_MARK: u64 = 9_999;
/// (not upstream) The kept edges are proved clear of the faces the operation moved, or the
/// operation is refused ([`Unsoundness::KeptEdgeNotClear`](crate::error::offset::Unsoundness)),
/// the input as it came in.
///
/// The body's check has no face-face intersection test, so a face that crosses an edge
/// the operation kept (an edge whose faces and vertices' faces all keep their surfaces: the
/// executive does not intersect it again) leaves a self-intersecting body it cannot see. The
/// certified bound (issue #2971): over the whole curve of each kept edge, a ball enclosure
/// of each cell, rounding counted, and a surface distance lower bound to the part of each
/// moved face's surface over the box of its trim curves in its parameters
/// ([`clearance::all_clear`], `surface_distance_lower_bound`), must exceed the sum of the
/// edge's and the face's tolerances. A moved face is found by its mark on the result; its
/// trimmed region is the surface's part over its loops' boxes (the whole surface when a
/// loop has none), not the surface's extension, which a plane's net makes wider than any
/// part. No edge is intersected again: an edge not proved clear refuses the operation.
fn refuse_kept_edges_not_clear(k: &mut Kernel, b: &mut Brep, kept: &[KeptEdge]) -> Result<()> {
use crate::error::offset::Unsoundness;
use crate::topology::loopuse;
// the marks come off the result whatever follows
let mut moved_faces = Vec::new();
for face in bp1::faces(b, None)? {
let handles: Vec<_> = b
.entity_attributes(EntityRef::Face(face))
.into_iter()
.filter(|(_, a)| a.attribute_id() == MOVED_FACE_MARK)
.map(|(h, _)| h)
.collect();
for h in &handles {
b.remove_attribute(EntityRef::Face(face), *h)?;
}
if !handles.is_empty() {
moved_faces.push(face);
}
}
if kept.is_empty() || moved_faces.is_empty() {
return Ok(());
}
// (the trim curves a face lacks are made on a private copy)
let mut copy = b.clone_standing_for_self()?;
let mut faces = Vec::new();
for face in moved_faces {
let surface = face_ops::surface(&copy, face)?.ok_or_else(null)?.clone();
let mut uv_box: Option<Extent2d> = None;
for l in face_ops::loops(&copy, face, false, None)? {
let Some(span) = loopuse::loop_uv_span(k, &mut copy, l)? else {
uv_box = None;
break;
};
uv_box = Some(uv_box.map_or(span, |u| u.union(&span)));
}
faces.push(MovedFace {
surface,
uv_box,
tolerance: copy.face(face)?.tolerance.max(copy.tolerance()),
});
}
if clearance::all_clear(k, kept, &faces)? {
Ok(())
} else {
Err(Failure::new(
OP,
OffsetFailure::Unsound {
what: Unsoundness::KeptEdgeNotClear,
},
)
.into_error())
}
}
/// (not upstream) The local operation's final gate: the body it built is refused when it
/// fails what its input did not ([`validation::soundness`] of both, `input` measured
/// before the edit, the body on a private copy):
@@ -584,7 +679,7 @@ fn seam_gap(
k: &Kernel,
surface: &Surface,
in_u: bool,
domain: &crate::geom::extent2d::Extent2d,
domain: &Extent2d,
) -> Result<Option<crate::geom::certified::Bounded>> {
use crate::geom::certified::{distance, gamma, Bounded};
use crate::geom::vector3d::Point3;
@@ -696,7 +791,7 @@ fn cone_seam_gap(
surface: &Surface,
cone: &crate::surface::cone::Cone,
in_u: bool,
domain: &crate::geom::extent2d::Extent2d,
domain: &Extent2d,
) -> Result<Option<crate::geom::certified::Bounded>> {
use crate::geom::certified::{distance, gamma, Bounded};
use crate::geom::vector2d::Point2;
@@ -1285,6 +1380,120 @@ mod tests {
assert!(manifold && (v - 10000.0).abs() <= 1e-9 * 10000.0, "{v}");
}
/// Issue 2976: the top of a 2 x 2 x 2 box becomes the plane z = x + y - 1. It cuts the
/// bottom edges y = 0 and x = 0 at (1, 0, 0) and (0, 1, 0), away from their vertices;
/// every face at those vertices is the bottom or a side, never the top, so the
/// vertex-neighbourhood rule keeps both crossed edges whole through the cut-off corner
/// (the check of the kept edges, `refuse_kept_edges_not_clear`, is tested on its own
/// below). The corner (0, 0, 0) lies above the plane, and the vertical edge above it
/// meets the plane's line only at (0, 0, -1), outside the edge: the corner's sound
/// solid needs that edge to vanish, which the extended offset does not build. The
/// operation therefore refuses the body, and `b` stays as it came in.
#[test]
fn a_tilted_top_that_crosses_unmoved_edges_is_refused_not_returned_with_them_kept() {
let mut k = Kernel::default();
let mut b = create_box(&mut k, Point3::ZERO, 2.0, 2.0, 2.0).unwrap();
let top = bp1::faces(&b, None)
.unwrap()
.into_iter()
.find(|&f| {
let s = face_ops::surface(&b, f).unwrap().unwrap();
let uv = s
.natural_uv_domain_with_nurb(&k)
.unwrap()
.evaluate(0.5, 0.5);
(s.evaluate_point(&k, uv).unwrap().z - 2.0).abs() < 1e-9
})
.unwrap();
let tilted = Surface::from(Plane::from_normal(
Vec3::new(0.0, 0.0, -1.0),
Vec3::new(-1.0, -1.0, 1.0),
));
let why = refused_unchanged_or_returned(&mut k, &mut b, &[top], vec![tilted]);
assert!(
matches!(why, Some(OffsetFailure::Unsound { .. })),
"{why:?}"
);
let (volume, manifold) = volume_and_manifold(&k, &mut b);
assert!(manifold && (volume - 8.0).abs() <= 1e-9 * 8.0, "{volume}");
}
/// The check of the kept edges against the faces moved (issue #2976, certified bound
/// #2971), on the result of raising a 2-cube's top to the plane z = 3 given as
/// `Plane::from_normal`, whose net is about 1.2e6 wide while the face on it is 2 x 2.
/// The moved face carries the mark. A vertical edge through the face's region at
/// (1, 1) refuses the operation (`KeptEdgeNotClear`); one through the plane's net far
/// outside the face, at (50, 50), is clear of the face (tested against the whole
/// surface instead of the face's trimmed box, it would be called crossed). The marks
/// are off the body after either.
#[test]
fn a_kept_edge_through_a_moved_face_refuses_and_one_through_its_plane_beside_it_does_not() {
use crate::curve::curve::Curve;
use crate::curve::line::Line;
use crate::error::offset::Unsoundness;
use crate::geom::extent1d::Extent1d;
let kept_through = |x: f64, y: f64| KeptEdge {
curve: Curve::from(Line::from_points(
Point3::new(x, y, 2.5),
Point3::new(x, y, 3.5),
3,
)),
interval: Extent1d::from_min_max(0.0, 1.0),
tolerance: 1e-5,
};
for (x, y, refused) in [(1.0, 1.0, true), (50.0, 50.0, false)] {
let mut k = Kernel::default();
let mut b = create_box(&mut k, Point3::ZERO, 2.0, 2.0, 2.0).unwrap();
let top = bp1::faces(&b, None)
.unwrap()
.into_iter()
.find(|&f| {
let s = face_ops::surface(&b, f).unwrap().unwrap();
let uv = s
.natural_uv_domain_with_nurb(&k)
.unwrap()
.evaluate(0.5, 0.5);
(s.evaluate_point(&k, uv).unwrap().z - 2.0).abs() < 1e-9
})
.unwrap();
let raised = Surface::from(Plane::from_normal(Point3::new(0.0, 0.0, 3.0), Vec3::Z));
with_notifications(&mut b, |b, notes| {
local_operation(&mut k, b, &[top], vec![raised], notes)
})
.unwrap();
let moved = bp1::faces(&b, None)
.unwrap()
.into_iter()
.find(|&f| {
let s = face_ops::surface(&b, f).unwrap().unwrap();
let uv = b.face(f).unwrap().uv_domain.evaluate(0.5, 0.5);
(s.evaluate_point(&k, uv).unwrap().z - 3.0).abs() < 1e-9
})
.unwrap();
b.add_attribute(
EntityRef::Face(moved),
Attribute::long(MOVED_FACE_MARK, 1, Behavior::Copy),
)
.unwrap();
let r = refuse_kept_edges_not_clear(&mut k, &mut b, &[kept_through(x, y)]);
if refused {
let e = r.unwrap_err();
assert!(
matches!(
e.failure().map(|f| &f.reason),
Some(crate::error::Reason::Offset(OffsetFailure::Unsound {
what: Unsoundness::KeptEdgeNotClear
}))
),
"{e}"
);
} else {
r.unwrap();
}
assert!(b.entity_attributes(EntityRef::Face(moved)).is_empty());
}
}
#[test]
fn moves_the_top_of_a_box_and_keeps_it_a_solid() {
let mut k = Kernel::default();