blend: the Mixed corner takes the seam's line run on past the vertex as an edge (#2953)

On the drafted peg the wall is one cone split along its generator through the seam, so the vertex has four edges and fell to the Join, which refused it. The ring now takes that fourth edge when it is straight, on the seam's line, tangent, with one face between it and the seam. V-Q lies on it as P-V lies on the seam.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-11-06 14:16:30 -05:00
parent b7002862b6
commit fc22126948
7 changed files with 339 additions and 62 deletions
@@ -0,0 +1,206 @@
//! #2953: the drafted peg's front top edge (convex) and concave arc rounded 2 together, the
//! tangent peg of #2360 extruded with a 5 degree inward taper. The bar's drafted front face runs
//! tangent into the cone along its generator through x = 40, and the top's cone is the same cone
//! split along that generator, so a fourth edge runs on from the seam past the vertex
//! V = (40, y0, 10), y0 = 10 tan 5 degrees. The corner is the Mixed one: the rails on the top
//! cross at X, and every seam within its box is G1.
//!
//! The ball rolls on the top z = 10 and on a wall that leans 5 degrees: the convex round's rail
//! on the top stands r tan(42.5 degrees) in from the front edge, the concave round's the same
//! out from the arc R0 = 8 - y0 about (40, 8). So X lies on the line y = y0 + d and the circle
//! of radius R0 + d, d = r tan(42.5 degrees).
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test
mod common;
use common::{edge_at, kernel_copy};
use serde_json::Value;
use solidcore::geom::global_solver::{SolutionRequested, SolverOperation};
use solidcore::math::{Point2, Vec3};
use solidcore::nurbs::knots::Side;
use solidcore::surface::surface::p4::NoObject;
use solidcore::surface::surface::SurfaceKind;
use solidcore::topology::edge;
use solidcore::topology::edgeuse::p1 as eu1;
use solidcore::topology::edgeuse::p3::{evaluate_binormal, BinormalOptions};
use solidcore::topology::{face::p2 as face, vertex, Brep};
use solidcore::Kernel;
use solidcore_ops::blend::{blend, Blend, Corners};
use solidcore_ops::{session, Body, CheckLevel};
use solidcore_record::brep::Brep as Record;
use std::io::Read as _;
const CASE: &str = "report-drafted-peg-front-and-concave-rounds-together-r2";
const CORPUS: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../solidcore/tests/kernel_cases/fillets"
);
/// The peg with the case's two edges rounded 2.
fn rounded() -> Body {
let fixes: Vec<String> = serde_json::from_str(include_str!(
"../../solidcore/tests/kernel_cases/fixes.json"
))
.unwrap();
session::set_fixes(fixes.iter().map(String::as_str)).unwrap();
let gz = std::fs::File::open(format!("{CORPUS}/{CASE}.bodies.json.gz")).unwrap();
let mut text = String::new();
flate2::read::GzDecoder::new(gz)
.read_to_string(&mut text)
.unwrap();
let mut records: Value = serde_json::from_str(&text).unwrap();
let record: Record = serde_json::from_value(records["b12"].take()).unwrap();
let (body, _) = Body::from_record(&record, true).unwrap();
let y0 = 10.0 * 5.0_f64.to_radians().tan();
let picks = [
edge_at(&body, [20.0, y0, 10.0]),
edge_at(&body, [32.0 + y0, 8.0, 10.0]),
];
blend(&body, Blend::Round, &picks, &[2.0], &[], Corners::Filled).unwrap();
body
}
/// X, the rails' crossing on the top.
fn crossing(r: f64) -> [f64; 3] {
let y0 = 10.0 * 5.0_f64.to_radians().tan();
let d = r * 42.5_f64.to_radians().tan();
let (radius, rail) = (8.0 - y0 + d, y0 + d);
[
40.0 - (radius * radius - (8.0 - rail) * (8.0 - rail)).sqrt(),
rail,
10.0,
]
}
fn angle(a: Vec3, b: Vec3) -> f64 {
a.cross(b).length().atan2(a.dot(b))
}
/// The largest angle between the faces' surface normals, turned as their face uses, at 31
/// stations inside each edge within `inside`, and how many edges have a station there.
fn worst_seam(k: &Kernel, b: &mut Brep, inside: impl Fn(Vec3) -> bool) -> (f64, usize) {
let (mut worst, mut seams) = (0.0_f64, 0);
for e in b.edge_list().to_vec() {
let Some(curve) = edge::curve(b, e).unwrap().cloned() else {
continue;
};
if edge::start_vertex(b, e).unwrap() == edge::end_vertex(b, e).unwrap() {
continue;
}
let ivl = edge::interval(b, e).unwrap();
let prim = eu1::primary_edgeuse(b, e).unwrap().unwrap();
let rad = eu1::radial(b, prim).unwrap().unwrap();
assert_ne!(prim, rad, "edge with one face");
let mut counted = false;
for st in 1..32 {
let t = ivl.evaluate(f64::from(st) / 32.0);
let m = curve.evaluate_point(k, t).unwrap();
if !inside(m) {
continue;
}
if !counted {
counted = true;
seams += 1;
}
let mut ns = Vec::new();
for eu in [prim, rad] {
let bi =
evaluate_binormal(k, b, eu, t, false, &BinormalOptions::default()).unwrap();
let f = eu1::face(b, eu).unwrap().unwrap();
let srf = b.face(f).unwrap().surface.clone().unwrap();
let dom = srf.natural_uv_domain_with_nurb(k).unwrap();
let sols = srf
.global_point_solve_point_test_off::<NoObject>(
k,
None,
&dom,
SolverOperation::Minimize,
m,
1e-9,
None,
SolutionRequested::Single,
)
.unwrap();
let sol = sols.get(0).unwrap();
let uv = Point2::new(sol.start.param(0).unwrap(), sol.start.param(1).unwrap());
let mut n = srf.evaluate_normal(k, uv, Side::Left, Side::Left).unwrap();
if n.dot(bi.face_normal) < 0.0 {
n = -n;
}
ns.push(n);
}
worst = worst.max(angle(ns[0], ns[1]));
}
}
(worst, seams)
}
#[test]
fn the_corner_is_the_mixed_one_and_g1() {
let body = rounded();
let report = body.check(CheckLevel::All).unwrap();
assert!(report.is_valid(), "{}", report.text);
let (k, mut b) = kernel_copy(&body);
// the peg's 8, the two rounds, the corner's two
assert_eq!(b.face_list().len(), 12);
let x = crossing(2.0);
let (nearest, at) = b
.vertex_list()
.iter()
.map(|&v| {
let p = vertex::point(&b, v).unwrap();
let d = (p.x - x[0]).hypot(p.y - x[1]).hypot(p.z - x[2]);
(d, v)
})
.min_by(|a, c| a.0.total_cmp(&c.0))
.unwrap();
assert!(nearest < 1e-7, "X {x:?} is {nearest} from the nearest vertex");
assert!(vertex::edges(&b, at, None).unwrap().len() >= 3);
// the corner's box: past X, and short of the rounds' ends and the sharp edges
let (worst, seams) = worst_seam(&k, &mut b, |m| {
m.x > x[0] - 2.0 && m.x <= 40.0 + 1e-6 && m.y < 8.0 && m.z > 5.0 && m.z < 15.0
});
assert!(seams >= 7, "{seams} seams");
assert!(worst < 1e-8, "{worst} rad");
// no patch stands out of the front face: below the top, every point of a free-form face
// is behind the plane through (x, 0, 0) and (x, y0, 10)
let tan = 5.0_f64.to_radians().tan();
let mut stands_out = f64::NEG_INFINITY;
for f in b.face_list().to_vec() {
let s = face::surface(&b, f).unwrap().unwrap();
if !matches!(s.kind(), SurfaceKind::BSpline(_)) {
continue;
}
let d = face::uv_domain(&b, f).unwrap();
for i in 0..=20 {
for j in 0..=20 {
let uv = Point2::new(
d.min().x + (d.max().x - d.min().x) * f64::from(i) / 20.0,
d.min().y + (d.max().y - d.min().y) * f64::from(j) / 20.0,
);
let p = s.evaluate_point(&k, uv).unwrap();
if p.z < 10.0 && p.x <= 40.0 {
stands_out = stands_out.max(tan * p.z - p.y);
}
}
}
}
assert!(stands_out < 4e-5, "{stands_out}");
}
/// The result as `.smb` under `ISSUE_2953_OUT`, for the case's render.
#[test]
#[ignore = "writes the body for the render"]
fn body_for_the_picture() {
let out = std::env::var("ISSUE_2953_OUT").unwrap();
std::fs::write(format!("{out}/{CASE}.smb"), rounded().to_smb(true).unwrap()).unwrap();
}
@@ -2,7 +2,10 @@
//! continue each other G1 at the vertex V, on a common face T (a plane), the third edge
//! at V, not blended, a straight tangent seam between their other faces F1 and F2 (the
//! tangent peg's front top edge and its concave arc, where the front face runs tangent
//! into the cylinder).
//! into the cylinder). A fourth edge at V, not the fork's (#2953): the seam's line runs on
//! past V as an edge between F2 and the face beyond the seam, tangent to F2 there (the
//! drafted peg's cone, split along its generator through the seam). V-Q then lies on that
//! edge, as P-V lies on the seam.
//!
//! Upstream: none (pinned SMLib builds this corner with `SmFillet3x2MixedConvexityCorner`).
//! The fork's `source/SMLib/src/SmBlendCornerMixed.cpp`, `source/SMLib/inc/SmBlendCorner.h`
@@ -120,6 +123,9 @@ pub struct MixedRing {
pub concave: EdgeuseId,
/// `pSeam`.
pub seam: EdgeuseId,
/// Not the fork's (#2953): the fourth edge at V, the seam's line run on past V as an
/// edge between F2 and the seam's other face; `None` at a vertex of three edges.
pub beyond: Option<EdgeuseId>,
/// `pT`.
pub t: FaceId,
/// `pF1`.
@@ -180,10 +186,12 @@ impl MixedCorner {
}
/// `sm_MixedRing`: the ring's parts, `None` where the vertex is not a Mixed one's shape
/// (not two solver edge uses of opposite convexity among three, no common face, no
/// other face of each blend at the seam, or a blend without a solver). `solver_of` is
/// (not two solver edge uses of opposite convexity among three or four, no common face,
/// no other face of each blend at the seam, or a blend without a solver). `solver_of` is
/// `GetFilletSolverOfEdgeuse` (`None`: `NULL`); the blends' solvers are returned with
/// the ring (B1, B2), each of its edge use or else of its mate.
/// the ring (B1, B2), each of its edge use or else of its mate. Of four edges (not the
/// fork's, #2953) the two not blended are the seam, on B1's other face, and the edge
/// beyond it, on B2's, with one face between them.
pub(crate) fn mixed_ring<S: Copy>(
k: &Kernel,
b: &mut Brep,
@@ -192,7 +200,7 @@ pub(crate) fn mixed_ring<S: Copy>(
solver_of: &dyn Fn(EdgeuseId) -> Option<S>,
) -> Result<Option<(MixedRing, [S; 2])>> {
let nul = || SolidError::null_pointer(OP_CLASSIFY);
let (&[eu0, eu1_], 3) = (solver_eus, all_eus.len()) else {
let &[eu0, eu1_] = solver_eus else {
return Ok(None);
};
let e0 = eu1::edge(b, eu0)?.ok_or_else(nul)?;
@@ -203,32 +211,69 @@ pub(crate) fn mixed_ring<S: Copy>(
}
let convex_eu = if concave[0] { eu1_ } else { eu0 };
let concave_eu = if concave[0] { eu0 } else { eu1_ };
let mut seam = None;
for &eu in all_eus {
if eu != eu0 && eu != eu1_ {
seam = Some(eu);
let rest: Vec<EdgeuseId> = all_eus
.iter()
.copied()
.filter(|&eu| eu != eu0 && eu != eu1_)
.collect();
let orders = match rest.as_slice() {
[seam] if all_eus.len() == 3 => vec![(*seam, None)],
[x, y] if all_eus.len() == 4 => vec![(*x, Some(*y)), (*y, Some(*x))],
_ => return Ok(None),
};
for (seam, beyond) in orders {
let found = ring_of(b, [convex_eu, concave_eu], seam, beyond, solver_of)?;
if found.is_some() {
return Ok(found);
}
}
let seam = seam.ok_or_else(nul)?;
Ok(None)
}
/// The face of `eu`'s sector other than `not`, where the sector holds `not`.
fn other_face(b: &Brep, eu: EdgeuseId, not: FaceId) -> Result<Option<FaceId>> {
let radial = eu1::radial(b, eu)?.ok_or_else(|| SolidError::null_pointer(OP_CLASSIFY))?;
let faces = [eu1::face(b, eu)?, eu1::face(b, radial)?];
Ok(match faces {
[Some(f), Some(g)] if f == not => Some(g),
[Some(f), Some(g)] if g == not => Some(f),
_ => None,
})
}
/// [`mixed_ring`]'s parts for one choice of the seam and the edge beyond it.
fn ring_of<S: Copy>(
b: &Brep,
[convex_eu, concave_eu]: [EdgeuseId; 2],
seam: EdgeuseId,
beyond: Option<EdgeuseId>,
solver_of: &dyn Fn(EdgeuseId) -> Option<S>,
) -> Result<Option<(MixedRing, [S; 2])>> {
let Some(t) = face_by_two_edgeuses(b, Some(convex_eu), Some(concave_eu), Some(seam))? else {
return Ok(None);
};
let radial = eu1::radial(b, seam)?;
let seam_faces = [
eu1::face(b, seam)?,
match radial {
Some(r) => eu1::face(b, r)?,
None => None,
},
];
let mut others = [None, None];
let mut solvers = [None, None];
for (ii, blend) in [convex_eu, concave_eu].into_iter().enumerate() {
// B2's other face is at the edge beyond the seam, where there is one
let side = if ii == 1 {
beyond.unwrap_or(seam)
} else {
seam
};
let radial = eu1::radial(b, side)?;
let side_faces = [
eu1::face(b, side)?,
match radial {
Some(r) => eu1::face(b, r)?,
None => None,
},
];
let mut other = None;
for &sf in &seam_faces {
for &sf in &side_faces {
if sf.is_some()
&& sf != Some(t)
&& face_by_two_edgeuses(b, Some(blend), Some(seam), None)? == sf
&& face_by_two_edgeuses(b, Some(blend), Some(side), None)? == sf
{
other = sf;
}
@@ -254,11 +299,19 @@ pub(crate) fn mixed_ring<S: Copy>(
if f1 == f2 {
return Ok(None);
}
if let Some(beyond) = beyond {
// one face between the seam and the edge beyond it, and it is not T
let between = other_face(b, seam, f1)?;
if between.is_none() || between == Some(t) || between != other_face(b, beyond, f2)? {
return Ok(None);
}
}
Ok(Some((
MixedRing {
convex: convex_eu,
concave: concave_eu,
seam,
beyond,
t,
f1,
f2,
@@ -270,7 +323,8 @@ pub(crate) fn mixed_ring<S: Copy>(
/// `SmBlendCorner::ClassifyMixed`: whether the filleted `vertex` is a Mixed corner: three
/// edges, two blended of opposite convexity continuing each other G1 there (within
/// `tangency_tol_deg`), the third a straight tangent seam between their other faces, the
/// blends' common face a plane (each to the smaller of the blends' tolerances).
/// blends' common face a plane (each to the smaller of the blends' tolerances). A fourth
/// edge (not the fork's, #2953) is the seam's line run on past `vertex`, tangent there too.
///
/// # Errors
///
@@ -316,7 +370,23 @@ pub fn classify_mixed<'s>(
Some(c) => c.is_line(k, 10, tol)?.is_some(),
None => false,
};
Ok(line)
if !line {
return Ok(false);
}
let Some(beyond) = ring.beyond else {
return Ok(true);
};
if !check_tangential_edgeuse_at_vertex(k, b, beyond, vertex, tangency_tol_deg)? {
return Ok(false);
}
let beyond_e = eu1::edge(b, beyond)?.ok_or_else(nul)?;
let straight = match edge::curve(b, beyond_e)? {
Some(c) => c.is_line(k, 10, tol)?.is_some(),
None => false,
};
let down = leaving(k, b, seam_e, vertex, OP_CLASSIFY)?;
let up = leaving(k, b, beyond_e, vertex, OP_CLASSIFY)?;
Ok(straight && down.dot(up) < 0.0 && down.is_parallel_to(up, tangency_tol_deg))
}
/// `smos_Min`.
+24 -18
View File
@@ -992,17 +992,19 @@ impl FilletCorner {
}
}
3 | 4 => {
if total_eus == 3
let opposite = total_eus == 3
&& eu2::is_convex_radial_sector(k, b, eu0, 4, false, 0.0)?
!= eu2::is_convex_radial_sector(k, b, eu1_, 4, false, 0.0)?
{
// The fork's (WR-697, 6fd0411; smlib#82): a convex and a
// concave blend continuing each other across a tangent
// seam are the blend corner's Mixed
// (SmBlendCorner::ClassifyMixed); a failed classification
// builds no corner.
!= eu2::is_convex_radial_sector(k, b, eu1_, 4, false, 0.0)?;
// The fork's (WR-697, 6fd0411; smlib#82): a convex and a
// concave blend continuing each other across a tangent
// seam are the blend corner's Mixed
// (SmBlendCorner::ClassifyMixed); of three edges a failed
// classification builds no corner. Four edges (not the
// fork's, #2953): the seam's line run on past the vertex as
// an edge; any other vertex of four goes on below.
let mixed = if opposite || total_eus == 4 {
let lookup = |eu: EdgeuseId| solver_of(eu).ok();
let mixed = match crate::blend::blend_corner::mixed::classify_mixed(
match crate::blend::blend_corner::mixed::classify_mixed(
k,
b,
vertex,
@@ -1014,16 +1016,20 @@ impl FilletCorner {
Ok(found) => Some(found),
Err(e) if e.is_unported_or_cancelled() => return Err(e),
Err(_) => None,
};
if mixed == Some(true) {
let mut c = make(FilletCornerKind::NByTwo);
c.blend = Some(crate::blend::blend_corner::BlendCorner {
event: BlendCornerEvent::Mixed,
turn: crate::blend::blend_corner::BlendTurn::Mitre,
end: BlendEnd::OnEndFace,
});
return Ok(Some(c));
}
} else {
Some(false)
};
if mixed == Some(true) {
let mut c = make(FilletCornerKind::NByTwo);
c.blend = Some(crate::blend::blend_corner::BlendCorner {
event: BlendCornerEvent::Mixed,
turn: crate::blend::blend_corner::BlendTurn::Mitre,
end: BlendEnd::OnEndFace,
});
return Ok(Some(c));
}
if opposite {
if mixed.is_none() {
return Ok(None); // MSG: Corner Case Not Implemented
}
@@ -33,7 +33,7 @@ impl FilletCorner {
/// (B2's rail end on F2); edges X-A (B1's trimline), A-P (on F1), P-V (the seam), V-X
/// (the inflection, in T), V-Q, Q-B (on F2), X-B (B2's trimline), every one
/// precomputed. The blends run on past the vertex: the corner is neither blending nor
/// a bevel.
/// a bevel. Q is on the edge beyond the seam, where the ring has one (#2953).
///
/// # Errors
///
@@ -117,9 +117,13 @@ impl FilletCorner {
fv.set_point_class(PointClassType::Vertex, Some(EntityRef::Vertex(vertex)));
fv.set_fillet_vertex_type(FilletVertexType::OnVertex);
}
let (q_class, q_on) = match ring.beyond {
Some(eu) => (PointClassType::Edgeuse, EntityRef::Edgeuse(eu)),
None => (PointClassType::Face, EntityRef::Face(ring.f2)),
};
pseudo
.fillet_vertex_mut(q)?
.set_point_class(PointClassType::Face, Some(EntityRef::Face(ring.f2)));
.set_point_class(q_class, Some(q_on));
pseudo
.fillet_vertex_mut(b)?
.set_point_class(PointClassType::Face, Some(EntityRef::Face(ring.f2)));
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -40,11 +40,13 @@
}
},
{
"error": "SMLib failed (status 1001)",
"inputs": [
"b15"
],
"op": "blend_dropping",
"outputs": [
"b16"
],
"params": {
"edges": {
"kind": "edge",
@@ -108,27 +110,21 @@
"continuity": {
"order": "g1"
},
"faces": 12,
"method": "the peg (a bar 40 x 16 whose far end is a half disc R8 about (40, 8)) extruded 10 with a 5 deg inward taper, then the top's end circle (R 8 - 10 tan 5 deg about (40, 8)) extruded 10 up with the same taper and joined: the bar's drafted front face runs tangent into the cone along its generator through x = 40, and the top's concave arc meets the front top edge where the two are tangent, as on the straight peg; the front top edge and the concave arc rounded r2 in one fillet, tangent chain on; no closed form: one valid body, every seam a round or the corner patch makes G1",
"source": "invariants",
"what": "as report-peg-front-and-concave-rounds-together-r2 on the drafted peg, whose front face runs tangent into a cone"
},
"expressible": false,
"gallery_note": "the corner refused is where the drafted front top edge runs tangent into the concave arc, on the cone",
"gallery_note": "the Mixed corner, where the seam's line runs on past the vertex as an edge of the cone, split along its generator through x = 40",
"operation_expressible": true,
"outcome": {
"engine_error": "step 13 (solid.fillet): SMLib fillet: operation: SMLib failed (status 1001)",
"kernel_error": "SMLib failed (status 1001)",
"ran": false
},
"records": "report-drafted-peg-front-and-concave-rounds-together-r2.bodies.json.gz",
"result": [],
"rig": {
"error": "step 13 (solid.fillet): SMLib fillet: operation: SMLib failed (status 1001)",
"known_failing": "[unsupported] step 13 (solid.fillet): invalid input: not supported yet: the fillet meets a corner of this kind, which SMLib's blends do not handle yet (where its ed",
"measured": null,
"refused_with": null,
"status": "fail"
},
"result": [
{
"body": "Peg",
"made": "b16"
}
],
"sources": null,
"traced_ops": [
"extrude",
@@ -109,11 +109,6 @@
"issue": "tangency-labs/solidcore#2864",
"reason": "refused: the picked edge runs on across the cylinder's seam into the rest of the same half circle, and a round does not end there; it passes as written (19091.9620) once the round runs on across the seam"
},
{
"case": "fillets/report-drafted-peg-front-and-concave-rounds-together-r2",
"issue": "tangency-labs/solidcore#2953",
"reason": "step 13 (solid.fillet): invalid input: not supported yet: the fillet meets a corner of this kind, which SMLib's blends do not handle yet (where its ed"
},
{
"case": "fillets/report-fillet-curvature-three-edges-at-bar-corner",
"issue": "solidcore#2697",