diff --git a/crates/solidcore/src/kernel.rs b/crates/solidcore/src/kernel.rs index 8b9180ca74..dde999e059 100644 --- a/crates/solidcore/src/kernel.rs +++ b/crates/solidcore/src/kernel.rs @@ -289,6 +289,8 @@ pub struct UpstreamFixes { pub fillet_nx1_tangent_side_edge: 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, + /// `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-493, #1453/#1130; the fork's tangency-labs/smlib@fddc366 and @98ab1b0). + pub offset_analytic_mate_edges: bool, /// `DoExtendedOffset`'s protective stitch makes one pass at the merged body's tolerance instead of `StitchIntoSolid`'s doubling up to a 200th of the body's size, which welds the rims of cut-off pieces `MakeManifold` removes next into one edge of a far larger tolerance (WR-455, #855; the fork's tangency-labs/smlib@601d804). pub offset_stitch_at_body_tolerance: bool, /// `SmCurve::GlobalCurveIntersect` moves a point solution to an end of either curve when that end lies within tolerance of the other curve and the curves stay within tolerance of each other over the whole stretch between (`sm_MoveContactsToCurveEnds`), so a tangential contact is not placed inside the curve (WR-455, #855; the fork's tangency-labs/smlib@601d804). diff --git a/crates/solidcore/src/offset/offset_geometry_creation.rs b/crates/solidcore/src/offset/offset_geometry_creation.rs index 892cb16d4c..b59e436e9f 100644 --- a/crates/solidcore/src/offset/offset_geometry_creation.rs +++ b/crates/solidcore/src/offset/offset_geometry_creation.rs @@ -29,6 +29,8 @@ //! | [`OffsetBrep`] | what the unit reads from and writes to the two B-reps | //! | `VertexOffsetBrep` (private) | the vertex creator's topology/owned-geometry subset | //! | `FaceOffsetBrep` (private) | the face creator's topology/owned-geometry subset | +//! | `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) | //! //! Deviations: //! - **Object handles.** Every `SmObject*` (topology, curves, surfaces) is a caller @@ -109,11 +111,17 @@ //! line as the [`Curve`] itself, so `GetDegree` / `IsLine` / `IsArc` / `Evaluate` //! dispatch on an `SmLine` (or other analytic) centre line as upstream does //! (`SmFilletStandardSolver.cpp:3353-3384`). +//! - **Opt-in fork fix.** With `Kernel::fixes.offset_analytic_mate_edges` (WR-493), +//! `mate_edges` runs the SolidCraft fork's exact-piece swap (tangency-labs/smlib@fddc366 +//! with @98ab1b0) between the marched intersection and its trimming; off, it is +//! upstream's. `exact_piece` counts the whole turns it moves a conic angle by and +//! returns [`SolidError::NotConverged`] past `MAX_EXACT_PIECE_TURNS` (65536), where +//! the fork's loop would not end for a non-finite or huge angle. //! - **Recursion.** None. use crate::blend::fillet_standard_solver::p1::CircularCrossSectionFsg; use crate::curve::curve::p1::GlobalPointSolveOptions; -use crate::curve::curve::p2::{Curve, CurveKind}; +use crate::curve::curve::p2::{Curve, CurveKind, ELLIPSE_TYPE, LINE_TYPE}; use crate::curve::curve::p8::CurveIntersectOptions; use crate::error::{Result, SolidError}; use crate::geom::axis2_placement::Axis2Placement; @@ -137,12 +145,15 @@ use crate::nurbs::b_spline_curve::BSplineCurve; use crate::nurbs::b_spline_curve_n_lib::TrimMode; use crate::nurbs::b_spline_surface::p1::SurfParamType; use crate::nurbs::b_spline_surface::BSplineSurface; -use crate::nurbs::b_spline_surface_n_lib::p2::OrientType; +use crate::nurbs::b_spline_surface_n_lib::p2::{EvaluateFlags, OrientType}; use crate::nurbs::knots::Side; use crate::query::edge::ClosestPoint; use crate::surface::offset_surface::{OffsetSurface, SS_UNKNOWN as OFFSET_SS_UNKNOWN}; use crate::surface::surface::p1::SurfaceSolveContext; -use crate::surface::surface::p2::{SurfaceKind, TsectCurveType}; +use crate::surface::surface::p2::{ + SurfaceKind, TsectCurveType, CONE_TYPE, CYLINDER_TYPE, PLANE_TYPE, SPHERE_TYPE, TORUS_TYPE, +}; +use crate::surface::surface::p4::NoObject; use crate::surface::surface::Surface; use crate::surface::surface_cache::face_context::FaceQueries; use crate::surface::surface_cache::SurfaceCache; @@ -156,6 +167,11 @@ const OP_UPDATE: &str = "offset::offset_geometry_creation::update_face_surface_d const OP_EXTEND: &str = "offset::offset_geometry_creation::extend_trim_edges"; const OP_MATE: &str = "offset::offset_geometry_creation::mate_edges"; const OP_EDGEUSE: &str = "offset::offset_geometry_creation::edgeuse_offset"; +const OP_EXACT_PIECE: &str = "offset::offset_geometry_creation::exact_piece"; + +/// The most whole turns `exact_piece` moves a conic's angle by (a guard: the angles +/// are within a few turns of each other). +const MAX_EXACT_PIECE_TURNS: u32 = 1 << 16; /// The `operation` of the [`SolidError::InvalidInput`] this module raises where upstream /// dereferences a null pointer or a failed cast (undefined behaviour), so that a runner @@ -1010,6 +1026,208 @@ fn distance(a: Point3, b: Point3) -> f64 { a.distance(b) } +/// `smos_Max`: `a > b ? a : b`. +fn smos_max(a: f64, b: f64) -> f64 { + if a > b { + a + } else { + b + } +} + +/// `smos_Min`: `a < b ? a : b`. +fn smos_min(a: f64, b: f64) -> f64 { + if a < b { + a + } else { + b + } +} + +/// The fork's `sm_CreateAnalyticExtension` (tangency-labs/smlib@fddc366 with +/// @98ab1b0, behind [`crate::kernel::UpstreamFixes::offset_analytic_mate_edges`]): a +/// plane, cylinder, cone, sphere or torus copied and extended as itself where the +/// offset's extended surfaces (`GetCreateExtendedSurface`, tangent (G1R) B-spline +/// extensions) still are that surface: a plane all round, a cylinder or cone along its +/// rulings (a cone up to its apex), each side by `dist` (3D) unless it is a +/// singularity. Not round a turn, where the tangent extension leaves the surface. +/// `None` for any other surface. +fn create_analytic_extension(k: &Kernel, surface: &Surface, dist: f64) -> Result> { + let ty = surface.type_code(); + let plane = ty == PLANE_TYPE; + if !plane && ty != CYLINDER_TYPE && ty != CONE_TYPE && ty != SPHERE_TYPE && ty != TORUS_TYPE { + return Ok(None); + } + + let domain = surface.step_uv_domain(); + let max = if plane { + domain + } else { + surface.max_analytic_domain(k)? + }; + let (mut new_min, mut new_max) = (domain.min(), domain.max()); + for dir in 0..2 { + // 0 = u, 1 = v + if !plane && (dir == 0 || ty == SPHERE_TYPE || ty == TORUS_TYPE) { + continue; // a turn: the faces' extensions are not the surface there + } + let lo = if dir == 0 { + domain.min().x + } else { + domain.min().y + }; + let hi = if dir == 0 { + domain.max().x + } else { + domain.max().y + }; + let mid = if dir == 0 { + 0.5 * (domain.min().y + domain.max().y) + } else { + 0.5 * (domain.min().x + domain.max().x) + }; + let mut grow = [0.0_f64, 0.0]; + for (side, g) in grow.iter_mut().enumerate() { + let at = if side == 0 { lo } else { hi }; + let uv = if dir == 0 { + Point2::new(at, mid) + } else { + Point2::new(mid, at) + }; + let from = if side == 0 { Side::Left } else { Side::Right }; + let ders = surface.evaluate_step( + k, + uv, + 1, + 1, + EvaluateFlags { + u_side: from, + v_side: from, + only_upper_half: false, + non_zero_tangents: true, + }, + )?; + // upstream's [D, Dv, Du, Duv]: Du is ders[1][0], Dv is ders[0][1] + let der = if dir == 0 { + ders.get(1).and_then(|r| r.first()) + } else { + ders.first().and_then(|r| r.get(1)) + }; + let speed = der.ok_or(dangling("surface derivative"))?.length(); + if speed > EFF_ZERO { + // no extension through a singularity + *g = dist / speed; + } + } + let [grow_lo, grow_hi] = grow; + let mut new_lo = lo - grow_lo; + let mut new_hi = hi + grow_hi; + if !plane { + // a cylinder's or cone's rulings, as far as the cone reaches (its apex) + let (max_lo, max_hi) = (max.min().y, max.max().y); + new_lo = smos_max(new_lo, smos_min(lo, max_lo)); + new_hi = smos_min(new_hi, smos_max(hi, max_hi)); + } + if dir == 0 { + new_min.x = new_lo; + new_max.x = new_hi; + } else { + new_min.y = new_lo; + new_max.y = new_hi; + } + } + + let mut copy = surface.copy(k)?; + copy.adjust_step_uv_domain(k, &Extent2d::from_points(new_min, new_max))?; + Ok(Some(copy)) +} + +/// The fork's `sm_ExactPiece` (tangency-labs/smlib@fddc366, behind +/// [`crate::kernel::UpstreamFixes::offset_analytic_mate_edges`]): the exact line or +/// conic of `exact` that `marched` (a marched intersection piece) approximates, as a +/// copy over the same span: from `marched`'s start point to its end point, the way +/// round through its middle (all of a closed conic for a closed piece). `None` when no +/// line or conic of `exact` runs through all three points within `tol`. +fn exact_piece(k: &Kernel, marched: &Curve, exact: &[Curve], tol: f64) -> Result> { + let ivl = marched.natural_interval(); + // start, middle, end + let p = [ + marched.evaluate_point(k, ivl.min())?, + marched.evaluate_point(k, ivl.evaluate(0.5))?, + marched.evaluate_point(k, ivl.max())?, + ]; + for candidate in exact { + let line = candidate.is_kind_of(LINE_TYPE); + if !line && !candidate.is_kind_of(ELLIPSE_TYPE) { + continue; + } + let mut t = [0.0_f64; 3]; + let mut on = true; + for (tk, pk) in t.iter_mut().zip(p.iter()) { + if !on { + break; + } + *tk = candidate.step_inversion(k, *pk)?; + let on_curve = candidate.evaluate_step_point(k, *tk)?; + on = distance(on_curve, *pk) <= tol; + } + if !on { + continue; + } + let [t0, t1, t2] = t; + let [p0, _, p2] = p; + let span = if line { + Extent1d::from_min_max(smos_min(t0, t2), smos_max(t0, t2)) + } else { + // a conic: its STEP parameter is its angle in degrees + let turn = 360.0; + // `angle` moved into (from, from + turn]; the turns are counted so that a + // parameter far outside a turn cannot loop without bound (the angles are + // a conic's STEP parameters, within a few turns) + let after = |mut angle: f64, from: f64| -> Result { + let mut turns = 0_u32; + while angle <= from { + angle += turn; + turns += 1; + if turns > MAX_EXACT_PIECE_TURNS { + return Err(SolidError::NotConverged { + operation: OP_EXACT_PIECE, + iterations: turns, + }); + } + } + while angle > from + turn { + angle -= turn; + turns += 1; + if turns > MAX_EXACT_PIECE_TURNS { + return Err(SolidError::NotConverged { + operation: OP_EXACT_PIECE, + iterations: turns, + }); + } + } + Ok(angle) + }; + if distance(p0, p2) <= tol { + Extent1d::from_min_max(t0, t0 + turn) + } else if after(t1, t0)? < after(t2, t0)? { + Extent1d::from_min_max(t0, after(t2, t0)?) + } else { + Extent1d::from_min_max(t2, after(t0, t2)?) + } + }; + let mut copy = candidate.copy(k)?; + match copy.kind_mut() { + CurveKind::Line(l) => l.adjust_step_interval(&span)?, + CurveKind::Ellipse(e) => e.adjust_step_interval(k, &span, None)?, + // (only lines and conics reach here) + _ => return Err(sm_err(OP_EXACT_PIECE)), + } + return Ok(Some(copy)); + } + Ok(None) +} + /// `pSur->CreateOffsetSurface(ctx, signed_distance, approx_tol_3d, rNewSurface)`: the /// virtual call ([`Surface::create_offset_surface`] dispatches on the kind: the plane, /// surface of revolution and offset surface overrides, else `SmSurface`'s, which @@ -2939,6 +3157,11 @@ impl OffsetGeometryCreation { /// UV curves and becomes the geometry of both offset edge uses /// (`ReplaceEdgeCurves`, oriented by the original edge uses). /// + /// With [`crate::kernel::UpstreamFixes::offset_analytic_mate_edges`] (the SolidCraft + /// fork's tangency-labs/smlib@fddc366 and @98ab1b0), each marched intersection + /// piece that lies on an exact line or conic of analytic copies of the two face + /// surfaces is first replaced by that curve, with UV curves dropped from it. + /// /// # Errors /// /// `SM_ERR_NULL_POINTER` for a null edge use or a missing UV curve; `SM_ERR` @@ -2998,7 +3221,7 @@ impl OffsetGeometryCreation { } else { &s2 }; - let ssi = s1.global_surface_intersect_with_context( + let mut ssi = s1.global_surface_intersect_with_context( k, [Some(ext_surface1), Some(ext_surface2)], &mut OffsetSurfaceSolveContext(brep), @@ -3009,6 +3232,79 @@ impl OffsetGeometryCreation { Some(ssi_tol), Some(deg20_rad), )?; + + // `Kernel::fixes.offset_analytic_mate_edges` (tangency-labs/smlib@fddc366 and + // @98ab1b0, #1453/#1130): two analytic surfaces meet on lines and conics. The + // marched intersection of their B-spline extensions gives the pieces (bounded by + // the extended domains and seams) and their UV curves; each piece that lies on + // both extended surfaces as the exact curve between the same ends (within the + // analytic surfaces, not on a tangent extension) is replaced by that curve, its + // UV curves dropped from it. + if k.fixes.offset_analytic_mate_edges { + let ext_dist = self.offset_distance().abs() * 5.0; + let analytic1 = create_analytic_extension(k, surface_of(brep, surf1)?, ext_dist)?; + let analytic2 = create_analytic_extension(k, surface_of(brep, surf2)?, ext_dist)?; + if let (Some(analytic1), Some(analytic2)) = (&analytic1, &analytic2) { + if !ssi.curves_3d.is_empty() { + let exact = analytic1.global_surface_intersect( + k, + &analytic1.natural_uv_domain_with_nurb(k)?, + analytic2, + &analytic2.natural_uv_domain_with_nurb(k)?, + use_surface_edges, + Some(ssi_tol), + Some(deg20_rad), + )?; + let tol = brep.brep_tolerance(edge)?; + for ii in 0..ssi.curves_3d.len() { + let marched = ssi.curves_3d.get(ii).ok_or(dangling("curve index"))?; + if marched.is_degenerate(k, EFF_ZERO, None)? { + continue; + } + let Some(piece) = exact_piece(k, marched, &exact.curves_3d, tol)? else { + continue; + }; + let mut uv: [Option; 2] = [None, None]; + for (i_surf, slot) in uv.iter_mut().enumerate() { + let (ext, dom) = if i_surf == 0 { + (&s1, &domain1) + } else { + (other, &domain2) + }; + let dropped = ext.drop_curve::( + k, + dom, + &piece, + &piece.natural_interval(), + ssi_tol, + true, + None, + true, + )?; + if dropped.uv_curves.len() == 1 && dropped.max_drop_to_surf <= tol { + *slot = dropped.uv_curves.into_iter().next(); + } + } + let [Some(uv1), Some(uv2)] = uv else { + // The piece leaves an extended surface where it is a tangent + // (G1R) extension, not the analytic surface: there the marched + // piece is these surfaces' intersection. + continue; + }; + if let Some(c) = ssi.curves_3d.get_mut(ii) { + *c = piece; + } + if let Some(c) = ssi.surface1_uv_curves.get_mut(ii) { + *c = Some(uv1); + } + if let Some(c) = ssi.surface2_uv_curves.get_mut(ii) { + *c = Some(uv2); + } + } + } + } + } + let mut curves_3d: Vec = Vec::new(); let mut surface1_uv_curves: Vec> = Vec::new(); let mut surface2_uv_curves: Vec> = Vec::new(); diff --git a/crates/solidcore/tests/issue_1453_shelled_cylinder_exact_edges.rs b/crates/solidcore/tests/issue_1453_shelled_cylinder_exact_edges.rs new file mode 100644 index 0000000000..191ef40f29 --- /dev/null +++ b/crates/solidcore/tests/issue_1453_shelled_cylinder_exact_edges.rs @@ -0,0 +1,254 @@ +//! tangency-labs/solidcore#1453 (the cylinder form of #1130): a cylinder r5, h10 +//! shelled 1 inward with its top cap open. `MateEdges` intersects the offset faces' +//! tangent (G1R) B-spline extensions, so every new inner edge between two analytic +//! faces is marched: the floor circle and the seam line are B-splines, the rim is split +//! at an extra vertex, and the volume is 3.5e-7 over the closed form. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] // test helpers + +#[path = "support/offset_fixture.rs"] +mod offset_fixture; + +use core::f64::consts::PI; + +use offset_fixture::{planar_faces_at, shell}; +use solidcore::boolean::api_brep::boolean_difference; +use solidcore::curve::curve::p2::{ELLIPSE_TYPE, LINE_TYPE}; +use solidcore::geom::attribute::{AttributeStore, ObjectKey}; +use solidcore::mass::integrator::properties::{brep_volume, PropertyAttributes}; +use solidcore::math::Point3; +use solidcore::primitive::api_primitives::{ + create_box, create_cone, create_cylinder, create_sphere, +}; +use solidcore::topology::brep::p1; +use solidcore::{Brep, Kernel}; + +fn volume_at(k: &Kernel, b: &mut Brep, accuracy: f64) -> solidcore::Result { + let store = AttributeStore::default(); + let attrs = PropertyAttributes { + store: &store, + key_of: &|_| ObjectKey(0), + }; + brep_volume(k, b, accuracy, &attrs) +} + +/// The inner edges (every sample within 1e-3 of `on`, which gives a point's distance +/// from the inner surface): (count, how many are circles/ellipses or lines, worst +/// sample distance). +fn inner_edges(k: &Kernel, b: &Brep, on: impl Fn(Point3) -> f64) -> (usize, usize, f64) { + let (mut count, mut exact, mut worst) = (0, 0, 0.0_f64); + for e in p1::edges(b, None).unwrap() { + let edge = b.edge(e).unwrap(); + let Some(curve) = edge.curve.as_ref() else { + continue; + }; + let mut dev = 0.0_f64; + for i in 0..=32 { + let t = edge.interval.evaluate(f64::from(i) / 32.0); + let p = curve.evaluate_point(k, t).unwrap(); + dev = dev.max(on(p).abs()); + } + if dev < 1e-3 { + count += 1; + worst = worst.max(dev); + let kind = curve.is_kind_of(ELLIPSE_TYPE) || curve.is_kind_of(LINE_TYPE); + eprintln!( + "PROBE issue_1453 inner edge {e:?}: type {} dev {dev:e}", + curve.type_code() + ); + exact += usize::from(kind); + } + } + (count, exact, worst) +} + +struct Shelled { + edges: usize, + vertices: usize, + inner: usize, + exact: usize, + worst: f64, + volume: Option, +} + +/// `part` shelled `distance` with the plane face at z = `open_z` open, measured (the +/// volume only when `want` is given: integrating a marched cone takes minutes). +fn shelled( + label: &str, + fixed: bool, + part: impl Fn(&mut Kernel) -> Brep, + open_z: f64, + distance: f64, + on: impl Fn(Point3) -> f64, + want: Option, +) -> Shelled { + let mut k = Kernel::default(); + k.fixes.offset_analytic_mate_edges = fixed; + let part = part(&mut k); + let open = planar_faces_at(&k, &part, 2, open_z); + assert_eq!(open.len(), 1, "one open face"); + let mut b = shell(&mut k, part, distance, &open).unwrap(); + solidcore::topology::integrity::check(&b).unwrap(); + let (inner, exact, worst) = inner_edges(&k, &b, on); + let volume = want.map(|_| volume_at(&k, &mut b, 1e-12).unwrap()); + let r = Shelled { + edges: b.edge_list().len(), + vertices: b.vertex_list().len(), + inner, + exact, + worst, + volume, + }; + eprintln!( + "PROBE {label} fixed={fixed}: edges={} vertices={} inner={inner} exact={exact} worst={worst:e} volume={volume:?} want={want:?}", + r.edges, + r.vertices + ); + r +} + +fn cylinder(k: &mut Kernel) -> Brep { + create_cylinder(k, Point3::new(0.0, 0.0, 0.0), 5.0, 10.0).unwrap() +} + +fn on_cylinder(p: Point3) -> f64 { + p.x.hypot(p.y) - 4.0 +} + +/// The issue's case: a cylinder r5, h10 shelled 1 inward, its top open. By default +/// (upstream) the new inner edges are marched: 7 edges and 5 vertices (the rim split +/// at an extra vertex), the volume 3.5e-7 over. With the fix: the floor and rim are +/// exact circles, 6 edges and 4 vertices, and the volume is pi 25 10 - pi 16 9 to +/// 1e-9. +#[test] +fn issue_1453_shelled_cylinder_edges_are_exact() { + let want = PI * 25.0 * 10.0 - PI * 16.0 * 9.0; + let stock = shelled( + "issue_1453 cylinder", + false, + cylinder, + 10.0, + -1.0, + on_cylinder, + Some(want), + ); + assert_eq!( + (stock.edges, stock.vertices), + (7, 5), + "upstream's marched rim" + ); + assert!(stock.exact < stock.inner, "a marched inner edge"); + let over = stock.volume.unwrap() - want; + assert!(over > 1e-7 * want, "upstream's volume 3.5e-7 over: {over}"); + let fixed = shelled( + "issue_1453 cylinder", + true, + cylinder, + 10.0, + -1.0, + on_cylinder, + Some(want), + ); + assert_eq!((fixed.edges, fixed.vertices), (6, 4), "no extra rim vertex"); + assert_eq!(fixed.inner, 3, "floor circle, seam, rim circle"); + // The floor and rim circles are exact. The seam, where the inner wall meets + // itself, has no exact intersection of two analytic copies (one cylinder twice), + // so it stays the marched B-spline, on the line x = 4, y = 0. + assert_eq!(fixed.exact, 2, "floor and rim are circles"); + assert!(fixed.worst <= 1e-12, "inner edges on r 4: {}", fixed.worst); + let got = fixed.volume.unwrap(); + assert!((got - want).abs() <= 1e-9 * want, "{got} vs {want}"); +} + +fn frustum(k: &mut Kernel) -> Brep { + create_cone(k, Point3::new(0.0, 0.0, 0.0), 5.0, 3.0, 10.0).unwrap() +} + +/// The frustum's inner wall: the cone moved sqrt(1.04) inward, from z = 1 to 10. +fn frustum_inner_radius(z: f64) -> f64 { + 5.0 - 0.2 * z - 1.04_f64.sqrt() +} + +fn on_frustum(p: Point3) -> f64 { + p.x.hypot(p.y) - frustum_inner_radius(p.z) +} + +/// A frustum r5 to r3 over h10 shelled 1 inward, its top open. By default its floor +/// and rim are marched; with the fix they are exact circles on the inner cone. +#[test] +fn issue_1453_shelled_cone_edges_are_exact() { + let stock = shelled( + "issue_1453 cone", + false, + frustum, + 10.0, + -1.0, + on_frustum, + None, + ); + assert_eq!(stock.exact, 0, "upstream's marched floor and rim"); + let fixed = shelled( + "issue_1453 cone", + true, + frustum, + 10.0, + -1.0, + on_frustum, + None, + ); + assert_eq!((fixed.edges, fixed.vertices), (6, 4)); + assert_eq!(fixed.exact, 2, "floor and rim are circles"); + assert!( + fixed.worst <= 1e-12, + "inner edges on the inner cone: {}", + fixed.worst + ); +} + +/// The fixed frustum shell's volume is the closed form to 1e-9 (2.2e-14 measured; +/// upstream's is 1.9e-7 over). Ignored by default: integrating the cone faces takes +/// about four minutes. +#[test] +#[ignore = "slow: about four minutes of volume integration"] +fn issue_1453_shelled_cone_volume_is_exact() { + let (r1, r2) = (frustum_inner_radius(1.0), frustum_inner_radius(10.0)); + let outer = PI * 10.0 / 3.0 * (25.0 + 15.0 + 9.0); + let want = outer - PI * 9.0 / 3.0 * (r1 * r1 + r1 * r2 + r2 * r2); + let fixed = shelled( + "issue_1453 cone", + true, + frustum, + 10.0, + -1.0, + on_frustum, + Some(want), + ); + let got = fixed.volume.unwrap(); + assert!((got - want).abs() <= 1e-9 * want, "{got} vs {want}"); +} + +/// #1130's dome (a sphere r20 above z = 0) shelled 2 inward, its base open: the rim +/// between the inner sphere and the base plane is the circle r18 with or without the +/// fix (the port does not march it, plan/findings/offset.offset_geometry_creation--issue-1130-not-reproduced-in-port.md); +/// the fix keeps it so. +#[test] +fn issue_1453_shelled_dome_rim_stays_exact() { + let dome = |k: &mut Kernel| { + let ball = create_sphere(k, Point3::new(0.0, 0.0, 0.0), 20.0).unwrap(); + let below = create_box(k, Point3::new(-30.0, -30.0, -30.0), 60.0, 60.0, 30.0).unwrap(); + boolean_difference(k, ball, below).unwrap() + }; + let on = |p: Point3| (p.distance(Point3::new(0.0, 0.0, 0.0)) - 18.0).abs() + p.z.abs(); + let stock = shelled("issue_1453 dome", false, dome, 0.0, -2.0, on, None); + let fixed = shelled("issue_1453 dome", true, dome, 0.0, -2.0, on, None); + assert!( + fixed.inner >= 1 && fixed.exact == fixed.inner, + "the rim a circle" + ); + assert!(fixed.worst <= 1e-12, "rim on r 18: {}", fixed.worst); + assert_eq!((fixed.edges, fixed.vertices), (stock.edges, stock.vertices)); +} diff --git a/fixtures/offset.offset_executive_p2/curved_primitives.json b/fixtures/offset.offset_executive_p2/curved_primitives.json index 5ebb962739..fa13c0e294 100644 --- a/fixtures/offset.offset_executive_p2/curved_primitives.json +++ b/fixtures/offset.offset_executive_p2/curved_primitives.json @@ -589,6 +589,36 @@ } } }, + { + "name": "api_shell_cylinder_r5_top_open_issue_1453", + "upstream_ref": "tests/SM_API_test/src/TestSmBrep.cpp:2548-2563 (tangency-labs/solidcore#1453: the inner floor circle, seam and rim are marched, the rim split at an extra vertex)", + "inputs": { + "operation": "api_shell", + "source": { + "kind": "cylinder", + "origin": [ + 0, + 0, + 0 + ], + "radius": 5, + "height": 10 + }, + "faces": [ + { + "origin": "source", + "index": 1 + } + ], + "tolerance": 1e-05, + "config": { + "offset_distance": -1.0, + "extend_convex": true, + "merge_results": true, + "create_offset_solid": true + } + } + }, { "name": "member_cylinder_extend_inset_past_radius", "upstream_ref": "tests/prog_test/src/smOffsetTest.cpp:269-295 (extend corners)", diff --git a/plan/upstream-issues/WR-493--mateedges-marches-offset-edges-between-analytic-faces.md b/plan/upstream-issues/WR-493--mateedges-marches-offset-edges-between-analytic-faces.md new file mode 100644 index 0000000000..2214f323bc --- /dev/null +++ b/plan/upstream-issues/WR-493--mateedges-marches-offset-edges-between-analytic-faces.md @@ -0,0 +1,10 @@ +#### WR-493. `SmOffsetGeometryCreation::MateEdges` marches every offset edge between two analytic faces on their B-spline extensions, so a shelled cylinder's floor and rim are not circles and its rim is split (#1453, #1130) +- **Severity:** Wrong result +- **Area:** Offset and shell +- **Location:** `source/SMLib/src/SmOffsetGeometryCreation.cpp` (at 3fd5b95), `SmOffsetGeometryCreation::MateEdges`: it intersects the two faces' extended surfaces from `SmOffsetExecutive::GetCreateExtendedSurface`, which builds each as a tangent (G1R) B-spline extension (`CreateExtendedSurface(..., SM_CT_G1R)`), so `GlobalSurfaceIntersect` marches even where both faces are planes, cylinders, cones, spheres or tori. +- **What happens:** the new edge between two analytic offset faces is a B-spline approximation of the line or conic they meet on. On a cylinder r5, h10 shelled 1 inward with its top open (source tolerance 1e-5), the inner floor circle (z = 1, r 4) is a B-spline, and the inner rim (z = 10, r 4) is two arcs split at an extra vertex 1.2e-6 inside r 4: 5 faces, 7 edges, 5 vertices, where the body has 6 edges and 4 vertices, and the volume is 333.0089389 against the closed form pi 25 10 - pi 16 9 = 333.0088213 (3.5e-7 relative). A frustum r5 to r3 over h10 shelled the same way gets a marched floor and rim (volume 1.9e-7 over). On the SolidCraft fork's C++ a shelled dome's rim is a marched cubic (#1130; not reproduced in the port, `plan/findings/offset.offset_geometry_creation--issue-1130-not-reproduced-in-port.md`). +- **Trigger:** `SmApiShellBrepFull` (or `SmOffsetExecutive::ShellBrep`) of `SmApiCreateCylinder(origin 0, r 5, h 10)` at distance -1 with the top face open. +- **How found:** SolidCraft, wiring solidcore in as a second kernel (tangency-labs/solidcore#1453). +- **Confirmed:** in the port, whose default is held at parity with the pinned C++ by difftest (`offset.offset_executive_p2` case `api_shell_cylinder_r5_top_open_issue_1453`), and by the regression tests below. The same defect is confirmed on the fork's C++ before its fix (#1130). +- **Fix:** the SolidCraft fork's tangency-labs/smlib@fddc366 ("an offset edge between two analytic faces is their exact line or conic") with its required follow-up @98ab1b0 ("an offset's analytic copy grows only along its rulings, not round a turn"). `MateEdges` also intersects analytic copies of the two face surfaces (`sm_CreateAnalyticExtension`: a plane grown all round, a cylinder or cone along its rulings up to the apex, by five times the offset distance; never round a turn, nor a sphere or torus, where the tangent extension leaves the surface). Each marched piece whose start, middle and end lie within the body's tolerance on one exact line or conic is replaced by that curve between the same ends, the way round through its middle (`sm_ExactPiece`), when it drops to exactly one UV curve on each extended surface within that tolerance; otherwise the marched piece stays. @98ab1b0 alone has no effect (it only narrows the copy @fddc366 builds), and @fddc366 alone fails shells whose cone or cylinder copies grow round a full turn (SolidCraft rig shell/3576), so both are one flag. +- **Rust port:** `Kernel::fixes.offset_analytic_mate_edges` (`create_analytic_extension`, `exact_piece` and the swap in `OffsetGeometryCreation::mate_edges`). Default off: upstream behaviour. Tests in `crates/solidcore/tests/issue_1453_shelled_cylinder_exact_edges.rs`: by default the cylinder gives 7 edges and 5 vertices; with the fix 6 edges and 4 vertices, the floor and rim are circles within 1e-12 of r 4, and the volume is the closed form to 1e-9 relative (2e-15 measured); the frustum's floor and rim are circles, and its volume is the closed form to 1e-9 (2.2e-14 measured; `#[ignore]`d test, about four minutes of integration); the dome's rim stays the circle r 18. The inner seam (the inner cylinder against itself) stays the marched B-spline, which is straight to 1e-15: two copies of one cylinder have no exact intersection, in the fork as well.