From 3487aa7f9721a723cd40d4c6b77a6861247d3246 Mon Sep 17 00:00:00 2001 From: Brett Lawson Date: Sat, 31 Oct 2026 17:33:39 +0100 Subject: [PATCH 1/3] claim: offset four faces at a vertex split into two vertices (fork 2e1bb8c) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MNXLXj2sJf943y2dDF36Vy From 5ef896dd872b06e5b2506ac3e130400cb216c7b5 Mon Sep 17 00:00:00 2001 From: Brett Lawson Date: Sat, 31 Oct 2026 17:53:51 +0100 Subject: [PATCH 2/3] fix: offset splits a vertex of four faces into two vertices + edge (fork 2e1bb8c, WR-666, opt-in) Port of the SolidCraft fork's tangency-labs/smlib@2e1bb8c under the new UpstreamFixes::offset_four_face_vertex_split (effective with offset_closes_loop_gaps, the fork's CloseLoopGaps): the offset creator's tangency tolerance is half a degree instead of two; TrimExtendedEdge keeps a near-tangent cut where the curve crosses the surface; and at a vertex whose edges are all convex (or all concave) where two adjacent faces' offsets cut the edge, the edge ends at the cut reached first from its other end. Regression test: a trough and a pocket whose four-facet floors fall to a drain shell to their closed-form volumes; the default refuses the trough (1001) and returns the pocket at the wrong volume. Defaults unchanged. Fixes #2052 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MNXLXj2sJf943y2dDF36Vy --- crates/solidcore/src/kernel.rs | 2 + .../src/offset/offset_executive/edgeuse.rs | 19 + .../src/offset/offset_executive/p1.rs | 13 + .../src/offset/offset_executive/p2.rs | 2 + .../src/offset/offset_geometry_creation.rs | 232 +++++++++- .../src/topology/brep/local_operation.rs | 2 + .../fork_2e1bb8c_four_face_vertex_split.rs | 400 ++++++++++++++++++ ...at-a-vertex-not-split-into-two-vertices.md | 15 + 8 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 crates/solidcore/tests/fork_2e1bb8c_four_face_vertex_split.rs create mode 100644 plan/upstream-issues/WR-666--offset-four-faces-at-a-vertex-not-split-into-two-vertices.md diff --git a/crates/solidcore/src/kernel.rs b/crates/solidcore/src/kernel.rs index a79d35d355..b93e530599 100644 --- a/crates/solidcore/src/kernel.rs +++ b/crates/solidcore/src/kernel.rs @@ -696,6 +696,8 @@ pub struct UpstreamFixes { pub fillet_mitre_gap_patch: bool, /// `TrimExtendedEdge` takes, on a closed curve, the arc that runs where its base edge does, not always the interval between its two trim points: a hemisphere's rim, two arcs of one circle, no longer becomes one arc twice (WR-463, #403; the fork's tangency-labs/smlib@fa7ff4a). pub offset_closed_curve_trim_arc: bool, + /// The offset builds a vertex where more than three faces meet (a floor of four facets falling 1 degree to a drain, and its corners where two walls meet two facets) as the two vertices and the new edge the four offset planes give, where upstream fails the shell (1001): `SmOffsetGeometryCreation`'s tangency tolerance is half a degree, not two (the facets, 1.2 degrees apart, are intersected, not left to meet by extending their trims); `TrimExtendedEdge` keeps a trim point within 5 degrees of tangent where the curve crosses the surface (only a touch is dropped); and, where two faces' offsets cut the edge at a vertex all of whose edges are convex (or all concave), the edge ends at the cut it reaches first from its other end, not at the one nearest the vertex. It has no effect without `offset_closes_loop_gaps`, which closes the loop gaps the split leaves on the faces round the vertex (alone, the split returns a solid with those loops cut short, of the wrong volume) (WR-666, #2052; the fork's tangency-labs/smlib@2e1bb8c). + pub offset_four_face_vertex_split: bool, /// `MateEdges` also intersects analytic copies of the two faces' surfaces (plane, cylinder, cone, sphere, torus; extended only along a plane or a cylinder's or cone's rulings, where the tangent (G1R) extensions still are the surface) and replaces each marched piece that lies on an exact line or conic between the same ends, and drops to one UV curve on each extended surface within the body's tolerance, by that exact curve, so a shelled cylinder's floor circle, seam line and rim stay exact (WR-495, #1453/#1130; the fork's tangency-labs/smlib@fddc366 and @98ab1b0). pub offset_analytic_mate_edges: bool, /// `ShellBrep` ends with `MakeEdgesOnAnalyticsExact` on its result (not on the non-manifold offset it returns early): each edge whose curve is a marched intersection between two faces on analytic surfaces (planes, cylinders, cones, spheres, tori) takes the exact line or conic those surfaces meet on through its ends and middle, unless that conic fails its own validity check, and its faces' UV trims are made again, so a shelled rounded box's rim is arcs, not cubics 2.4e-6 off them (WR-545; the fork's tangency-labs/smlib@cd85d34). diff --git a/crates/solidcore/src/offset/offset_executive/edgeuse.rs b/crates/solidcore/src/offset/offset_executive/edgeuse.rs index eeb5f7b4ec..5ac2d555cb 100644 --- a/crates/solidcore/src/offset/offset_executive/edgeuse.rs +++ b/crates/solidcore/src/offset/offset_executive/edgeuse.rs @@ -912,6 +912,25 @@ impl OffsetBrep for EdgeBrep<'_> { .collect()) } + fn edge_is_closed(&self, e: Handle) -> Result { + let (arena, e) = e.edge()?; + edge::is_closed(self.brep(arena), e) + } + + fn vertex_edges(&self, v: Handle) -> Result> { + let (arena, v) = v.vertex()?; + Ok(vertex::edges(self.brep(arena), v, None)? + .into_iter() + .map(|e| Handle::topology(arena, EntityRef::Edge(e))) + .collect()) + } + + fn faceuse_shell(&self, fu: Handle) -> Result> { + let (arena, fu) = fu.faceuse()?; + Ok(eu::faceuse_shell(self.brep(arena), fu)? + .map(|s| Handle::topology(arena, EntityRef::Shell(s)))) + } + fn edge_is_lamina(&self, e: Handle) -> Result { let (arena, e) = e.edge()?; edge::is_lamina(self.brep(arena), e) diff --git a/crates/solidcore/src/offset/offset_executive/p1.rs b/crates/solidcore/src/offset/offset_executive/p1.rs index b55d87bd6b..3798c5345c 100644 --- a/crates/solidcore/src/offset/offset_executive/p1.rs +++ b/crates/solidcore/src/offset/offset_executive/p1.rs @@ -422,6 +422,19 @@ impl OffsetExecutive { self.tangency_tol_radians * 180.0 / PI } + /// Takes the geometry creator's constructor tolerance under `kernel`'s fixes + /// ([`OffsetGeometryCreation::default_tangency_tol_radians`]): half a degree with + /// `Kernel::fixes.offset_four_face_vertex_split` and `offset_closes_loop_gaps` (WR-666), + /// else the 2 degrees of + /// [`Self::new`] unchanged. Called right after construction by the executives that + /// `SmApiOffsetBrepFull`, `SmApiShellBrepFull` and `SmBrep::LocalOperation` build. + pub fn use_creator_tangency_tol(&mut self, kernel: &Kernel) { + if crate::offset::offset_geometry_creation::four_face_vertex_split(&kernel.fixes) { + self.tangency_tol_radians = + OffsetGeometryCreation::default_tangency_tol_radians(&kernel.fixes); + } + } + /// Offset distance supplied by the geometry creator. pub const fn offset_distance(&self) -> f64 { self.offset_distance diff --git a/crates/solidcore/src/offset/offset_executive/p2.rs b/crates/solidcore/src/offset/offset_executive/p2.rs index 95d1d5b7bc..3858ad9afb 100644 --- a/crates/solidcore/src/offset/offset_executive/p2.rs +++ b/crates/solidcore/src/offset/offset_executive/p2.rs @@ -252,6 +252,7 @@ pub fn offset_brep( && kernel.fixes.offset_splits_corner_vertices && brep_is_manifold_solid(original)?; let mut executive = configured_executive(original, options); + executive.use_creator_tangency_tol(kernel); let mut result = collapse_offset_status(executive.do_solid_offset( kernel, Some(original), @@ -1752,6 +1753,7 @@ fn run_shell_offset( notifications: &mut OffsetNotifications<'_>, ) -> Result { let mut executive = configured_shell_executive(original, options, faces); + executive.use_creator_tangency_tol(kernel); // Unlike offset_brep, shelling must not collapse ordinary inner statuses. executive.do_solid_offset( kernel, diff --git a/crates/solidcore/src/offset/offset_geometry_creation.rs b/crates/solidcore/src/offset/offset_geometry_creation.rs index 4614c21c81..39e5894960 100644 --- a/crates/solidcore/src/offset/offset_geometry_creation.rs +++ b/crates/solidcore/src/offset/offset_geometry_creation.rs @@ -35,8 +35,17 @@ //! | `create_analytic_extension` (private) | the SolidCraft fork's `sm_CreateAnalyticExtension` (tangency-labs/smlib@fddc366, @98ab1b0; opt-in) | //! | `exact_piece` (private) | the fork's `sm_ExactPiece` (tangency-labs/smlib@fddc366; opt-in) | //! | [`make_edges_on_analytics_exact`] | the fork's `MakeEdgesOnAnalyticsExact` (tangency-labs/smlib@cd85d34; opt-in) | +//! | [`OffsetGeometryCreation::default_tangency_tol_radians`] | the constructor's `m_dTangencyTolRadians` (half a degree in the fork's tangency-labs/smlib@2e1bb8c; opt-in) | +//! | `first_cut_from_far_end` (private) | the fork's cut-first-from-the-far-end choice in `TrimExtendedEdge` (tangency-labs/smlib@2e1bb8c; opt-in) | //! //! Deviations: +//! - **Four faces at a vertex (WR-666, opt-in).** With `Kernel::fixes.offset_four_face_vertex_split` +//! and `offset_closes_loop_gaps`, the fork's tangency-labs/smlib@2e1bb8c: the tangency +//! tolerance is half a degree, `trim_extended_edge` keeps a near-tangent trim point where +//! the curve crosses the surface, and at an all-convex or all-concave vertex takes the cut +//! reached first from the edge's other end. That choice also skips, on an open edge's +//! second end, a cut at the first trim point again (the fork's `bFirstAgain`, used there +//! only; the main candidate search is upstream's, WR-463's opt-in covers closed curves). //! - **Object handles.** Every `SmObject*` (topology, curves, surfaces) is a caller //! handle `O` ([`ClassObject`] + [`SolutionObject`]), as in //! [`proto_topology`](crate::topology::proto_topology) and the topology intersector. @@ -578,6 +587,12 @@ pub trait OffsetBrep: ProtoGeometry { fn edge_faces(&self, edge: O) -> Result>; /// `SmEdge::GetEdgeuses` (`topology::edge::edgeuses`). fn edge_edgeuses(&self, edge: O) -> Result>; + /// `SmEdge::IsClosed` (`topology::edge::is_closed`). + fn edge_is_closed(&self, edge: O) -> Result; + /// `SmVertex::GetEdges` (`topology::vertex::edges`, no face filter). + fn vertex_edges(&self, vertex: O) -> Result>; + /// `SmFaceuse::GetShell` (`topology::edgeuse::p1::faceuse_shell`). + fn faceuse_shell(&self, fu: O) -> Result>; /// `SmEdge::IsLamina` (`topology::edge::is_lamina`). fn edge_is_lamina(&self, edge: O) -> Result; /// `SmEdge::IsSeam(*face->GetSurface())` (`topology::edge::is_seam_of_surface`). @@ -1528,6 +1543,14 @@ fn is_arc(k: &Kernel, c: &BSplineCurve, num_samples: u64, tol: f64) -> Result bool { + fixes.offset_four_face_vertex_split && fixes.offset_closes_loop_gaps +} + /// The offset geometry creator (`SmOffsetGeometryCreation`). #[derive(Debug, Clone, PartialEq)] pub struct OffsetGeometryCreation { @@ -1554,6 +1577,19 @@ impl OffsetGeometryCreation { } } + /// The constructor's angular tolerance (`m_dTangencyTolRadians`): `2.0 * PI / 180.0`; + /// with `Kernel::fixes.offset_four_face_vertex_split` and `offset_closes_loop_gaps` + /// (WR-666), half a degree, + /// `0.5 * PI / 180.0`, as the fork's tangency-labs/smlib@2e1bb8c. + #[must_use] + pub fn default_tangency_tol_radians(fixes: &crate::kernel::UpstreamFixes) -> f64 { + if four_face_vertex_split(fixes) { + 0.5 * PI / 180.0 + } else { + 2.0 * PI / 180.0 + } + } + /// The same creator with the angular tolerance `tangency_tol_radians`, which an /// upstream creator subclass may override (`m_dTangencyTolRadians`). #[must_use] @@ -2688,6 +2724,9 @@ impl OffsetGeometryCreation { // for every sOffAdjEU - try letting trimPoint = OffAdjEu->ExtendedSurface/3DCurve intersection let mut last_face: Option = None; + // (WR-666, opt-in) each adjacent face's candidates in `solutions` + let four_face = four_face_vertex_split(&k.fixes); + let mut face_solutions: Vec<(usize, usize)> = Vec::new(); for &off_adj_eu in &off_adj_eus { let f = brep .edgeuse_face(off_adj_eu)? @@ -2724,6 +2763,9 @@ impl OffsetGeometryCreation { // Note that the adjacent surface intersection overrides tangent intersections // Test the solutions to see if they are not nearly tangent. + if four_face { + face_solutions.push((solutions.len(), solutions.len())); + } for sol in sols2.as_slice() { // (neither status is checked upstream: a failed evaluation leaves its // output at SmPoint3d's SM_UNDEF_DOUBLE default) @@ -2749,9 +2791,37 @@ impl OffsetGeometryCreation { .unitize_or_z(); let angle_rad = tangent.angle_between(normal)?; let angle_deg = angle_rad * 180.0 / PI; - if angle_deg < 85.0 || angle_deg > 95.0 { + // `Kernel::fixes.offset_four_face_vertex_split` (WR-666, the fork's + // tangency-labs/smlib@2e1bb8c): a curve that crosses the surface at a + // shallow angle crosses it all the same (a trough's floor facets falling + // 1 degree, whose offsets cut the offset edges along the walls 0.6 + // degrees off them): only a touch, the curve meeting the surface without + // passing through it, is no trim point. The curve crosses where it lies + // on either side of the surface just before and just after the point. + let mut crosses = false; + if four_face && !(angle_deg < 85.0 || angle_deg > 95.0) { + let c_ivl = curve_of(brep, curve_3d)?.natural_interval(); + let step = 1.0e-3 * c_ivl.length(); + let t = sol.start.param(0)?; + let (before, after) = (t - step, t + step); + if before >= c_ivl.min() && after <= c_ivl.max() { + let on = surface_of(brep, ext_surface)?.evaluate_point(k, uv)?; + let curve = curve_of(brep, curve_3d)?; + let b_pnt = curve.evaluate_point(k, before)?; + let a_pnt = curve.evaluate_point(k, after)?; + let d_b = (b_pnt - on).dot(normal); + let d_a = (a_pnt - on).dot(normal); + let least = + 10.0 * EFF_ZERO * (1.0 + distance(on, Point3::new(0.0, 0.0, 0.0))); + crosses = d_b * d_a < 0.0 && d_b.abs() > least && d_a.abs() > least; + } + } + if angle_deg < 85.0 || angle_deg > 95.0 || crosses { // save solution as possible trimPoint solutions.add(sol); + if let Some(range) = face_solutions.last_mut() { + range.1 = solutions.len(); + } } } } // end iter every sOffAdjEU @@ -2805,6 +2875,32 @@ impl OffsetGeometryCreation { } } + // `Kernel::fixes.offset_four_face_vertex_split` (WR-666, the fork's + // tangency-labs/smlib@2e1bb8c): where more than three faces meet at the vertex, + // the faces on either side of the edge's ends there are different faces, and + // their offsets cut the edge's offset at two points: four offset planes do not + // meet in one point. The offset vertex is two vertices joined by a new edge, + // and where every edge at the vertex is convex (or every one concave) the edge + // ends at the one of the two cuts its offset reaches first coming from its + // other end. With a vertex of mixed edges the nearest point stays. + if four_face && found && face_solutions.len() == 2 { + if let Some(param) = self.first_cut_from_far_end( + k, + brep, + base_edgeuse, + base_edge, + base_vertex, + curve_3d, + i, + &trim_ivl, + &solutions, + &face_solutions, + tan_tol_deg, + )? { + found_param = param; + } + } + // BadEdge when no trim point was found for this iteration if !found { return Ok(None); @@ -2859,6 +2955,140 @@ impl OffsetGeometryCreation { } } + /// `TrimExtendedEdge`'s choice at a vertex of more than three faces under + /// `Kernel::fixes.offset_four_face_vertex_split` (WR-666, the fork's + /// tangency-labs/smlib@2e1bb8c): when every edge at `base_vertex` is convex (or every + /// one concave; a lamina, tangent or shell-less edge counts as neither) and each of + /// the two adjacent faces (`face_solutions`, ranges of `solutions`) has a cut nearest + /// the vertex (not the start already chosen, nor, on an open edge's second end, the + /// first trim point again), and the two cuts are apart, the one nearer along the + /// curve to where the base edge's middle drops onto it. `None` keeps the nearest. + #[allow(clippy::too_many_arguments)] + fn first_cut_from_far_end( + &self, + k: &Kernel, + brep: &mut impl OffsetBrep, + base_edgeuse: O, + base_edge: O, + base_vertex: O, + curve_3d: O, + i: usize, + trim_ivl: &Extent1d, + solutions: &SolutionArray, + face_solutions: &[(usize, usize)], + tan_tol_deg: f64, + ) -> Result> { + // an open edge's second trim point is never the first one again + let open_second = i == 1 && !brep.edge_is_closed(base_edge)?; + let first = if open_second { + Some(curve_of(brep, curve_3d)?.evaluate_point(k, trim_ivl.min())?) + } else { + None + }; + let same_tol = 10.0 * self.this_approx_tol_3d; + let first_again = |pnt: Point3| first.is_some_and(|f| distance(pnt, f) <= same_tol); + + let mut convex = true; + let mut concave = true; + let v_edges = brep.vertex_edges(base_vertex)?; + let base_fu = brep + .edgeuse_faceuse(base_edgeuse)? + .ok_or(dangling("edge use without face use"))?; + let shell = brep.faceuse_shell(base_fu)?; + for &e in &v_edges { + if !(convex || concave) { + break; + } + let mut side: Option = None; + for eu in brep.edge_edgeuses(e)? { + if side.is_some() { + break; + } + if let Some(fu) = brep.edgeuse_faceuse(eu)? { + if brep.faceuse_shell(fu)? == shell { + side = Some(eu); + } + } + } + let Some(side) = side else { + convex = false; + concave = false; + continue; + }; + if brep.edgeuse_radial(side)? == Some(side) + || brep.edgeuse_is_tangent_sector(k, side, tan_tol_deg)? + { + convex = false; + concave = false; + continue; + } + if brep.edgeuse_is_convex_radial_sector(k, side, 10)? { + concave = false; + } else { + convex = false; + } + } + + // Each face's cut nearest the vertex, and which comes first from the edge's other + // end: the cut nearer, along the curve, to the base edge's middle. + let mut face_param = [0.0, 0.0]; + let mut face_found = [false, false]; + let base_point = brep.vertex_point(base_vertex)?; + for ff in 0..2 { + if !(convex || concave) { + break; + } + let &(lo, hi) = face_solutions + .get(ff) + .ok_or(dangling("adjacent face candidates"))?; + let mut best = BIG_DOUBLE; + for sol in solutions.as_slice().get(lo..hi).unwrap_or(&[]) { + let t = sol.start.param(0)?; + let pnt = curve_of(brep, curve_3d)?.evaluate_point(k, t)?; + let dist = distance(pnt, base_point); + if dist < best && !are_same(t, trim_ivl.min()) && !first_again(pnt) { + best = dist; + if let (Some(p), Some(f)) = (face_param.get_mut(ff), face_found.get_mut(ff)) { + *p = t; + *f = true; + } + } + } + } + let [p0, p1] = face_param; + let [found0, found1] = face_found; + if !(found0 && found1) { + return Ok(None); + } + let curve = curve_of(brep, curve_3d)?; + let cut0 = curve.evaluate_point(k, p0)?; + let cut1 = curve.evaluate_point(k, p1)?; + if distance(cut0, cut1) <= same_tol { + return Ok(None); + } + let mid = edgeuse_normalized_evaluate(k, brep, base_edgeuse, 0.5, false, false, true)?; // FALSE = 3d Eval + let tol = brep.brep_tolerance(base_edge)?; + let m_solutions = Self::point_solve( + k, + brep, + curve_3d, + SolverOperation::Minimize, + mid, + tol, + None, + SolutionRequested::Single, + )?; + let Some(first_sol) = m_solutions.as_slice().first() else { + return Ok(None); + }; + let d_mid = first_sol.start.param(0)?; + Ok(Some(if (p0 - d_mid).abs() <= (p1 - d_mid).abs() { + p0 + } else { + p1 + })) + } + /// The neighbour classification of `TrimExtendedEdge` (repeated for each end of /// each base edge use): a lamina or tangent neighbour goes to the tangent lists /// with the base UV curve; otherwise, when the base edge and the neighbour's edge diff --git a/crates/solidcore/src/topology/brep/local_operation.rs b/crates/solidcore/src/topology/brep/local_operation.rs index 35d5709ffd..c0d336f1ec 100644 --- a/crates/solidcore/src/topology/brep/local_operation.rs +++ b/crates/solidcore/src/topology/brep/local_operation.rs @@ -153,6 +153,8 @@ pub fn local_operation( radius / 2.0, tolerance / 2.0, ); + // (the geometry creator's constructor tolerance, half a degree under WR-666's opt-in) + executive.use_creator_tangency_tol(k); executive.set_local_surfaces(&all_faces, new_list); // the shell faces get copied without being offset: none executive.set_shell_faces(&[]); diff --git a/crates/solidcore/tests/fork_2e1bb8c_four_face_vertex_split.rs b/crates/solidcore/tests/fork_2e1bb8c_four_face_vertex_split.rs new file mode 100644 index 0000000000..44ddfe1563 --- /dev/null +++ b/crates/solidcore/tests/fork_2e1bb8c_four_face_vertex_split.rs @@ -0,0 +1,400 @@ +//! The SolidCraft fork's tangency-labs/smlib@2e1bb8c under +//! `Kernel::fixes.offset_four_face_vertex_split` with `offset_closes_loop_gaps` (WR-666, +//! #2052), on the commit message's two cases, rebuilt: +//! +//! - a trough 60 x 40 x 20 whose floor is four facets falling to a drain at its middle (the +//! long sides' facets fall 1 degree, the short sides' 2/3 degree, adjacent facets meet at +//! about 1.2 degrees), shelled 2 inward open at the top; +//! - an 80 x 60 x 20 slab with that floor's pocket cut 10 deep into its top, shelled 3 +//! inward open at the top, +//! +//! both as SolidCraft's `smlib_shell` calls `SmApiShellBrepFull(b, -t, TRUE, TRUE, TRUE, +//! [top])`. Four faces meet at each corner of the floor (two walls, two facets) and at the +//! drain (four facets), every edge there convex (the trough) or concave (the pocket), and +//! their four offset planes do not meet in one point: each offset vertex is two, joined by +//! a new edge. Upstream refuses the trough (1001) and returns the pocket at the wrong +//! volume; with the opt-in both shell to the closed form (each cavity is the intersection +//! or difference of the faces' half-spaces moved by the thickness, integrated apart from +//! the kernel), within 1e-15 relative here. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] // test helpers + +#[path = "support/offset_fixture.rs"] +mod offset_fixture; + +use offset_fixture::{api_shell, planar_faces_at, volume}; +use solidcore::curve::curve::Curve; +use solidcore::curve::line::Line; +use solidcore::kernel::UpstreamFixes; +use solidcore::math::Point3; +use solidcore::offset::offset_executive::p2::{OffsetOptions, ShellOptions}; +use solidcore::query::api_queries::brep_is_manifold_solid; +use solidcore::topology::brep::p2::ArenaNotifyState; +use solidcore::topology::brep::p5::{stitch_and_orient, StitchAndOrientOptions}; +use solidcore::topology::brep::p8::{create_planar_face_with_3d_curves, FaceBuildEnv}; +use solidcore::topology::topology_traverser::Marks; +use solidcore::{Brep, Kernel}; + +const LX: f64 = 60.0; +const LY: f64 = 40.0; +const H: f64 = 20.0; +const T: f64 = 2.0; + +/// The drain's depth below the bottom edges: the long sides' facets fall 1 degree. +fn drain_depth() -> f64 { + (LY / 2.0) * 1.0_f64.to_radians().tan() +} + +/// A solid from planar polygons (each a closed loop of corner points), stitched and +/// oriented, as `SmApiCreatePyramid` builds its faces. +fn polyhedron(k: &mut Kernel, faces: &[Vec], holed: &[(Vec, Vec)]) -> Brep { + let mut brep = solidcore::topology::brep::p3::new_brep(k, -1.0, -1.0).unwrap(); + let region = brep.infinite_region(); + let mut state = ArenaNotifyState::new(&brep); + let mut env = state.env(None); + let mut marks = Marks::default(); + let this_face = + |f| solidcore::topology::loopuse::notify_ref(solidcore::error::EntityRef::Face(f)); + let mut fenv = FaceBuildEnv { + env: &mut env, + marks: &mut marks, + this_face: &this_face, + }; + let lines = |corners: &[Point3]| -> Vec { + (0..corners.len()) + .map(|i| { + Curve::from( + Line::create_line_segment( + 3, + corners[i], + corners[(i + 1) % corners.len()], + None, + ) + .unwrap(), + ) + }) + .collect() + }; + let loops: Vec> = faces + .iter() + .map(|f| lines(f)) + .chain(holed.iter().map(|(outer, hole)| { + let mut c = lines(outer); + c.extend(lines(hole)); + c + })) + .collect(); + for curves in &loops { + create_planar_face_with_3d_curves(k, &mut brep, region, curves, 1.0e-5, &mut fenv) + .unwrap() + .unwrap(); + } + stitch_and_orient(k, &mut brep, &StitchAndOrientOptions::default()).unwrap(); + brep.refresh_cached_topology(); + brep +} + +/// The four facets of a floor over the rectangle `[x0, x1] x [y0, y1]` at height `zb`, +/// each through one side and the drain `drain_depth()` below the middle, each moved by `d` +/// along its upward normal, as `z = a x + b y + c`. +fn floor_planes(x0: f64, x1: f64, y0: f64, y1: f64, zb: f64, d: f64) -> [[f64; 3]; 4] { + let h = drain_depth(); + let (sx, sy) = (h / ((x1 - x0) / 2.0), h / ((y1 - y0) / 2.0)); + let (ox, oy) = (d * (1.0 + sx * sx).sqrt(), d * (1.0 + sy * sy).sqrt()); + [ + [0.0, -sy, zb + sy * y0 + oy], + [0.0, sy, zb - sy * y1 + oy], + [-sx, 0.0, zb + sx * x0 + ox], + [sx, 0.0, zb - sx * x1 + ox], + ] +} + +/// The floor's facets (corner points) over `[x0, x1] x [y0, y1]` at `zb`. +fn facets(x0: f64, x1: f64, y0: f64, y1: f64, zb: f64) -> Vec> { + let b = [ + Point3::new(x0, y0, zb), + Point3::new(x1, y0, zb), + Point3::new(x1, y1, zb), + Point3::new(x0, y1, zb), + ]; + let drain = Point3::new((x0 + x1) / 2.0, (y0 + y1) / 2.0, zb - drain_depth()); + (0..4).map(|i| vec![b[(i + 1) % 4], b[i], drain]).collect() +} + +/// The upright walls over the sides of `[x0, x1] x [y0, y1]` from `z0` to `z1`. +fn walls(x0: f64, x1: f64, y0: f64, y1: f64, z0: f64, z1: f64) -> Vec> { + let c = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + (0..4) + .map(|i| { + let (p, q) = (c[i], c[(i + 1) % 4]); + vec![ + Point3::new(p[0], p[1], z0), + Point3::new(q[0], q[1], z0), + Point3::new(q[0], q[1], z1), + Point3::new(p[0], p[1], z1), + ] + }) + .collect() +} + +fn rect(x0: f64, x1: f64, y0: f64, y1: f64, z: f64) -> Vec { + vec![ + Point3::new(x0, y0, z), + Point3::new(x1, y0, z), + Point3::new(x1, y1, z), + Point3::new(x0, y1, z), + ] +} + +/// The trough: four walls from z = 0 to the top at `H`, the floor four facets each +/// through one bottom edge and the drain below the middle. +fn trough(k: &mut Kernel) -> Brep { + let mut faces = vec![rect(0.0, LX, 0.0, LY, H)]; + faces.extend(walls(0.0, LX, 0.0, LY, 0.0, H)); + faces.extend(facets(0.0, LX, 0.0, LY, 0.0)); + polyhedron(k, &faces, &[]) +} + +/// The pocket's slab, 80 x 60 x 20, and the pocket cut 10 deep into its top over +/// `[10, 70] x [10, 50]` (the trough's 60 x 40), its floor four facets falling to a drain. +const SLAB: [f64; 3] = [80.0, 60.0, 20.0]; +const POCKET: [f64; 4] = [10.0, 70.0, 10.0, 50.0]; +const POCKET_FLOOR: f64 = 10.0; +const POCKET_T: f64 = 3.0; + +fn pocket(k: &mut Kernel) -> Brep { + let [px0, px1, py0, py1] = POCKET; + let (sx, sy, sz) = (SLAB[0], SLAB[1], SLAB[2]); + let mut faces = vec![rect(0.0, sx, 0.0, sy, 0.0)]; + faces.extend(walls(0.0, sx, 0.0, sy, 0.0, sz)); + faces.extend(walls(px0, px1, py0, py1, POCKET_FLOOR, sz)); + faces.extend(facets(px0, px1, py0, py1, POCKET_FLOOR)); + let hole = rect(px0, px1, py0, py1, sz); + polyhedron(k, &faces, &[(rect(0.0, sx, 0.0, sy, sz), hole)]) +} + +/// Sutherland-Hodgman clip of `poly` to `a x + b y + c >= 0`. +fn clip(poly: &[[f64; 2]], a: f64, b: f64, c: f64) -> Vec<[f64; 2]> { + let f = |q: [f64; 2]| a * q[0] + b * q[1] + c; + let mut out = Vec::new(); + for i in 0..poly.len() { + let (p, q) = (poly[i], poly[(i + 1) % poly.len()]); + let (fp, fq) = (f(p), f(q)); + if fp >= 0.0 { + out.push(p); + } + if (fp >= 0.0) != (fq >= 0.0) { + let s = fp / (fp - fq); + out.push([p[0] + s * (q[0] - p[0]), p[1] + s * (q[1] - p[1])]); + } + } + out +} + +/// The integral over `[x0, x1] x [y0, y1]` of `top` less the greatest of `planes`: each +/// plane over the polygon where it is the greatest (area times its value at the centroid). +fn under(top: f64, x0: f64, x1: f64, y0: f64, y1: f64, planes: &[[f64; 3]; 4]) -> f64 { + let rect = vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + let mut total = 0.0; + for (i, pi) in planes.iter().enumerate() { + let mut poly = rect.clone(); + for (j, pj) in planes.iter().enumerate() { + if i != j { + poly = clip(&poly, pi[0] - pj[0], pi[1] - pj[1], pi[2] - pj[2]); + } + } + if poly.len() < 3 { + continue; + } + let (mut area2, mut cx, mut cy) = (0.0, 0.0, 0.0); + for k in 0..poly.len() { + let (p, q) = (poly[k], poly[(k + 1) % poly.len()]); + let cross = p[0] * q[1] - q[0] * p[1]; + area2 += cross; + cx += (p[0] + q[0]) * cross; + cy += (p[1] + q[1]) * cross; + } + let (cx, cy) = (cx / (3.0 * area2), cy / (3.0 * area2)); + total += area2 / 2.0 * (top - (pi[0] * cx + pi[1] * cy + pi[2])); + } + total +} + +fn trough_solid_volume() -> f64 { + LX * LY * H + LX * LY * drain_depth() / 3.0 +} + +/// The shelled trough: the solid less the cavity, the region inside the walls moved in by +/// `T`, below the top and above the four facets' planes moved in by `T` along their +/// normals (the cavity is convex: the intersection of those half-spaces). +fn trough_shell_volume() -> f64 { + let planes = floor_planes(0.0, LX, 0.0, LY, 0.0, T); + trough_solid_volume() - under(H, T, LX - T, T, LY - T, &planes) +} + +fn pocket_volume(depth_below_top: f64, area: f64) -> f64 { + area * depth_below_top + area * drain_depth() / 3.0 +} + +fn pocket_solid_volume() -> f64 { + let [px0, px1, py0, py1] = POCKET; + SLAB[0] * SLAB[1] * SLAB[2] - pocket_volume(SLAB[2] - POCKET_FLOOR, (px1 - px0) * (py1 - py0)) +} + +/// The shelled slab: the solid less the cavity, the slab's box moved in by `POCKET_T` +/// (open at the top) less the pocket grown by `POCKET_T` (its walls moved out, its +/// facets' planes moved down along their normals, up to the open top). +fn pocket_shell_volume() -> f64 { + let [px0, px1, py0, py1] = POCKET; + let t = POCKET_T; + let boxed = (SLAB[0] - 2.0 * t) * (SLAB[1] - 2.0 * t) * (SLAB[2] - t); + let planes = floor_planes(px0, px1, py0, py1, POCKET_FLOOR, -t); + let grown = under(SLAB[2], px0 - t, px1 + t, py0 - t, py1 + t, &planes); + pocket_solid_volume() - (boxed - grown) +} + +#[derive(Debug, PartialEq)] +struct Shelled { + volume: f64, + faces: usize, + manifold: bool, +} + +/// `part` shelled `t` inward open at its top (the plane face at `top`) under `fixes`, as +/// SolidCraft's `smlib_shell` calls `SmApiShellBrepFull`. +fn shell( + fixes: &UpstreamFixes, + make: fn(&mut Kernel) -> Brep, + top: f64, + t: f64, +) -> Result { + let mut k = Kernel::default(); + k.fixes.clone_from(fixes); + let mut part = make(&mut k); + assert!(brep_is_manifold_solid(&part).unwrap()); + let open = planar_faces_at(&k, &part, 2, top); + assert_eq!(open.len(), 1); + let options = ShellOptions { + offset: OffsetOptions { + distance: -t, + extend_convex_edges: true, + merge_results: true, + }, + create_offset_solid: true, + merge_sheet_side_walls: false, + }; + let mut wall = api_shell(&mut k, &mut part, options, &open).map_err(|e| e.to_string())?; + wall.refresh_cached_topology(); + let manifold = brep_is_manifold_solid(&wall).unwrap(); + let volume = volume(&k, &mut wall).map_err(|e| e.to_string())?; + Ok(Shelled { + volume, + faces: wall.face_list().len(), + manifold, + }) +} + +fn shell_trough(fixes: &UpstreamFixes) -> Result { + shell(fixes, trough, H, T) +} + +fn shell_pocket(fixes: &UpstreamFixes) -> Result { + shell(fixes, pocket, SLAB[2], POCKET_T) +} + +/// The opt-in with the loop-gap closing it builds on (`offset_closes_loop_gaps`, WR-578). +fn split() -> UpstreamFixes { + let mut f = gaps_only(); + f.offset_four_face_vertex_split = true; + f +} + +fn gaps_only() -> UpstreamFixes { + let mut f = UpstreamFixes::default(); + f.offset_closes_loop_gaps = true; + f +} + +fn assert_closed_form(got: &Shelled, expected: f64, faces: usize, label: &str) { + assert!(got.manifold, "{label}: {got:?}"); + assert_eq!(got.faces, faces, "{label}: {got:?}"); + assert!( + (got.volume - expected).abs() <= 1e-9 * expected, + "{label}: {} vs closed form {expected}", + got.volume + ); +} + +#[test] +fn the_parts_have_their_closed_form_volumes() { + let mut k = Kernel::default(); + let mut b = trough(&mut k); + assert_eq!(b.face_list().len(), 9); + let v = volume(&k, &mut b).unwrap(); + assert!((v - trough_solid_volume()).abs() <= 1e-9 * v, "{v}"); + let mut b = pocket(&mut k); + assert_eq!(b.face_list().len(), 14); + let v = volume(&k, &mut b).unwrap(); + assert!((v - pocket_solid_volume()).abs() <= 1e-9 * v, "{v}"); +} + +/// Upstream (the default fixes) refuses the trough's shell: the merge fails (1001). +#[test] +fn default_fixes_refuse_the_trough_shell() { + let got = shell_trough(&UpstreamFixes::default()); + assert_eq!( + got, + Err("boolean::merge::p2::make_faceuse: upstream status 1001".to_owned()) + ); +} + +/// The loop-gap closing alone does not build it either: the corners and the drain are +/// still one vertex each. +#[test] +fn loop_gap_closing_alone_refuses_the_trough_shell() { + let got = shell_trough(&gaps_only()); + assert!(got.is_err(), "{got:?}"); +} + +/// The flag without `offset_closes_loop_gaps` has no effect. +#[test] +fn the_flag_alone_is_the_default() { + let mut f = UpstreamFixes::default(); + f.offset_four_face_vertex_split = true; + assert_eq!(shell_trough(&f), shell_trough(&UpstreamFixes::default())); + assert_eq!(shell_pocket(&f), shell_pocket(&UpstreamFixes::default())); +} + +/// With the opt-in the trough (every edge at its corners and drain convex) shells to the +/// closed form: the 8 outer faces, their 8 offsets and the rim. +#[test] +fn trough_shells_to_its_closed_form() { + let got = shell_trough(&split()).unwrap(); + assert_closed_form(&got, trough_shell_volume(), 17, "trough"); +} + +/// With the opt-in the pocket (every edge at its corners and drain concave) shells to the +/// closed form: the slab's 13 faces below the top, their 13 offsets and the two rims (round the outside and round the pocket). +#[test] +fn pocket_shells_to_its_closed_form() { + let got = shell_pocket(&split()).unwrap(); + assert_closed_form(&got, pocket_shell_volume(), 28, "pocket"); +} + +/// Upstream (the default fixes) returns the pocket's shell, a manifold solid, at the wrong +/// volume (43302.668, the closed form 43522.321); the loop-gap closing alone refuses it. +#[test] +fn without_the_flag_the_pocket_shell_is_wrong_or_refused() { + let got = shell_pocket(&UpstreamFixes::default()).unwrap(); + assert!( + (got.volume - pocket_shell_volume()).abs() > 100.0, + "{got:?} vs {}", + pocket_shell_volume() + ); + assert!(shell_pocket(&gaps_only()).is_err()); +} diff --git a/plan/upstream-issues/WR-666--offset-four-faces-at-a-vertex-not-split-into-two-vertices.md b/plan/upstream-issues/WR-666--offset-four-faces-at-a-vertex-not-split-into-two-vertices.md new file mode 100644 index 0000000000..b9777ba6ad --- /dev/null +++ b/plan/upstream-issues/WR-666--offset-four-faces-at-a-vertex-not-split-into-two-vertices.md @@ -0,0 +1,15 @@ +#### WR-666. An offset leaves a vertex of four faces whose offset planes do not meet in one point as one vertex, so a shell of a floor falling to a drain fails or comes out wrong (#2052) +- **Severity:** Wrong result +- **Area:** Offsets and shells +- **Location:** `source/SMLib/inc/SmOffsetGeometryCreation.h:69` (the constructor's `m_dTangencyTolRadians(2.0*SM_PI/180.0)`) and `source/SMLib/src/SmOffsetGeometryCreation.cpp`, `SmOffsetGeometryCreation::TrimExtendedEdge`: the near-tangent test on each adjacent face's cut (`:1294`) and the choice of the cut nearest the base vertex (`:1303`-`:1350`), at 3fd5b95. +- **What happens:** where four faces meet at a vertex (the bottom corners of a floor of four facets falling to a drain, two walls and two facets, and the drain itself, four facets), the four offset planes in general do not meet in one point: the offset vertex is two vertices joined by a new edge between two faces that did not meet before. Three things keep the offset from building it: + - faces meeting at up to 2 degrees are taken as tangent (`IsTangentSector` with the creator's tolerance), so facets 1.2 degrees apart are left to meet by extending their trims instead of being intersected; + - a cut where an edge's offset crosses a neighbour's offset surface within 5 degrees of tangent is dropped even where the curve passes through the surface (the walls' offset edges cross the facets' offsets 0.6 degrees off them); + - the edge's offset is cut by the offsets of the two faces on either side of its end, and the cut nearest the old vertex is taken. Where every edge at the vertex is convex (or every one concave) the edge must end at the cut it reaches first coming from its other end: past it, it has left the cavity (or entered another face's side). + The loops of the faces round the vertex then have gaps off their surfaces. +- **Trigger:** a trough 60 x 40 x 20 (walls from z = 0 to the top at z = 20) whose floor is four facets, each through one bottom edge and the drain (30, 20, -20 tan 1 deg), shelled 2 inward open at the top (`SmApiShellBrepFull(b, -2, TRUE, TRUE, TRUE, [top])`): refused, 1001 in `SmMerge::MakeFaceuse`. An 80 x 60 x 20 slab with that floor's pocket cut 10 deep over [10, 70] x [10, 50], shelled 3 inward open at the top: a manifold solid of volume 43302.668 against the closed form 43522.321. +- **How found:** the SolidCraft kernel regression rig (shell/cov-shell-trough-whose-floor-falls-to-a-drain, shell/cov-shell-pocket-whose-floor-falls-to-a-drain); fixed in the SolidCraft fork, tangency-labs/smlib@2e1bb8c. +- **Confirmed:** by reading the pinned source; the port follows it (the regression test's default-fixes cases above). The C++ reference was not run on these bodies. +- **Fix:** tangency-labs/smlib@2e1bb8c: tangency tolerance half a degree; a candidate within 5 degrees of tangent is kept where the curve lies on either side of the surface 1e-3 of its interval before and after the point (each side farther than `10 * SM_EFF_ZERO * (1 + |point|)`); and where exactly two adjacent faces gave cuts and every edge at the base vertex is convex (or every one concave; a lamina, tangent or shell-less edge counts as neither), each face's cut nearest the vertex is found, and when they are apart (more than ten times the approximation tolerance) the edge ends at the one nearer, along the curve, to where the base edge's middle drops onto it. +- **Rust port:** behaves as upstream by default. With `Kernel::fixes.offset_four_face_vertex_split` together with `offset_closes_loop_gaps` (WR-578, the fork's `CloseLoopGaps`, which closes the loop gaps the split leaves; the new flag has no effect without it), `OffsetExecutive::use_creator_tangency_tol` takes half a degree and `trim_extended_edge` makes the two other changes (`first_cut_from_far_end`). `tests/fork_2e1bb8c_four_face_vertex_split.rs`: the trough shells to 11718.162842036698 (17 faces) and the pocket to 43522.32137406869 (28 faces), each the closed form (the cavity as the faces' half-spaces moved by the thickness, integrated apart from the kernel) to 1e-15 relative; `offset_closes_loop_gaps` alone refuses both (1001). +- **Related:** WR-657 (`offset_splits_corner_vertices`) splits a convex corner of exactly four plane faces after the edge offsets, inside `CloseLoopGaps`; it is not needed here (with it on as well the trough comes out the same). From 193194e76ec4dcfa23ef067df047082e38a08839 Mon Sep 17 00:00:00 2001 From: Brett Lawson Date: Sat, 31 Oct 2026 18:30:44 +0100 Subject: [PATCH 3/3] difftest: the geometry-creation runner's B-rep pair gives the vertex edges, edge closure and face-use shell the WR-666 trim reads Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MNXLXj2sJf943y2dDF36Vy --- .../src/units/offset_offset_geometry_creation.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/difftest/src/units/offset_offset_geometry_creation.rs b/crates/difftest/src/units/offset_offset_geometry_creation.rs index 3bede996b3..004d731cb6 100644 --- a/crates/difftest/src/units/offset_offset_geometry_creation.rs +++ b/crates/difftest/src/units/offset_offset_geometry_creation.rs @@ -1342,6 +1342,18 @@ impl OffsetBrep for Two { fn edge_is_lamina(&self, edge: H) -> solidcore::Result { ed::is_lamina(self.brep_r(edge)?, eid(edge)) } + fn edge_is_closed(&self, edge: H) -> solidcore::Result { + ed::is_closed(self.brep_r(edge)?, eid(edge)) + } + fn vertex_edges(&self, vertex: H) -> solidcore::Result> { + Ok(vx::edges(self.brep_r(vertex)?, vid(vertex), None)? + .into_iter() + .map(|e| hd(vertex.b, K_EDGE, e.index())) + .collect()) + } + fn faceuse_shell(&self, fu: H) -> solidcore::Result> { + Ok(eu1::faceuse_shell(self.brep_r(fu)?, fuid(fu))?.map(|s| hd(fu.b, K_SHELL, s.index()))) + } fn edge_is_seam_of_face_surface( &mut self, k: &Kernel,