Compare commits

...

4 Commits

Author SHA1 Message Date
dudedagger42 8ffbb0b050 test: pass the fixes by reference and build the kernel from its options (clippy) (#3129)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:31:58 -05:00
dudedagger42 1289fbc82e style: rustfmt evaluation_scale (#3129)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:31:58 -05:00
dudedagger42 42c76341b4 fix(curve): scale the curve/curve exact-solution zero by the curves' evaluation (solidcraft#13004)
`filter_solution` keeps an interior, non-tangent curve/curve answer only when
its gap is below EFF_ZERO * (1 + the largest solution coordinate). A point of
a curve evaluated from control points is a convex combination of them, so it
is only good to about u * max|P_i| (Farouki & Rajan 1987). A line mirrored
into a degree-1 B-spline over the line's infinite NURB has its control points
1.2e6 mm out; its crossing with a slanted edge converged to a gap of 4.4e-11,
over the 2.8e-11 limit, was dropped, and the union misclassified faces (wrong
volume, no solid, or status 1001 with the leak guard).

The zero now also scales with the largest control-point coordinate of each
curve evaluated from control points; analytic curves keep the old zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:31:58 -05:00
dudedagger42 96559e3969 test(boolean): a mirrored pin joined through a slanted face keeps its volume (solidcraft#13004)
SolidCraft's torso, pin and arm (.smb, from the issue's script). Mirrored, the pin's
infinite-line edges become degree-1 B-splines with control points 1.2e6 mm out; the union
with the torso loses 16.87 mm3 (1348.66 for 1365.53), and the mirrored arm's union is
refused under boolean_union_refuses_solid_leak (status 1001 at face 6). Fails on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:31:58 -05:00
7 changed files with 234 additions and 13 deletions
+1
View File
@@ -877,6 +877,7 @@ fn rust(k: &Kernel, op: &OpIn) -> Result<Output, HarnessError> {
[cont(k1[0])?, cont(k1[1])?],
[cont(k2[0])?, cont(k2[1])?],
*adj,
0.0,
&sol,
);
let keep = tri!(o, r);
+26 -10
View File
@@ -476,10 +476,21 @@ fn reverse_ignored(k: &Kernel, curve: &mut Curve, domain: &Extent1d) -> Result<b
/// `ivl1` / `ivl2` of the two curves (`start.parameters = [t1, t2]`,
/// `start.solution_value` the gap) is kept. Exact (gap below `SM_EFF_ZERO` or the
/// scaled zero) and tangential (gap perpendicular to both tangents) answers are
/// kept; an answer on an interval end where the curve continues (`conts*` at least
/// `G1`) is dropped when the next interval gets closer; one on a curve end (`conts*`
/// at most `C0`) is kept; others are dropped. `adjacency` 1 / 2 drops the shared end
/// of adjacent spans in self intersection (`[1st, 2nd]` / `[2nd, 1st]`).
/// kept. The scaled zero is `SM_EFF_ZERO` times one plus the largest of the solution
/// points, tangents and parameters and `evaluation_scale`: the largest coordinate the
/// curves' points are evaluated from (zero for analytic curves, whose points are the
/// solution's own magnitude). A B-spline point is a convex combination of its control
/// points, rounded to about `SM_EFF_ZERO` times their largest coordinate (Farouki and
/// Rajan, "On the numerical condition of polynomials in Bernstein form", CAGD 4, 1987),
/// so a gap of that size is as exact as the curve can give: upstream scaled the zero by
/// the solution alone and dropped such crossings, for instance on a line's B-spline,
/// whose control points lie at the infinite parameter, 1.2e6 mm out.
///
/// The other answers are kept or dropped as upstream: an answer on an interval end
/// where the curve continues (`conts*` at least `G1`) is dropped when the next interval
/// gets closer; one on a curve end (`conts*` at most `C0`) is kept; others are dropped.
/// `adjacency` 1 / 2 drops the shared end of adjacent spans in self intersection
/// (`[1st, 2nd]` / `[2nd, 1st]`).
///
/// # Errors
///
@@ -493,6 +504,7 @@ pub fn filter_solution<C1, C2, O>(
conts1: [ContinuityType; 2],
conts2: [ContinuityType; 2],
adjacency: u64,
evaluation_scale: f64,
solution: &Solution<O>,
) -> Result<bool>
where
@@ -523,14 +535,18 @@ where
let [p1, t1] = pv1;
let [p2, t2] = pv2;
// keep answers good to ScaledZero (sensitive to parameter values [B628])
// keep answers good to ScaledZero (sensitive to parameter values [B628]), and to the
// rounding of the curves' evaluation (not upstream)
let scaled_zero = EFF_ZERO
* (1.0
+ max4(
max2(p1.max_dimension(), t1.max_dimension()),
max2(p2.max_dimension(), t2.max_dimension()),
s0,
s1,
+ max2(
max4(
max2(p1.max_dimension(), t1.max_dimension()),
max2(p2.max_dimension(), t2.max_dimension()),
s0,
s1,
),
evaluation_scale,
));
if value < scaled_zero {
return Ok(true);
+50 -3
View File
@@ -86,7 +86,8 @@ use crate::geom::tree::{NodeData, Tree, TreeNode};
use crate::geom::vector3d::{Point3, Vec3};
use crate::kernel::Kernel;
use crate::math::{max2, min2, rad_to_deg, BIG_DOUBLE, EFF_ZERO, EFF_ZERO_SQ, EFF_ZERO_SQRT, PI};
use crate::nurbs::b_spline_curve::ProjectionType;
use crate::nurbs::b_spline_curve::{BSplineCurve, ProjectionType};
use crate::nurbs::math::{NOW, NOZ};
const OP_CCI: &str = "curve::curve::p7::CciGlobalSolver::local_solve_branch";
const OP_GPA: &str = "curve::curve::p7::Curve::global_property_analysis";
@@ -125,6 +126,28 @@ fn leaf_span<O: Copy + PartialEq>(tree: &Tree<O>, node: &TreeNode<O>) -> Result<
Ok(*tree.curve_span(node)?)
}
/// The largest coordinate a point of `curve` is evaluated from: that of its control points
/// for a curve evaluated from them (a B-spline, a line made a B-spline by a mirror), zero for
/// an analytic curve (a line, an ellipse, a parabola, a hyperbola), which evaluates from its
/// own frame, and for the kinds without control points (not upstream: see `filter_solution`).
fn evaluation_scale(curve: &Curve) -> f64 {
if curve.is_analytic() {
return 0.0;
}
let Some(Ok(points)) = curve.b_spline_base().map(BSplineCurve::control_points) else {
return 0.0;
};
points.iter().fold(0.0, |m, p| {
let w = if p.w == NOW || p.w == 0.0 {
1.0
} else {
p.w.abs()
};
let z = if p.z == NOZ { 0.0 } else { p.z.abs() };
max2(m, max2(max2(p.x.abs(), p.y.abs()), z) / w)
})
}
impl<'t, O: SolutionObject + 't> CciGlobalSolver<'t, O> {
/// A two-variable single value solution between the branch's nodes naming both
/// curves.
@@ -181,6 +204,12 @@ impl<'t, O: SolutionObject + 't> CciGlobalSolver<'t, O> {
let k: &Kernel = self
.kernel
.ok_or(SolidError::invalid_input(OP_CCI, "solver without kernel"))?;
// the largest coordinate the two curves' points are evaluated from (not upstream:
// see `filter_solution`)
let evaluation_scale = max2(
evaluation_scale(parent_curve1),
evaluation_scale(parent_curve2),
);
let tol = self.base.tolerance_3d;
let interval1 = self.interval1;
let interval2 = self.interval2;
@@ -716,7 +745,16 @@ impl<'t, O: SolutionObject + 't> CciGlobalSolver<'t, O> {
// Now check the refined solution.
let keep = filter_solution(
k, curve1, &ivl1, curve2, &ivl2, conts1, conts2, adjacency, sol,
k,
curve1,
&ivl1,
curve2,
&ivl2,
conts1,
conts2,
adjacency,
evaluation_scale,
sol,
)?;
// save aSolutions, which should be kept, to m_pSolutions
@@ -939,7 +977,16 @@ impl<'t, O: SolutionObject + 't> CciGlobalSolver<'t, O> {
// Filter out intersections which are actually outside of the boundary
// of the curve and are not of the tangent sort
let keep = filter_solution(
k, curve1, &ivl1, curve2, &ivl2, conts1, conts2, adjacency, &solution,
k,
curve1,
&ivl1,
curve2,
&ivl2,
conts1,
conts2,
adjacency,
evaluation_scale,
&solution,
)?;
// Filter out the nasty end case where the intersection converges to
@@ -0,0 +1,157 @@
//! tangency-labs/solidcraft#13004: SolidCraft joins a mirrored arm to a torso with slanted
//! sides, and the union fails (`boolean_union_refuses_solid_leak`) or, without that fix,
//! returns no solid; joined to the arm as made it succeeds.
//!
//! The bodies are SolidCraft's (binary `.smb`, written by SolidCraft from the issue's
//! script): the torso, a trapezoid extruded 8 mm with its sides slanted 13 degrees; the pin, a
//! cylinder of radius 2 along x from (5, 0, 26) to (7, 0, 26) through the torso's slanted
//! side; the arm, a box joined to that pin. Their straight edges are infinite lines (natural
//! interval [-1234567, 1234567]), as SolidCraft and STEP make them.
//!
//! Mirroring turns each line into a degree-1 B-spline over the line's NURB, whose two
//! control points lie about 1.2e6 mm out. A point of it near the part is a convex combination
//! of those far points, good to about 1e-16 * 1.2e6 mm. The pin's seam crosses the torso's
//! slanted side; the curve/curve solve found that crossing with a gap of 4.4e-11 and the
//! solution filter dropped it (its exact zero was scaled by the solution point, 2.8e-11), so
//! the intersection of the pin with the side was not split at the seam and the faces were
//! misclassified.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
use solidcore::boolean::api_brep::boolean_union;
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::kernel::{KernelOptions, UpstreamFixes};
use solidcore::mass::integrator::properties::{
brep_properties, MassPropertyBehavior, PropertyAttributes,
};
use solidcore::query::brep::{calculate_bounding_box, BoundingBoxOptions};
use solidcore::topology::api_files::read_brep_from_file;
use solidcore::topology::brep::mirror::transform_matrix;
use solidcore::topology::brep::p10::TopologyEnv;
use solidcore::topology::brep::p2::is_manifold_solid;
use solidcore::topology::topology::NotifyRecords;
use solidcore::{Brep, Kernel};
/// The mirror in the plane x = 0 (row-vector convention: the fourth row is the translation).
const MIRROR_X: [[f64; 4]; 4] = [
[-1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
const TORSO: &[u8] = include_bytes!("data/solidcraft_13004_torso.smb");
const PIN: &[u8] = include_bytes!("data/solidcraft_13004_pin.smb");
const ARM: &[u8] = include_bytes!("data/solidcraft_13004_arm.smb");
fn read(k: &mut Kernel, bytes: &[u8]) -> Brep {
let fresh = Brep::new();
let mut store = AttributeStore::new();
let mut records = NotifyRecords::new();
let key_of = |e| fresh.owner_identity(e);
let mut env = TopologyEnv {
all_attributes: &[],
attributes: &mut store,
key_of: &key_of,
records: &mut records,
next_key: 1 << 50,
};
let mut b = read_brep_from_file(k, Some(bytes.to_vec()), false, false, &mut env).unwrap();
b.refresh_cached_topology();
b
}
fn volume(k: &Kernel, b: &mut Brep) -> f64 {
let bbox = calculate_bounding_box(k, &mut *b, BoundingBoxOptions::default()).unwrap();
let store = AttributeStore::default();
let key_of = |_| ObjectKey(0);
brep_properties(
k,
b,
1e-9,
bbox.mid(),
MassPropertyBehavior::default(),
&PropertyAttributes {
store: &store,
key_of: &key_of,
},
)
.unwrap()
.volume
}
/// The union of the torso with `tool` mirrored `mirrors` times in x = 0, run with `fixes`:
/// its volume when it is a closed solid, else why not. The torso is symmetric about x = 0,
/// so the union's volume does not depend on `mirrors`.
fn union_volume(tool: &[u8], mirrors: usize, fixes: &UpstreamFixes) -> Result<f64, String> {
let mut k = Kernel::new(&KernelOptions::default());
k.fixes = *fixes;
let torso = read(&mut k, TORSO);
let mut t = read(&mut k, tool);
for _ in 0..mirrors {
transform_matrix(&mut k, &mut t, &MIRROR_X, true).unwrap();
}
let mut u = boolean_union(&mut k, torso, t).map_err(|e| e.to_string())?;
if !is_manifold_solid(&u).unwrap() {
return Err("not a solid".into());
}
Ok(volume(&k, &mut u))
}
/// `got` is a solid of volume `want`: to 1e-6 relative, the volume integration's error on
/// the same body with B-spline in place of line edges (about 2e-8); the misclassified union
/// was 1.2% off.
fn assert_same(what: &str, got: Result<f64, String>, want: f64) {
match got {
Ok(v) => assert!(
(v - want).abs() <= 1e-6 * want,
"{what}: {v}, expected {want}"
),
Err(e) => panic!("{what}: {e}, expected a solid of {want}"),
}
}
/// Torso and pin: 1352 + pi 2^2 2 less the pin's part inside the torso. Mirrored twice
/// (the same pin, its edges now B-splines) the union keeps that volume; it was 1348.66.
#[test]
fn torso_and_twice_mirrored_pin() {
let made = union_volume(PIN, 0, &UpstreamFixes::default()).unwrap();
assert!((made - 1365.533).abs() < 1e-3, "{made}");
assert_same(
"default fixes",
union_volume(PIN, 2, &UpstreamFixes::default()),
made,
);
assert_same(
"all fixes",
union_volume(PIN, 2, &UpstreamFixes::all()),
made,
);
}
/// Torso and arm, as in the issue: the arm mirrored to the other side of the torso joins with
/// the volume of the arm as made, 1516.80 (by symmetry). With every fix on it was refused
/// (`boolean_union_refuses_solid_leak`, status 1001); with none it gave no solid.
#[test]
fn torso_and_mirrored_arm() {
let made = union_volume(ARM, 0, &UpstreamFixes::all()).unwrap();
assert!((made - 1516.798).abs() < 1e-3, "{made}");
assert_same(
"all fixes",
union_volume(ARM, 1, &UpstreamFixes::all()),
made,
);
assert_same(
"default fixes",
union_volume(ARM, 1, &UpstreamFixes::default()),
made,
);
assert_same(
"twice, all fixes",
union_volume(ARM, 2, &UpstreamFixes::all()),
made,
);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.