Compare commits

...

3 Commits

Author SHA1 Message Date
Brennen Herbruck 30a306b376 Loft: the parallel-planes check never fails a loft that builds with chord-length parameters
`SectionPlanes::of` asks only whether each section face is a plane, so it reads the
surface's kind as stored (`kind()`) instead of building a NURB for it
(`kind_with_nurb`), which could fail. And an error from the check or from the
plane-distance rebuild now keeps the chord-length skins, as upstream builds them; only
cancellation and unported calls still stop the loft. A loft that builds on main builds
the same here whatever the check meets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:11:47 -05:00
Brennen Herbruck a78e36bd9e Loft: plane-distance parameters only where the chord-length skin leaves its end planes
Review of the previous commit: plane-distance parameters by default reshape lofts that
build correctly today (a waist r 20/10/20 at z 0/5/40 pinches through its axis; another
four-section loft grows 29%), and the parallel test is a hard cutoff.

The skin now takes upstream's chord-length parameters as before, and checks that skin's
control points against the slab between the first and last section planes (within the
B-rep's tolerance; by the convex hull the skin is in the slab when they are). Only when
one lies outside, on parallel sections in order, is every layer skinned again at the
planes' distances, where the height is linear in the parameter and the skin stays in the
slab exactly. Every loft whose chord-length skin stays between its end planes rebuilds bit
for bit; the carved top, whose control points fall 0.24 below its first plane, is still
fixed.

The boolean.api_brep golden moved by the previous commit is back to main's (no golden
changes: 283 units unchanged). New test: the waist and the four-section loft keep the
volumes measured with main's skin (16791.357595192487 and 272283.3927004977).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:43:47 -05:00
Brennen Herbruck 1982f7dfa3 Loft: sections on parallel planes are skinned at their planes' distances, so the loft stays between its end planes
A loft through three or more sections on parallel planes took chord-length parameters
across the sections, from the whole motion of their control points. Where the sections
move far sideways for a small step up (a carved top's first steps: 10 to 24 mm sideways
for 0.6 mm up), the cubic interpolant of the height overshot: the side dipped below the
first section's plane and crossed its own start cap. Joining such a loft onto the body
whose face it starts on then failed ("the mapped B-rep does not validate: outer loop is
not an edge loop", or "the union would delete face ..."), or came out wrong.

The skin now takes each section's parameter as its plane's distance from the first over
the last's. Every section's height is then one linear function of the parameter, which
the interpolation reproduces exactly: the side faces' control points lie between the end
planes, so by the convex hull the loft does too. Sections that are not all parallel, or
not in order along the normal, keep the chord-length parameters.

Results that change: lofts through three or more parallel sections whose spacing differs
from their chord lengths take a slightly different shape between the sections (the same
sections, interpolated at different parameters). One golden moves:
boolean.api_brep issue1036_coincident_skins_intersection_measured, two degree-1 skins
whose ruled surfaces are the same point set under any parameters; its volume moves
26136.268243625735 -> 26136.26817288386 (2.7e-9 relative, the integrator's 1e-6
accuracy) and one face area 3788.658561467813 -> 3788.6586869951957 (3.3e-8, against
1e-4).

Tests: solidcore tests/skin_parallel_sections.rs (control points within the slab; union
with a slab under the first section adds the volumes; both fail without the change, the
first at a control point 0.24 below the first plane, the second with a negative union
volume) and the app regression examples/regressions/loft_join_carved_top_on_slab.json
(the join's volume is the slab's plus the loft's built as its own body; 0.40.0 fails it
with the error above).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:43:47 -05:00
10 changed files with 1402 additions and 14 deletions
@@ -321,6 +321,27 @@ fn shallow_loft_cut_from_the_top_face() {
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
}
/// A carved top lofted through six closed spline sections from its slab's top face and joined
/// onto the slab. The loft lies on and above that face and shares only it with the slab, so the
/// joined top is exactly the slab plus the loft built as its own body.
#[test]
fn carved_top_lofted_onto_its_slab_joins() {
let script = include_str!("../../../examples/regressions/loft_join_carved_top_on_slab.json");
let joined = replay(script);
assert_eq!(joined.model.state().bodies.len(), 1);
let mut apart: Value = serde_json::from_str(script).unwrap();
let loft = apart["commands"].as_array_mut().unwrap().last_mut().unwrap();
loft["params"]["operation"] = json!("new_body");
loft["params"]["body_name"] = json!("Arch");
loft["params"].as_object_mut().unwrap().remove("targets");
let mut s = Session::default();
s.run_script(&apart).unwrap();
let (slab, arch) = (volume(&s, "Top"), volume(&s, "Arch"));
assert!(slab > 0.0 && arch > 0.0, "{slab} {arch}");
let got = volume(&joined, "Top");
assert!((got - (slab + arch)).abs() < 1e-7 * got, "{got} vs {slab} + {arch}");
}
#[test]
fn oblique_cut_through_a_small_arc_matches_a_split() {
let scripts = [
File diff suppressed because it is too large Load Diff
@@ -589,6 +589,7 @@ fn rust(k: &Kernel, i: &Inputs) -> Result<Output, HarnessError> {
intersections_with_rails: params.as_deref(),
deriv_surfs: ds.as_ref().map(|[a, b]| [a.as_ref(), b.as_ref()]),
degree: *degree,
section_params: None,
};
BSplineSurface::create_skinned_surface(k, &secs, opts).map(|s| {
o.push(1.0);
@@ -1467,6 +1467,7 @@ impl EiffelTowerCrossSectionFsg {
d1.as_ref().map(SurfOfExtrusion::base),
]),
degree: 3,
section_params: None,
};
BSplineSurface::create_skinned_surface(k, &cross_sections, opts)
}
@@ -3167,6 +3167,7 @@ impl CircularCrossSectionFsg {
intersections_with_rails: Some(&params),
deriv_surfs: Some([d0.as_ref(), d1.as_ref()]),
degree: 3,
section_params: None,
};
let fillet_surface = BSplineSurface::create_skinned_surface(k, &sections, opts)?;
@@ -220,6 +220,10 @@ pub struct SkinnedSurfaceOptions<'a> {
pub deriv_surfs: Option<[Option<&'a BSplineSurface>; 2]>,
/// Degree in the sweep direction (`lDegree`, upstream default 3).
pub degree: usize,
/// Not upstream: the skinning parameter of each cross section, increasing from 0 to 1,
/// used where there are no rails (with rails, `intersections_with_rails` governs);
/// `None` takes the chord-length parameters, as upstream.
pub section_params: Option<&'a [f64]>,
}
impl Default for SkinnedSurfaceOptions<'_> {
@@ -234,6 +238,7 @@ impl Default for SkinnedSurfaceOptions<'_> {
intersections_with_rails: None,
deriv_surfs: None,
degree: 3,
section_params: None,
}
}
}
@@ -1029,6 +1034,7 @@ impl BSplineSurface {
intersections_with_rails,
deriv_surfs,
degree,
section_params,
} = opts;
let num_sections = cross_sections.len();
@@ -1303,7 +1309,12 @@ impl BSplineSurface {
deg: nl_deg,
es: approx_tol,
dir: nl_dir,
ts: int_params,
// Not upstream: without rails, the caller's section parameters (if any).
ts: if has_rails {
int_params
} else {
int_params.or_else(|| section_params.map(<[f64]>::to_vec))
},
kns: nl_knots,
};
match create_skin_srf_boundary_continuity(&mut curves, &mut sk) {
@@ -26,6 +26,10 @@
//! Not upstream (the SolidCraft fork's tangency-labs/smlib 5d3939e, WR-523, #1450):
//! [`PrimitiveCreation::create_curve_sweep_with_placements`] reports the rigid motions
//! carrying the profile onto the first, middle and last sections.
//! Not upstream: [`PrimitiveCreation::create_skin_from_faces`] skins sections on
//! parallel planes at their planes' distances (`SectionPlanes`) where the chord-length
//! skin's control points leave the slab between the first and last planes, so the skin
//! stays between them; every other skin keeps the chord-length parameters.
//! **Statuses pass on** (opt-in `Kernel::fixes.sweep_statuses_pass_on`, WR-662;
//! SolidCraft change, tangency-labs/smlib c8f6a31): the statuses upstream drops here
//! are returned instead (`SER`).
@@ -73,6 +77,116 @@ fn face_surface(a: &impl PrimitiveAccess, face: FaceRef) -> Result<&Surface> {
.ok_or_else(|| SolidError::primitive_missing(OP, "the face's surface"))
}
/// Not upstream: section faces that are planes, three or more, all parallel to the first
/// (within `EFF_ZERO_DEG`) and strictly in order along its normal.
///
/// A skin through them is meant to stay in the slab between the first and last planes.
/// Upstream's chord-length parameters come from the control points' whole motion: where
/// sections move far sideways for a small step along the normal (a carved top's first
/// steps), the height's interpolant overshoots, the skin dips behind its first section's
/// plane and crosses its own cap face, and a union with a body whose face is coplanar
/// with that cap fails. [`SectionPlanes::leaves`] finds that on the chord-length skin's
/// control points; only then is the skin built again at [`SectionPlanes::params`].
struct SectionPlanes {
origin: crate::math::Point3,
unit: Vec3,
/// The last plane's height above the first along `unit` (non-zero).
last: f64,
/// Each plane's height over `last`: 0 at the first section, 1 at the last.
params: Vec<f64>,
}
impl SectionPlanes {
/// `None` for sections that are not all on parallel planes in order.
fn of(a: &impl PrimitiveAccess, faces: &[FaceRef]) -> Result<Option<Self>> {
use crate::math::EFF_ZERO_DEG;
use crate::surface::surface::SurfaceKind;
if faces.len() < 3 {
return Ok(None);
}
let mut planes = Vec::new();
for &face in faces {
match face_surface(a, face)?.kind() {
SurfaceKind::Plane(p) => {
let (point, normal, _) = p.is_planar();
planes.push((point, normal));
}
_ => return Ok(None),
}
}
let Some(&(origin, normal)) = planes.first() else {
return Ok(None);
};
let Ok(unit) = normal.unitize() else {
return Ok(None);
};
let mut heights = Vec::new();
for &(point, n) in &planes {
if !unit.is_parallel_to(n, EFF_ZERO_DEG) {
return Ok(None);
}
heights.push(unit.dot(point - origin));
}
let Some(&last) = heights.last() else {
return Ok(None);
};
// Strictly in order along the normal, either way (NaN fails every comparison).
let increasing = heights
.iter()
.zip(heights.iter().skip(1))
.all(|(p, q)| q > p);
let decreasing = heights
.iter()
.zip(heights.iter().skip(1))
.all(|(p, q)| q < p);
if !(increasing || decreasing) || !last.is_finite() || last == 0.0 {
return Ok(None);
}
let mut params: Vec<f64> = heights.iter().map(|h| h / last).collect();
// The ends exactly 0 and 1, as the interpolation's knot vector needs.
if let Some(f) = params.first_mut() {
*f = 0.0;
}
if let Some(l) = params.last_mut() {
*l = 1.0;
}
if !params.iter().all(|t| t.is_finite()) {
return Ok(None);
}
Ok(Some(Self {
origin,
unit,
last,
params,
}))
}
/// Whether a control point of `surface` lies farther than `tol` outside the slab
/// between the first and last planes. A non-rational skin lies in its control points'
/// convex hull, so with none outside it stays in the slab (within `tol`); a rational
/// one (positive weights) likewise, read on its Euclidean points.
fn leaves(&self, surface: &BSplineSurface, tol: f64) -> Result<bool> {
let (low, high) = if self.last > 0.0 {
(-tol, self.last + tol)
} else {
(self.last - tol, tol)
};
let (_, _, points) = surface.control_points()?;
Ok(points.into_iter().any(|p| {
let h = self.unit.dot(p.to_euclid() - self.origin);
// NaN counts as leaving.
!(h >= low && h <= high)
}))
}
/// Plane-distance parameters (each section's height is then one linear function of
/// its parameter, which the interpolation reproduces exactly: the control rows lie
/// on the planes at their Greville abscissae's heights and the skin in the slab).
fn params(&self) -> &[f64] {
&self.params
}
}
/// Parameters for `CreateBlendPrimitive`.
#[derive(Debug, Clone, Copy)]
pub struct BlendPrimitiveOptions {
@@ -292,25 +406,63 @@ impl PrimitiveCreation {
layers.resize(skin_curves.len(), 0);
untraced = Some(NotTraced::OpenProfiles);
}
let mut traced: Vec<(FaceId, Vec<usize>)> = Vec::new();
let mut made = Vec::new();
// Each layer's skin at chord-length parameters, as upstream.
let skin = |k: &crate::kernel::Kernel, curves: &[BSplineCurve], params: Option<&[f64]>| {
BSplineSurface::create_skinned_surface(
k,
curves,
SkinnedSurfaceOptions {
approx_tol,
degree,
deriv_surfs: Some([None, None]),
section_params: params,
..Default::default()
},
)
};
let mut layer_curves = Vec::new();
let mut surfaces = Vec::new();
for layer in 0..layer_count {
let curves: Vec<_> = skin_curves
let curves: Vec<BSplineCurve> = skin_curves
.iter()
.zip(&layers)
.filter(|(_, l)| **l == layer)
.map(|(c, _)| c.clone())
.collect();
let surface = BSplineSurface::create_skinned_surface(
a.kernel(brep)?,
&curves,
SkinnedSurfaceOptions {
approx_tol,
degree,
deriv_surfs: Some([None, None]),
..Default::default()
},
)?;
surfaces.push(skin(a.kernel(brep)?, &curves, None)?);
layer_curves.push(curves);
}
// Not upstream: sections on parallel planes whose chord-length skin leaves the slab
// between the first and last planes are skinned at their planes' distances, every
// layer alike (`SectionPlanes`).
// A failure of the check or of the second build keeps the chord-length skins, as
// upstream builds them; only cancellation and unported calls stop the loft.
let replanned = (|| -> Result<Option<Vec<BSplineSurface>>> {
let Some(planes) = SectionPlanes::of(&*a, faces)? else {
return Ok(None);
};
let tol = a.brep(brep)?.tolerance();
let mut leaves = false;
for s in &surfaces {
leaves = leaves || planes.leaves(s, tol)?;
}
if !leaves {
return Ok(None);
}
let mut again = Vec::new();
for curves in &layer_curves {
again.push(skin(a.kernel(brep)?, curves, Some(planes.params()))?);
}
Ok(Some(again))
})();
match replanned {
Ok(Some(again)) => surfaces = again,
Err(e) if e.is_unported_or_cancelled() => return Err(e),
Ok(None) | Err(_) => {}
}
let mut traced: Vec<(FaceId, Vec<usize>)> = Vec::new();
let mut made = Vec::new();
for (layer, surface) in surfaces.into_iter().enumerate() {
// (the trace: the surface's knots across the sections, V)
let surface_knots = trace
.is_some()
+1
View File
@@ -6181,6 +6181,7 @@ impl DerivSurfDefinition<'_> {
intersections_with_rails: Some(params),
deriv_surfs: Some([None, None]),
degree,
section_params: None,
},
)
}
+1
View File
@@ -445,6 +445,7 @@ pub fn create_skin(k: &Kernel, profiles: &[BSplineCurve]) -> Result<BSplineSurfa
intersections_with_rails: None,
deriv_surfs: Some([None, None]), // derivative surfaces
degree: 3,
section_params: None,
},
)
}
@@ -0,0 +1,183 @@
//! A skin through sections on parallel planes stays between its first and last planes.
//!
//! The skin takes upstream's chord-length parameters across the sections. Only where that
//! skin's control points leave the slab between the first and last planes does it take the
//! planes' distances from the first instead (`SectionPlanes` in
//! `primitive_creation::p3::construct`): every section's height above the first plane is then
//! one linear function of the parameter, which the interpolating skin reproduces exactly, so
//! every control point of the side faces lies between the two end planes and, by the convex
//! hull, so does the skin. Checked on the control points, never on samples. A skin whose
//! chord-length control points stay in the slab is built as before, bit for bit.
//!
//! The carved top's sections take wide steps sideways for small steps up near the first
//! section (0.6 high, up to 16 in), larger ones after. Chord-length parameters come from the
//! whole motion of the control points, so the first step's parameter is far larger than its
//! share of the height; the cubic interpolant of the height overshoots: control points fall
//! 0.24 below the first plane, the skin dips behind it and crosses its own start cap, and its
//! union with a slab whose top is that cap's plane comes out with a negative volume.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
use solidcore::boolean::api_brep::boolean_union;
use solidcore::curve::curve::Curve;
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::mass::integrator::properties::{brep_volume, PropertyAttributes};
use solidcore::math::Point3;
use solidcore::nurbs::b_spline_curve::BSplineCurve;
use solidcore::nurbs::b_spline_surface::SurfParamType;
use solidcore::nurbs::crv_fit::p1::ParamMethod;
use solidcore::primitive::api_primitives::create_box;
use solidcore::surface::surface::SurfaceKind;
use solidcore::sweep::api_surfaces::create_skin_primitive;
use solidcore::topology::Brep;
use solidcore::Kernel;
/// (height, half-length, half-width, shift along x) of each section, as a carved top's.
const SECTIONS: [(f64, f64, f64, f64); 6] = [
(0.0, 200.0, 150.0, 0.0),
(0.6, 184.0, 132.0, 6.0),
(3.2, 160.0, 105.0, 23.0),
(6.4, 120.0, 68.0, 43.0),
(8.8, 90.0, 45.0, 58.0),
(10.0, 66.0, 30.0, 68.0),
];
/// A closed cubic through 16 points of an ellipse bulging toward -x (the waist of a
/// body), at height `z`.
fn section(z: f64, a: f64, b: f64, dx: f64) -> Curve {
let mut pts: Vec<Point3> = (0..16)
.map(|j| {
let t = 2.0 * std::f64::consts::PI * f64::from(j) / 16.0;
let r = 1.0 + 0.12 * (2.0 * t).cos();
Point3::new(dx + a * r * t.cos(), b * r * t.sin(), z)
})
.collect();
pts.extend(pts.first().copied());
Curve::from(
BSplineCurve::interpolate_points(&pts, None, 3, None, None, true, ParamMethod::ChordLength)
.unwrap(),
)
}
fn carved_top(k: &mut Kernel) -> Brep {
let cs: Vec<Curve> = SECTIONS
.iter()
.map(|&(z, a, b, dx)| section(z, a, b, dx))
.collect();
let refs: Vec<Option<&Curve>> = cs.iter().map(Some).collect();
create_skin_primitive(k, &vec![1; cs.len()], &refs, 3, true)
.unwrap()
.brep
}
fn volume(k: &Kernel, b: &mut Brep) -> f64 {
let store = AttributeStore::default();
let attrs = PropertyAttributes {
store: &store,
key_of: &|_| ObjectKey(0),
};
brep_volume(k, b, 1e-9, &attrs).unwrap()
}
#[test]
fn a_skin_through_parallel_sections_stays_between_its_end_planes() {
let mut k = Kernel::default();
let b = carved_top(&mut k);
let top = SECTIONS[5].0;
// the heights' rounding: a few ulps of the largest coordinate
let slack = 64.0 * f64::EPSILON * 300.0;
let mut sides = 0;
for &f in b.face_list() {
let Some(s) = b.face(f).unwrap().surface.clone() else {
continue;
};
let SurfaceKind::BSpline(bs) = s.kind() else {
continue;
};
sides += 1;
// cubic across the six sections, and polynomial: its hull is its control points'
assert!(bs.degree(SurfParamType::U).max(bs.degree(SurfParamType::V)) >= 3);
assert!(!bs.is_rational(), "{f:?}");
let (_, _, cps) = bs.control_points().unwrap();
for p in cps {
let p = p.to_euclid();
assert!(
p.z >= -slack && p.z <= top + slack,
"{f:?}: a control point at height {} leaves the slab [0, {top}]",
p.z
);
}
}
assert!(sides > 0, "no B-spline side face");
}
#[test]
fn a_skin_through_parallel_sections_joins_a_slab_under_its_first_section() {
// The slab's top face is the skin's start cap's plane; they share only that face, so
// the union's volume is the sum of theirs.
let mut k = Kernel::default();
let mut skin = carved_top(&mut k);
let v_skin = volume(&k, &mut skin);
let slab = create_box(&mut k, Point3::new(-260.0, -200.0, -5.0), 520.0, 400.0, 5.0).unwrap();
let mut u = boolean_union(&mut k, slab, skin).unwrap();
let v = volume(&k, &mut u);
let want = 520.0 * 400.0 * 5.0 + v_skin;
assert!(
(v - want).abs() <= 1e-7 * want,
"union volume {v} against slab + skin {want}"
);
}
/// A closed cubic through 16 points of the circle of radius `r` about the z axis at height
/// `z`.
fn circle(z: f64, r: f64) -> Curve {
let mut pts: Vec<Point3> = (0..16)
.map(|j| {
let t = 2.0 * std::f64::consts::PI * f64::from(j) / 16.0;
Point3::new(r * t.cos(), r * t.sin(), z)
})
.collect();
pts.extend(pts.first().copied());
Curve::from(
BSplineCurve::interpolate_points(&pts, None, 3, None, None, true, ParamMethod::ChordLength)
.unwrap(),
)
}
/// The capped skin through circles given as (height, radius), and its volume.
fn round_loft(sections: &[(f64, f64)]) -> f64 {
let mut k = Kernel::default();
let cs: Vec<Curve> = sections.iter().map(|&(z, r)| circle(z, r)).collect();
let refs: Vec<Option<&Curve>> = cs.iter().map(Some).collect();
let mut b = create_skin_primitive(&mut k, &vec![1; cs.len()], &refs, 3, true)
.unwrap()
.brep;
volume(&k, &mut b)
}
/// The volumes of the chord-length skins below, measured with the skin as it was before
/// plane distances (solidcore at origin/main of 2026-11-11).
const WAIST_VOLUME: f64 = 16791.357595192487;
const BULGE_VOLUME: f64 = 272283.3927004977;
/// Lofts whose chord-length skins stay between their end planes keep their shape: a waist
/// (r 20, 10, 20 at z 0, 5, 40) that plane distances would pinch through its axis, and four
/// unevenly spaced sections that plane distances would grow by 29%. Their volumes are the
/// chord-length skins', as built before plane distances existed.
#[test]
fn lofts_that_stay_between_their_end_planes_keep_their_chord_length_shape() {
let waist = round_loft(&[(0.0, 20.0), (5.0, 10.0), (40.0, 20.0)]);
assert!(
(waist - WAIST_VOLUME).abs() <= 1e-9 * WAIST_VOLUME,
"waist {waist}"
);
let bulge = round_loft(&[(0.0, 30.0), (10.0, 50.0), (60.0, 15.0), (80.0, 25.0)]);
assert!(
(bulge - BULGE_VOLUME).abs() <= 1e-9 * BULGE_VOLUME,
"bulge {bulge}"
);
}