Merge pull request #1489 from brett19/fix/swept-bar-shell-offsets

fix: shell a swept bar (SurfOfExtrusion offsets, input-scaled HANG-44 bound) (fixes #1476)
This commit is contained in:
bherbruck
2026-10-29 19:20:59 +00:00
committed by GitHub
10 changed files with 6398 additions and 14 deletions
@@ -53,7 +53,9 @@ use solidcore::nurbs::math::CPoint;
use solidcore::nurbs::srf_basic::Surface as NurbSurface;
use solidcore::offset::offset_executive::p1::{OffsetExecutive, OffsetOperation};
use solidcore::offset::offset_geometry_creation::SsiData;
use solidcore::surface::offset_surface::{OffsetSurface, SS_UNKNOWN};
use solidcore::surface::plane::Plane;
use solidcore::surface::surf_of_extrusion::SurfOfExtrusion;
use solidcore::surface::surface::{Surface, SurfaceKind};
use solidcore::topology::brep::p1 as brep_p1;
use solidcore::topology::brep::p7::{self as brep_p7, FaceCurvesInput};
@@ -89,6 +91,59 @@ enum Inputs {
Probe { branch: Probe, approx_tol: f64 },
FaceTail(FaceTailIn),
MapTwin(MapTwinIn),
Replace(ReplaceIn),
}
/// `ReplaceImplicitOffsets` on one face whose surface is an owning
/// `SmOffsetSurface(offset_distance, base)` (see [`ffi::Replace`]).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReplaceIn {
base: OffsetBaseIn,
offset_distance: f64,
approx_tol: f64,
samples: Vec<[f64; 2]>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum OffsetBaseIn {
Surface {
surface: SurfaceIn,
},
Extrusion {
generator: CurveIn,
origin: [f64; 3],
direction: [f64; 3],
domain: [f64; 4],
swap_uv: bool,
},
}
impl ReplaceIn {
fn ffi(&self) -> ffi::Replace {
ffi::Replace {
base: match &self.base {
OffsetBaseIn::Surface { surface } => ffi::OffsetBase::Surface(surface.ffi()),
OffsetBaseIn::Extrusion {
generator,
origin,
direction,
domain,
swap_uv,
} => ffi::OffsetBase::Extrusion {
generator: generator.ffi(),
origin: *origin,
direction: *direction,
domain: *domain,
swap_uv: *swap_uv,
},
},
offset_distance: self.offset_distance,
approx_tol: self.approx_tol,
samples: self.samples.clone(),
}
}
}
/// `BuildOffsetOfFaceuse` of a unit-square plane face onto the plane
@@ -842,6 +897,30 @@ fn decode(input: &Inputs, raw: &[f64]) -> HResult<Output> {
let uses = mapped_uses(&mut snapshot)?;
output = map_twin_output(status, uses);
}
Inputs::Replace(_) => {
output = output.int("replace.status", snapshot.integer()?);
let faces = snapshot.count()?;
output = output.int("replace.face_count", faces as i64);
for face in 0..faces {
let key = format!("replace.face.{face}");
let kind = snapshot.integer()?;
output = output.int(&format!("{key}.surface_type"), kind);
output = output.values(&format!("{key}.uv_domain"), snapshot.take(4)?.to_vec());
if kind < 0 {
continue;
}
output = output.values(&format!("{key}.natural_domain"), snapshot.take(4)?.to_vec());
let samples = snapshot.count()?;
let mut statuses = Vec::with_capacity(samples);
let mut points = Vec::with_capacity(samples * 3);
for _ in 0..samples {
statuses.push(snapshot.integer()?);
points.extend_from_slice(snapshot.take(3)?);
}
output = output.ints(&format!("{key}.sample_status"), statuses);
output = output.values(&format!("{key}.samples"), points);
}
}
}
snapshot.done()?;
normalize_sync_ties(output)
@@ -2309,6 +2388,79 @@ fn rust_map_twin(input: &MapTwinIn) -> RResult<Output> {
Ok(map_twin_output(status, uses))
}
/// The shim's `replace`: the same record, built from the port.
fn rust_replace(input: &ReplaceIn) -> RResult<Output> {
guard(ffi::validate_replace(&input.ffi()))?;
let mut kernel = Kernel::default();
let base = match &input.base {
OffsetBaseIn::Surface { surface } => surface.rust()?,
OffsetBaseIn::Extrusion {
generator,
origin,
direction,
domain,
swap_uv,
} => {
let [u0, v0, u1, v1] = *domain;
let extrusion = SurfOfExtrusion::new(
&kernel,
generator.rust()?,
v3(*origin),
v3(*direction),
Extent2d::from_bounds(u0, v0, u1, v1),
*swap_uv,
)
.map_err(setup)?;
Surface::from(extrusion)
}
};
let offset = OffsetSurface::new(&kernel, input.offset_distance, base, true, SS_UNKNOWN)
.map_err(setup)?;
let mut original = super::blend_fillet_geom_p1::new_brep(&mut kernel)?;
super::blend_fillet_geom_p1::natural_face(&mut kernel, &mut original, Surface::from(offset))?;
let result = probe_env(|env, _| {
OffsetExecutive::replace_implicit_offsets(
&mut kernel,
&mut original,
input.approx_tol,
&mut solidcore::topology::edge::NotificationStores {
records: &mut *env.records,
attributes: &mut *env.attributes,
key_of: env.key_of,
},
)
});
let (status, _) = operation(result)?;
let faces = brep_p1::faces(&original, None).map_err(setup)?;
let mut raw = vec![0.0, status as f64, faces.len() as f64];
for f in faces {
let face = original.face(f).map_err(setup)?;
let d = face.uv_domain;
let Some(surface) = face.surface.as_ref() else {
raw.extend([-1.0, d.min().x, d.min().y, d.max().x, d.max().y]);
continue;
};
raw.extend([surface.type_code() as f64, d.min().x, d.min().y, d.max().x, d.max().y]);
let n = surface.natural_uv_domain_with_nurb(&kernel)?;
raw.extend([n.min().x, n.min().y, n.max().x, n.max().y]);
raw.push(input.samples.len() as f64);
for &[fu, fv] in &input.samples {
let uv = Vec2::new(
(1.0 - fu) * n.min().x + fu * n.max().x,
(1.0 - fv) * n.min().y + fv * n.max().y,
);
match surface.evaluate_point(&kernel, uv) {
Ok(p) => raw.extend([f64::from(ffi::NATIVE_SUCCESS), p.x, p.y, p.z]),
Err(SolidError::Upstream { code, .. }) => {
raw.extend([f64::from(code), 0.0, 0.0, 0.0]);
}
Err(e) => return Err(RunError::Kernel(e)),
}
}
}
Ok(decode(&Inputs::Replace(input.clone()), &raw)?)
}
fn vertex_point(brep: &Brep, edgeuse: EdgeuseId) -> HResult<Vec3> {
let vertexuse = brep
.edgeuse(edgeuse)
@@ -2522,6 +2674,7 @@ impl UnitDiff for Runner {
Inputs::Probe { branch, approx_tol } => ffi::probe(branch.ffi(), *approx_tol),
Inputs::FaceTail(input) => ffi::face_tail(&input.ffi()),
Inputs::MapTwin(input) => ffi::map_twin(&input.ffi()),
Inputs::Replace(input) => ffi::replace(&input.ffi()),
}
.map_err(|error| bad(format!("native adapter: {error}")))?;
decode(&input, &raw)
@@ -2536,6 +2689,7 @@ impl UnitDiff for Runner {
Inputs::Probe { branch, approx_tol } => rust_probe(k, branch, approx_tol),
Inputs::FaceTail(input) => rust_face_tail(&input),
Inputs::MapTwin(input) => rust_map_twin(&input),
Inputs::Replace(input) => rust_replace(&input),
};
match result {
Ok(output) => normalize_sync_ties(output),
@@ -2802,6 +2956,37 @@ mod tests {
use super::*;
use crate::{compare, CaseSource};
/// Issue #1476: every `replace` fixture (extrusion, plane and B-spline bases)
/// replaces its offset surface by a B-spline approximation on both sides, and the
/// sides agree; an extrusion base was refused with `SM_ERR` before.
#[test]
fn replace_fixtures_build_bspline_approximations() {
let context = CaseContext {
root: crate::case::repo_root(),
seeds: 0,
};
let mut extrusions = 0;
for fixture in &Runner.cases(&context).unwrap() {
let Inputs::Replace(input) = fixture.parse().unwrap() else {
continue;
};
extrusions += usize::from(matches!(input.base, OffsetBaseIn::Extrusion { .. }));
let native = Runner.run_cpp(fixture).unwrap();
let rust = Runner.run_rust(fixture).unwrap();
let diff = compare(&native, &rust, &[]);
assert!(diff.pass(), "{}: {diff:?}", fixture.name);
assert_eq!(rust.ints["replace.status"], [1000], "{}", fixture.name);
assert_eq!(rust.ints["replace.face_count"], [1], "{}", fixture.name);
assert_eq!(
rust.ints["replace.face.0.surface_type"],
[BSplineSurface::TYPE],
"{}",
fixture.name
);
}
assert!(extrusions >= 10, "{extrusions}");
}
#[test]
fn vertex_fixtures_compare_actual_caps_not_only_presence() {
let context = CaseContext {
@@ -3006,8 +3191,8 @@ mod tests {
.iter()
.filter(|case| matches!(case.source, CaseSource::Fixture { .. }))
.collect();
assert_eq!(fixtures.len(), 93 + 16 + 312 + 27);
// 93 original, 16 vertex, 312 face_tail, 27 map_twin.
assert_eq!(fixtures.len(), 93 + 16 + 312 + 27 + 12);
// 93 original, 16 vertex, 312 face_tail, 27 map_twin, 12 replace (#1476).
for case in fixtures {
case.parse::<Inputs>().unwrap();
}
@@ -11,6 +11,7 @@
//! | [`vertex`] | `BuildOffsetOfVertex`, including both real `VertexOffset` callsites |
//! | [`face_tail`] | `BuildOffsetOfFaceuse` (contained-domain branch), `MapEdgeuses`, `MakeFaceWithCurves` (fixture) |
//! | [`map_twin`] | `MapEdgeuses` alone, between two `MakeFaceWithCurves` rectangle faces |
//! | [`replace`] | `ReplaceImplicitOffsets` on one face whose surface is an owning `SmOffsetSurface` of a real `SmPlane`, `SmBSplineSurface` or `SmSurfOfExtrusion` |
//! | [`probe`] | `ReplaceImplicitOffsets`, `RemoveExcessFaces`, `BuildSideWallOfEdgeuse`, `BuildOffsetOfEdgeuses`, `FillGapEdgeuses`, `BuildOffsetOfVertex`, `BuildOffsetOfFaceuse`, `PiecewiseMerge`, `MapEdgeuses`; `sm_NotifyEdgeAndVertices` indirectly via `MapEdgeuses` |
//!
//! Deviations: caller-owned flat snapshots instead of pointers/out-parameters;
@@ -121,6 +122,9 @@
//! `1, start_xyz[3], mate_start_xyz[3]` of the offset use.
//! * `map_twin`: `0, status, number_original_edgeuses` and the same per-use
//! records as `face_tail`.
//! * `replace`: `0, status, face_count`, per face `surface_type, face_uv_domain[4]`
//! and, with a surface, its `natural_uv_domain[4]` and counted samples (fractions
//! of that domain) `point_status, xyz[3]`.
//!
//! **Relation ordering:** this checkout uses pointer-hashed `unordered_set`
//! values in `SmRelation`. We preserve actual `GetSeconds` order; equal-minimum
@@ -418,6 +422,42 @@ pub struct MapTwin {
pub split_bottom: Option<f64>,
}
/// The base of the offset surface of a [`replace`] fixture.
#[derive(Debug, Clone, PartialEq)]
pub enum OffsetBase {
/// A real `SmPlane` or `SmBSplineSurface`.
Surface(SurfaceFixture),
/// `SmSurfOfExtrusion(generator, origin, direction, domain, swap_uv)`, without
/// NURB (the executive's `GetOrCreateGwNurbPointer` makes it).
Extrusion {
/// The generator (an `SmBSplineCurve` kind).
generator: CurveFixture,
/// A point on the generator.
origin: [f64; 3],
/// The extrusion vector.
direction: [f64; 3],
/// The analytic domain `umin, vmin, umax, vmax` (U = the generator's domain).
domain: [f64; 4],
/// The generator is a constant-U iso-curve of the NURB.
swap_uv: bool,
},
}
/// A [`replace`] fixture: one `CreateFaceFromSurface` face (natural domain) on an
/// owning `SmOffsetSurface(offset_distance, base)`, then
/// `ReplaceImplicitOffsets(brep, approx_tol)`.
#[derive(Debug, Clone, PartialEq)]
pub struct Replace {
/// The offset surface's base.
pub base: OffsetBase,
/// The offset distance.
pub offset_distance: f64,
/// The approximation tolerance passed to `ReplaceImplicitOffsets`.
pub approx_tol: f64,
/// Sample fractions in `[0, 1]^2` of each result face surface's natural domain.
pub samples: Vec<[f64; 2]>,
}
mod sys {
extern "C" {
pub fn sc_offset_offset_executive_p1_eval(
@@ -947,6 +987,55 @@ pub fn map_twin(input: &MapTwin) -> Result<Vec<f64>, FfiError> {
run(5, &map_twin_input(input)?)
}
fn replace_input(input: &Replace) -> Result<Pack, FfiError> {
finite(&[input.offset_distance, input.approx_tol])?;
if input.samples.len() > 64 {
return Err(bad("at most 64 replace samples"));
}
let mut pack = Pack::default();
match &input.base {
OffsetBase::Surface(surface) => {
pack.add(&[0.0])?;
pack.surface(surface)?;
}
OffsetBase::Extrusion {
generator,
origin,
direction,
domain,
swap_uv,
} => {
finite(origin)?;
finite(direction)?;
let [a, b, c, d] = *domain;
interval([a, c], true)?;
interval([b, d], true)?;
pack.add(&[1.0])?;
pack.curve(generator)?;
pack.add(origin)?;
pack.add(direction)?;
pack.add(domain)?;
pack.add(&[f64::from(u8::from(*swap_uv))])?;
}
}
pack.add(&[input.offset_distance, input.approx_tol])?;
pack.size(input.samples.len())?;
for sample in &input.samples {
finite(sample)?;
if sample.iter().any(|v| !(0.0..=1.0).contains(v)) {
return Err(bad("surface sample fractions must be in [0, 1]"));
}
pack.add(sample)?;
}
Ok(pack)
}
/// Runs the native `ReplaceImplicitOffsets` on one real offset-surface face. See the
/// module-level protocol for the returned record.
pub fn replace(input: &Replace) -> Result<Vec<f64>, FfiError> {
run(7, &replace_input(input)?)
}
// The packers' input checks without the native call: the difftest runner's Rust
// side refuses exactly what the FFI refuses by running the same code (counts,
// finiteness, selector ranges, fixture geometry validation and the packed-size
@@ -969,6 +1058,10 @@ pub fn validate_extend(input: &Extension) -> Result<(), FfiError> {
pub fn validate_face_tail(input: &FaceTail) -> Result<(), FfiError> {
face_tail_input(input).map(|_| ())
}
/// [`replace`]'s input checks alone.
pub fn validate_replace(input: &Replace) -> Result<(), FfiError> {
replace_input(input).map(|_| ())
}
/// [`map_twin`]'s input checks alone.
pub fn validate_map_twin(input: &MapTwin) -> Result<(), FfiError> {
map_twin_input(input).map(|_| ())
+110 -7
View File
@@ -24,10 +24,15 @@
//! [`MAX_SHORT_DEGREE`](crate::nurbs::bezier::MAX_SHORT_DEGREE) (impossible
//! upstream, where `NL_DEGREE` is a `short`) is [`SolidError::InvalidInput`], so
//! index arithmetic and allocations sized from a degree cannot overflow.
//! - More than [`MAX_OFFSET_SAMPLES`] sample points in total is
//! [`SolidError::InvalidInput`]. Upstream samples any count, but the knot removal
//! that follows takes time quadratic in it (about 5 s for 23 000 samples, and
//! practically forever for the counts a pole or a tiny tolerance produce).
//! - HANG-44: more sample points in total than the input-scaled
//! [`offset_sample_bound`] is [`SolidError::InvalidInput`]. Upstream samples any
//! count, but the knot removal that follows takes time quadratic in it (practically
//! forever for the counts a pole or a tiny tolerance produce). The bound is
//! `min(2^17, max(16 384, 4 * segments * ceil((1/tol)^exp * 2 deg)))`: any count up
//! to [`MAX_OFFSET_SAMPLES`] (the former fixed cap, so every input it accepted is
//! unchanged), four times the curvature-free count a curve of that many Bezier
//! segments needs at `tol` (a swept wall of 127 segments at `1e-5` needs 38 227),
//! and never more than [`MAX_OFFSET_SAMPLES_ANY`] (about 30 s at opt-level 3).
use crate::error::{NlibCode, Result, SolidError};
use crate::math::{max2, Vec3};
@@ -47,10 +52,37 @@ use crate::nurbs::math::{blend_pt, float_op_is_bad, FloatOp, MODEL_TOL};
const OP: &str = "nurbs::crv_offset::Curve::offset_pt_sampling";
/// Most sample points [`Curve::offset_pt_sampling`] takes on a free-form curve (the
/// sum of the per-segment counts; the difftest guard allows 4000).
/// Sample points [`Curve::offset_pt_sampling`] always allows on a free-form curve (the
/// sum of the per-segment counts; the difftest guard allows 4000). Above it the
/// input-scaled [`offset_sample_bound`] applies.
pub const MAX_OFFSET_SAMPLES: isize = 16_384;
/// How many times its curvature-free count (`ceil(f1 * 2 deg)` per Bezier segment) a
/// curve may sample in [`offset_sample_bound`].
pub const OFFSET_SAMPLE_SLACK: isize = 4;
/// The most sample points [`Curve::offset_pt_sampling`] takes on any curve: the knot
/// removal that follows is quadratic in the count.
pub const MAX_OFFSET_SAMPLES_ANY: isize = 1 << 17;
/// The most sample points [`Curve::offset_pt_sampling`] takes on a free-form curve of
/// `segments` Bezier segments whose per-segment count without the curvature term
/// (`ceil((1/tol)^exp * 2 deg)`, the least upstream samples a segment) is `floor`:
/// `OFFSET_SAMPLE_SLACK * segments * floor`, clamped to
/// `[MAX_OFFSET_SAMPLES, MAX_OFFSET_SAMPLES_ANY]`. A well-formed curve samples at
/// most `OFFSET_SAMPLE_SLACK` times its floor (its offset's second derivative `Muu`
/// stays below `8 (2 deg OFFSET_SAMPLE_SLACK)^2` on average); a pole or a cusp of the
/// offset drives `Muu`, and so the count, far beyond it.
#[must_use]
pub fn offset_sample_bound(segments: usize, floor: isize) -> isize {
let scaled = isize::try_from(segments)
.ok()
.and_then(|n| n.checked_mul(floor))
.and_then(|n| n.checked_mul(OFFSET_SAMPLE_SLACK))
.unwrap_or(isize::MAX);
scaled.clamp(MAX_OFFSET_SAMPLES, MAX_OFFSET_SAMPLES_ANY)
}
/// Upstream `NL_UNITSPAN`.
const UNIT_SPAN: Interval = Interval::new(0.0, 1.0);
@@ -211,7 +243,9 @@ impl Curve {
nu.push(c);
mu = mu.checked_add(c).ok_or_else(unrepresentable)?;
}
if mu > MAX_OFFSET_SAMPLES {
// HANG-44: the input-scaled bound, from `del` without the curvature term.
if mu > MAX_OFFSET_SAMPLES && mu > offset_sample_bound(bez.len(), count((f1 * f2).ceil())?)
{
return Err(SolidError::InvalidInput {
operation: OP,
reason: "too many sample points",
@@ -438,6 +472,9 @@ mod tests {
1.0,
];
let c = Curve::new(pw, 3, u).unwrap();
// HANG-44 addendum 2026-10-29: still refused under the input-scaled bound (5
// segments, floor 13 each, so the bound is 16 384), before any sampling.
let clock = std::time::Instant::now();
let r = c.offset_pt_sampling(
Vec3::new(0.0, 0.0, 1.0),
0.0,
@@ -446,7 +483,10 @@ mod tests {
OffsetParam::Inherited,
0.11799393559359235,
);
let took = clock.elapsed();
eprintln!("fuzz00366 refused in {took:?}");
assert!(matches!(r, Err(SolidError::InvalidInput { .. })), "{r:?}");
assert!(took < std::time::Duration::from_secs(2), "{took:?}");
let r = cubic().offset_pt_sampling(
Vec3::new(0.0, 0.0, 1.0),
0.1,
@@ -457,4 +497,67 @@ mod tests {
);
assert!(matches!(r, Err(SolidError::InvalidInput { .. })), "{r:?}");
}
/// HANG-44 addendum 2026-10-29: the bound scales with the segment count and the
/// tolerance, never drops below the former cap and never exceeds the ceiling.
#[test]
fn sample_bound_is_input_scaled() {
assert_eq!(offset_sample_bound(5, 13), MAX_OFFSET_SAMPLES);
assert_eq!(offset_sample_bound(60, 301), 4 * 60 * 301);
assert_eq!(offset_sample_bound(127, 301), MAX_OFFSET_SAMPLES_ANY);
assert_eq!(
offset_sample_bound(usize::MAX, isize::MAX),
MAX_OFFSET_SAMPLES_ANY
);
assert_eq!(offset_sample_bound(0, 0), MAX_OFFSET_SAMPLES);
}
/// A tiny tolerance asks for about 72 000 samples per segment even on a gentle
/// curve: refused at once by the ceiling.
#[test]
fn tiny_tolerance_is_refused_quickly() {
let clock = std::time::Instant::now();
let r = cubic().offset_pt_sampling(
Vec3::new(0.0, 0.0, 1.0),
0.1,
3,
false,
OffsetParam::Inherited,
1e-12,
);
let took = clock.elapsed();
assert!(matches!(r, Err(SolidError::InvalidInput { .. })), "{r:?}");
assert!(took < std::time::Duration::from_secs(2), "{took:?}");
}
/// A gentle cubic of `n` segments (control points on a sine).
fn wavy(n: usize) -> Curve {
let pw = (0..n + 3)
.map(|i| {
let x = i as f64 * 0.5;
CPoint::new(x, (x * 0.7).sin() * 2.0, 0.0, crate::nurbs::math::NOW)
})
.collect();
let mut u = vec![0.0; 4];
u.extend((1..n).map(|i| i as f64 / n as f64));
u.extend([1.0; 4]);
Curve::new(pw, 3, u).unwrap()
}
/// Issue #1476: 60 segments at `1e-5` sample 60 * 301 = 18 060 points, above the
/// former fixed cap of 16 384, and are offset now. Slow in a debug build (the
/// quadratic knot removal); `issue_1476_swept_bar_shell` keeps the 38 227-sample
/// case live.
#[test]
#[ignore = "slow in debug builds (about 18 000 samples)"]
fn many_segments_at_a_tight_tolerance_are_offset() {
let c = wavy(60);
let b = Vec3::new(0.0, 0.0, 1.0);
let q = c
.offset_pt_sampling(b, 0.5, 3, false, OffsetParam::Inherited, 1e-5)
.unwrap();
let (p0, t0) = c.eval_tangent(0.0, Side::Left).unwrap();
let o = pt_along_vector(p0, 0.5, b.cross(t0));
assert!((q.cpts()[0].to_euclid() - o).length() < 1e-5);
}
}
@@ -669,8 +669,10 @@ impl OffsetExecutive {
let base = offset
.extended_base_surface()
.unwrap_or_else(|| offset.base_surface());
// make sure base surface is an SmBSplineSurface
let SurfaceKind::BSpline(nurb) = base.kind() else {
// make sure base surface is an SmBSplineSurface (`SM_CAST_PTR`, which every
// subclass passes: plane, extrusion, revolution, cone, sphere, torus); its
// B-spline part with the NURB `GetOrCreateGwNurbPointer` makes when missing
let Some(nurb) = crate::topology::brep::p3::with_nurb(kernel, base)? else {
return Err(SolidError::sm_err(OP));
};
// use NLIB function to build offset surface approximation
@@ -3821,9 +3823,31 @@ mod tests {
.unwrap();
assert!((p.z - 0.25).abs() < 1.0e-6, "{p:?}");
// a base that is not a B-spline surface is SM_ERR
// a plane is an SmBSplineSurface too (issue #1476): its offset is approximated
let offset = OffsetSurface::new(&kernel, 0.25, plane(0.0), true, 0).unwrap();
let mut brep = Brep::new();
let (offset_face, _) = add_face(&mut brep, Surface::from(offset));
OffsetExecutive::replace_implicit_offsets(
&mut kernel,
&mut brep,
1.0e-6,
&mut NotificationStores {
records: &mut records,
attributes: &mut attributes,
key_of: &loopuse::notify_ref,
},
)
.unwrap();
let surface = brep.face(offset_face).unwrap().surface.clone().unwrap();
assert!(matches!(surface.kind(), SurfaceKind::BSpline(_)));
let mid = surface.natural_uv_domain_if_built().mid();
let p = surface.evaluate_point(&kernel, mid).unwrap();
assert!((p.z - 0.25).abs() < 1.0e-6, "{p:?}");
// a base that is not a B-spline surface (here an offset surface) is SM_ERR
let inner = OffsetSurface::new(&kernel, 0.25, plane(0.0), true, 0).unwrap();
let offset = OffsetSurface::new(&kernel, 0.25, Surface::from(inner), true, 0).unwrap();
let mut brep = Brep::new();
add_face(&mut brep, Surface::from(offset));
let status = OffsetExecutive::replace_implicit_offsets(
&mut kernel,
+1 -1
View File
@@ -3547,7 +3547,7 @@ fn squeeze_loops(
/// # Errors
///
/// [`SolidError::Unported`] from a surface of revolution's `MakeNurb`.
fn with_nurb<'a>(k: &Kernel, s: &'a Surface) -> Result<Option<Cow<'a, BSplineSurface>>> {
pub(crate) fn with_nurb<'a>(k: &Kernel, s: &'a Surface) -> Result<Option<Cow<'a, BSplineSurface>>> {
// a cone, sphere, torus or surface of revolution makes its NURB on the surface and
// keeps it (`Surface::kind_with_nurb`, status ignored)
if let Some(base) = s.revolution_base_with_nurb(k)? {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
//! tangency-labs/solidcore#1476: shelling a swept bar failed with 1001. SolidCraft rig
//! case shell/566: an 8 x 5 rectangle swept (by the C++ sweep) along a 30 mm line, a
//! quarter arc of r 30 and a 30 mm line (14 faces: 12 B-spline or extrusion walls, two
//! planar caps), shelled 0.5 inward open at its start cap (z = 0). The operand is the
//! rig's working copy right before the shell (`data/issue_1476_swept_bar.smd`).
//!
//! Two causes, both fixed unconditionally:
//! - `OffsetExecutive::replace_implicit_offsets` took only a plain B-spline base;
//! upstream's `SM_CAST_PTR(SmBSplineSurface, pBase)` takes every subclass, here a
//! straight wall's `SmSurfOfExtrusion`. Both tests fail with 1001 without it.
//! - With SolidCraft's opt-in fixes on (the issue's configuration), the bend walls'
//! offset curves (125 and 127 cubic segments) need 37 625 and 38 227 samples at
//! 1e-5, above the former fixed HANG-44 cap of 16 384; the bound is now
//! input-scaled (`nurbs::crv_offset::offset_sample_bound`). The second test fails
//! with 1001 without it. With the defaults the shell takes another path and never
//! samples that many.
//!
//! Expected: one solid, V = 40 L - 28 (L - 0.5) = 1299.48668 with L = 60 + 15 pi. The
//! port gives 1299.48768 (defaults, 7.7e-7 relative above) and 1299.48231 (SolidCraft's
//! fixes, 3.4e-6 below; SolidCraft measured 1299.48275 on an older main); the
//! operand's own volume is 1.2e-2 below its closed form. Both are pinned to 1e-9.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
#[path = "support/offset_fixture.rs"]
mod offset_fixture;
use offset_fixture::{planar_faces_at, volume, Notifications};
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::geom::object::ObjectRef;
use solidcore::kernel::UpstreamFixes;
use solidcore::offset::offset_executive::p1::OffsetExecutive;
use solidcore::offset::offset_executive::p2::{OffsetOptions, ShellOptions};
use solidcore::query::api_queries::brep_is_manifold_solid;
use solidcore::topology::brep::p10::TopologyEnv;
use solidcore::topology::brep::p7::read_from_file;
use solidcore::topology::database_io::FileType;
use solidcore::topology::topology::NotifyRecords;
use solidcore::{Brep, EntityRef, Kernel};
/// `SmBrep::ReadFromFile` of the rig's operand.
fn operand(k: &mut Kernel) -> Brep {
let bytes = include_bytes!("data/issue_1476_swept_bar.smd").to_vec();
let mut b = Brep::new_with_infinite_region().unwrap();
let mut records = NotifyRecords::new();
let mut attributes = AttributeStore::default();
let key = |_: EntityRef| ObjectRef {
key: ObjectKey(0),
type_code: 0,
};
let mut env = TopologyEnv {
all_attributes: &[],
attributes: &mut attributes,
key_of: &key,
records: &mut records,
next_key: 1 << 40,
};
read_from_file(k, &mut b, bytes, FileType::Ascii, false, &mut env).unwrap();
b.refresh_cached_topology();
b
}
/// SolidCraft's `core::fixes()`: every opt-in `UpstreamFixes` flag it runs with (the
/// issue's configuration).
fn solidcraft_fixes() -> UpstreamFixes {
let mut f = UpstreamFixes::default();
f.fillet_retry_radius_as_first = true;
f.fillet_rail_rim_on_face = true;
f.fillet_refuse_rails_leaving_faces = true;
f.fillet_refuse_face_consuming_blends = true;
f.fillet_refuse_non_manifold_result = true;
f.fillet_refuse_crossing_rails = true;
f.fillet_corner_ball_patch = true;
f.fillet_chamfer_cone_on_arc = true;
f.fillet_torus_center_on_axis = true;
f.offset_excess_faces_after_merge = true;
f.offset_clip_right_side_out = true;
f.revolution_offset_axis_both_sides = true;
f.offset_extended_edges_mated = true;
f.remove_slit_loops_where_solids_touch = true;
f.keep_operand_uv_trims = true;
f.geometric_g2_continuity = true;
f.proto_vertex_miss_is_false = true;
f.check_loop_vertex_tolerance = true;
f.check_loop_projected_seam_ends = true;
f.trim_boundary_seam_wrap = true;
f.bspline_check_min_radius = true;
f.oscillating_skips_failed_guesses = true;
f.refine_cc_range_ends = true;
f.flat_corners_test_bits = true;
f.cylinder_rebuild_mean_radius = true;
f.planar_path_uncapped_open_profiles = true;
f.revolve_analytic_profile_faces = true;
f.revolve_arc_centre_near_axis_is_sphere = true;
f.taper_pivot_nearest_corner = true;
f.revolve_nested_shells_as_cavities = true;
f.curve_sweep_caps_at_sweep_ends = true;
f.join_touching_loops = true;
f.uv_trim_reversal_on_surface = true;
f.offset_resolve_crossing_loops = true;
f.offset_walls_touch_refused = true;
f.offset_analytic_mate_edges = true;
f.precise_properties_split_at_surface_knots = true;
f.nurb_cylinder_inside_out_frame = true;
f.shell_inner_shell_of_statuses = true;
f.offset_collapsed_rounds_mated = true;
f.offset_missing_surface_fails = true;
f.fillet_nx1_end_rib_global_merge = true;
f.uv_trim_corner_end_points_only = true;
f.bspline_trim_preserves_uv_spans = true;
f.cone_parallel_line_midpoint = true;
f.cone_parallel_line_domains = true;
f.proto_edge_self_loop_on_vertex_degenerate = true;
f.slice_coincident_faces_sheet_side = true;
f.boolean_outer_shell_first = true;
f.edge_reversal_keeps_failed_edge = true;
f.topology_failed_evaluations_propagate = true;
f.heal_stage3_failures_propagate = true;
f.sweep_frame_on_planar_path_normal = true;
f.cover_refuses_folded_continuation = true;
f.boolean_vertex_by_best_crossing = true;
f.boolean_cleanup_removes_slits = true;
f.classify_edge_interval = true;
f.ellipse_trim_seam_lines_span_cone_height = true;
f.degenerate_proto_edge_in_one_vertex = true;
f.new_edge_vertex_tolerance_covers_gap = true;
f.torus_signed_gen_curve_interval = true;
f.sheet_sweep_joins_profile_wires = true;
f.point_solve_keeps_triangle_guess = true;
f.fillet_setback_from_start_vertex = true;
f.imprint_trims_edge_to_proto_vertex = true;
f.imprint_extends_edge_to_proto_vertex = true;
f.fillet_point_step_per_point = true;
f.analytic_copy_keeps_parameterization = true;
f.fillet_point_bigger_step_retry = true;
f.fillet_through_extended_rail_end = true;
f.precise_properties_body_length_scale = true;
f.offset_extension_from_base = true;
f.corner_blend_stays_inside_neighbours = true;
f.fillet_cleanup_keeps_target_faces = true;
f.fillet_nx1_tangent_side_edge = true;
f.offset_closed_curve_trim_arc = true;
f.offset_stitch_at_body_tolerance = true;
f.curve_contacts_at_curve_ends = true;
f.shell_empty_cavity_fails = true;
f.fillet_refuse_ball_not_fitting = true;
f.boolean_lists_virtual_surface_copies = true;
f.sphere_small_circle_not_touching = true;
f.ssi_tangent_contact_points = true;
f.ssi_trace_tangent_curves_once = true;
f.found_torus_sphere_keep_tested_nurb = true;
f
}
/// The shell of the operand (`SmApiShellBrepFull(b, -0.5, TRUE, TRUE, TRUE, [start
/// cap])`), with its run time.
fn shell(fixes: UpstreamFixes) -> (Kernel, solidcore::Result<Brep>) {
let mut k = Kernel::default();
k.fixes.clone_from(&fixes);
let mut part = operand(&mut k);
assert_eq!(part.face_list().len(), 14);
let start = planar_faces_at(&k, &part, 2, 0.0);
assert_eq!(start.len(), 1);
let mut notifications = Notifications::new(&mut part);
let clock = std::time::Instant::now();
let result = OffsetExecutive::shell_brep(
&mut k,
part,
ShellOptions {
offset: OffsetOptions {
distance: -0.5,
extend_convex_edges: true,
merge_results: true,
},
create_offset_solid: true,
merge_sheet_side_walls: true,
},
&start,
&mut notifications.context(),
)
.result;
eprintln!("issue_1476 shell: {:.2?}", clock.elapsed());
(k, result)
}
fn check(fixes: UpstreamFixes, want: f64) {
let (k, result) = shell(fixes);
let mut b = result.unwrap();
b.refresh_cached_topology();
assert!(brep_is_manifold_solid(&b).unwrap());
let v = volume(&k, &mut b).unwrap();
eprintln!(
"issue_1476 shell: {} faces, volume {v}",
b.face_list().len()
);
let analytic =
40.0 * (60.0 + 15.0 * std::f64::consts::PI) - 28.0 * (59.5 + 15.0 * std::f64::consts::PI);
assert!((v - analytic).abs() <= 1e-5 * analytic, "{v} vs {analytic}");
assert!((v - want).abs() <= 1e-9 * want, "{v} vs {want}");
}
#[test]
fn issue_1476_swept_bar_shells() {
check(UpstreamFixes::default(), 1299.4876808844865);
}
/// About 6 s at opt-level 3 but over 4 min in a debug build (the two 38 000-sample
/// offsets' knot removal): run with `--ignored` (`--profile difftest`). The default
/// test above keeps the extrusion fix live; `nurbs::crv_offset`'s unit tests keep the
/// bound live.
#[test]
#[ignore = "slow in debug builds (two 38 000-sample curve offsets)"]
fn issue_1476_swept_bar_shells_with_solidcraft_fixes() {
check(solidcraft_fixes(), 1299.482314966473);
}
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,9 @@
// face tail -> BuildOffsetOfFaceuse's contained-domain branch on a MakeFaceWithCurves
// plane face, including MapEdgeuses and self-intersection merging.
// map twin -> MapEdgeuses alone, between two MakeFaceWithCurves rectangle faces.
// replace -> ReplaceImplicitOffsets on one CreateFaceFromSurface face whose surface is
// an owning SmOffsetSurface(d, base) of a real SmPlane, SmBSplineSurface or
// SmSurfOfExtrusion (lazy: no NURB until GetOrCreateGwNurbPointer).
// Deviations: bounded plain-C fixture protocol, traversal IDs instead of pointers;
// extension-only nonnegative-distance validation. Native array ordering is unchanged.
// No implementation file is included; no native numerical/helper code is copied.
@@ -39,9 +42,11 @@
#include <SmNurbsSrf.h>
#include <SmOffsetExecutive.h>
#include <SmOffsetGeometryCreation.h>
#include <SmOffsetSurface.h>
#include <SmPlane.h>
#include <SmRegion.h>
#include <SmShell.h>
#include <SmSurfOfExtrusion.h>
#include <SmSSIData.h>
#include <SmVertex.h>
#include <SmVertexuse.h>
@@ -1112,10 +1117,71 @@ void map_twin(Reader& rd, std::vector<double>& out) {
out.insert(out.end(), {0, (double)status});
put_mapped_uses(out, executive, use);
}
void replace(Reader& rd, std::vector<double>& out) {
SmContext ctx;
int64_t base_kind = rd.index(1);
std::unique_ptr<SmSurface> base;
if (base_kind == 0) {
base = read_surface(rd, ctx);
} else {
std::unique_ptr<SmCurve> curve = read_curve(rd, ctx);
SmBSplineCurve* generator = SM_CAST_PTR(SmBSplineCurve, curve.get());
if (!generator) throw Argument{};
SmPoint3d origin = rd.point();
SmVector3d direction = rd.point();
SmExtent2d domain = rd.domain();
bool swap = rd.flag();
// The extrusion owns its generator curve.
base.reset(new (ctx) SmSurfOfExtrusion(generator, origin, direction, domain,
swap ? TRUE : FALSE));
curve.release();
}
double distance = rd.number(), tol = rd.number();
int64_t nsamples = rd.index(64);
std::vector<SmPoint2d> samples;
for (int64_t i=0; i<nsamples; ++i) {
SmPoint2d sample = rd.uv();
if (sample.x<0 || sample.x>1 || sample.y<0 || sample.y>1) throw Argument{};
samples.push_back(sample);
}
rd.done();
auto original = brep(ctx);
struct Pending {
SmSurface* p;
~Pending() { if (p && !p->GetOwner()) delete p; }
} pending{new (ctx) SmOffsetSurface(distance, *base, TRUE)};
base.release();
SmExtent2d natural = pending.p->GetNaturalUVDomain();
SmFace* face = nullptr;
constructed(original->CreateFaceFromSurface(pending.p, natural, face));
if (!face) throw Argument{};
SmStatus status = SmOffsetExecutive::ReplaceImplicitOffsets(original.get(), tol);
SmTArray<SmFace*> faces;
original->GetFaces(faces);
out.insert(out.end(), {0, (double)status, (double)faces.GetSize()});
for (ULONG i=0; i<faces.GetSize(); ++i) {
SmSurface* surface = faces[i]->GetSurface();
out.push_back(surface ? (double)surface->GetType() : -1.0);
put_domain(out, faces[i]->GetUVDomain());
if (!surface) continue;
SmExtent2d domain = surface->GetNaturalUVDomain();
put_domain(out, domain);
out.push_back((double)samples.size());
for (const SmPoint2d& f : samples) {
SmPoint2d uv((1.0-f.x)*domain.GetMin().x + f.x*domain.GetMax().x,
(1.0-f.y)*domain.GetMin().y + f.y*domain.GetMax().y);
SmPoint3d point(0,0,0);
SmStatus eval = surface->EvaluatePoint(uv, point);
out.push_back(eval);
if (eval == SM_SUCCESS) put_point(out, point);
else out.insert(out.end(), 3, 0.0);
}
}
}
} // namespace
// Plain-C ABI, mode 0 config / 1 synchronize / 2 extend / 3 probe / 4 face tail /
// 5 map twin / 6 vertex.
// 5 map twin / 6 vertex / 7 replace.
// Caller owns every buffer; no native allocation or object address crosses it.
SC_EXPORT int32_t sc_offset_offset_executive_p1_eval(
int32_t mode, const double* input, int64_t input_len,
@@ -1135,6 +1201,7 @@ SC_EXPORT int32_t sc_offset_offset_executive_p1_eval(
case 6: vertex(rd, result); break;
case 4: face_tail(rd, result); break;
case 5: map_twin(rd, result); break;
case 7: replace(rd, result); break;
default: throw Argument{};
}
} catch (const Rejected& rejection) {
@@ -0,0 +1,40 @@
Updates: HANG-44
#### HANG-44 addendum (2026-10-29). The port's sample cap is input-scaled (issue #1476)
- **Severity:** Hang
- **Area:**
- **What changed:** the fixed `MAX_OFFSET_SAMPLES = 16 384` refused a legitimate
offset: shelling a C++-swept bar (issue #1476, SolidCraft rig case shell/566) offsets
bend walls whose curves have 125 and 127 cubic Bezier segments, and at
`approximation_tol_3d = 1e-5` `N_CrvOffsetPtSampling` asks for 37 625 and 38 227
samples. That is the least upstream samples on those segments: each takes
`ceil((1/tol)^0.34 * max(2 deg, sqrt(Muu / 8)))` and here `2 deg` dominates
(`ceil(50.12 * 6) = 301` per segment). The count grows with the segment count and
the tolerance, not only with a pole.
- **New bound** (`nurbs::crv_offset::offset_sample_bound`, unconditional, decision 78):
the total count `mu` may be at most
`min(MAX_OFFSET_SAMPLES_ANY, max(MAX_OFFSET_SAMPLES, OFFSET_SAMPLE_SLACK * segments * ceil((1/tol)^exp * 2 deg)))`
with `MAX_OFFSET_SAMPLES = 16 384` (the former cap: every input it accepted is
accepted with an identical result), `OFFSET_SAMPLE_SLACK = 4` and
`MAX_OFFSET_SAMPLES_ANY = 2^17 = 131 072`. `ceil((1/tol)^exp * 2 deg)` is the
per-segment count without the curvature term, the floor upstream samples any segment
at; four times it on average admits an offset second derivative `Muu` up to
`8 (8 deg)^2` per segment (cubic: 4 608), far above what smooth walls reach, while a
pole or an offset cusp drives `Muu`, and the count, orders of magnitude past it. The
ceiling bounds the quadratic knot removal for any input.
- **Timings** (opt-level 3, `--profile difftest`, shared machine):
- the swept bar's shell (`crates/solidcore/tests/issue_1476_swept_bar_shell.rs`)
with SolidCraft's opt-in fixes, which offsets both 38 000-sample curves: 6.4 s for
the whole shell (255 s in a debug build); with the default fixes it takes a path
that never samples that many (1.2 s);
- one curve of 127 cubic segments at `1e-5` (38 227 samples): 2.1 s to 3.6 s;
- one curve of 435 segments (130 935 samples, just under the ceiling): 29.7 s; 436
segments (131 236): refused in 16 ms;
- fuzz00366 (727 541 samples, bound 16 384 there): refused before any sampling,
in 7.7 ms in a debug build (unit test `sample_count_and_degree_are_bounded` asserts under 2 s in a debug
build); a gentle cubic at `tol = 1e-12` (about 72 000 samples per segment) is
refused by the ceiling the same way (`tiny_tolerance_is_refused_quickly`).
- **Still open:** upstream has no bound at all; the C++ was not run on the refused
inputs (the shim keeps its own 4 000-sample guard).