fix(blend): a round at a plate's arc end builds where offsets continue as themselves (#3045) (#3105)

Fixes #3045

solidcore: 78d1eb12d222652219313c12787b65389c4d9db8
This commit is contained in:
Andrzej Błasik
2026-11-08 16:19:09 +01:00
committed by jyviko
parent d186df0930
commit b11c5554b3
3 changed files with 239 additions and 9 deletions
@@ -0,0 +1,212 @@
//! #3045: a round on the edge where a plate's straight side meets its arc end. The round is the
//! cylinder of radius R tangent to the side and inside the arc's cylinder; it removes 8 times
//! (the thickness) the area between the corner and that circle.
//!
//! The failing configuration needs three of the fixes together: the arc face a cylinder
//! (`linear_sweep_arc_is_cylinder`) and its offsets continued as themselves
//! (`offset_extension_from_base`, `offset_extension_as_itself`). The continued cylinder is a
//! whole circle, so the drop of the rail's end onto it found the circle's far side, where the
//! solve has a second root outside the face. Measured on this plate (arc 16.25, bisected to
//! 0.01): without the fix the round failed with status 1001 for R from 6.26 to 11.8 and built
//! below and above; the radii below bracket both ends.
#![allow(clippy::disallowed_methods)] // closed-form oracle, not ops geometry
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // test
mod common;
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4};
use common::{edge_at, kernel_copy};
use solidcore::surface::surface::SurfaceKind;
use solidcore::Point2;
use solidcore_ops::blend::{blend, Blend, Corners};
use solidcore_ops::sweep::{extrude, Curve, DraftCorner};
use solidcore_ops::{make_box, session, Body, CheckLevel};
/// The plate, scaled by `s`: 100 x 30 with the +x end an arc of radius 16.25 about (93.75, 15),
/// 8 thick.
struct Plate {
s: f64,
}
impl Plate {
fn cx(&self) -> f64 {
93.75 * self.s
}
fn cy(&self) -> f64 {
15.0 * self.s
}
fn rc(&self) -> f64 {
16.25 * self.s
}
fn thickness(&self) -> f64 {
8.0 * self.s
}
fn body(&self) -> Body {
let s = self.s;
let half = (self.cy() / self.rc()).asin();
let line = |start: [f64; 3], end: [f64; 3]| Curve::Line { start, end };
let profile = vec![
line([0.0, 0.0, 0.0], [100.0 * s, 0.0, 0.0]),
Curve::Arc {
center: [self.cx(), self.cy(), 0.0],
axis: [0.0, 0.0, 1.0],
reference: [1.0, 0.0, 0.0],
radius: self.rc(),
start: -half,
end: half,
},
line([100.0 * s, 30.0 * s, 0.0], [0.0, 30.0 * s, 0.0]),
line([0.0, 30.0 * s, 0.0], [0.0, 0.0, 0.0]),
];
extrude(
&profile,
[0.0, 0.0, 1.0],
self.thickness(),
0.0,
DraftCorner::default(),
true,
)
.unwrap()
}
/// The plate's volume: the 100 x 30 rectangle and the arc's segment past x = 100.
fn volume(&self) -> f64 {
let turn = 2.0 * (self.cy() / self.rc()).asin();
let (rc, s) = (self.rc(), self.s);
self.thickness() * (3000.0 * s * s + rc * rc / 2.0 * (turn - turn.sin()))
}
/// The round's centre abscissa, `cx + dx`: the circle of radius `r` tangent to the side
/// (y = 0) and inside the arc's circle, `|centre - (cx, cy)| = rc - r`.
fn round_x(&self, r: f64) -> f64 {
self.cx() + ((self.rc() - r).powi(2) - (self.cy() - r).powi(2)).sqrt()
}
/// The area the round of radius `r` removes: between the corner (100, 0), the side and the
/// arc, and the round's circle. Green's theorem round the loop corner, foot on the side,
/// contact on the arc: each circular arc contributes `1/2 (r^2 (b - a) + cx r (sin b -
/// sin a) + cy r (cos a - cos b))`, the side nothing.
fn removed_area(&self, r: f64) -> f64 {
let fx = self.round_x(r);
let contact = (r - self.cy()).atan2(fx - self.cx());
let corner = (-self.cy()).atan2(100.0 * self.s - self.cx());
let arc = |(cx, cy): (f64, f64), rad: f64, a: f64, b: f64| {
0.5 * (rad * rad * (b - a)
+ cx * rad * (b.sin() - a.sin())
+ cy * rad * (a.cos() - b.cos()))
};
(arc((fx, r), r, -FRAC_PI_2, contact)
+ arc((self.cx(), self.cy()), self.rc(), contact, corner))
.abs()
}
}
/// Resets the process-wide fixes when the test ends, a failed assertion included.
struct ResetFixes;
impl Drop for ResetFixes {
fn drop(&mut self) {
session::set_fixes([]).unwrap();
}
}
/// `b` is valid, has volume `expected` and a round face that is the cylinder of radius `r`
/// about the axis through (`axis_x`, `r`): an exact surface, not a B-spline of that shape.
fn check_round(b: &Body, r: f64, axis_x: f64, expected: f64, what: &str) {
let report = b.check(CheckLevel::All).unwrap();
assert!(report.is_valid(), "{what}: {report}");
let volume = b.volume(1e-9).unwrap();
assert!(
(volume - expected).abs() < 1e-6,
"{what}: volume {volume}, closed form {expected}"
);
let (k, brep) = kernel_copy(b);
let rounds: Vec<_> = brep
.face_list()
.iter()
.filter_map(|&f| {
let surface = brep.face(f).unwrap().surface.as_ref()?;
match surface.kind() {
SurfaceKind::Cone(c) if c.is_cylinder() && (c.bot_radius() - r).abs() < 1e-9 => {
let uv = surface.natural_uv_domain_with_nurb(&k).unwrap();
let mid = Point2::new(
0.5 * (uv.min().x + uv.max().x),
0.5 * (uv.min().y + uv.max().y),
);
surface.evaluate_point(&k, mid).ok()
}
_ => None,
}
})
.collect();
assert_eq!(rounds.len(), 1, "{what}: one cylinder of radius {r}");
let p = rounds.first().copied().unwrap();
assert!(
((p.x - axis_x).hypot(p.y - r) - r).abs() < 1e-9,
"{what}: the round's cylinder is about ({axis_x}, {r})"
);
}
fn check_plate_round(s: f64, r: f64, flags: &str) {
let plate = Plate { s };
let b = plate.body();
let e = edge_at(&b, [100.0 * s, 0.0, plate.thickness() / 2.0]);
let what = format!("{flags}, scale {s}, R {r}");
blend(&b, Blend::Round, &[e], &[r], &[], Corners::Filled)
.unwrap_or_else(|err| panic!("{what}: {err:?}"));
let expected = plate.volume() - plate.thickness() * plate.removed_area(r);
check_round(&b, r, plate.round_x(r), expected, &what);
}
/// The control the issue names: the same plate with a straight end, a round of R 10 on the
/// corner edge. It removes `(1 - pi/4) r^2` of area, 8 thick.
fn check_straight_end(flags: &str) {
let b = make_box([0.0; 3], [100.0, 30.0, 8.0]).unwrap();
let e = edge_at(&b, [100.0, 0.0, 4.0]);
let r = 10.0;
blend(&b, Blend::Round, &[e], &[r], &[], Corners::Filled)
.unwrap_or_else(|err| panic!("{flags}, straight end: {err:?}"));
let expected = 8.0 * (3000.0 - (1.0 - FRAC_PI_4) * r * r);
check_round(
&b,
r,
100.0 - r,
expected,
&format!("{flags}, straight end"),
);
}
const SCALE_1: [f64; 10] = [3.0, 6.0, 6.25, 6.3, 6.5, 7.0, 10.0, 11.8, 11.9, 12.0];
const SCALE_2: [f64; 4] = [6.0, 12.5, 13.0, 20.0];
/// Without the guard the three fixes fail with status 1001 for R from 6.26 to 11.8 on the scale-1
/// plate (and the same fractions of the arc's radius on the scale-2 plate); with none of them
/// it always built. The radii bracket both ends of the band (6.25 and 6.3, 11.8 and 11.9) and
/// sit inside it (6.5, 7, 10). One test: the fixes are one process-wide setting.
#[test]
fn the_round_builds_for_every_radius() {
let _reset = ResetFixes;
for (flags, names) in [
("default flags", &[][..]),
(
"the three fixes",
&[
"linear_sweep_arc_is_cylinder",
"offset_extension_from_base",
"offset_extension_as_itself",
][..],
),
] {
session::set_fixes(names.iter().copied()).unwrap();
check_straight_end(flags);
for r in SCALE_1 {
check_plate_round(1.0, r, flags);
}
for r in SCALE_2 {
check_plate_round(2.0, r, flags);
}
}
}
@@ -105,6 +105,10 @@
//! the other base is the edge use's own UV point. Upstream reads an existing curve only and
//! otherwise solves globally on the base, which finds a point on a closed surface's seam
//! once at each end of the period and refuses the two answers.
//! - **The drop onto the other offset** (solidcore's): when that offset continues as itself
//! (`offset_continues_as_itself`) `LoadInitialValues` drops the rail end onto the face's own
//! domain, not the extended one, which holds a second root of the rail-end solve on the far
//! side of the whole circle (the solve's interval is the extended one, as upstream's).
use crate::blend::fillet_custom_solver::{
face_owner, offset_compute_inward_vector, offset_evaluate_1st, offset_evaluate_point,
@@ -147,7 +151,7 @@ use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::nurbs::b_spline_surface::BSplineSurface;
use crate::nurbs::b_spline_surface_n_lib::p2::{EvaluateFlags, OrientType};
use crate::nurbs::knots::Side;
use crate::surface::offset_surface::SS_UNKNOWN;
use crate::surface::offset_surface::{offset_continues_as_itself, SS_UNKNOWN};
use crate::surface::surface::p3::{out_of_bounds_enabled, set_out_of_bounds_enabled};
use crate::surface::surface::Surface;
use crate::topology::edgeuse::p1 as edgeuse;
@@ -706,7 +710,13 @@ fn cr_initial_body<S: ConstantRadiusOps + ?Sized>(
// to get a better guess
let uv_guess = Point2::new(srf2_guess_uv.x, srf2_guess_uv.y);
let mut domain = base2.natural_uv_domain_with_nurb(k)?;
solver.setup_offset_extension(k, base2, &mut domain)?; // expands sDomain
// Not upstream (#3045): an offset continued as itself is the whole circle, not upstream's
// tangent extension that stays near the face. The drop on the extended domain finds the
// nearest point of that circle, whose far side holds a second root of the rail-end solve
// outside the face; the rail end lies on the face's edge, so the drop stays on the face.
if !offset_continues_as_itself(k, base2) {
solver.setup_offset_extension(k, base2, &mut domain)?; // expands sDomain
}
if let Some((sol_uv, dist)) = offset_local_point_solve(k, surface2, &domain, pnt, uv_guess)?
{
@@ -2947,12 +2957,10 @@ fn rolled_on<'a>(
let base = offset.base_surface();
match offset.extended_base_surface() {
Some(extended)
if k.fixes.offset_extension_as_itself
&& matches!(
base.kind(),
crate::surface::surface::SurfaceKind::SurfOfRevolution(_)
)
&& crate::surface::offset_surface::extends_as_itself(k, base) =>
if matches!(
base.kind(),
crate::surface::surface::SurfaceKind::SurfOfRevolution(_)
) && offset_continues_as_itself(k, base) =>
{
extended
}
+11 -1
View File
@@ -2371,6 +2371,16 @@ pub(crate) fn extends_as_itself(k: &Kernel, surface: &Surface) -> bool {
}
}
/// Whether the offset of `surface` continues as itself: `fixes.offset_extension_from_base` and
/// `fixes.offset_extension_as_itself` on, and [`extends_as_itself`]. The one place that decides
/// it: the extension ([`extend_from_base`]), the face a round rolls on and the drop that seeds a
/// rail end all ask this.
pub(crate) fn offset_continues_as_itself(k: &Kernel, surface: &Surface) -> bool {
k.fixes.offset_extension_from_base
&& k.fixes.offset_extension_as_itself
&& extends_as_itself(k, surface)
}
/// The fork's `sm_GeneratorIsCircle` (tangency-labs/smlib@43fd6b6): whether a surface of
/// revolution's generator is an arc of one circle, and if so that circle's centre (in the
/// first column's meridian plane) and the arc's angle. A circle's NURB spans are rational
@@ -2952,7 +2962,7 @@ fn extend_from_base(
// So does (the fork's tangency-labs/smlib@43fd6b6) a surface of revolution whose
// generator is a circle (a round's cross section revolved, as SMLib's concave round of an
// arc is): its generator carried on as its own circle.
let as_itself = k.fixes.offset_extension_as_itself && extends_as_itself(k, base);
let as_itself = offset_continues_as_itself(k, base);
// WR-650 (the fork's tangency-labs/smlib@b298cc2): round the axis such a surface is a
// circle, and its extension reaches no further than closing the turn (half the gap past
// either end, `sm_RotTurn`); from there on that direction is periodic, its parameters