diff --git a/crates/solidcore/src/curve/ellipse/p1.rs b/crates/solidcore/src/curve/ellipse/p1.rs index f576e42195..10855d7bfd 100644 --- a/crates/solidcore/src/curve/ellipse/p1.rs +++ b/crates/solidcore/src/curve/ellipse/p1.rs @@ -1897,6 +1897,9 @@ impl Ellipse { /// Makes `new_interval` (degrees) the STEP domain and rebuilds the NURB over it, /// reparameterised to the NURB parameters the old table gives the new domain ends /// (`AdjustSTEPInterval`); nothing when the domains agree within `SM_EFF_ZERO`. + /// The old table clamps an end outside the current arc to the arc's ends (WR-211); + /// with [`crate::kernel::UpstreamFixes::ellipse_adjust_step_interval_keeps_turns`] + /// such an end is converted by [`Self::step_to_nurbs_keeping_turns`] instead. /// /// # Errors /// @@ -1920,14 +1923,21 @@ impl Ellipse { let step_t1 = new_interval.max(); // After these calls, the conversion may not be reciprocal. - let nurbs_t0 = self - .polar - .convert_to_nurbs_parameter(k, self, step_t0, 0.0)? - .0; - let nurbs_t1 = self - .polar - .convert_to_nurbs_parameter(k, self, step_t1, 0.0)? - .0; + let (nurbs_t0, nurbs_t1) = if k.fixes.ellipse_adjust_step_interval_keeps_turns { + ( + self.step_to_nurbs_keeping_turns(k, step_t0)?, + self.step_to_nurbs_keeping_turns(k, step_t1)?, + ) + } else { + ( + self.polar + .convert_to_nurbs_parameter(k, self, step_t0, 0.0)? + .0, + self.polar + .convert_to_nurbs_parameter(k, self, step_t1, 0.0)? + .0, + ) + }; let new_nurbs_ivl = Extent1d::from_min_max(nurbs_t0, nurbs_t1); // save the input @@ -1944,6 +1954,48 @@ impl Ellipse { Ok(()) } + /// The NURBS parameter of the STEP angle `step_t` (degrees) for + /// [`Self::adjust_step_interval`] under + /// [`crate::kernel::UpstreamFixes::ellipse_adjust_step_interval_keeps_turns`] (the + /// fork's `ConvertAngle`, tangency-labs/smlib@5c0b5ed): inside the current arc (or for + /// an arc of no length) the polar converter's; outside a full-turn arc, the angle taken + /// whole turns back into the arc, its parameter moved by the NURBS length per turn + /// (negated when inside out); outside a partial arc, the arc's NURBS interval continued + /// in proportion to the angle. + /// + /// # Errors + /// + /// Those of [`PolarConversion::convert_to_nurbs_parameter`]. + fn step_to_nurbs_keeping_turns(&self, k: &Kernel, step_t: f64) -> Result { + let old_step = self.anal_domain; + let old_nurbs = self.base.natural_interval(); + let span = old_step.length(); + if old_step.contains_value(step_t, EFF_ZERO_DEG) + || span.partial_cmp(&EFF_ZERO_DEG) != Some(core::cmp::Ordering::Greater) + { + return Ok(self + .polar + .convert_to_nurbs_parameter(k, self, step_t, 0.0)? + .0); + } + let sign = if self.inside_out { -1.0 } else { 1.0 }; + if (span - 360.0).abs() < EFF_ZERO_DEG { + let turns = ((step_t - old_step.min()) / 360.0).floor(); + let inside = old_step.clamp_value(step_t - turns * 360.0); + let nurbs_t = self + .polar + .convert_to_nurbs_parameter(k, self, inside, 0.0)? + .0; + return Ok(nurbs_t + sign * turns * old_nurbs.length()); + } + let fraction = (step_t - old_step.min()) / span; + Ok(if self.inside_out { + old_nurbs.max() - fraction * old_nurbs.length() + } else { + old_nurbs.min() + fraction * old_nurbs.length() + }) + } + /// The points of the ellipse solving `operation` for `test_point`, as STEP angles /// (`GlobalPointSolveSTEP`): `SmCurve::GlobalPointSolve` over the natural interval /// ([`Curve::global_point_solve`] on a copy of the ellipse), each solution @@ -2312,6 +2364,40 @@ mod tests { assert!(knots[1].is_nan() && knots[2].is_nan()); } + /// #428 (`WR-211`) with `ellipse_adjust_step_interval_keeps_turns` (the fork's + /// tangency-labs/smlib@5c0b5ed): a full circle moved a turn on to `[360, 540]` keeps + /// half its NURBS length and starts where angle 0 is; a half circle moved there + /// continues its NURBS interval in proportion. The knots stay finite. + #[test] + fn step_interval_past_a_turn_keeps_its_length_with_the_fix() { + let mut k = kernel(); + k.fixes.ellipse_adjust_step_interval_keeps_turns = true; + let past = Extent1d::from_min_max(360.0, 540.0); + for d1 in [360.0, 180.0] { + let mut c = circle(&k, 0.0, d1, 2.0); + c.adjust_step_interval(&k, &past, None).unwrap(); + assert_eq!(c.anal_domain, past); + let nat = c.base.natural_interval(); + assert!(nat.max() > nat.min(), "[0, {d1}]"); + // the start is angle 0 again, the end angle 180: (1, 2, 3) +- 2 x + let start = c.base.evaluate_point(nat.min()).unwrap(); + let end = c.base.evaluate_point(nat.max()).unwrap(); + assert!( + (start - Point3::new(3.0, 2.0, 3.0)).length() < 1e-12, + "{start:?}" + ); + assert!( + (end - Point3::new(-1.0, 2.0, 3.0)).length() < 1e-12, + "{end:?}" + ); + } + let mut c = circle(&k, 0.0, 360.0, 2.0); + c.adjust_step_interval(&k, &past, None).unwrap(); + c.base.edit_parameterization(&past).unwrap(); + let (knots, _) = c.base.knots(None).unwrap(); + assert!(knots.iter().all(|x| x.is_finite()), "{knots:?}"); + } + #[test] fn circle_class_and_types() { let k = kernel(); diff --git a/crates/solidcore/src/kernel.rs b/crates/solidcore/src/kernel.rs index 8374ad28b0..702a7b150a 100644 --- a/crates/solidcore/src/kernel.rs +++ b/crates/solidcore/src/kernel.rs @@ -177,8 +177,6 @@ pub struct UpstreamFixes { pub corner_blend_coons_patch_tangent_to_sides: bool, /// A torus blend whose section centre is on the arc's axis turns rail points into the edge ends' half-planes (WR-422, #417). pub fillet_torus_center_on_axis: bool, - /// `CalcTorusFilletGeom` moves a section's rail point that lies within the solver's tolerance (`GetThisApproxTol3d`) of the edge arc's axis onto the axis: it turns about the axis in a circle smaller than the tolerance, so the rail collapses to the axis's point and the surface of revolution is exactly singular there, where upstream left the point off the axis by the model's residues (1.2e-11 after a sketch's 1e-10) and the surface nearly singular, which `smlib_brep_check` refused (`SmSurface` #5, Bad Surf Pole Approximation; a round along an earlier round's end arc of the ball's own radius) (WR-592; the fork's tangency-labs/smlib@994f129). - pub fillet_torus_rail_on_axis: bool, /// `SmFilletExecutive::CreateFilletBrep` stitches the fillet body keeping a fillet's own edge where an edge of corner-patch faces only meets an edge of other fillet faces only (`SmFilletPatchStitchCallback`): an N-sided corner patch is bounded by its surfaces' isoparametric curves, the approximations it was fitted to, and the default choice between the two near-equal distances left one half of a set-back section the exact arc and the other the polynomial approximation, off the round (WR-546, #1377; the fork's tangency-labs/smlib@1072d1b). pub fillet_patch_keeps_fillet_edges: bool, /// A knife edge (a cusp: faces tangent along the edge that fold back on each other, @@ -458,6 +456,15 @@ pub struct UpstreamFixes { pub boolean_new_face_closure_both_sides: bool, /// `SmStitch::StitchIntoSolid`, when its stitch leaves no lamina edge and more than one region, makes the B-rep a nested solid model (`FindAndSetInfiniteRegion(NULL, SM_VS_NESTEDSOLID)`, with editing disabled): material inside the outer shell, a void inside a shell within it, and so on, each region's outer shell first. Upstream's stitch only finds the infinite region (`SM_VS_INFINITEREGION_SWAP`), so the region inside a closed shell nested in another stays solid and the shells stay where the gluing put them: a holed profile swept round a closed path measures as if it had no hole, and twisted a half turn fails the check (Test #12, an inner shell not inside its region's outer shell) (WR-569; the fork's tangency-labs/smlib@351c57c). pub stitch_solid_nests_cavities: bool, + /// `SmEllipse::AdjustSTEPInterval` converts a new interval end outside the current arc + /// itself: past a full-turn arc it is taken whole turns back into the arc and its NURBS + /// parameter moved by the circle's NURBS length per turn (the other way for an + /// inside-out curve); past a partial arc the arc's NURBS interval continues in + /// proportion to the angle. Upstream's polar converter clamps such an angle to the arc's + /// ends, so a full circle moved to `[360, 540]` (a torus face whose tube range starts a + /// turn on) collapses to one NURBS parameter and the surface built on it gets NaN knots + /// and fails with `SM_ERR` (WR-211, #428; the fork's tangency-labs/smlib@5c0b5ed). + pub ellipse_adjust_step_interval_keeps_turns: bool, } /// Options for [`Kernel::new`]. diff --git a/plan/upstream-issues/WR-211--addendum-2026-10-30-fork-fix-ported-as-flag.md b/plan/upstream-issues/WR-211--addendum-2026-10-30-fork-fix-ported-as-flag.md new file mode 100644 index 0000000000..c5292ea4f8 --- /dev/null +++ b/plan/upstream-issues/WR-211--addendum-2026-10-30-fork-fix-ported-as-flag.md @@ -0,0 +1,5 @@ +Updates: WR-211 + +#### WR-211 (update, 2026-10-30). The fork's fix is ported as an opt-in flag + +The fork's fix, as cherry-picked on its replay/1.0.1 branch (tangency-labs/smlib@5c0b5ed, from 74bed94), is ported behind `Kernel::fixes.ellipse_adjust_step_interval_keeps_turns` (off by default, so the default path still collapses as upstream). With the flag, `Ellipse::adjust_step_interval` converts an interval end outside the current arc as the fork's `ConvertAngle` does: whole turns back into a full-turn arc, its NURBS parameter moved by the NURBS length per turn (negated when inside out); in proportion past a partial arc. Test `step_interval_past_a_turn_keeps_its_length_with_the_fix` (a full and a half circle moved to `[360, 540]`: the curve keeps its NURBS length, starts at angle 0 and ends at 180, and the reparameterised knots stay finite) beside the unchanged `step_interval_past_a_turn_collapses_as_upstream`. Found through the SolidCraft C API's flat round trip (`capi/tests/flat_roundtrip.cpp`: a torus face whose minor range starts a turn on imports with the fix, fails with `SM_ERR` without).