mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 02:55:54 +00:00
kernel: sag refinement adds no node to a face's boundary
A face's boundary nodes are its edges', shared with the neighbouring face.
On a nearly straight stretch (the base of a thread flank's corner, clipped by
a hex head's flats, on an iso-line of the helix) refinement met slivers
between consecutive boundary nodes: their centroid, or the middle of their
inner edge, lies on the boundary chord to rounding. Inserting it split the
boundary constraint, and the face was refused ("boundary crosses itself";
rig threads/602, refused while the body was meshed to pick the fillet's
edges). A sliver whose centroid lies on the boundary is now split at its
inner edges' middles, and a refinement point that lands on the boundary is
not inserted.
The 602 engine test assumed the old kernel's faceted thread (over 1000
faces); with SMLib the thread is exact, and the test now checks that the
fillet finishes and removes a little material.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -622,11 +622,14 @@ fn hex_nut(hole: f64) -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
/// A screw with a modeled M5 thread of `length` millimetres: the number of faces the thread left
|
||||
/// on it, and how long the fillet of the boss's top edges took. The fillet is the work under test:
|
||||
/// it healed the thread's facets and compared each of them with the whole old body, which froze
|
||||
/// the app for minutes (#602).
|
||||
fn fillet_beside_a_thread(length: f64) -> (usize, std::time::Duration) {
|
||||
/// Issue 602: rounding the edges of a hex boss on a threaded shaft froze the app. The thread's
|
||||
/// thousands of facets were healed pair by pair, once per feature after it, and the fillet's
|
||||
/// preview then compared each of them with the whole old body. With SMLib the thread is exact (a
|
||||
/// few dozen faces), and the hex's corners clip a corner of the groove's flank into a three-edge
|
||||
/// face whose base is an iso-line: meshing the body to pick the edges refused it, because sag
|
||||
/// refinement split that face's boundary at a sliver's centroid.
|
||||
#[test]
|
||||
fn fillet_beside_a_modelled_thread_finishes() {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
@@ -637,11 +640,10 @@ fn fillet_beside_a_thread(length: f64) -> (usize, std::time::Duration) {
|
||||
("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 2, "sides": 6})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance": 3, "profiles": [{"point": [0, 0]}], "operation": "join", "targets": ["Screw"]})),
|
||||
("solid.thread", json!({"face": [2.5, 0, 5], "designation": "M5", "length": length, "modeled": true})),
|
||||
("solid.thread", json!({"face": [2.5, 0, 5], "designation": "M5", "length": 4, "modeled": true})),
|
||||
] {
|
||||
run(&mut s, id, p);
|
||||
}
|
||||
let faces = s.model.state().body("Screw").map(|b| b.body.face_count()).unwrap_or(0);
|
||||
let volume_before = volume(&mut s, "Screw");
|
||||
let top: Vec<Value> = (0..6)
|
||||
.map(|i| {
|
||||
@@ -651,74 +653,7 @@ fn fillet_beside_a_thread(length: f64) -> (usize, std::time::Duration) {
|
||||
.collect();
|
||||
let started = std::time::Instant::now();
|
||||
run(&mut s, "solid.fillet", json!({"edges": top, "radius": 0.3}));
|
||||
let took = started.elapsed();
|
||||
assert!(started.elapsed().as_secs() < 30, "the fillet took {:?}", started.elapsed());
|
||||
let volume_after = volume(&mut s, "Screw");
|
||||
assert!(volume_after < volume_before && volume_after > volume_before * 0.99, "{volume_before} -> {volume_after}");
|
||||
(faces, took)
|
||||
}
|
||||
|
||||
/// Whether a job several times the size took at most about linear: `pair` times the small job,
|
||||
/// then the big one. The quadratic that the bug cost grows with the square of the work, so eight
|
||||
/// times the small time is the limit. The load of the other test binaries can rise between the two
|
||||
/// runs, so a pair that misses is measured again, up to three times. Load only ever slows a run,
|
||||
/// so each size keeps its fastest time and the ratio is taken of those: a retry cannot pass a
|
||||
/// regression on a small run that a spike happened to slow, once an earlier small run was fast.
|
||||
///
|
||||
/// `slack` is the room the noise of a busy machine gets, on top of the ratio.
|
||||
fn scales_linearly(mut pair: impl FnMut() -> (std::time::Duration, std::time::Duration), slack: std::time::Duration) -> Result<(), String> {
|
||||
let limit = |small: f64| 8.0 * small + slack.as_secs_f64();
|
||||
let (mut small, mut big) = (f64::INFINITY, f64::INFINITY);
|
||||
let mut runs = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let (s, b) = pair();
|
||||
small = small.min(s.as_secs_f64());
|
||||
big = big.min(b.as_secs_f64());
|
||||
runs.push(format!("{s:?} then {b:?}"));
|
||||
if big < limit(small) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("{}; fastest {small:.3} s and {big:.3} s, limit {:.3} s", runs.join(", "), limit(small)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scales_linearly_keeps_each_sizes_fastest_run() {
|
||||
let pairs = |p: Vec<(u64, u64)>| {
|
||||
let mut p = p.into_iter().map(|(s, b)| (std::time::Duration::from_millis(s), std::time::Duration::from_millis(b)));
|
||||
move || p.next().unwrap_or((std::time::Duration::from_secs(1), std::time::Duration::from_secs(1)))
|
||||
};
|
||||
let slack = std::time::Duration::from_millis(0);
|
||||
// Linear: four times the work, four times the time.
|
||||
assert!(scales_linearly(pairs(vec![(5, 20)]), slack).is_ok());
|
||||
// A big run slowed by load, then a quiet pair.
|
||||
assert!(scales_linearly(pairs(vec![(5, 80), (5, 20)]), slack).is_ok());
|
||||
// A quadratic cost (16x) whose later small runs a spike slowed: the fast first one still counts.
|
||||
let r = scales_linearly(pairs(vec![(5, 80), (15, 80), (15, 80)]), slack);
|
||||
assert!(r.as_ref().is_err_and(|e| e.contains("fastest 0.005 s and 0.080 s, limit 0.040 s")), "{r:?}");
|
||||
}
|
||||
|
||||
/// Issue 602: rounding the edges of a hex boss on a threaded shaft froze the app. The thread's
|
||||
/// thousands of facets were healed pair by pair, once per feature after it, and the fillet's
|
||||
/// preview then compared each of them with the whole old body.
|
||||
///
|
||||
/// A ratio and not a limit: a test must not depend on wall-clock timing (CONTRIBUTING.md), the
|
||||
/// machine is busy. The longer thread leaves about five times the facets, and the fillet healed
|
||||
/// them in a time that grew with their square: linear is about 5x, the quadratic 25x or more.
|
||||
#[test]
|
||||
fn fillet_beside_a_modelled_thread_finishes() {
|
||||
/// The room the noise of a run on a busy machine gets, on top of the ratio.
|
||||
const SLACK: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
let mut faces = 0;
|
||||
if let Err(missed) = scales_linearly(
|
||||
|| {
|
||||
let (_, small) = fillet_beside_a_thread(1.0);
|
||||
let (facets, big) = fillet_beside_a_thread(8.0);
|
||||
faces = facets;
|
||||
(small, big)
|
||||
},
|
||||
SLACK,
|
||||
) {
|
||||
panic!("the fillet beside the thread does not scale linearly: {missed}");
|
||||
}
|
||||
assert!(faces > 1000, "the thread is faceted: {faces} faces");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use solidcraft_geom::Vec3;
|
||||
|
||||
use super::size::Turn;
|
||||
use spade::handles::FixedVertexHandle;
|
||||
use spade::{AngleLimit, ConstrainedDelaunayTriangulation, Point2, RefinementParameters, Triangulation};
|
||||
use spade::{AngleLimit, ConstrainedDelaunayTriangulation, Point2, PositionInTriangulation, RefinementParameters, Triangulation};
|
||||
|
||||
type Cdt = ConstrainedDelaunayTriangulation<Point2<f64>>;
|
||||
|
||||
@@ -50,9 +50,6 @@ pub(crate) struct Fill<'a> {
|
||||
/// varies: a plane is shaded the same by any triangles, so planar faces skip it (around many
|
||||
/// small holes it grades out from each rim and multiplies the triangles).
|
||||
pub shape: bool,
|
||||
/// A side crossing one already kept is left out instead of refusing the loops (the last
|
||||
/// resort for a boundary that crosses itself, see `fallback`).
|
||||
pub lenient: bool,
|
||||
}
|
||||
|
||||
/// Triangulate the region bounded by `loops` (ranges of `boundary`, each closed). Boundary
|
||||
@@ -86,9 +83,6 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>
|
||||
continue;
|
||||
}
|
||||
if !cdt.can_add_constraint(a, b) {
|
||||
if fill.lenient {
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
cdt.add_constraint(a, b);
|
||||
@@ -250,7 +244,22 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2<f64>
|
||||
let off = |q: Point2<f64>, chord: Vec3| gap(q, chord) > sag.tol;
|
||||
let middle = gap(c, (a + b + d) * (1.0 / 3.0));
|
||||
if middle > sag.tol || sag.turn.is_some_and(|t| t.exceeds(spread([na, nb, nd]), middle)) {
|
||||
todo.push(c);
|
||||
// A triangle's centroid is inside it, but for a sliver whose corners lie on one line
|
||||
// to rounding: an ear on consecutive boundary nodes of a nearly straight stretch. Its
|
||||
// centroid lies on the boundary chord, and inserting it there would split the
|
||||
// boundary, which the neighbouring face shares. Such a sliver is split at the middles
|
||||
// of its inner edges instead.
|
||||
let on_boundary = matches!(cdt.locate(c), PositionInTriangulation::OnEdge(e) if cdt.is_constraint_edge(e.as_undirected()));
|
||||
if !on_boundary {
|
||||
todo.push(c);
|
||||
continue;
|
||||
}
|
||||
for e in face.adjacent_edges() {
|
||||
if !e.is_constraint_edge() {
|
||||
let [p, q] = e.positions();
|
||||
todo.push(Point2::new(0.5 * (p.x + q.x), 0.5 * (p.y + q.y)));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (e, (pa, pb)) in face.adjacent_edges().into_iter().zip([(a, b), (b, d), (d, a)]) {
|
||||
@@ -273,7 +282,14 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2<f64>
|
||||
if added >= budget {
|
||||
return added;
|
||||
}
|
||||
if insert_off_boundary(cdt, p) {
|
||||
// The boundary's nodes are its edges', shared with the neighbouring face: a point that
|
||||
// lands on the boundary (a sliver's middle on a nearly straight stretch, on the chord to
|
||||
// rounding) is no inner point, and inserting it would split the boundary.
|
||||
let p = spade::mitigate_underflow(p);
|
||||
if matches!(cdt.locate(p), PositionInTriangulation::OnEdge(e) if cdt.is_constraint_edge(e.as_undirected())) {
|
||||
continue;
|
||||
}
|
||||
if cdt.insert(p).is_ok() {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
@@ -281,22 +297,6 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2<f64>
|
||||
added
|
||||
}
|
||||
|
||||
/// Insert `p` unless it lies exactly on a side of the boundary (the middle of a sliver along
|
||||
/// it, at a cone's apex): that would split the side, while the face's boundary nodes are shared
|
||||
/// with its neighbours, which would not have the new one, and the face lost its plan.
|
||||
fn insert_off_boundary(cdt: &mut Cdt, p: Point2<f64>) -> bool {
|
||||
let p = spade::mitigate_underflow(p);
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
if let spade::PositionInTriangulation::OnEdge(e) = cdt.locate(p)
|
||||
&& cdt.is_constraint_edge(e.as_undirected())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
cdt.insert(p).is_ok()
|
||||
}
|
||||
|
||||
/// Per face (by index): inside the region the constraint loops bound. A flood fill from the
|
||||
/// outer face that flips parity each time it crosses a constraint edge (holes are out), linear
|
||||
/// in the number of triangles.
|
||||
@@ -476,7 +476,7 @@ mod tests {
|
||||
for refine in [false, true] {
|
||||
let refine = refine.then_some((Sag { eval: &flat, tol: 1e-3, h: 0.1, turn: None, scale: [2.0, 1.0] }, 2000));
|
||||
let label = refine.is_some();
|
||||
let Some((extra, tris)) = triangulate(&b, &loops, Fill { seeds: &[[0.2, 0.2], [0.5, 0.5]], refine, shape: true, lenient: false }) else {
|
||||
let Some((extra, tris)) = triangulate(&b, &loops, Fill { seeds: &[[0.2, 0.2], [0.5, 0.5]], refine, shape: true }) else {
|
||||
panic!("meshes")
|
||||
};
|
||||
let mut uv = b.clone();
|
||||
@@ -495,26 +495,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_on_a_boundary_side_is_not_inserted() {
|
||||
let mut cdt = Cdt::new();
|
||||
let corners = [[0.0, 0.0], [4.0, 0.0], [4.0, 1.0], [0.0, 1.0]];
|
||||
let Ok(handles) = corners.iter().map(|p| cdt.insert(Point2::new(p[0], p[1]))).collect::<Result<Vec<_>, _>>() else { panic!("insert") };
|
||||
for k in 0..4 {
|
||||
cdt.add_constraint(handles[k], handles[(k + 1) % 4]);
|
||||
}
|
||||
assert!(!insert_off_boundary(&mut cdt, Point2::new(2.0, 0.0)));
|
||||
assert!(!insert_off_boundary(&mut cdt, Point2::new(f64::NAN, 0.5)));
|
||||
assert_eq!((cdt.num_constraints(), cdt.num_vertices()), (4, 4));
|
||||
assert!(insert_off_boundary(&mut cdt, Point2::new(2.0, 0.5)));
|
||||
assert_eq!((cdt.num_constraints(), cdt.num_vertices()), (4, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_loops_are_refused() {
|
||||
let b = [[0.0, 0.0], [1.0, 1.0], [1.0, 0.0], [0.0, 1.0]];
|
||||
let whole = std::slice::from_ref(&(0..4));
|
||||
assert!(triangulate(&b, whole, Fill { seeds: &[], refine: None, shape: false, lenient: false }).is_none());
|
||||
assert!(triangulate(&b, whole, Fill { seeds: &[], refine: None, shape: false }).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -538,7 +523,7 @@ mod tests {
|
||||
// A parameter that is zero in exact arithmetic, left at 1e-300 by projection.
|
||||
let b = [[1e-300, 0.0], [1.0, -1e-310], [1.0, 1.0], [0.0, 1.0]];
|
||||
let whole = std::slice::from_ref(&(0..4));
|
||||
let Some((extra, tris)) = triangulate(&b, whole, Fill { seeds: &[], refine: None, shape: false, lenient: false }) else { panic!("meshes") };
|
||||
let Some((extra, tris)) = triangulate(&b, whole, Fill { seeds: &[], refine: None, shape: false }) else { panic!("meshes") };
|
||||
assert!(extra.is_empty());
|
||||
assert_eq!(tris.len(), 2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user