Compare commits

...

4 Commits

Author SHA1 Message Date
dudedagger42 bd03ebd334 test(boolean): the hair-off cut test passes the revolve angle again (#3150)
The previous commit dropped the angle from `tool` while removing the
trigonometry; the revolve needs it. `ANGLES` carries each angle's radians
beside the sine and cosine of its half.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 16:31:51 -05:00
dudedagger42 1c29ddedd6 test(boolean): the hair-off cut test computes no trigonometry (#3150)
The ops tests' clippy configuration disallows `f64::atan2`, `sin_cos` and
`to_radians` (the conversion computes no geometry). The tangent-point angle,
the two revolve angles and the sine and cosine of their halves are written
out as constants.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 16:31:50 -05:00
dudedagger42 ab5cf3b1db fix(proto_topology): the edge end fit aims at the vertex the edge is made on (WR-753, #3150)
`SmEdgeDefinition::Imprint`'s end fit (WR-452, WR-453) sends an end to its
proto vertex's position when that proto vertex is one vertex in each B-rep
(WR-498), assuming the vertex sits there. A proto vertex imprinted onto a
vertex the B-rep already had keeps the mean of its definitions' points, and
the vertex is not moved: when one definition is an ill-conditioned hit, the
fitted end stops off the vertex by half the hit's error.

solidcraft#13085: a tool face coaxial with the wall it cuts and 4e-10 outside
it meets a torus/cylinder tangent junction where the wall's rim meets the lug
fillets tangentially. The rim hit lands 5.8e-5 along the rim from the corner,
the proto vertex is the mean, and the rim edge ends 2.9e-5 short of the cup's
corner vertex: loops out of tolerance (the gaps the issue reports).

New opt-in flag `Kernel::fixes.imprint_fits_edge_end_to_final_vertex`: the
target is the end's final vertex whenever the two B-reps' final vertices lie
within the edge definition's tolerance of each other; WR-498's rule otherwise.
Off by default (the fork's end fit at bec8a0b targets the proto vertex).

Not fixed here: from 1e-9 outside, the rulings where the tool cylinder meets
the fillet cylinders (internally tangent within the offset) come out of the
surface/surface intersection 9.1e-5 beside the tangent line and
`merge::p2::make_faceuse` fails; a second cause, recorded on #3150.

Record: plan/upstream-issues/WR-753--edge-end-fit-targets-the-proto-vertex-not-the-vertex-it-was-imprinted-onto.md.
Test: crates/solidcore-ops/tests/boolean_cut_tool_wall_a_hair_off_coincident.rs
(the three imprint flags set; 1e-10, 4e-10, 6e-10 at 28 and 36 degrees; fails
without the new flag at all three offsets).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 16:31:50 -05:00
dudedagger42 808686d3fd test(boolean): a cut whose tool face lies a hair outside the wall it coincides with (#3150)
solidcraft#13085: a revolve cut of a half-ring cup with a lug, the tool's
cylindrical face 1e-10 outside the cup's wall (a sketch rounding) and meeting
it at a torus/cylinder tangent junction. With the face exactly on the wall the
cut is right at 28 and 36 degrees; 1e-10 outside, the 36 degree result fails
the check (loops out of tolerance, gaps 5.9e-5 and 2.9e-5 against 2e-5);
1e-9 to 1e-6 outside, the merge fails in `make_faceuse` (1001); 1e-5
outside, a loop gap of 4.6e-3 and a bad vertex. The operands are built with
the ops crate from three profiles, no records.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 16:31:50 -05:00
4 changed files with 204 additions and 8 deletions
@@ -0,0 +1,176 @@
//! solidcraft#13085 (tangency-labs/solidcore#3150): a revolve cut whose tool has a cylindrical
//! face a hair (1e-10 to 6e-10) outside the wall it is meant to coincide with, meeting it at a
//! torus/cylinder tangent junction. The intersection curve's hit on the cup's rim is
//! ill-conditioned there, so the proto vertex at the corner keeps the mean of that hit and the
//! corner, and the end fit of `SmEdgeDefinition::Imprint` (the imprint flags) stops the rim
//! edge short of the corner vertex the B-rep already had: loops out of tolerance. With
//! `imprint_fits_edge_end_to_final_vertex` the end goes to that vertex (WR-753).
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use solidcore_ops::boolean::{boolean, BooleanOp};
use solidcore_ops::sweep::{extrude, revolve, Curve, DraftCorner};
use solidcore_ops::{session, Body, CheckLevel};
/// SolidCraft's end-fit flags and the fix under test.
fn imprint_fixes() {
session::set_fixes([
"imprint_trims_edge_to_proto_vertex",
"imprint_extends_edge_to_proto_vertex",
"imprint_fits_edge_end_to_final_vertex",
])
.unwrap();
}
fn line(start: [f64; 3], end: [f64; 3]) -> Curve {
Curve::Line { start, end }
}
fn arc(
center: [f64; 3],
axis: [f64; 3],
reference: [f64; 3],
radius: f64,
start: f64,
end: f64,
) -> Curve {
Curve::Arc {
center,
axis,
reference,
radius,
start,
end,
}
}
/// The cup: a half-disc of radius 24.75 (y <= 0) with a 7 mm lug down to y = -34.75 (its end
/// round r 3.5 about (0, -31.25), its junctions with the ring filleted r 5 about
/// (+-8.5, -28.5099), tangent to the ring and to the lug's sides x = +-3.5) and a 2.5 mm eye
/// hole through the lug, extruded 20; a half-disc of radius 23.75 cut 19 deep from the top
/// (a 1 mm wall and floor).
fn cup() -> Body {
const R: f64 = 24.75;
const FY: f64 = -28.509_866_713_120_786;
let z = [0.0, 0.0, 1.0];
let zn = [0.0, 0.0, -1.0];
let x = [1.0, 0.0, 0.0];
// The ring's tangent point with a fillet: along the line from the origin through the
// fillet's centre, 24.75 out.
// atan2(-23.718292475621443, -7.071428571428571)
let t = -1.860_548_028_230_959_4_f64;
let outline = [
line([R, 0.0, 0.0], [-R, 0.0, 0.0]),
arc([0.0; 3], z, x, R, PI, t + TAU),
arc([-8.5, FY, 0.0], zn, x, 5.0, -(PI + t), 0.0),
line([-3.5, FY, 0.0], [-3.5, -31.25, 0.0]),
arc([0.0, -31.25, 0.0], z, x, 3.5, PI, TAU),
line([3.5, -31.25, 0.0], [3.5, FY, 0.0]),
arc([8.5, FY, 0.0], zn, x, 5.0, -PI, t),
arc([0.0; 3], z, x, R, -(PI + t), 0.0),
arc([0.0, -31.25, 0.0], zn, x, 2.5, -TAU, 0.0),
];
let body = extrude(&outline, z, 20.0, 0.0, DraftCorner::Curvature, true).unwrap();
let inner = [
line([23.75, 0.0, 1.0], [-23.75, 0.0, 1.0]),
arc([0.0, 0.0, 1.0], z, x, 23.75, PI, TAU),
];
let hollow = extrude(&inner, z, 19.0, 0.0, DraftCorner::Curvature, true).unwrap();
boolean(body, hollow, BooleanOp::Subtract)
.unwrap()
.expect("the cup")
}
/// The tool: the profile Y in [-34.75, wall], Z in [5, 20], its lower corner a quarter arc of
/// radius 10 about (-34.75, 15) ending tangent to the vertical line at (`wall`, 15), revolved
/// `angle` about Z, placed symmetrically about the YZ plane (the profile turned by -angle/2,
/// whose sine and cosine `half` gives; `angle` is in radians).
fn tool(wall: f64, angle: f64, half: (f64, f64)) -> Body {
let (s, c) = (-half.0, half.1);
let turn = |p: [f64; 3]| [c * p[0] - s * p[1], s * p[0] + c * p[1], p[2]];
let turn_dir = |d: [f64; 3]| [c * d[0] - s * d[1], s * d[0] + c * d[1], d[2]];
let profile = [
line(turn([0.0, -34.75, 20.0]), turn([0.0, -34.75, 5.0])),
arc(
turn([0.0, -34.75, 15.0]),
turn_dir([1.0, 0.0, 0.0]),
turn_dir([0.0, 1.0, 0.0]),
10.0,
-FRAC_PI_2,
0.0,
),
line(turn([0.0, wall, 15.0]), turn([0.0, wall, 20.0])),
line(turn([0.0, wall, 20.0]), turn([0.0, -34.75, 20.0])),
];
revolve(&profile, [0.0; 3], [0.0, 0.0, 1.0], angle, true).unwrap()
}
/// The two revolve angles, 28 and 36 degrees: their names, their radians, and the sine and
/// cosine of half of each (the ops tests compute no geometry, so the values are written out).
const ANGLES: [(&str, f64, (f64, f64)); 2] = [
(
"28 deg",
0.488_692_190_558_412_3,
(0.241_921_895_599_667_73, 0.970_295_726_275_996_5),
),
(
"36 deg",
0.628_318_530_717_958_6,
(0.309_016_994_374_947_4, 0.951_056_516_295_153_5),
),
];
/// The cut, or why it failed: the boolean's error, or no body.
fn cut(wall: f64, angle: (&str, f64, (f64, f64))) -> Result<Body, String> {
match boolean(cup(), tool(wall, angle.1, angle.2), BooleanOp::Subtract) {
Ok(Some(b)) => Ok(b),
Ok(None) => Err("no body".to_string()),
Err(e) => Err(format!("{e}")),
}
}
/// The tool's vertical face exactly on the cup's outer wall: the cut is right.
#[test]
fn a_tool_face_on_the_wall_cuts() {
imprint_fixes();
for angle in ANGLES {
let b = cut(-24.75, angle).unwrap_or_else(|e| panic!("{}: {e}", angle.0));
let report = b.check(CheckLevel::All).unwrap();
assert!(
report.is_valid(),
"{}:
{}",
angle.0,
report.text
);
}
}
/// The tool's vertical face a hair outside the wall (the reporter's sketch left it 4e-10
/// out): at 36 degrees the tool's end planes reach into the lug's fillets and the rim edge
/// between the fillets' tangent points is rebuilt from the intersection; at 28 degrees they
/// clear the fillets. Both cuts are right.
#[test]
fn a_tool_face_a_hair_outside_the_wall_cuts_too() {
imprint_fixes();
for off in [1e-10, 4e-10, 6e-10] {
for angle in ANGLES {
let b = cut(-24.75 - off, angle)
.unwrap_or_else(|e| panic!("offset {off}, {}: {e}", angle.0));
let report = b.check(CheckLevel::All).unwrap();
assert!(
report.is_valid(),
"offset {off}, {}:
{}",
angle.0,
report.text
);
}
}
}
+2
View File
@@ -1171,6 +1171,8 @@ pub struct UpstreamFixes {
pub imprint_extends_edge_to_proto_vertex: bool,
/// With `imprint_extends_edge_to_proto_vertex`, a line, circle or ellipse is continued to its proto vertex over its NURB and stays that kind: a circle or ellipse keeps its frame, radii and inside-out sense, its angular domain carried on to the new end, and a line runs between the continued ends (the bar-top/boss arc of a union, an exact `SmCircle` from `SmCone::IntersectWithPlane`, stopped 5.4e-10 short of its tangent point). Without it those kinds are not continued (the fork before this change made them plain B-splines). The new curve must evaluate as the continued NURB does, or the curve is not continued (WR-601; the fork's tangency-labs/smlib@bec8a0b).
pub imprint_extension_keeps_analytic_curve: bool,
/// With `imprint_trims_edge_to_proto_vertex` or `imprint_extends_edge_to_proto_vertex`, an end whose proto vertex resolved to one vertex in each B-rep is fitted to that vertex's position when the two B-reps' vertices lie within the edge definition's tolerance of each other, not to the proto vertex's position. A proto vertex imprinted onto a vertex the B-rep already has keeps the mean of its definitions' points, which lies off that vertex when one definition is an ill-conditioned hit (a tool face coaxial with the wall it cuts, a hair outside it, meeting a tangent torus junction: the end of the rim edge stops 1.45e-5 to 4.6e-5 short of its vertex, loops out of tolerance or `make_faceuse` fails; WR-753, tangency-labs/solidcore#3150). Off by default: the fork's end fit (bec8a0b) targets the proto vertex.
pub imprint_fits_edge_end_to_final_vertex: bool,
/// `SmEdgeDefinition::Imprint` takes an edge Brep2's face already has between the definition's two vertices along its curve (`SmTools::FindCoincidentEdge` within the larger of the face's and the definition's tolerance) as the definition's edge there, where upstream makes a second edge beside it: the face then has coincident edges and the merge copies the boundary edge into Brep1 beside the curve's (`SmFace` tests 33 and 34; two equal tubes meeting end to end) (WR-580, #1519; the fork's tangency-labs/smlib@81cd61a).
pub imprint_reuses_coincident_face_edge: bool,
/// `SmFilletSolver::ComputePointValues` chooses its finite-difference step for each point (1e-3 at a tangent point, 1e-5 otherwise), not once per process as the function-local static `sdStaticStep`, so a blend does not depend on the blends before it ([`Kernel::fillet_point_step`] is then neither read nor set, and an existing latch is kept as it is) (ND-3, #1173; the fork's tangency-labs/smlib@9e17f64 (was 0a05860)).
@@ -100,15 +100,22 @@ impl<O: ClassObject> ProtoTopologyManager<O> {
v.final_vertex(1) == Some(vtx1) && v.final_vertex(2) == Some(vtx2)
});
}
let pv_pos = if one_vertex {
proto.position()
} else {
let p1 = brep.vertex_point(vtx1)?;
let p2 = brep.vertex_point(vtx2)?;
if !(p1.distance(p2) <= self.ed_ref(ed)?.raw_tolerance()) {
continue;
}
// WR-498: the proto vertex's position when it is one vertex in each B-rep, else
// the end's own final vertex when the two B-reps agree on it. WR-753 (opt-in): the
// final vertex first whenever the B-reps agree on it, since a proto vertex imprinted
// onto a vertex the B-rep already had keeps the mean of its definitions' points, not
// that vertex's position.
let p1 = brep.vertex_point(vtx1)?;
let p2 = brep.vertex_point(vtx2)?;
let finals_agree = p1.distance(p2) <= self.ed_ref(ed)?.raw_tolerance();
let pv_pos = if k.fixes.imprint_fits_edge_end_to_final_vertex && finals_agree {
p1
} else if one_vertex {
proto.position()
} else if finals_agree {
p1
} else {
continue;
};
let d = self.ed_ref(ed)?;
let interval = d.xsect_interval();
@@ -0,0 +1,11 @@
#### WR-753. `SmEdgeDefinition::Imprint`'s end fit (WR-452, WR-453, WR-498) aims at the proto vertex's position when that proto vertex was imprinted onto a vertex the B-rep already had, which sits elsewhere
- **Severity:** Wrong result (a rim edge ends 1.45e-5 to 3e-5 short of its vertex: loops out of tolerance, the ops crate's `NotSolid`; SolidCraft's Subtract fails its check)
- **Area:** Booleans / proto topology
- **Location:** `crates/solidcore/src/topology/proto_topology/p2/imprint_ends.rs`, `fit_edge_def_ends_to_proto_vertices`; the fork's end-fit block at the top of `SmEdgeDefinition::Imprint` (tangency-labs/smlib b43876f, a8caccd, ffaf4e8, bec8a0b). Not in upstream SMLib, which has no end fit.
- **What happens:** WR-498 sends an end to its proto vertex's position when every vertex definition of that proto vertex resolved to the edge's own final vertex in each B-rep, assuming the vertex sits there. A proto vertex whose definition was imprinted onto a vertex the B-rep already had (`imprint_vertex_definition`'s merge within tolerance) keeps the mean of its definitions' points; the B-rep's vertex is not moved. When one definition is an ill-conditioned hit, the mean lies off the vertex by half the hit's error, and the fitted end stops there.
- **Trigger:** a subtract whose tool has a cylindrical face coaxial with the wall it cuts and a hair (1e-10 to 6e-10) outside it, meeting the wall at a torus/cylinder tangent junction, where the wall's rim meets fillets tangentially: the intersection curve along the rim hits the fillet's rim arc tangentially, the hit lands 2.9e-5 (1e-10) to 5.8e-5 (4e-10) along the rim from the exact corner, the proto vertex's position is the mean of the hit and the corner, and the rim edge is fitted to that mean: 1.45e-5 to 2.9e-5 short of the corner vertex, over the 2e-5 loop tolerance. Without the imprint flags the edge keeps the raw hit parameter (2.9e-5 off), as upstream. SolidCraft: tangency-labs/solidcraft#13085 (a half-ring cup with a lug, a revolve cut of 36 degrees whose profile corner the sketch solver left 4e-10 off the wall; tangency-labs/solidcore#3150).
- **How found:** SolidCraft triage of #13085; the ops-crate test `crates/solidcore-ops/tests/boolean_cut_tool_wall_a_hair_off_coincident.rs` (committed first, failing), probes of the proto vertex position and the final vertices at the fit.
- **Confirmed:** yes, in the port: with SolidCraft's fix set the 4e-10 cut fails its check with the gaps the issue reports (5.9e-5 and 2.9e-5), and with this flag the 2e-10, 4e-10 and 6e-10 cuts pass at 28 and 36 degrees. Not confirmed against the C++ fork (no fork build here); the fork at bec8a0b carries the same WR-498 rule.
- **Fix:** `Kernel::fixes.imprint_fits_edge_end_to_final_vertex` (opt-in, off by default): the fit's target is the end's final vertex whenever its final vertices in the two B-reps lie within the edge definition's tolerance of each other; the proto vertex's position only when they do not and the proto vertex is one vertex in each B-rep (WR-498's rule), else the end is left alone. The edge is made between those final vertices, so that is where its curve must end.
- **Not fixed here:** from 1e-9 outside, the rulings where the tool cylinder meets the lug fillets (two cylinders internally tangent within the offset) come out of the surface/surface intersection beside the true tangent line (9.1e-5 at 1e-9), which no end fit can move, and `merge::p2::make_faceuse` fails; a second cause, recorded on tangency-labs/solidcore#3150.
- Fixes tangency-labs/solidcore#3150 for offsets up to 6e-10 (the reporter's 4e-10).