diff --git a/crates/solidcore/src/blend.rs b/crates/solidcore/src/blend.rs index fb6cc9c80c..cb06726600 100644 --- a/crates/solidcore/src/blend.rs +++ b/crates/solidcore/src/blend.rs @@ -8,6 +8,7 @@ pub mod feature_executive; pub mod fillet_classification; +pub mod fillet_cliff_overflow; pub mod fillet_corner; pub mod fillet_custom_solver; pub mod fillet_executive; diff --git a/crates/solidcore/src/blend/fillet_cliff_overflow.rs b/crates/solidcore/src/blend/fillet_cliff_overflow.rs new file mode 100644 index 0000000000..9debb5c3ef --- /dev/null +++ b/crates/solidcore/src/blend/fillet_cliff_overflow.rs @@ -0,0 +1,1103 @@ +//! The cliff overflow of a convex blend (not upstream): a blend whose rails leave the faces +//! they roll on across cliffs (an opening's rim, the body's outline) is built as if those +//! faces went on past the cliffs, and the material it removes is subtracted from the body. +//! +//! Not upstream: the SolidCraft fork's `source/SMLib/src/SmFilletCliffOverflow.cpp` +//! (tangency-labs/smlib `8ba4667` and `ae2a6de`, rev `68fb9162`), behind +//! `fixes.fillet_cliff_overflow` (WR-631, WR-638). The executive computes the fillet on the +//! support faces' surfaces (`m_bCliffOverflow`): its fillet B-rep is the sheet of fillet and +//! corner faces, its boundary on the surfaces of the faces the blends roll on and end on. +//! The session's `SubtractRemovalRegion` +//! 1. cuts the fillet off at the ends its Nx1 corners left uncut, by the end face's +//! surface continued ([`cut_fillet_at_open_end`]); +//! 2. closes the sheet into the region the blends remove ([`close_removal_region`]): on +//! each target face the sheet's boundary runs on, a face on that face's surface, +//! continued exactly where it needs to be, bounded by the sheet's boundary there, the +//! edges blended, and the side edges (continued along their lines past their ends +//! when they must be) that join the two; +//! 3. subtracts that region from the target with the manifold Boolean. +//! +//! Exactness: a plane continues exactly (the same plane, a larger domain); any other +//! surface is used within its own domain, and a needed continuation of one is refused +//! (`FilletErrorType::NotExact`). A side edge continues past its end only when it is a +//! straight line. An end the fillet runs past is cut only by a planar end face. +//! Configurations the construction does not cover stay too large for the faces +//! (`FilletErrorType::NoRoom`, the reason in the error's comment). +//! +//! | Rust | Fork | +//! |------------------------------|----------------------------------------| +//! | `curve_ends` (private) | `sm_CurveEnds` | +//! | `add_curve` (private) | `sm_AddCurve` | +//! | `edge_curve` (private) | `sm_EdgeCurve` | +//! | [`distance_to_surface`] | `sm_DistanceToSurface` | +//! | `samples` (private) | `sm_Samples` | +//! | [`covering_surface`] | `sm_CoveringSurface` | +//! | `same` (private) | `sm_Same` | +//! | [`cut_fillet_at_open_end`] | `SmFilletExecutive::CutFilletAtOpenEnd` | +//! | [`close_removal_region`] | `SmFilletExecutive::CloseRemovalRegion` | +//! | [`lamina_hosts`] | its host test | +//! | [`lamina_chains`] | its chain walk | +//! | [`closing_face`] | its closing face of one chain | +//! +//! Deviations: +//! - **Errors.** `GetFilletErrorInfo()->NoteError(code, comment)` followed by `SM_ERR` +//! becomes a [`Refusal`] written to the caller's note, then `SM_ERR`. +//! - **Sets and maps.** The fork's pointer-keyed `std::set` / `std::map` are `BTreeSet` / +//! `BTreeMap` keyed by ids, used for membership only; every loop follows a vector (the +//! lamina edges in the sheet's edge order, the hosts in first-seen order, the chains). +//! - **Bounded walks.** The chain walks (`for(;;)` in the fork) stop with `SM_ERR` after as +//! many steps as there are lamina edges; the walk along the filleted edges keeps the +//! fork's bound (the host's edge count). +//! - **Curve ownership.** The closing face's curves are owned values handed to +//! `MakeFaceWithCurves`; the fork's `sm_Loop` deletion on failure is the drop. +//! - **Connector line.** The fork's `new SmLine( sV, sP, 3, &m_crContext )` is the +//! two-point line `SmLine(StartPoint, EndPoint, lDim, cpContext)` ([`Line::from_points`]). + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::blend::fillet_executive::p3::NoRoomReason; +use crate::blend::fillet_geom::p1::FilletErrorType; +use crate::curve::curve::Curve; +use crate::curve::line::Line; +use crate::error::{EntityRef, Result, SolidError}; +use crate::geom::extent2d::Extent2d; +use crate::geom::global_solver::SolverOperation; +use crate::intersect::point_class::{PointClassType, PointClassification}; +use crate::kernel::Kernel; +use crate::math::{Point2, Point3, Vec2, BIG_DOUBLE, EFF_ZERO}; +use crate::nurbs::b_spline_curve_n_lib::TrimMode; +use crate::nurbs::b_spline_surface_n_lib::p2::OrientType; +use crate::query::face::p1::{point_3d_classify, Point3dClassifyOptions}; +use crate::surface::plane::Plane; +use crate::surface::surface::p2::SurfaceKind; +use crate::surface::surface::p4::NoObject; +use crate::surface::surface::p5::DropPointOptions; +use crate::surface::surface::Surface; +use crate::topology::brep::p3::{delete_face, with_kernel_callbacks, DeleteFaceOptions}; +use crate::topology::brep::p7::{make_face_with_curves, FaceCurvesInput}; +use crate::topology::brep::planar_section::face_planar_section_curves; +use crate::topology::topology_traverser::Marks; +use crate::topology::{edge, face, vertex, Brep, EdgeId, FaceId, VertexId}; + +const OP: &str = "blend::fillet_cliff_overflow"; + +/// `smos_Max` of two doubles. +fn smos_max(a: f64, b: f64) -> f64 { + if a > b { + a + } else { + b + } +} + +/// `smos_Min` of two doubles. +fn smos_min(a: f64, b: f64) -> f64 { + if a < b { + a + } else { + b + } +} + +/// A refusal of the cliff overflow's construction: the fork's `NoteError(code, comment)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Refusal { + /// The fillet error code (`SM_FILERR_NO_ROOM`, `SM_FILERR_NOT_EXACT` or + /// `SM_FILERR_BAD_MERGE`). + pub code: FilletErrorType, + /// The typed reason of a `NoRoom` refusal. + pub reason: Option, + /// The error's comment. + pub comment: &'static str, +} + +impl Refusal { + const fn no_room(reason: NoRoomReason, comment: &'static str) -> Self { + Self { + code: FilletErrorType::NoRoom, + reason: Some(reason), + comment, + } + } + + const fn not_exact(comment: &'static str) -> Self { + Self { + code: FilletErrorType::NotExact, + reason: None, + comment, + } + } +} + +/// Write `refusal` to `note` and fail (`NoteError`, then `return SM_ERR`). +fn refuse(note: &mut Option, refusal: Refusal) -> Result { + *note = Some(refusal); + Err(SolidError::sm_err(OP)) +} + +const BRANCHES: Refusal = Refusal::no_room( + NoRoomReason::CliffBoundaryBranches, + "Fillet Error: a cliff overflow's fillet boundary branches on a face", +); + +// ---- helpers ------------------------------------------------------------------------------ + +/// `sm_CurveEnds`: the two ends of a curve over its natural interval. +fn curve_ends(k: &Kernel, curve: &Curve) -> Result<(Point3, Point3)> { + let ivl = curve.natural_interval(); + Ok(( + curve.evaluate_point(k, ivl.min())?, + curve.evaluate_point(k, ivl.max())?, + )) +} + +/// A curve of a closing face's loop and its orientation (`sm_LoopCurve`). +#[derive(Debug, Clone)] +struct LoopCurve { + curve: Curve, + orient: OrientType, +} + +/// `sm_AddCurve`: adds `curve` to `lp`, oriented to run from `from` to `to`. +fn add_curve( + k: &Kernel, + lp: &mut Vec, + curve: Curve, + from: Point3, + to: Point3, +) -> Result<()> { + let (start, end) = curve_ends(k, &curve)?; + let same = start.distance(from) + end.distance(to); + let opposite = start.distance(to) + end.distance(from); + let orient = if same <= opposite { + OrientType::Same + } else { + OrientType::Opposite + }; + lp.push(LoopCurve { curve, orient }); + Ok(()) +} + +/// `sm_EdgeCurve`: a copy of the edge's curve over the edge's interval. +fn edge_curve(k: &Kernel, b: &Brep, e: EdgeId) -> Result { + let curve = edge::curve(b, e)?.ok_or_else(|| SolidError::null_pointer(OP))?; + let mut copy = curve.copy(k)?; + let mut ivl = edge::interval(b, e)?; + if ivl != copy.natural_interval() { + copy.trim(k, &mut ivl, TrimMode::Notify)?; + } + Ok(copy) +} + +/// `sm_DistanceToSurface`: the distance from `point` to the surface of `f` continued, as far +/// as it continues exactly: a plane's to the whole plane, any other surface's to its own +/// domain (`BIG_DOUBLE` when the drop fails). +/// +/// # Errors +/// `SM_ERR_NULL_POINTER` for a face without surface; those of the drop. +pub fn distance_to_surface(k: &Kernel, b: &Brep, f: FaceId, point: Point3) -> Result { + let surface = face::p2::surface(b, f)?.ok_or_else(|| SolidError::null_pointer(OP))?; + if let SurfaceKind::Plane(plane) = surface.kind() { + let frame = plane.position(); + return Ok((point - frame.origin()).dot(frame.z_axis()).abs()); + } + let surface = surface.clone_for_evaluation()?; + let domain = surface.natural_uv_domain_with_nurb(k)?; + let dropped = + surface.drop_point::(k, point, &domain, &DropPointOptions::default())?; + Ok(if dropped.success { + dropped.gap + } else { + BIG_DOUBLE + }) +} + +/// `sm_Samples`: `count + 1` points along a curve's natural interval, its ends among them, +/// appended to `points`. +fn samples(k: &Kernel, curve: &Curve, count: u32, points: &mut Vec) -> Result<()> { + let ivl = curve.natural_interval(); + for i in 0..=count { + points.push(curve.evaluate_point(k, ivl.evaluate(f64::from(i) / f64::from(count)))?); + } + Ok(()) +} + +/// `sm_CoveringSurface`: a surface that is `f`'s surface, whose domain holds every point of +/// `points` (all on it): a plane the same plane over a domain that covers them (a margin of +/// 1% of the box's diagonal and `10 tol`), any other surface a copy of itself when its own +/// domain does (`None`, not exact, when it does not). +/// +/// # Errors +/// `SM_ERR` for no points on a plane; `SM_ERR_NULL_POINTER` for a face without surface; +/// those of the distances and the copy. +pub fn covering_surface( + k: &Kernel, + b: &Brep, + f: FaceId, + points: &[Point3], + tol: f64, +) -> Result> { + let surface = face::p2::surface(b, f)?.ok_or_else(|| SolidError::null_pointer(OP))?; + if let SurfaceKind::Plane(plane) = surface.kind() { + // the plane through the same origin along the same axes, at unit scale: the same + // points + let frame = plane.position(); + let origin = frame.origin(); + let (x, y) = (frame.x_axis(), frame.y_axis()); + let mut bbox: Option = None; + for &p in points { + let d = p - origin; + let uv = Point2::new(d.dot(x), d.dot(y)); + match bbox.as_mut() { + None => bbox = Some(Extent2d::from_points(uv, uv)), + Some(bx) => bx.add_point(uv), + } + } + let Some(bbox) = bbox else { + return Err(SolidError::sm_err(OP)); + }; + let margin = 0.01 * bbox.size().length() + 10.0 * tol; + let domain = Extent2d::from_bounds( + bbox.min().x - margin, + bbox.min().y - margin, + bbox.max().x + margin, + bbox.max().y + margin, + ); + return Ok(Some(Surface::from(Plane::new( + origin, + x, + y, + Vec2::new(1.0, 1.0), + &domain, + )))); + } + // Within its own domain, or not exactly. + for &p in points { + if distance_to_surface(k, b, f, p)? > tol { + return Ok(None); + } + } + Ok(Some(surface.copy(k)?)) +} + +/// `sm_Same`: whether two points coincide to `tol`. +fn same(a: Point3, b: Point3, tol: f64) -> bool { + a.distance(b) <= tol +} + +// ---- CutFilletAtOpenEnd ------------------------------------------------------------------- + +/// The fork's `SmFilletExecutive::CutFilletAtOpenEnd`: cut the fillet off at an end its Nx1 +/// corner left uncut, by the end face's surface continued. +/// +/// The fillet faces of the end's solver (`faces`, one per fillet geometry, the face its +/// fillet surface is on; `None` for none) run on past the end, their rails past their side +/// edges. The end face (`end_surface`, planar) continues as its plane: the plane sections +/// each fillet face, its section nearest the end's vertex (`vertex_point`, the end rib) +/// splits it (`MergeCurvesOnSurface` with the plane as the intersection surface), and of the +/// two pieces the one beyond the end is deleted: the piece kept holds the fillet's point +/// nearest `edge_mid`, the middle of the filleted edge. A kept new piece becomes the +/// geometry's face in `faces`. Refused (`NoRoom`, in `note`) for an end face that is not +/// planar and for a rib that does not split a face in two or part it from its middle. +/// +/// # Errors +/// The refusals as `SM_ERR`; `SM_ERR` for a failed drop of the edge's middle; those of the +/// section, the split, the classifications and the deletion. +#[allow(clippy::too_many_arguments)] +pub fn cut_fillet_at_open_end( + k: &mut Kernel, + region: &mut Brep, + faces: &mut [Option], + end_surface: &Surface, + vertex_point: Point3, + edge_mid: Point3, + tol: f64, + note: &mut Option, +) -> Result<()> { + let planar = end_surface.is_planar(k, EFF_ZERO)?; + if !planar.is_planar { + return refuse( + note, + Refusal::no_room( + NoRoomReason::CliffEndFaceNotPlanar, + "Fillet Error: a cliff overflow runs past a fillet end whose end face is not planar", + ), + ); + } + let (at, normal) = (planar.point, planar.normal); + let cut = Plane::from_normal(at, normal); + + for slot in faces.iter_mut() { + let Some(f) = *slot else { + continue; + }; + let surface = face::p2::surface(region, f)? + .ok_or_else(|| SolidError::null_pointer(OP))? + .clone_for_evaluation()?; + let uv_domain = face::p2::uv_domain(region, f)?; + let dropped = surface.drop_point::( + k, + edge_mid, + &uv_domain, + &DropPointOptions::default(), + )?; + if !dropped.success { + return Err(SolidError::sm_err(OP)); + } + let keep = surface.evaluate_point(k, dropped.result_uv)?; + + // section the face by the end's plane, and split it there + let section = face_planar_section_curves( + k, + region, + f, + &surface, + &uv_domain, + at, + normal, + Some(tol), + None, + )?; + if section.curves_3d.is_empty() { + continue; // this face does not reach the end + } + // The end rib: the section nearest the edge's end (a fillet traced far past its end + // can cross the plane again out there, where it is cut off anyway). + let mut rib = 0; + let mut nearest = BIG_DOUBLE; + for (c, curve) in section.curves_3d.iter().enumerate() { + let on = curve.drop_point( + k, + &curve.natural_interval(), + vertex_point, + None, + BIG_DOUBLE, + None, + SolverOperation::Minimize, + )?; + if on.success && on.distance < nearest { + nearest = on.distance; + rib = c; + } + } + let mut rib_3d: Vec = section.curves_3d.get(rib).cloned().into_iter().collect(); + let rib_uv: Option>> = (section.uv_curves.len() + == section.curves_3d.len()) + .then(|| vec![section.uv_curves.get(rib).cloned().flatten()]); + let (new_faces, _) = with_kernel_callbacks(k, region, |k, b, env| { + let serial = b.notify_serial(); + let curve_ref = |e: EdgeId, kind: i64| crate::geom::object::ObjectRef { + key: match serial { + Some(s) => crate::topology::brep::p3::topology_ref(s, EntityRef::Edge(e)).key, + None => crate::topology::loopuse::notify_ref(EntityRef::Edge(e)).key, + }, + type_code: kind, + }; + crate::topology::brep::p10::merge_curves_on_surface_through_surface( + k, + b, + env, + &curve_ref, + f, + tol, + &mut rib_3d, + rib_uv.as_deref(), + Some(Surface::from(cut.clone())), + ) + })?; + let [new_face] = new_faces[..] else { + if new_faces.is_empty() { + continue; // the section only touches it + } + return refuse( + note, + Refusal::no_room( + NoRoomReason::CliffEndNotCut, + "Fillet Error: the end face's plane does not cut a fillet that overflows its end in two", + ), + ); + }; + // keep the piece holding the fillet's middle, delete the one beyond the end + let pieces = [f, new_face]; + let mut holds = [false, false]; + for (piece, held) in pieces.iter().zip(holds.iter_mut()) { + let zone = face::p2::tolerance(region, *piece)?; + let mut class = PointClassification::new(zone); + point_3d_classify( + k, + region, + *piece, + keep, + zone, + true, + &mut class, + Point3dClassifyOptions::default(), + )?; + *held = class.point_class() != PointClassType::Unknown; + } + let [held_first, held_new] = holds; + if held_first == held_new { + return refuse( + note, + Refusal::no_room( + NoRoomReason::CliffEndNotCut, + "Fillet Error: the end face's plane does not part a fillet that overflows its end from its middle", + ), + ); + } + let beyond = if held_first { new_face } else { f }; + if held_new { + // the kept piece is the fillet surface's face from here on + *slot = Some(new_face); + } + with_kernel_callbacks(k, region, |k, b, env| { + let mut marks = Marks::default(); + delete_face( + k, + b, + env, + &mut marks, + beyond, + DeleteFaceOptions { + delete_edges_vertices: true, + ..DeleteFaceOptions::default() + }, + ) + })?; + } + Ok(()) +} + +// ---- CloseRemovalRegion ------------------------------------------------------------------- + +/// The filleted edges of a cliff overflow and what [`close_removal_region`] reads of them. +#[derive(Debug, Clone, Default)] +pub struct Blended { + /// `sBlended`: the filleted edges of the target. + pub edges: BTreeSet, + /// `sBlendedVerts`: their vertices. + pub vertices: BTreeSet, + /// `vHosts`: the faces round those vertices, in first-seen order. + pub hosts: Vec, +} + +impl Blended { + /// The filleted edges `edges` (one per solver, in solver order), their vertices and the + /// faces round them, each face once in the order first seen (`GetFaces` of each start + /// then end vertex). + /// + /// # Errors + /// `SM_ERR_NULL_POINTER` for an edge without both vertices; those of the lookups. + pub fn of_edges(target: &Brep, edges: &[EdgeId]) -> Result { + let mut out = Self::default(); + let mut host_set = BTreeSet::new(); + for &e in edges { + out.edges.insert(e); + let (start, end) = edge::start_end_vertices(target, e)?; + for v in [start, end] { + let v = v.ok_or_else(|| SolidError::null_pointer(OP))?; + out.vertices.insert(v); + for f in vertex::faces(target, v)? { + if host_set.insert(f) { + out.hosts.push(f); + } + } + } + } + Ok(out) + } +} + +/// The lamina edges of the sheet and their hosts (the first half of +/// `CloseRemovalRegion`). +#[derive(Debug, Clone, Default)] +pub struct LaminaHosts { + /// `vLamina`: the sheet's lamina edges, in its edge order. + pub lamina: Vec, + /// `sHostOf`: the target face each lies on. + pub host_of: BTreeMap, + /// `sLaminaAt`: the lamina edges at each sheet vertex, in [`Self::lamina`] order. + pub at: BTreeMap>, +} + +impl LaminaHosts { + fn host(&self, e: EdgeId) -> Result { + self.host_of + .get(&e) + .copied() + .ok_or_else(|| SolidError::null_pointer(OP)) + } + + fn at(&self, v: VertexId) -> &[EdgeId] { + self.at.get(&v).map_or(&[], Vec::as_slice) + } +} + +/// The host test of `CloseRemovalRegion`: each lamina edge of the sheet lies on the surface +/// (continued) of exactly one target face round the filleted edges' vertices, at five points +/// along it within `on_tol`. Refused when one lies on two (`NoRoom`) or none (`NotExact`). +/// +/// # Errors +/// The refusals as `SM_ERR`; those of the curves, samples and distances. +pub fn lamina_hosts( + k: &Kernel, + region: &Brep, + target: &Brep, + hosts: &[FaceId], + on_tol: f64, + note: &mut Option, +) -> Result { + let mut out = LaminaHosts::default(); + for e in crate::topology::brep::p1::edges(region, None)? { + if !edge::is_lamina(region, e)? { + continue; + } + let curve = edge_curve(k, region, e)?; + let mut points = Vec::new(); + samples(k, &curve, 4, &mut points)?; + let mut host = None; + for &f in hosts { + let mut on = true; + for &p in &points { + if !on { + break; + } + on = distance_to_surface(k, target, f, p)? <= on_tol; + } + if !on { + continue; + } + if host.is_some() { + return refuse( + note, + Refusal::no_room( + NoRoomReason::CliffBoundaryOnTwoFaces, + "Fillet Error: a cliff overflow's fillet boundary lies on the surfaces of two faces", + ), + ); + } + host = Some(f); + } + let Some(host) = host else { + return refuse( + note, + Refusal::not_exact( + "Fillet Error: a cliff overflow's fillet boundary lies on no face's exact continuation", + ), + ); + }; + out.lamina.push(e); + out.host_of.insert(e, host); + let (start, end) = edge::start_end_vertices(region, e)?; + for v in [start, end] { + let v = v.ok_or_else(|| SolidError::null_pointer(OP))?; + out.at.entry(v).or_default().push(e); + } + } + Ok(out) +} + +/// A chain of consecutive lamina edges on one host, from its start to its end vertex +/// (`sm_Chain`). +#[derive(Debug, Clone)] +pub struct Chain { + /// `m_pHost`. + pub host: FaceId, + /// `m_vEdges`. + pub edges: Vec, + /// `m_vVerts`: one more than the edges. + pub vertices: Vec, +} + +/// The chain walk of `CloseRemovalRegion`: the lamina edges in chains of consecutive edges +/// on one host, each from the first edge not yet in a chain, forward from its end vertex, +/// then backward from its start vertex. Refused (`NoRoom`) where the boundary branches on a +/// face or closes on one. +/// +/// # Errors +/// The refusals as `SM_ERR`; `SM_ERR` past the walk's bound (the lamina edge count); +/// those of the lookups. +pub fn lamina_chains( + region: &Brep, + hosts: &LaminaHosts, + note: &mut Option, +) -> Result> { + let bound = hosts.lamina.len(); + let mut chains = Vec::new(); + let mut done: BTreeSet = BTreeSet::new(); + for &first in &hosts.lamina { + if done.contains(&first) { + continue; + } + let host = hosts.host(first)?; + // the next lamina edge on the same host at `v`, other than `from` (`None` at a + // chain's end; `Err` at a branch) + let next = |v: VertexId, from: EdgeId| -> Result, ()>> { + let mut found = None; + for &e in hosts.at(v) { + if e == from || hosts.host(e)? != host { + continue; + } + if found.is_some() { + return Ok(Err(())); // a branch of the boundary + } + found = Some(e); + } + Ok(Ok(found)) + }; + let (start, end) = edge::start_end_vertices(region, first)?; + let start = start.ok_or_else(|| SolidError::null_pointer(OP))?; + let end = end.ok_or_else(|| SolidError::null_pointer(OP))?; + let mut chain = Chain { + host, + edges: vec![first], + vertices: vec![start, end], + }; + done.insert(first); + // forward from the first edge's end vertex + let mut closed = false; + let mut steps = 0; + loop { + steps += 1; + if steps > bound { + return Err(SolidError::sm_err(OP)); + } + let (Some(&at), Some(&from)) = (chain.vertices.last(), chain.edges.last()) else { + return Err(SolidError::null_pointer(OP)); + }; + let Ok(found) = next(at, from)? else { + return refuse(note, BRANCHES); + }; + let Some(found) = found else { + break; + }; + if found == first { + closed = true; + break; + } + let other = edge::other_vertex(region, found, Some(at))? + .ok_or_else(|| SolidError::null_pointer(OP))?; + chain.edges.push(found); + chain.vertices.push(other); + done.insert(found); + } + if closed { + return refuse( + note, + Refusal::no_room( + NoRoomReason::CliffBoundaryClosed, + "Fillet Error: a cliff overflow's fillet boundary closes on one face", + ), + ); + } + // backward from the first edge's start vertex + let mut at = start; + let mut from = first; + let mut steps = 0; + loop { + steps += 1; + if steps > bound { + return Err(SolidError::sm_err(OP)); + } + let Ok(found) = next(at, from)? else { + return refuse(note, BRANCHES); + }; + let Some(found) = found else { + break; + }; + let other = edge::other_vertex(region, found, Some(at))? + .ok_or_else(|| SolidError::null_pointer(OP))?; + chain.edges.insert(0, found); + chain.vertices.insert(0, other); + done.insert(found); + at = other; + from = found; + } + chains.push(chain); + } + Ok(chains) +} + +/// The side edge at one end of a chain: the filleted edges' vertex it starts at, and the +/// connector from that vertex to the chain's end (`None` when the chain ends at the vertex). +struct SideEnd { + vertex: VertexId, + connector: Option, +} + +/// At the chain's end `end_vertex` (of the sheet; its edge there `mine`), the side edge of +/// `host` the sheet's boundary passes along onto the next host, from a filleted edges' +/// vertex: the chain's end lies at the vertex, on the side edge, or on its line past its +/// other end (a line only). +#[allow(clippy::too_many_arguments)] +fn side_end( + k: &Kernel, + region: &Brep, + target: &Brep, + blended: &Blended, + hosts: &LaminaHosts, + host: FaceId, + end_vertex: VertexId, + mine: EdgeId, + on_tol: f64, + note: &mut Option, +) -> Result { + let mut next_host = None; + for &e in hosts.at(end_vertex) { + if e == mine { + continue; + } + if next_host.is_some() || hosts.host(e)? == host { + return refuse(note, BRANCHES); + } + next_host = Some(hosts.host(e)?); + } + let next_host = next_host.ok_or_else(|| SolidError::null_pointer(OP))?; + let p = vertex::point(region, end_vertex)?; + let mut inexact = false; + let mut found: Option = None; + for side in face::p4::edges(target, host, None, false, None)? { + if found.is_some() { + break; + } + if blended.edges.contains(&side) { + continue; + } + if !edge::faces(target, side, None)?.contains(&next_host) { + continue; + } + let (start, end) = edge::start_end_vertices(target, side)?; + for v in [start, end] { + let Some(v) = v else { + continue; + }; + if found.is_some() || !blended.vertices.contains(&v) { + continue; + } + let pv = vertex::point(target, v)?; + if same(pv, p, on_tol) { + // the chain ends at the vertex itself: no connector + found = Some(SideEnd { + vertex: v, + connector: None, + }); + continue; + } + // on the side edge itself + let side_curve = + edge::curve(target, side)?.ok_or_else(|| SolidError::null_pointer(OP))?; + let side_ivl = edge::interval(target, side)?; + let on = side_curve.drop_point( + k, + &side_ivl, + p, + None, + BIG_DOUBLE, + None, + SolverOperation::Minimize, + )?; + if on.success && on.distance <= on_tol { + let on_v = side_curve.drop_point( + k, + &side_ivl, + pv, + None, + BIG_DOUBLE, + None, + SolverOperation::Minimize, + )?; + if !on_v.success || on_v.distance > on_tol { + return Err(SolidError::sm_err(OP)); + } + let mut piece = side_curve.copy(k)?; + let mut part = crate::geom::extent1d::Extent1d::from_min_max( + smos_min(on.parameter, on_v.parameter), + smos_max(on.parameter, on_v.parameter), + ); + piece.trim(k, &mut part, TrimMode::Notify)?; + found = Some(SideEnd { + vertex: v, + connector: Some(piece), + }); + continue; + } + // on its line past its other end + let w = edge::other_vertex(target, side, Some(v))? + .ok_or_else(|| SolidError::null_pointer(OP))?; + let along = vertex::point(target, w)? - pv; + let out = p - pv; + let len = along.length(); + if len <= on_tol || out.dot(along) <= 0.0 { + continue; + } + let cross = out.cross(along); + if cross.length() / len > on_tol || out.length() <= len { + continue; + } + if !side_curve.is_linear(k, EFF_ZERO, false)?.linear { + inexact = true; // the chain's end lies along it, but it is no line + continue; + } + found = Some(SideEnd { + vertex: v, + connector: Some(Curve::from(Line::from_points(pv, p, 3))), + }); + } + } + match found { + Some(found) => Ok(found), + None if inexact => refuse( + note, + Refusal::not_exact( + "Fillet Error: a cliff overflow needs a side edge continued past its end that is not a line", + ), + ), + None => refuse( + note, + Refusal::no_room( + NoRoomReason::CliffBoundaryLeavesAlongNoSideEdge, + "Fillet Error: a cliff overflow's fillet boundary leaves a face along no side edge of the filleted edges", + ), + ), + } +} + +/// The closing face of one chain: on its host's surface, along the chain, back along the +/// side edge at its end to the filleted edges' vertex there, along the host's filleted +/// edges to the vertex at the chain's start, and out along that side edge to the chain's +/// start; on the host's covering surface, anticlockwise by the loop's signed area in the +/// surface's parameters; made in the sheet's infinite region. +/// +/// # Errors +/// The refusals as `SM_ERR`; `SM_ERR` past the walk's bound and for a failed drop; those of +/// the curves, the covering surface and the face construction. +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] // one fork routine +pub fn closing_face( + k: &mut Kernel, + region: &mut Brep, + target: &Brep, + blended: &Blended, + hosts: &LaminaHosts, + chain: &Chain, + on_tol: f64, + note: &mut Option, +) -> Result { + let host = chain.host; + let mut lp: Vec = Vec::new(); + + // the chain, from its start to its end + for (e, pair) in chain.edges.iter().zip(chain.vertices.windows(2)) { + let [from, to] = pair else { + return Err(SolidError::null_pointer(OP)); + }; + let curve = edge_curve(k, region, *e)?; + add_curve( + k, + &mut lp, + curve, + vertex::point(region, *from)?, + vertex::point(region, *to)?, + )?; + } + + // At each end of the chain, the side edge the sheet's boundary passes along onto the + // next host, and the filleted edges' vertex it starts at. + let (Some(&front_v), Some(&back_v), Some(&front_e), Some(&back_e)) = ( + chain.vertices.first(), + chain.vertices.last(), + chain.edges.first(), + chain.edges.last(), + ) else { + return Err(SolidError::null_pointer(OP)); + }; + let start_side = side_end( + k, region, target, blended, hosts, host, front_v, front_e, on_tol, note, + )?; + let end_side = side_end( + k, region, target, blended, hosts, host, back_v, back_e, on_tol, note, + )?; + + // from the chain's end back along its side edge to the vertex + if let Some(connector) = end_side.connector { + add_curve( + k, + &mut lp, + connector, + vertex::point(region, back_v)?, + vertex::point(target, end_side.vertex)?, + )?; + } + // along the filleted edges of the host from there to the other end's vertex + let host_edges = face::p4::edges(target, host, None, false, None)?; + let mut at = end_side.vertex; + let mut from: Option = None; + let mut steps = 0; + while at != start_side.vertex { + if steps > host_edges.len() { + return Err(SolidError::sm_err(OP)); + } + steps += 1; + let mut found = None; + for &e in &host_edges { + if Some(e) == from || !blended.edges.contains(&e) { + continue; + } + let (s, t) = edge::start_end_vertices(target, e)?; + if s != Some(at) && t != Some(at) { + continue; + } + if found.is_some() { + return refuse( + note, + Refusal::no_room( + NoRoomReason::CliffClosingFaceUnbounded, + "Fillet Error: a cliff overflow's closing face meets two filleted edges at a vertex", + ), + ); + } + found = Some(e); + } + let Some(found) = found else { + return refuse( + note, + Refusal::no_room( + NoRoomReason::CliffClosingFaceUnbounded, + "Fillet Error: a cliff overflow's closing face is not bounded by filleted edges between its side edges", + ), + ); + }; + let other = edge::other_vertex(target, found, Some(at))? + .ok_or_else(|| SolidError::null_pointer(OP))?; + let curve = edge_curve(k, target, found)?; + add_curve( + k, + &mut lp, + curve, + vertex::point(target, at)?, + vertex::point(target, other)?, + )?; + from = Some(found); + at = other; + } + // out along the start's side edge to the chain's start + if let Some(connector) = start_side.connector { + add_curve( + k, + &mut lp, + connector, + vertex::point(target, start_side.vertex)?, + vertex::point(region, front_v)?, + )?; + } + + // the host's surface over the loop, exactly + let mut points = Vec::new(); + for c in &lp { + samples(k, &c.curve, 8, &mut points)?; + } + let Some(surface) = covering_surface(k, target, host, &points, on_tol)? else { + return refuse( + note, + Refusal::not_exact( + "Fillet Error: a cliff overflow needs a face's surface continued past its domain, which is not a plane", + ), + ); + }; + + // anticlockwise on the surface: the loop's points in its parameters, by their signed + // area + let domain = surface.natural_uv_domain_with_nurb(k)?; + let mut uvs: Vec = Vec::new(); + for c in &lp { + let mut along = Vec::new(); + samples(k, &c.curve, 8, &mut along)?; + if c.orient == OrientType::Opposite { + along.reverse(); + } + let count = along.len().saturating_sub(1); + for &p in along.iter().take(count) { + let dropped = + surface.drop_point::(k, p, &domain, &DropPointOptions::default())?; + if !dropped.success { + return Err(SolidError::sm_err(OP)); + } + uvs.push(dropped.result_uv); + } + } + let mut area = 0.0; + for (i, a) in uvs.iter().enumerate() { + let Some(b) = uvs.get((i + 1) % uvs.len()) else { + return Err(SolidError::null_pointer(OP)); + }; + area += a.x * b.y - b.x * a.y; + } + if area < 0.0 { + lp.reverse(); + for c in &mut lp { + c.orient = if c.orient == OrientType::Same { + OrientType::Opposite + } else { + OrientType::Same + }; + } + } + + let input = FaceCurvesInput { + loops: vec![lp.len()], + curves_3d: Some(lp.iter().map(|c| Some(c.curve.clone())).collect()), + uv_curves: None, + orientations: lp.iter().map(|c| c.orient).collect(), + loop_points: Vec::new(), + }; + let made = with_kernel_callbacks(k, region, |k, b, env| { + let region_id = b.infinite_region(); + let key_of = env.key_of; + let face_ref = |f: FaceId| key_of(EntityRef::Face(f)); + make_face_with_curves( + k, + b, + region_id, + input, + surface, + &domain, + OrientType::Same, + None, + &face_ref, + env, + ) + })?; + made.face.ok_or_else(|| SolidError::null_pointer(OP)) +} + +/// The fork's `SmFilletExecutive::CloseRemovalRegion`: close `region`, the sheet of fillet +/// and corner faces, into the region the blends remove: a face on the surface of each +/// target face its boundary runs on. +/// +/// Each lamina edge of the sheet lies on the surface (continued) of one target face round +/// the filleted edges' vertices: its host ([`lamina_hosts`]). Consecutive lamina edges on +/// one host form a chain ([`lamina_chains`]). Where a chain ends the sheet's boundary passes +/// onto the next host X, along the side edge s of the chain's host F that F shares with X +/// and that starts at a filleted edge's vertex v: the chain's end lies on s, or on s's line +/// past s's other end. The closing face on F's surface runs along the chain, back along s +/// to v, along the filleted edges of F from v to the other end's vertex, and out along +/// that end's side edge to the chain's start ([`closing_face`]). `tol` is the solvers' +/// largest tolerance; points lie on a surface within ten times it. +/// +/// # Errors +/// The refusals as `SM_ERR`, noted in `note`; those of the steps. +pub fn close_removal_region( + k: &mut Kernel, + region: &mut Brep, + target: &Brep, + blended: &Blended, + tol: f64, + note: &mut Option, +) -> Result<()> { + let on_tol = 10.0 * tol; + let hosts = lamina_hosts(k, region, target, &blended.hosts, on_tol, note)?; + let chains = lamina_chains(region, &hosts, note)?; + for chain in &chains { + closing_face(k, region, target, blended, &hosts, chain, on_tol, note)?; + } + Ok(()) +} diff --git a/crates/solidcore/src/blend/fillet_corner/p1.rs b/crates/solidcore/src/blend/fillet_corner/p1.rs index d2a9547ba2..841f53b0ce 100644 --- a/crates/solidcore/src/blend/fillet_corner/p1.rs +++ b/crates/solidcore/src/blend/fillet_corner/p1.rs @@ -171,6 +171,8 @@ use crate::nurbs::b_spline_surface_n_lib::p2::OrientType; use crate::nurbs::knots::Side; use crate::query::brep::{BrepFaceServices, FaceCaches}; use crate::surface::plane::Plane; +use crate::surface::surface::p4::NoObject; +use crate::surface::surface::p5::DropPointOptions; use crate::surface::surface::Surface; use crate::topology::brep::p7::{make_face_with_curves, FaceCurvesInput}; use crate::topology::brep_cache::CacheObject; @@ -1468,33 +1470,70 @@ impl FilletCorner { /// The start of the fork's `SmFilletNx1Corner::CalcCornerEdgeGeom` (not upstream; opt-in /// `fixes.fillet_nx1_end_rib_global_merge`, WR-303, tangency-labs/solidcore#841; - /// tangency-labs/smlib `967f7ab`). At a corner of at most three edges the one corner - /// edge is the end rib, from one rail's end on its side edge to the other's. When - /// one rail missed its side edge (`SM_FV_NO_INT_RAIL_EU`) because that edge ends - /// within the fillet's width, there is no end rib: it is deleted and the caller sets - /// the executive's global merge, which cuts the round off where it meets the body. - /// Not when the side edge also lies on the fillet's other side surface: it continues - /// the filleted edge between the fillet's own two surfaces, no end exists there, and - /// the end rib fails as before. Returns whether the rib was deleted. + /// tangency-labs/smlib `967f7ab`), outside a cliff overflow: see + /// [`FilletCorner::drop_end_rib`]. Returns whether the rib was deleted for the global + /// merge. /// /// # Errors - /// `SM_ERR_NULL_POINTER` for a missing edge use, face, surface or curve (upstream - /// dereferences them); those of the surface coincidence test and the deletion. + /// Those of [`FilletCorner::drop_end_rib`]. pub fn drop_end_rib_for_global_merge( &mut self, k: &mut Kernel, target: &mut Brep, pseudo: &mut FilletBrep, ) -> Result { - const OP: &str = "blend::fillet_corner::p1::drop_end_rib_for_global_merge"; + let mut no_open_ends = |_, _, _| Ok(()); + Ok( + self.drop_end_rib(k, target, pseudo, false, &mut no_open_ends)? + == EndRibDrop::GlobalMerge, + ) + } + + /// The start of the fork's `SmFilletNx1Corner::CalcCornerEdgeGeom` (not upstream; opt-in + /// `fixes.fillet_nx1_end_rib_global_merge`, WR-303, tangency-labs/solidcore#841; + /// tangency-labs/smlib `967f7ab`; in a cliff overflow, `fixes.fillet_cliff_overflow`, + /// WR-631, tangency-labs/smlib `8ba4667` and `3f22e97`). At a corner of at most three + /// edges the one corner edge is the end rib, from one rail's end on its side edge to + /// the other's. When one rail missed its side edge (`SM_FV_NO_INT_RAIL_EU`) because + /// that edge ends within the fillet's width, there is no end rib: it is deleted and + /// the caller sets the executive's global merge, which cuts the round off where it + /// meets the body. Not when the side edge also lies on the fillet's other side + /// surface: it continues the filleted edge between the fillet's own two surfaces, no + /// end exists there, and the end rib fails as before. + /// + /// In a cliff overflow (`cliff_overflow`, the executive's `m_bCliffOverflow`) a rail + /// end off the fillet surface (farther than ten times the corner's tolerance), or + /// found on its side edge's line past the edge's end (off the edge by more than the + /// tolerance), is a miss too, and its fillet vertex's status becomes + /// `SM_FV_NO_INT_RAIL_EU`; both rails may miss. A miss that does not run on leaves the + /// end uncut: `note_open_end` (the executive's `NoteOpenEnd`) takes the filleted + /// edge's edge use, the corner's vertex and the end face, then the end rib is deleted + /// ([`EndRibDrop::OpenEnd`]), without the global merge. Both rails missing outside + /// that case is no global merge either. + /// + /// # Errors + /// `SM_ERR_NULL_POINTER` for a missing edge use, face, surface or curve (upstream + /// dereferences them; in a cliff overflow, the end rib's fillet surface); `SM_ERR` + /// for an open end whose end face is a face of the filleted edge; those of the + /// drops, the surface coincidence test and the deletion. + #[allow(clippy::too_many_lines)] // one fork routine + pub fn drop_end_rib( + &mut self, + k: &mut Kernel, + target: &mut Brep, + pseudo: &mut FilletBrep, + cliff_overflow: bool, + note_open_end: &mut dyn FnMut(EdgeuseId, VertexId, FaceId) -> Result<()>, + ) -> Result { + const OP: &str = "blend::fillet_corner::p1::drop_end_rib"; let [end_rib] = self.edges[..] else { - return Ok(false); + return Ok(EndRibDrop::Kept); }; - if !k.fixes.fillet_nx1_end_rib_global_merge + if !(k.fixes.fillet_nx1_end_rib_global_merge || cliff_overflow) || !matches!(self.kind, FilletCornerKind::NByOne | FilletCornerKind::Open) || pseudo.fillet_edge(end_rib)?.edge_type != FilletEdgeType::FilletXSideFace { - return Ok(false); + return Ok(EndRibDrop::Kept); } let start_fv = edge::vertex(pseudo.brep(), end_rib)?.ok_or_else(|| SolidError::null_pointer(OP))?; @@ -1506,19 +1545,93 @@ impl FilletCorner { && pseudo.fillet_vertex(other)?.is_processed(), ) }; - let miss_fv = if missed(start_fv, end_fv)? { - start_fv + let mut miss_fv = if missed(start_fv, end_fv)? { + Some(start_fv) } else if missed(end_fv, start_fv)? { - end_fv + Some(end_fv) } else { - return Ok(false); + None + }; + // The fork's (8ba4667, 3f22e97): in a cliff overflow a rail can miss its side edge + // with the intersection clamped to the side edge's far vertex, off the fillet, or + // found on the side edge's line past its end: that is a miss too; and both rails + // can miss. + let mut misses = [false, false]; + if cliff_overflow { + misses = [ + pseudo.fillet_vertex(start_fv)?.status() == FilStatus::NoIntRailEu, + pseudo.fillet_vertex(end_fv)?.status() == FilStatus::NoIntRailEu, + ]; + let rib_eu = eu1::primary_edgeuse(pseudo.brep(), end_rib)?; + let rib_geom = match rib_eu { + Some(eu) => pseudo.fillet_edgeuse(eu)?.fillet_geom(), + None => None, + }; + let fillet_surface = match rib_geom { + Some(g) => pseudo.geom(g)?.fillet_surface(None)?.cloned(), + None => None, + } + .ok_or_else(|| SolidError::null_pointer(OP))?; + let domain = fillet_surface.natural_uv_domain_with_nurb(k)?; + for (miss, fv) in misses.iter_mut().zip([start_fv, end_fv]) { + if *miss || !pseudo.fillet_vertex(fv)?.is_processed() { + continue; + } + let point = pseudo.vertex_point(fv)?; + let dropped = fillet_surface.drop_point::( + k, + point, + &domain, + &DropPointOptions::default(), + )?; + let mut off = !dropped.success || dropped.gap > 10.0 * self.approx_tol_3d; + // The rail's end found on the side edge's line continued past the edge's + // end (SmFilletSolver::RailEdgeuseIntersect continues a straight side + // edge): the rail misses the side edge itself, however close past the end. + let record = pseudo.fillet_vertex(fv)?; + if !off && record.point_class() == PointClassType::Edgeuse { + let Some(EntityRef::Edgeuse(side_eu)) = record.point_class_object() else { + return Err(SolidError::null_pointer(OP)); + }; + let side_edge = + eu1::edge(target, side_eu)?.ok_or_else(|| SolidError::null_pointer(OP))?; + let curve = edge::curve(target, side_edge)? + .ok_or_else(|| SolidError::null_pointer(OP))?; + let on_side = curve.drop_point( + k, + &edge::interval(target, side_edge)?, + point, + None, + BIG_DOUBLE, + None, + SolverOperation::Minimize, + )?; + off = !on_side.success || on_side.distance > self.approx_tol_3d; + } + if off { + *miss = true; + // its rail is not trimmed there + pseudo + .fillet_vertex_mut(fv)? + .set_status(FilStatus::NoIntRailEu); + } + } + match misses { + [true, _] => miss_fv = Some(start_fv), + [false, true] => miss_fv = Some(end_fv), + [false, false] => {} + } + } + let both_miss = cliff_overflow && misses == [true, true]; + let Some(miss_fv) = miss_fv else { + return Ok(EndRibDrop::Kept); }; let fv = pseudo.fillet_vertex(miss_fv)?; let Some(EntityRef::Edgeuse(side_eu)) = fv.point_class_object() else { - return Ok(false); + return Ok(EndRibDrop::Kept); }; if fv.point_class() != PointClassType::Edgeuse { - return Ok(false); + return Ok(EndRibDrop::Kept); } // The side edge the rail missed, and the fillet's side face it does not bound. @@ -1534,6 +1647,7 @@ impl FilletCorner { eu1::face(target, eu)?.ok_or_else(|| SolidError::null_pointer(OP)) }; let side_faces = [face_of(side_eu)?, face_of(radial(side_eu)?)?]; + let fil_faces = [face_of(fil_eu)?, face_of(radial(fil_eu)?)?]; let mut other_face = face_of(fil_eu)?; if side_faces.contains(&other_face) { other_face = face_of(radial(fil_eu)?)?; @@ -1565,11 +1679,31 @@ impl FilletCorner { )? .is_some(); if runs_on { - return Ok(false); + return Ok(EndRibDrop::Kept); + } + if cliff_overflow { + // The end face: the side edge's face that is neither of the fillet's side faces. + let [first, second] = side_faces; + let end_face = if first == other_face || fil_faces.contains(&first) { + second + } else { + first + }; + if fil_faces.contains(&end_face) { + return Err(SolidError::sm_err(OP)); + } + let vertex = self.vertex.ok_or_else(|| SolidError::null_pointer(OP))?; + note_open_end(fil_eu, vertex, end_face)?; + pseudo.delete_wire_fillet_edge(k, end_rib)?; + self.edges.clear(); + return Ok(EndRibDrop::OpenEnd); + } + if both_miss || !k.fixes.fillet_nx1_end_rib_global_merge { + return Ok(EndRibDrop::Kept); } pseudo.delete_wire_fillet_edge(k, end_rib)?; self.edges.clear(); - Ok(true) + Ok(EndRibDrop::GlobalMerge) } /// `CalcCornerEdgeGeom`: `SmFilletEdge::CalcCornerEdgeGeom` on every corner fillet edge @@ -2826,6 +2960,18 @@ fn make_face( Ok(made.face) } +/// What [`FilletCorner::drop_end_rib`] did with an Nx1 corner's end rib. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndRibDrop { + /// The end rib stays. + Kept, + /// The end rib was deleted; the executive does a global merge (WR-303). + GlobalMerge, + /// The end rib was deleted in a cliff overflow and the fillet left uncut there, + /// noted as an open end (the executive's `NoteOpenEnd`, WR-631). + OpenEnd, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/solidcore/src/blend/fillet_executive/p3.rs b/crates/solidcore/src/blend/fillet_executive/p3.rs index 8809494ea6..63357fbdca 100644 --- a/crates/solidcore/src/blend/fillet_executive/p3.rs +++ b/crates/solidcore/src/blend/fillet_executive/p3.rs @@ -109,6 +109,26 @@ pub enum NoRoomReason { /// A rail leaves its face and the fillet ends on more than one face (the global merge, /// WR-631). OverflowWithGlobalMerge, + /// A variable round of a cliff overflow, traced on its faces' continuations, does not + /// hold its radius law (the fork's `sm_HoldsItsLaw`, WR-631). + CliffLawNotHeld, + /// A cliff overflow runs past a fillet end whose end face is not planar (WR-631). + CliffEndFaceNotPlanar, + /// The end face's plane does not cut a fillet that overflows its end in two, or does + /// not part it from its middle (WR-631). + CliffEndNotCut, + /// A cliff overflow's fillet boundary lies on the surfaces of two faces (WR-631). + CliffBoundaryOnTwoFaces, + /// A cliff overflow's fillet boundary branches on a face (WR-631). + CliffBoundaryBranches, + /// A cliff overflow's fillet boundary closes on one face (WR-631). + CliffBoundaryClosed, + /// A cliff overflow's fillet boundary leaves a face along no side edge of the filleted + /// edges (WR-631). + CliffBoundaryLeavesAlongNoSideEdge, + /// A cliff overflow's closing face meets two filleted edges at a vertex, or is not + /// bounded by filleted edges between its side edges (WR-631). + CliffClosingFaceUnbounded, } /// The initialized error-info members p3 reads or writes. Corner kinds use the @@ -2456,10 +2476,8 @@ impl FilletExecutive { /// together with a rail rolling over a tangent edge; an overflow with the global /// merge on (a fillet end on more than one face). /// - /// The cliff overflow's construction (the open ends and the removal region the - /// later #960 changes port) is not in this port yet: an accepted overflow is - /// refused too, as `fillet_refuse_rails_leaving_faces` alone refuses it, after - /// [`FilletExecutive::cliff_overflow`] is set. + /// An accepted overflow is built by the session's `DoFilleting`, which subtracts the + /// region the blends remove (`SubtractRemovalRegion`). /// /// # Errors /// The refusal as `SM_ERR`, noted in the error info; those of the checks. @@ -2518,11 +2536,6 @@ impl FilletExecutive { return self.record_no_room(reason, why); } self.cliff_overflow = true; - // the overflow's construction is not ported yet (see the doc) - return self.record_no_room( - NoRoomReason::RailLeavesFace, - "Fillet Error: a rail leaves the face it rolls on (the blend is larger than the faces allow)", - ); } Ok(()) } @@ -2979,6 +2992,13 @@ impl FilletExecutive { return Err(failure()); } self.shrink_fillet_faces(k, access)?; + // the fork's (WR-631): a cliff overflow is subtracted, on the live session only + if self.cliff_overflow { + return Err(SolidError::unported( + "SmFilletExecutive::SubtractRemovalRegion (fork)", + "blend.fillet_executive_p3", + )); + } let target = self.target.ok_or(invalid("missing target B-rep"))?; let editing = access.editing_enabled(target)?; access.set_editing_enabled(target, true)?; diff --git a/crates/solidcore/src/blend/fillet_executive/session.rs b/crates/solidcore/src/blend/fillet_executive/session.rs index ca0399e723..12bc0c4c50 100644 --- a/crates/solidcore/src/blend/fillet_executive/session.rs +++ b/crates/solidcore/src/blend/fillet_executive/session.rs @@ -59,9 +59,10 @@ use super::p2::{EdgeMapAccess, FilletParameters, FilletParametersAccess, SolverS use super::p3::{ BrepCreationAccess, CornerAdjustmentAccess, CornerEdgeAccess, CornerVertexAccess, CornerView, ErrorInfo, FaceConstructionAccess, FaceShrinkAccess, FaceStitchAccess, FilletExecutive, - RailClassificationAccess, RailTrimAccess, SolverGeometryAccess, StitchAttempt, VertexEntry, + OpenEnd, RailClassificationAccess, RailTrimAccess, SolverGeometryAccess, StitchAttempt, + VertexEntry, }; -use crate::blend::fillet_corner::p1::LawIntervalSolvers; +use crate::blend::fillet_corner::p1::{EndRibDrop, LawIntervalSolvers}; use crate::blend::fillet_corner::p3::{ CornerFilletBrep, CornerPatchEnv, CornerSolverStore, ExecutiveErrorObject, FilletCorner, FilletCornerKind, Nx2AdjustContext, TangentSplitEnv, TangentSplitOps, @@ -95,6 +96,7 @@ use crate::topology::{Brep, EdgeId, EdgeuseId, FaceId, VertexId}; mod big_radius; mod classification; +mod cliff_overflow; mod concave_nxn; mod corner_marks; mod crossing_rails; @@ -2173,6 +2175,9 @@ impl<'target> FilletSession<'target> { } } +/// The operation of the fork's `SmFilletExecutive::NoteOpenEnd` (WR-631). +const OP_NOTE_OPEN_END: &str = "blend::fillet_executive::session::note_open_end"; + impl CornerEdgeAccess for SessionObjects<'_> { fn edges_adjust_tangent( &mut self, @@ -2242,8 +2247,28 @@ impl CornerEdgeAccess for SessionObjects<'_> { let corner = corners .get_mut(id.0 as usize) .ok_or_else(|| invalid("corner is not live"))?; - // WR-303: the Nx1 corner's end rib hands the round to the global merge. - if corner.drop_end_rib_for_global_merge(k, target, pseudo)? { + // WR-303: the Nx1 corner's end rib hands the round to the global merge; in a + // cliff overflow (WR-631) the end is left open, noted for the subtraction. + let cliff_overflow = executive.cliff_overflow; + let mut open_ends = Vec::new(); + let mut note_open_end = |eu, vertex, end_face| -> Result<()> { + // GetFilletSolverOfEdgeuse(pFilEdgeuse), then NoteOpenEnd + let solver = executive + .fillet_solver_of_edgeuse(FilletObject::Topology( + FilletBody::Target, + EntityRef::Edgeuse(eu), + )) + .ok_or_else(|| SolidError::null_pointer(OP_NOTE_OPEN_END))?; + open_ends.push(OpenEnd { + solver, + vertex: FilletObject::Topology(FilletBody::Target, EntityRef::Vertex(vertex)), + end_face: FilletObject::Topology(FilletBody::Target, EntityRef::Face(end_face)), + }); + Ok(()) + }; + let dropped = corner.drop_end_rib(k, target, pseudo, cliff_overflow, &mut note_open_end)?; + executive.open_ends.append(&mut open_ends); + if dropped == EndRibDrop::GlobalMerge { executive.options.global_merge = true; } let mut context = SessionCornerEdgeContext { diff --git a/crates/solidcore/src/blend/fillet_executive/session/cliff_overflow.rs b/crates/solidcore/src/blend/fillet_executive/session/cliff_overflow.rs new file mode 100644 index 0000000000..b9969639bc --- /dev/null +++ b/crates/solidcore/src/blend/fillet_executive/session/cliff_overflow.rs @@ -0,0 +1,403 @@ +//! The cliff overflow's subtraction on the live session (not upstream): the fork's +//! `SmFilletExecutive::SubtractRemovalRegion` and `sm_HoldsItsLaw` +//! (`source/SMLib/src/SmFilletCliffOverflow.cpp`, tangency-labs/smlib `8ba4667`, `ae2a6de`, +//! rev `68fb9162`; `fixes.fillet_cliff_overflow`, WR-631, WR-638). The construction steps +//! are in [`crate::blend::fillet_cliff_overflow`]. +//! +//! | Rust | Fork | +//! |--------------------------------------------|-------------------------------------------| +//! | `FilletSession::subtract_removal_region` | `SmFilletExecutive::SubtractRemovalRegion` | +//! | `FilletSession::holds_its_law` (private) | `sm_HoldsItsLaw` | +//! +//! Deviations: +//! - **The removal region leaves the session first.** Once each round's law is checked +//! (which reads the fillet surfaces where the intermediate body owns them), the +//! intermediate body is taken out of the session, as the fork sets `m_pFilletBrep` to +//! `NULL` before the subtraction; the open ends are cut, the region closed, stitched and +//! oriented on it as a stand-alone body. The faces of each solver's fillet geometries are +//! followed through the cuts in a local map rather than by resetting the geometries' +//! fillet surfaces (`SetFilletSurface`), which nothing reads afterwards. +//! - **A failed subtraction keeps the target.** The Boolean consumes its operands, so the +//! target is copied before it and the copy put back when it fails (the fork's in-place +//! merge leaves whatever it reached). + +use std::collections::BTreeMap; + +use super::{invalid, FilletBody, FilletObject, FilletSession, SolverObject}; +use crate::blend::fillet_cliff_overflow::{ + close_removal_region, cut_fillet_at_open_end, Blended, Refusal, +}; +use crate::blend::fillet_executive::p3::NoRoomReason; +use crate::blend::fillet_geom::p1::{FilletErrorType, FilletGeomId, FilletSurfaceStorage}; +use crate::blend::fillet_solver::p1::FilletSurfaceGeneratorType; +use crate::boolean::api_brep::boolean; +use crate::error::{EntityRef, Result, SolidError}; +use crate::geom::a_object::BooleanOperation; +use crate::geom::attribute::ids; +use crate::kernel::Kernel; +use crate::math::{Vec3, EFF_ZERO, EFF_ZERO_SQRT, PI}; +use crate::surface::surface::p4::NoObject; +use crate::surface::surface::p5::DropPointOptions; +use crate::topology::brep::p3::{orient_trimmed_surfaces, with_kernel_callbacks}; +use crate::topology::topology_traverser::Marks; +use crate::topology::{edge, edgeuse::p1 as eu1, face, vertex, Brep, FaceId}; + +const OP: &str = "blend::fillet_executive::session::cliff_overflow"; + +/// `smos_Max` of two doubles. +fn smos_max(a: f64, b: f64) -> f64 { + if a > b { + a + } else { + b + } +} + +/// `smos_Min` of two doubles. +fn smos_min(a: f64, b: f64) -> f64 { + if a < b { + a + } else { + b + } +} + +/// The `SmStatus` upstream's `SER` returns for a failed callee (a `double` function's +/// status as its value, as `SmVariableRadiusFS::GetFilletRadius` returns it). +fn status_of(e: &SolidError) -> i32 { + match e { + SolidError::Upstream { code, .. } => *code, + SolidError::InvalidInput { .. } | SolidError::PortInvariant { .. } => { + crate::error::SM_ERR_INVALID_INPUT + } + _ => crate::error::SM_ERR, + } +} + +impl FilletSession<'_> { + /// `GetFilletErrorInfo()->NoteError(code, comment)` of a [`Refusal`]. + fn note_refusal(&mut self, refusal: Refusal) { + self.executive.error.code = refusal.code as i32; + self.executive.error.comment = refusal.comment.into(); + self.executive.error.no_room = refusal.reason; + } + + /// Note `note`'s refusal, if any, and pass `result` on. + fn noted(&mut self, note: Option, result: Result) -> Result { + if let Some(refusal) = note { + self.note_refusal(refusal); + } + result + } + + /// The fork's `sm_HoldsItsLaw`: whether a variable round on planar faces holds its + /// radius law between the filleted edge's ends. + /// + /// A rolling ball of radius r between two planes meeting at the interior angle a stands + /// off the edge by `r (1 / sin(a/2) - 1)` where its section crosses the wedge's + /// bisector. That point must lie on the fillet surface, at seven points along the edge, + /// to twenty times the solver's tolerance and a ten-thousandth of the radius; of the two + /// angles the planes make, and the bisector either way, the one that fits. Rounds of + /// other solvers, with other than a circular section, on faces that are not both + /// planes, with other than one fillet geometry, or along an edge that is not straight, + /// pass. + fn holds_its_law(&self, k: &Kernel, solver: FilletObject) -> Result { + let FilletObject::Solver(id) = solver else { + return Err(invalid("object is not a solver")); + }; + let object = self.objects.solver(id)?; + let SolverObject::VariableRadius(vr) = object else { + return Ok(true); + }; + let base = object.base(); + if base.fsg.as_ref().map( + crate::blend::fillet_solver::p1::FilletSurfaceGenerator::fillet_surface_generator_type, + ) != Some(FilletSurfaceGeneratorType::Circular) + { + return Ok(true); + } + let mut normals = [Vec3::UNDEF; 2]; + for (rail, normal) in normals.iter_mut().enumerate() { + let offset = base + .surface(rail)? + .ok_or_else(|| SolidError::null_pointer(OP))?; + let planar = offset.surface.base_surface().is_planar(k, EFF_ZERO)?; + if !planar.is_planar { + return Ok(true); + } + *normal = planar.normal; + } + let [n0, n1] = normals; + let phi = smos_max(-1.0, smos_min(1.0, n0.dot(n1))).acos(); + let [geom] = base.fillet_geoms[..] else { + return Ok(true); + }; + let Some(fillet) = self.objects.fillet_surface(geom)? else { + return Ok(true); + }; + let fillet = fillet.clone_for_evaluation()?; + let eu = base + .edgeuse(0)? + .ok_or_else(|| SolidError::null_pointer(OP))?; + let target = &*self.objects.target; + let e = eu1::edge(target, eu)?.ok_or_else(|| SolidError::null_pointer(OP))?; + let curve = edge::curve(target, e)?.ok_or_else(|| SolidError::null_pointer(OP))?; + if !curve.is_linear(k, EFF_ZERO, false)?.linear { + return Ok(true); + } + let interval = edge::interval(target, e)?; + let domain = fillet.natural_uv_domain_with_nurb(k)?; + let tol = 20.0 * base.approx_tol_3d; + // (the law evaluates through its own caches: a copy, the solver stays shared) + let mut law = vr.law.clone(); + let mut holds = true; + let mut i = 1; + while i < 8 && holds { + let t = interval.evaluate(f64::from(i) / 8.0); + i += 1; + let pnt = curve.evaluate_point(k, t)?; + // SmVariableRadiusFS::GetFilletRadius: SER inside a `double` function + let radius = match law.evaluate(t, &vr.law_interval, vr.law_orientation) { + Ok(values) => values.first().copied().unwrap_or(0.0), + Err(e) if e.is_unported() => return Err(e), + Err(e) => f64::from(status_of(&e)), + } + .abs(); + // The section's middle: along the wedge's bisector, r (1 / sin(a/2) - 1) off the + // edge. The planes' normals are both outward (or inward), the bisector along + // their sum and the angle pi less theirs, or one of each, the bisector along + // their difference. + let mut fits = false; + let mut c = 0; + while c < 4 && !fits { + let bisector = if c < 2 { n0 + n1 } else { n0 - n1 }; + let angle = if c < 2 { PI - phi } else { phi }; + let sign = if c % 2 == 1 { -1.0 } else { 1.0 }; + c += 1; + let len = bisector.length(); + if len <= EFF_ZERO_SQRT || angle <= EFF_ZERO_SQRT { + continue; + } + let stand_off = radius * (1.0 / (angle / 2.0).sin() - 1.0); + let middle = pnt + bisector * (sign * stand_off / len); + let dropped = fillet.drop_point::( + k, + middle, + &domain, + &DropPointOptions::default(), + )?; + // the solver's tolerance, and its circular sections' relative accuracy (a + // non-rational cubic across the round, within about 2e-5 of the radius) + fits = dropped.success && dropped.gap <= tol + 1.0e-4 * radius; + } + holds = fits; + } + Ok(holds) + } + + /// The face of the intermediate body each of `solver`'s fillet geometries' surfaces is + /// on (`pFG->GetFilletSurface()->GetOwner()` in `m_pFilletBrep`), `None` for none. + fn fillet_faces(&self, solver: FilletObject) -> Result)>> { + let FilletObject::Solver(id) = solver else { + return Err(invalid("object is not a solver")); + }; + let body = self + .objects + .intermediate_identity(EntityRef::Brep(crate::topology::BrepId::from_index(0)))?; + let mut out = Vec::new(); + for &g in &self.objects.solver(id)?.base().fillet_geoms { + let face = match self.objects.pseudo.geom(g)?.fillet_surface { + Some(FilletSurfaceStorage::ManagedFace { body: owner, face }) if owner == body => { + Some(face) + } + _ => None, + }; + out.push((g, face)); + } + Ok(out) + } + + /// The fork's `SmFilletExecutive::SubtractRemovalRegion` (WR-631, `8ba4667`; its errors + /// as `ae2a6de` keeps them, WR-638): in a cliff overflow, subtract the region the blends + /// remove from the target: the blends built on their faces' continuations, then cut by + /// what lies beyond the cliffs. + /// + /// Each variable round on planes must hold its law (`NoRoom` otherwise); each open end + /// is cut ([`cut_fillet_at_open_end`]); the intermediate body is closed into the + /// removal region ([`close_removal_region`]), stitched, and oriented as a solid; a + /// region that is not a manifold solid without lamina edges is `BadMerge`. The fillet + /// edges' attribute leaves the region's edges, and the region is subtracted from the + /// target by the manifold Boolean (`BadMerge` when that fails). Each refusal is noted + /// in the error info. + /// + /// # Errors + /// The refusals as `SM_ERR`; those of the steps. + #[allow(clippy::too_many_lines)] // one fork routine + pub(super) fn subtract_removal_region(&mut self, k: &mut Kernel) -> Result<()> { + if self.objects.intermediate.is_none() { + return Err(SolidError::null_pointer(OP)); + } + let solvers = self.executive.solvers.clone(); + for &solver in &solvers { + if !self.holds_its_law(k, solver)? { + self.note_refusal(Refusal { + code: FilletErrorType::NoRoom, + reason: Some(NoRoomReason::CliffLawNotHeld), + comment: "Fillet Error: a variable round traced on its faces' continuations does not hold its radius law", + }); + return Err(SolidError::sm_err(OP)); + } + } + + // What the cuts and the closing read of the solvers, before the region leaves. + let mut faces_of: BTreeMap)>> = + BTreeMap::new(); + let mut tol = 0.0; + let mut blended_edges = Vec::new(); + for &solver in &solvers { + let FilletObject::Solver(id) = solver else { + return Err(invalid("object is not a solver")); + }; + let base = self.objects.solver(id)?.base(); + tol = smos_max(tol, base.approx_tol_3d); + let eu = base + .edgeuse(0)? + .ok_or_else(|| SolidError::null_pointer(OP))?; + blended_edges.push( + eu1::edge(self.objects.target, eu)?.ok_or_else(|| SolidError::null_pointer(OP))?, + ); + faces_of.insert(solver, self.fillet_faces(solver)?); + } + let open_ends = self.executive.open_ends.clone(); + + let mut region = self + .objects + .intermediate + .take() + .ok_or_else(|| SolidError::null_pointer(OP))?; + + let mut note = None; + for end in &open_ends { + let FilletObject::Solver(id) = end.solver else { + return Err(invalid("open end solver is not a solver")); + }; + let ( + FilletObject::Topology(FilletBody::Target, EntityRef::Vertex(v)), + FilletObject::Topology(FilletBody::Target, EntityRef::Face(end_face)), + ) = (end.vertex, end.end_face) + else { + return Err(invalid("open end is not target topology")); + }; + let base = self.objects.solver(id)?.base(); + let solver_tol = base.approx_tol_3d; + let target = &*self.objects.target; + let end_surface = face::p2::surface(target, end_face)? + .ok_or_else(|| SolidError::null_pointer(OP))? + .clone_for_evaluation()?; + // the fillet's point nearest the middle of the filleted edge + let eu = base + .edgeuse(0)? + .ok_or_else(|| SolidError::null_pointer(OP))?; + let fil_edge = eu1::edge(target, eu)?.ok_or_else(|| SolidError::null_pointer(OP))?; + let edge_mid = edge::curve(target, fil_edge)? + .ok_or_else(|| SolidError::null_pointer(OP))? + .evaluate_point(k, edge::interval(target, fil_edge)?.evaluate(0.5))?; + let vertex_point = vertex::point(target, v)?; + let entry = faces_of + .get_mut(&end.solver) + .ok_or_else(|| SolidError::null_pointer(OP))?; + let mut faces: Vec> = entry.iter().map(|&(_, f)| f).collect(); + let cut = cut_fillet_at_open_end( + k, + &mut region, + &mut faces, + &end_surface, + vertex_point, + edge_mid, + solver_tol, + &mut note, + ); + for (slot, f) in entry.iter_mut().zip(faces) { + slot.1 = f; + } + self.noted(note.take(), cut)?; + } + let blended = Blended::of_edges(self.objects.target, &blended_edges)?; + let closed = close_removal_region( + k, + &mut region, + self.objects.target, + &blended, + tol, + &mut note, + ); + self.noted(note.take(), closed)?; + + // stitch the closing faces to the sheet, and orient the closed shell as a solid + let tolerance = region.tolerance(); + let stitched = + crate::topology::brep::p4::stitch_faces(k, &mut region, tolerance, None, false)?; + let mut maybe_open = false; + if stitched.lamina_edges == 0 { + let oriented = with_kernel_callbacks(k, &mut region, |k, b, env| { + let mut marks = Marks::default(); + orient_trimmed_surfaces(k, b, env, &mut marks, true, false, None) + })?; + maybe_open = oriented.maybe_not_closed_solid; + } + if stitched.lamina_edges != 0 + || maybe_open + || !crate::topology::brep::p2::is_manifold_solid(®ion)? + { + self.note_refusal(Refusal { + code: FilletErrorType::BadMerge, + reason: None, + comment: + "Fillet Error: a cliff overflow's removal region does not close into a solid", + }); + return Err(SolidError::sm_err(OP)); + } + + // the fillet edges' attribute stays with the executive's own B-reps + if let (Some(serial), Some(FilletObject::Attribute(handle))) = + (region.notify_serial(), self.executive.edge_attribute) + { + for e in crate::topology::brep::p1::edges(®ion, None)? { + let key = crate::topology::brep::p3::topology_ref(serial, EntityRef::Edge(e)).key; + if self + .objects + .attributes + .find_attribute(key, ids::ALL_FILLET_EDGES) + == Some(handle) + { + self.objects + .attributes + .remove_attribute(key, handle, false)?; + } + } + } + + // the region from the target, by the Boolean (the merge modifies the target in + // place; the region is deleted after) + let kept = (*self.objects.target).clone(); + let taken: Brep = std::mem::take(&mut *self.objects.target); + match boolean(k, taken, region, BooleanOperation::Difference) { + Ok(outcome) => { + *self.objects.target = outcome.result; + Ok(()) + } + Err(e) => { + *self.objects.target = kept; + if e.is_unported() { + return Err(e); + } + self.note_refusal(Refusal { + code: FilletErrorType::BadMerge, + reason: None, + comment: "Fillet Error: subtracting a cliff overflow's removal region failed", + }); + Err(SolidError::sm_err(OP)) + } + } + } +} diff --git a/crates/solidcore/src/blend/fillet_executive/session/finish.rs b/crates/solidcore/src/blend/fillet_executive/session/finish.rs index 9d16e43fcd..1a94b7c7b0 100644 --- a/crates/solidcore/src/blend/fillet_executive/session/finish.rs +++ b/crates/solidcore/src/blend/fillet_executive/session/finish.rs @@ -590,6 +590,17 @@ impl FilletSession<'_> { session.mark_corner_patches()?; let editing = session.objects.target.editing_enabled(); session.objects.target.set_editing_enabled(true); + // The fork's (WR-631, WR-638): a cliff overflow closes the fillet faces into the + // region the blends remove and subtracts it from the target, instead of merging + // the fillet faces in; the error a failed subtraction noted stands. + if session.executive.cliff_overflow { + let result = session.subtract_removal_region(k); + session.objects.target.set_editing_enabled(editing); + if let Some(output) = error_out { + output.assign(&session.executive.error); + } + return result; + } let result = session.merge_fillet(k); session.objects.target.set_editing_enabled(editing); result?; diff --git a/crates/solidcore/src/kernel.rs b/crates/solidcore/src/kernel.rs index affd1b594d..c0e27f1ea4 100644 --- a/crates/solidcore/src/kernel.rs +++ b/crates/solidcore/src/kernel.rs @@ -472,7 +472,7 @@ pub struct UpstreamFixes { pub fillet_trace_reaches_rails: bool, /// `SmFilletVertex::CalcRailIntEdgeuse` places a rail's end found on a side edge at the rail's base surface evaluated with out-of-bounds evaluation on (the surface continued, as `RailEdgeuseIntersect`'s solve over the extended domain found it), not clamped to the surface's own domain, where a rail meeting a side edge's line just past the edge's end (a cliff overflow, with `fillet_trace_reaches_rails`) was put on the edge's end vertex, off the rail by the square of that distance over twice the radius, and taken as meeting the side edge (WR-630, #960/#1467; the fork's tangency-labs/smlib@3f22e97). pub fillet_rail_end_on_continued_surface: bool, - /// SolidCraft's cliff overflow (WR-631, #960; the fork's tangency-labs/smlib@8ba4667 and later, rev 68fb9162): with `fillet_refuse_rails_leaving_faces` (which must be on too; this flag does nothing without it), a convex blend whose rails leave the faces they roll on across cliffs (an opening's rim, the body's outline) is built on the faces' continuations and the material it removes is subtracted from the body, instead of being refused. A concave overflow, a round running on into the body's material, an overflow with another blend concave, with a rollover, or with the global merge on, are refused with `FilletErrorType::NoRoom` and their own messages. In this port so far the choice and its refusals are ported (`FilletExecutive::cliff_overflow`); an accepted overflow is still refused with the `fillet_refuse_rails_leaving_faces` message until its construction lands. Use with `fillet_trace_reaches_rails` and `fillet_rail_end_on_continued_surface`, as the fork has them. + /// SolidCraft's cliff overflow (WR-631, #960; the fork's tangency-labs/smlib@8ba4667 and later, rev 68fb9162): with `fillet_refuse_rails_leaving_faces` (which must be on too; this flag does nothing without it), a convex blend whose rails leave the faces they roll on across cliffs (an opening's rim, the body's outline) is built on the faces' continuations and the material it removes is subtracted from the body, instead of being refused. A concave overflow, a round running on into the body's material, an overflow with another blend concave, with a rollover, or with the global merge on, are refused with `FilletErrorType::NoRoom` and their own messages. An accepted overflow (`FilletExecutive::cliff_overflow`) leaves the Nx1 corners' ends its rails run past uncut (`FilletCorner::drop_end_rib`, the open ends), and `DoFilleting` subtracts it instead of merging (`SubtractRemovalRegion`, `crate::blend::fillet_cliff_overflow`): each variable round on planes must hold its radius law; each open end is cut by the end face's plane; the fillet sheet is closed into the region the blends remove by faces on the hosts' surfaces (a plane continued exactly), stitched, oriented, and subtracted from the target by the Boolean. A construction that fails keeps its own error (WR-638, tangency-labs/smlib@ae2a6de): `NoRoom` where it finds no room, `NotExact` for a continuation that cannot be exact (a non-planar surface past its domain, a curved side edge past its end), `BadMerge` for a region that does not close or a failed subtraction. Use with `fillet_trace_reaches_rails` and `fillet_rail_end_on_continued_surface`, as the fork has them. pub fillet_cliff_overflow: bool, /// `SmMerge::BooleanLists` copies its surface operands with the virtual `Copy` (the derived surface, its natural domain and its kind), where upstream's base copy constructor slices every surface to a geometry-less `SmSurface`, so a surface list always fails with `SM_ERR` and the planar 2D path is never taken (WR-196; `plan/upstream-issues/WR-196--booleanlists-slices-derived-surfaces-into-base-smsurface.md`). pub boolean_lists_virtual_surface_copies: bool, diff --git a/crates/solidcore/tests/wr631_cliff_overflow_choice.rs b/crates/solidcore/tests/wr631_cliff_overflow_choice.rs index 04cf2380ca..230a53a40e 100644 --- a/crates/solidcore/tests/wr631_cliff_overflow_choice.rs +++ b/crates/solidcore/tests/wr631_cliff_overflow_choice.rs @@ -9,8 +9,7 @@ //! that side and the 3 wide step; the edge at (7,5) is concave, the step's foot. //! //! - r6 at (10,0): the rail on the 5 high side runs past its rim (10,5), a cliff: an -//! overflow, `cliff_overflow` set. Its construction is not ported yet, so it is still -//! refused with the generic message. +//! overflow, `cliff_overflow` set, and built (`wr631_cliff_overflow_construction`). //! - r4 at (10,5): the rail on the 3 wide step runs into the wall beyond its foot: a //! round is refused ("into the body's material"); a chamfer is an overflow. //! - r8 at (7,5): concave, the rail on the step runs past its rim: refused ("adds @@ -84,8 +83,8 @@ fn edge_at(k: &Kernel, b: &Brep, x: f64, y: f64) -> EdgeId { } /// Rounds (`chamfer` false) or chamfers the edge at `(x, y)`: whether it was taken for a -/// cliff overflow, the fillet error code and comment. -fn blend(cliff: bool, x: f64, y: f64, r: f64, chamfer: bool) -> (bool, i32, String) { +/// cliff overflow, and the fillet error code and comment of a refusal (`None`: built). +fn blend(cliff: bool, x: f64, y: f64, r: f64, chamfer: bool) -> (bool, Option<(i32, String)>) { let mut k = kernel(cliff); let mut b = stepped_bar(&mut k); let e = edge_at(&k, &b, x, y); @@ -94,12 +93,11 @@ fn blend(cliff: bool, x: f64, y: f64, r: f64, chamfer: bool) -> (bool, i32, Stri session .prepare_constant_radius_edges(&mut k, &[e], r, xsect, 1, 1.0) .unwrap(); - assert!(session.do_fillet(&mut k).is_err()); + let built = session.do_fillet(&mut k).is_ok(); let info = session.error_info(); ( session.executive().cliff_overflow, - info.code, - info.comment.clone(), + (!built).then(|| (info.code, info.comment.clone())), ) } @@ -107,14 +105,11 @@ const NO_ROOM: i32 = FilletErrorType::NoRoom as i32; #[test] fn a_convex_round_over_a_cliff_is_a_cliff_overflow() { - assert_eq!( - blend(true, 10.0, 0.0, 6.0, false), - (true, NO_ROOM, GENERIC.into()) - ); + assert_eq!(blend(true, 10.0, 0.0, 6.0, false), (true, None)); // without the flag: refused as before, no overflow assert_eq!( blend(false, 10.0, 0.0, 6.0, false), - (false, NO_ROOM, GENERIC.into()) + (false, Some((NO_ROOM, GENERIC.into()))) ); } @@ -124,14 +119,13 @@ fn a_round_into_the_material_is_refused_and_a_chamfer_overflows() { blend(true, 10.0, 5.0, 4.0, false), ( false, - NO_ROOM, - "Fillet Error: a round's rail leaves the face it rolls on into the body's material (beyond a wall)".into() + Some(( + NO_ROOM, + "Fillet Error: a round's rail leaves the face it rolls on into the body's material (beyond a wall)".into() + )) ) ); - assert_eq!( - blend(true, 10.0, 5.0, 4.0, true), - (true, NO_ROOM, GENERIC.into()) - ); + assert_eq!(blend(true, 10.0, 5.0, 4.0, true), (true, None)); } #[test] @@ -140,16 +134,22 @@ fn a_concave_overflow_is_refused() { blend(true, 7.0, 5.0, 8.0, false), ( false, - NO_ROOM, - "Fillet Error: a rail leaves the face it rolls on (the blend is larger than the faces allow, and adds material)".into() + Some(( + NO_ROOM, + "Fillet Error: a rail leaves the face it rolls on (the blend is larger than the faces allow, and adds material)".into() + )) ) ); - assert_eq!(blend(false, 7.0, 5.0, 8.0, false).1, NO_ROOM); + assert_eq!( + blend(false, 7.0, 5.0, 8.0, false).1.map(|e| e.0), + Some(NO_ROOM) + ); } -/// The typed reason of the refusal of the edge at `(x, y)`. -fn reason(x: f64, y: f64, r: f64, chamfer: bool) -> Option { - let mut k = kernel(true); +/// The typed reason of the refusal of the edge at `(x, y)` (`cliff`: with the cliff +/// overflow). +fn reason(cliff: bool, x: f64, y: f64, r: f64, chamfer: bool) -> Option { + let mut k = kernel(cliff); let mut b = stepped_bar(&mut k); let e = edge_at(&k, &b, x, y); let mut session = FilletSession::new(&mut k, &mut b).unwrap(); @@ -163,15 +163,15 @@ fn reason(x: f64, y: f64, r: f64, chamfer: bool) -> Option { #[test] fn each_refusal_names_its_reason() { assert_eq!( - reason(10.0, 0.0, 6.0, false), + reason(false, 10.0, 0.0, 6.0, false), Some(NoRoomReason::RailLeavesFace) ); assert_eq!( - reason(10.0, 5.0, 4.0, false), + reason(true, 10.0, 5.0, 4.0, false), Some(NoRoomReason::RoundIntoMaterial) ); assert_eq!( - reason(7.0, 5.0, 8.0, false), + reason(true, 7.0, 5.0, 8.0, false), Some(NoRoomReason::ConcaveOverflow) ); } diff --git a/crates/solidcore/tests/wr631_cliff_overflow_construction.rs b/crates/solidcore/tests/wr631_cliff_overflow_construction.rs new file mode 100644 index 0000000000..a6b84a4be4 --- /dev/null +++ b/crates/solidcore/tests/wr631_cliff_overflow_construction.rs @@ -0,0 +1,368 @@ +//! WR-631 / WR-638 (#960; the fork's tangency-labs/smlib@8ba4667 and ae2a6de, rev 68fb9162): +//! with `fillet_refuse_rails_leaving_faces` and `fillet_cliff_overflow`, a convex blend +//! whose rail leaves its face across a cliff is built on the faces' continuations, closed +//! into the region it removes and subtracted from the body. Without the flag the same blends +//! are refused (`NoRoom`), as before. +//! +//! Every expected volume is the body less the blend's corner region clipped to the body, in +//! closed form: for a round of radius r on an edge between two perpendicular faces w and h +//! wide, the section removed is `{0 <= u <= min(w, r), 0 <= v <= min(h, r)}` outside the +//! circle of radius r about `(r, r)`. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::cast_precision_loss +)] + +use solidcore::blend::fillet_executive::session::FilletSession; +use solidcore::blend::fillet_geom::p1::FilletErrorType; +use solidcore::blend::fillet_standard_solver::p1::FilletLaw; +use solidcore::boolean::api_brep::boolean_difference; +use solidcore::curve::curve::Curve; +use solidcore::geom::attribute::{AttributeStore, ObjectKey}; +use solidcore::mass::integrator::properties::{brep_volume, PropertyAttributes}; +use solidcore::math::{Point3, Vec3}; +use solidcore::primitive::api_curves::create_line_segment; +use solidcore::primitive::api_primitives::{create_box, create_cylinder}; +use solidcore::sweep::api_brep::create_linear_sweep; +use solidcore::topology::brep::p2::is_manifold_solid; +use solidcore::topology::{edge, EdgeId}; +use solidcore::{Brep, Kernel}; + +fn kernel(cliff: bool) -> Kernel { + let mut k = Kernel::default(); + k.fixes.fillet_refuse_rails_leaving_faces = true; + k.fixes.fillet_trace_reaches_rails = true; + k.fixes.fillet_rail_end_on_continued_surface = true; + k.fixes.fillet_cliff_overflow = cliff; + k +} + +fn volume(k: &Kernel, b: &mut Brep) -> f64 { + let store = AttributeStore::default(); + let attrs = PropertyAttributes { + store: &store, + key_of: &|_| ObjectKey(0), + }; + brep_volume(k, b, 1e-6, &attrs).unwrap() +} + +/// The area of the section a round of radius `r` removes between faces `w` and `h` wide +/// (perpendicular), in closed form. +fn removed_area(r: f64, w: f64, h: f64) -> f64 { + let a = w.min(r); + let f = |s: f64| s / 2.0 * (r * r - s * s).max(0.0).sqrt() + r * r / 2.0 * (s / r).asin(); + let u_h = if h >= r { + 0.0 + } else { + r - (r * r - (r - h) * (r - h)).sqrt() + }; + let mut area = if h < r { h * u_h.min(a) } else { 0.0 }; + if a > u_h { + area += r * (a - u_h) - (f(r - u_h) - f(r - a)); + } + area +} + +/// The edge from `p` to `q` (either way). +fn edge_between(k: &Kernel, b: &Brep, p: Point3, q: Point3) -> EdgeId { + b.edges() + .ids() + .find(|&e| { + let ivl = edge::interval(b, e).unwrap(); + let c = edge::curve(b, e).unwrap().unwrap(); + let s = c.evaluate_point(k, ivl.min()).unwrap(); + let t = c.evaluate_point(k, ivl.max()).unwrap(); + (s.distance(p) < 1e-9 && t.distance(q) < 1e-9) + || (s.distance(q) < 1e-9 && t.distance(p) < 1e-9) + }) + .expect("edge") +} + +/// The outcome of a blend: the error code and comment of a refusal, or the volume of the +/// result (a manifold solid). +#[derive(Debug)] +enum Outcome { + Built(f64), + Refused(i32, String), +} + +/// Rounds (`chamfer` false: circular section; true: linear) the edge from `p` to `q` of +/// the body `make` builds, by `r`. +fn blend( + cliff: bool, + make: fn(&mut Kernel) -> Brep, + p: Point3, + q: Point3, + r: f64, + chamfer: bool, +) -> Outcome { + let mut k = kernel(cliff); + let mut b = make(&mut k); + let e = edge_between(&k, &b, p, q); + let mut session = FilletSession::new(&mut k, &mut b).unwrap(); + session + .prepare_constant_radius_edges(&mut k, &[e], r, u32::from(!chamfer), 1, 1.0) + .unwrap(); + let result = session.do_fillet(&mut k); + let info = session.error_info().clone(); + drop(session); + match result { + Ok(()) => { + assert!( + is_manifold_solid(&b).unwrap(), + "r {r}: not a manifold solid" + ); + Outcome::Built(volume(&k, &mut b)) + } + Err(_) => Outcome::Refused(info.code, info.comment), + } +} + +fn assert_builds(outcome: &Outcome, expected: f64, rel: f64) { + let Outcome::Built(v) = outcome else { + panic!("expected a build at {expected}, got {outcome:?}"); + }; + assert!( + (v - expected).abs() <= rel * expected, + "volume {v} against {expected} (rel {})", + (v - expected).abs() / expected + ); +} + +fn assert_no_room(outcome: &Outcome) { + let Outcome::Refused(code, _) = outcome else { + panic!("expected a refusal, got {outcome:?}"); + }; + assert_eq!(*code, FilletErrorType::NoRoom as i32, "{outcome:?}"); +} + +fn p(x: f64, y: f64, z: f64) -> Point3 { + Point3::new(x, y, z) +} + +/// The stepped bar of `wr631_cliff_overflow_choice`: its profile +/// `(0,0) (10,0) (10,5) (7,5) (7,10) (0,10)` swept 20 along z. +fn stepped_bar(k: &mut Kernel) -> Brep { + const PROFILE: [[f64; 2]; 6] = [ + [0.0, 0.0], + [10.0, 0.0], + [10.0, 5.0], + [7.0, 5.0], + [7.0, 10.0], + [0.0, 10.0], + ]; + let cs: Vec = PROFILE + .iter() + .zip(PROFILE.iter().cycle().skip(1)) + .map(|(a, b)| { + Curve::from( + create_line_segment(Vec3::new(a[0], a[1], 0.0), Vec3::new(b[0], b[1], 0.0)) + .unwrap(), + ) + }) + .collect(); + let refs: Vec> = cs.iter().map(Some).collect(); + create_linear_sweep(k, &refs, Vec3::new(0.0, 0.0, 1.0), 20.0, true).unwrap() +} + +/// An 80 x 5 x 5 bar (#1466). +fn bar(k: &mut Kernel) -> Brep { + create_box(k, Point3::ZERO, 80.0, 5.0, 5.0).unwrap() +} + +/// A 40 x 20 x 10 box (#1467). +fn box_40_20_10(k: &mut Kernel) -> Brep { + create_box(k, Point3::ZERO, 40.0, 20.0, 10.0).unwrap() +} + +#[test] +fn a_round_over_a_step_rim_is_subtracted() { + // r6 at (10, 0): the rail on the 5 high side runs past the step's rim + let expected = 1700.0 - 20.0 * removed_area(6.0, 10.0, 5.0); + let (a, b) = (p(10.0, 0.0, 0.0), p(10.0, 0.0, 20.0)); + assert_builds(&blend(true, stepped_bar, a, b, 6.0, false), expected, 1e-8); + assert_no_room(&blend(false, stepped_bar, a, b, 6.0, false)); +} + +#[test] +fn a_chamfer_into_a_wall_foot_is_subtracted() { + // chamfer 4 at (10, 5): it reaches x = 6 on the 3 wide step, past the wall's foot; the + // triangle it removes lies inside the body + let (a, b) = (p(10.0, 5.0, 0.0), p(10.0, 5.0, 20.0)); + assert_builds( + &blend(true, stepped_bar, a, b, 4.0, true), + 1700.0 - 20.0 * 8.0, + 1e-9, + ); + assert_no_room(&blend(false, stepped_bar, a, b, 4.0, true)); +} + +#[test] +fn a_round_wider_than_a_bar_is_subtracted() { + // #1466: the long top edge of an 80 x 5 x 5 bar; r9 and r10 run past both faces + let (a, b) = (p(0.0, 5.0, 5.0), p(80.0, 5.0, 5.0)); + for r in [9.0, 10.0, 12.0] { + let expected = 80.0 * 25.0 - 80.0 * removed_area(r, 5.0, 5.0); + assert_builds(&blend(true, bar, a, b, r, false), expected, 1e-8); + } + assert_no_room(&blend(false, bar, a, b, 10.0, false)); +} + +#[test] +fn a_round_just_past_a_box_side_is_subtracted() { + // #1467: the top edge of the 40 x 20 x 10 box on its 10 high face, r just over 10 (the + // radii of the fork's test) + let (a, b) = (p(0.0, 20.0, 10.0), p(40.0, 20.0, 10.0)); + for r in [10.05, 10.07, 12.0] { + let expected = 8000.0 - 40.0 * removed_area(r, 20.0, 10.0); + assert_builds(&blend(true, box_40_20_10, a, b, r, false), expected, 1e-8); + } + assert_no_room(&blend(false, box_40_20_10, a, b, 10.05, false)); +} + +/// A 40 x 20 x 10 block with a through hole of radius 3 at (20, 14.5) (#422, SolidCraft +/// rig 324, `fixtures/solidcraft/324-round-beside-through-hole.json`). +fn block_with_hole(k: &mut Kernel) -> Brep { + let block = create_box(k, Point3::ZERO, 40.0, 20.0, 10.0).unwrap(); + let drill = create_cylinder(k, p(20.0, 14.5, -1.0), 3.0, 12.0).unwrap(); + boolean_difference(k, block, drill).unwrap() +} + +#[test] +fn a_round_over_a_hole_is_subtracted() { + // #422: R4 on the top edge at y = 20; its rail on the top face runs into the hole, whose + // rim is a cliff. The block less the hole and the round's corner, plus the part of that + // corner inside the hole: O = int_16^17.5 2 sqrt(9 - (y - 14.5)^2) (4 - sqrt(16 - (y - + // 16)^2)) dy = 0.3758296360 (the fixture's formula, its value) + let (a, b) = (p(0.0, 20.0, 10.0), p(40.0, 20.0, 10.0)); + let expected = 7580.287315387334; + assert_builds( + &blend(true, block_with_hole, a, b, 4.0, false), + expected, + 1e-8, + ); + assert_no_room(&blend(false, block_with_hole, a, b, 4.0, false)); +} + +/// The 10 x 20 x 30 box of `wr609_trace_reaches_rails`; its top edge at x = 0 is 20 long, +/// the top face 10 wide, the side 30 high. +fn box_10_20_30(k: &mut Kernel) -> Brep { + create_box(k, Point3::ZERO, 10.0, 20.0, 30.0).unwrap() +} + +#[test] +fn a_constant_round_past_a_box_top_is_subtracted() { + // R12: the rail on the 10 wide top runs past its far edge (the fork's 5384.17828518) + let (a, b) = (p(0.0, 0.0, 30.0), p(0.0, 20.0, 30.0)); + let expected = 6000.0 - 20.0 * removed_area(12.0, 10.0, 30.0); + assert!((expected - 5384.17828518).abs() < 1e-8); + assert_builds( + &blend(true, box_10_20_30, a, b, 12.0, false), + expected, + 1e-8, + ); + assert_no_room(&blend(false, box_10_20_30, a, b, 12.0, false)); +} + +/// The variable round from `r0` to `r1` (linear) of the box's top edge at x = 0. +fn variable_round(cliff: bool, r0: f64, r1: f64) -> Outcome { + let mut k = kernel(cliff); + let mut b = box_10_20_30(&mut k); + let e = edge_between(&k, &b, p(0.0, 0.0, 30.0), p(0.0, 20.0, 30.0)); + let mut session = FilletSession::new(&mut k, &mut b).unwrap(); + session + .prepare_variable_radius_edges(&mut k, &[e], r0, FilletLaw::linear(r0, r1, None), 1, 1) + .unwrap(); + let result = session.do_fillet(&mut k); + let info = session.error_info().clone(); + drop(session); + match result { + Ok(()) => { + assert!(is_manifold_solid(&b).unwrap(), "not a manifold solid"); + Outcome::Built(volume(&k, &mut b)) + } + Err(_) => Outcome::Refused(info.code, info.comment), + } +} + +/// The box less the variable round's section along the edge, by the midpoint rule (the +/// rolling ball's section across the edge is the circle of the radius there, to the +/// accuracy of the round's cubic sections). +fn variable_round_expected(r0: f64, r1: f64) -> f64 { + let n = 20_000; + let mut removed = 0.0; + for i in 0..n { + let t = (f64::from(i) + 0.5) / f64::from(n); + removed += removed_area(r0 + (r1 - r0) * t, 10.0, 30.0); + } + 6000.0 - removed * 20.0 / f64::from(n) +} + +#[test] +fn a_variable_round_past_a_box_top_is_subtracted() { + // the fork's 2 -> 11, 20, 30, 50 (5789.69878, 5391.54581, 4819.37775, 3546.18768), from + // either end, to 1e-4 (the variable round's cubic sections' accuracy) + for (r1, fork) in [ + (11.0, 5789.69878), + (20.0, 5391.54581), + (30.0, 4819.37775), + (50.0, 3546.18768), + ] { + let expected = variable_round_expected(2.0, r1); + assert!((expected - fork).abs() < 1e-4, "{r1}: {expected}"); + assert_builds(&variable_round(true, 2.0, r1), expected, 1e-4); + assert_builds(&variable_round(true, r1, 2.0), expected, 1e-4); + } + assert_no_room(&variable_round(false, 2.0, 11.0)); +} + +/// The 40 x 20 x 10 box with its x = 0 end cut concave by a vertical cylinder of radius 30 +/// about (-25, 10): the top edge at y = 20 ends on that cylinder. +fn box_with_round_end(k: &mut Kernel) -> Brep { + let block = create_box(k, Point3::ZERO, 40.0, 20.0, 10.0).unwrap(); + let cutter = create_cylinder(k, p(-25.0, 10.0, -1.0), 30.0, 12.0).unwrap(); + boolean_difference(k, block, cutter).unwrap() +} + +#[test] +fn an_overflow_the_construction_cannot_cut_reports_its_own_error() { + // WR-638 (ae2a6de): R12 on the top edge at y = 20 runs past the 10 high side; at the + // cylinder end its rails miss their side edges, and the end face that would cut the + // fillet off there is not planar. The refusal is the construction's own, not the generic + // "larger than the faces" (which the flag off still gives). + let x0 = -25.0 + 800.0f64.sqrt(); + let (a, b) = (p(x0, 20.0, 10.0), p(40.0, 20.0, 10.0)); + let Outcome::Refused(code, comment) = blend(true, box_with_round_end, a, b, 12.0, false) else { + panic!("expected a refusal"); + }; + assert_eq!(code, FilletErrorType::NoRoom as i32); + assert_eq!( + comment, + "Fillet Error: a cliff overflow runs past a fillet end whose end face is not planar" + ); + let Outcome::Refused(code, comment) = blend(false, box_with_round_end, a, b, 12.0, false) + else { + panic!("expected a refusal"); + }; + assert_eq!(code, FilletErrorType::NoRoom as i32); + assert_eq!( + comment, + "Fillet Error: a rail leaves the face it rolls on (the blend is larger than the faces allow)" + ); +} + +#[test] +fn a_cliff_overflow_is_deterministic() { + let (a, b) = (p(10.0, 0.0, 0.0), p(10.0, 0.0, 20.0)); + let runs: Vec = (0..2) + .map(|_| match blend(true, stepped_bar, a, b, 6.0, false) { + Outcome::Built(v) => v, + other @ Outcome::Refused(..) => panic!("{other:?}"), + }) + .collect(); + assert_eq!(runs[0].to_bits(), runs[1].to_bits()); +} diff --git a/plan/upstream-issues/WR-631--addendum-2026-10-31-overflow-construction.md b/plan/upstream-issues/WR-631--addendum-2026-10-31-overflow-construction.md new file mode 100644 index 0000000000..2d0e3bd36b --- /dev/null +++ b/plan/upstream-issues/WR-631--addendum-2026-10-31-overflow-construction.md @@ -0,0 +1,9 @@ +Updates: WR-631 + +The construction of an accepted cliff overflow is ported (the rest of tangency-labs/smlib@8ba4667, rev `68fb9162`, with the Nx1 miss test of tangency-labs/smlib@3f22e97), behind the same `Kernel::fixes.fillet_cliff_overflow`. An accepted overflow builds; it is no longer refused with the WR-302 message. + +- **Open ends** (`SmFilletNx1Corner::CalcCornerEdgeGeom`): in an overflow a rail end off the fillet surface by more than ten times the corner's tolerance, or found on its side edge's line past the edge's end (off the edge by more than the tolerance, 3f22e97), is a miss; both rails may miss. A miss that does not run on deletes the end rib and notes the end (`NoteOpenEnd`) instead of setting the global merge: `FilletCorner::drop_end_rib`, `blend/fillet_corner/p1.rs`; the session's `edges_calculate` records `FilletExecutive::open_ends`. +- **Subtraction** (`SmFilletExecutive::DoFilleting`, `SubtractRemovalRegion`, `sm_HoldsItsLaw`): `blend/fillet_executive/session/cliff_overflow.rs`. A variable round on planes must keep its section's middle on its surface at seven points; each open end is cut by the end face's plane (`CutFilletAtOpenEnd`); the sheet is closed (`CloseRemovalRegion`: the host test, the chain walk and the closing face are separate functions, `blend/fillet_cliff_overflow.rs`), stitched, oriented and checked to be a manifold solid, and subtracted from the target by the Boolean. +- **Not covered by the fork's 68fb9162 either:** a box edge rounded r10.02 on its 10 high face (the fillet meets the bottom face 2e-5 from its edge) builds an empty body; the fork's test uses r10.05 and up, which build. + +Tests: `crates/solidcore/tests/wr631_cliff_overflow_construction.rs` (closed-form volumes: a stepped bar's round over a step rim and chamfer under a wall; the 80 x 5 x 5 bar r9/r10/r12, #1466; the 40 x 20 x 10 box r10.05/r10.07/r12, #1467; the 10 x 20 x 30 box R12, 5384.17828518, and the variable rounds 2 -> 11, 20, 30, 50 from either end; the round over a through hole, #422, 7580.28731536; each refused without the flag; determinism). diff --git a/plan/upstream-issues/WR-638--cliff-overflow-failure-keeps-its-error.md b/plan/upstream-issues/WR-638--cliff-overflow-failure-keeps-its-error.md new file mode 100644 index 0000000000..42de247235 --- /dev/null +++ b/plan/upstream-issues/WR-638--cliff-overflow-failure-keeps-its-error.md @@ -0,0 +1,11 @@ +#### WR-638. A cliff overflow that cannot be built is reported as having no room +- **Severity:** Wrong result (error report) +- **Area:** Blend (fillet executive) +- **Location:** the fork's `source/SMLib/src/SmFilletExecutive.cpp`, `SmFilletExecutive::DoFilleting` and `SmFilletCliffOverflow.cpp`, `NoteUnbuiltOverflow` (tangency-labs/smlib@8ba4667). +- **What happens:** when the construction of a cliff overflow (WR-631) fails, 8ba4667 rewrote whatever the failing step noted into `SM_FILERR_NO_ROOM` with the generic "larger than the faces" comment (`NoteUnbuiltOverflow`), unless a continuation was not exact. A caller fitting the radius could not tell a removal region that did not close (`SM_FILERR_BAD_MERGE`), or a step that found no room for its own reason, from a blend too large for its faces. +- **Trigger:** a round on a box's top edge past its 10 high side whose end face is a concave cylinder: the open end cannot be cut by a plane. +- **How found:** SolidCraft (#1071, #960). The fork removes `NoteUnbuiltOverflow` in tangency-labs/smlib@ae2a6de ("a cliff overflow that fails reports its own error"): `CreateFilletBrep` and `SubtractRemovalRegion` failures keep the error their step noted. +- **Confirmed:** no C++ run of the fork was made; not reachable in the pinned C++ (`3fd5b95`), which has no cliff overflow. +- **Rust port:** follows ae2a6de with `Kernel::fixes.fillet_cliff_overflow` (the session's `DoFilleting` passes the subtraction's error on, `blend/fillet_executive/session/finish.rs`): `NoRoom` with the step's own comment and `NoRoomReason`, `NotExact`, or `BadMerge`. + + Tests: `crates/solidcore/tests/wr631_cliff_overflow_construction.rs` (`an_overflow_the_construction_cannot_cut_reports_its_own_error`).