1 parent
d29ca29578
commit
dc6eca6830
3 files changed
+167
-22
No files matched your search
@@ -0,0 +1,88 @@
|
|||||||
|
//! Shell regressions found by the robustness fuzzer (tests/robustness/goldens.json).
|
||||||
|
//!
|
||||||
|
//! The box-on-box designs below have a saddle corner: where the top box's left face meets the
|
||||||
|
//! lower box's back face, four planar faces meet with convex and concave edges in turn (the
|
||||||
|
//! top box overhangs the lower box's back face). A shell splits that corner into two corners
|
||||||
|
//! joined by a new edge.
|
||||||
|
//!
|
||||||
|
//! Lower box `[0, 25] × [0, 17.5] × [0, 35]`, top box `[7.5, 22.5] × [0, 17.5 + o] × [35, 42.5]`.
|
||||||
|
//! With the bottom face open, a shell of thickness `t` leaves this cavity (for `o < 17.5 − 2t`):
|
||||||
|
//!
|
||||||
|
//! - in the lower box: `[t, 25 − t] × [t, 17.5 − t] × [0, 35 − t]`
|
||||||
|
//! - in the top box, over the lower box: `[7.5 + t, 22.5 − t] × [t, 17.5 − t] × [35 − t, 42.5 − t]`
|
||||||
|
//! - in the overhang: `[7.5 + t, 22.5 − t] × [17.5 − t, 17.5 + o − t] × [35 + t, 42.5 − t]`
|
||||||
|
//!
|
||||||
|
//! The shell's volume is the solid's volume less those three boxes.
|
||||||
|
use serde_json::json;
|
||||||
|
use solidcraft_engine::Session;
|
||||||
|
|
||||||
|
fn run(s: &mut Session, id: &str, p: serde_json::Value) {
|
||||||
|
if let Err(e) = s.execute(id, &p) {
|
||||||
|
panic!("{id} {p}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The box on a box, overhanging the lower box's back face by `o`.
|
||||||
|
fn box_on_box(o: f64) -> Session {
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "solid.box", json!({"height": 35.0, "length": 25.0, "width": 17.5}));
|
||||||
|
run(&mut s, "solid.box", json!({"corner": [7.5, 0.0, 35.0], "height": 7.5, "length": 15.0, "operation": "join", "width": 17.5 + o}));
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shell's volume with the bottom face open (see the module comment).
|
||||||
|
fn open_bottom_shell_volume(o: f64, t: f64) -> f64 {
|
||||||
|
let solid = 25.0 * 17.5 * 35.0 + 15.0 * (17.5 + o) * 7.5;
|
||||||
|
let lower = (25.0 - 2.0 * t) * (17.5 - 2.0 * t) * (35.0 - t);
|
||||||
|
let above = (15.0 - 2.0 * t) * (17.5 - 2.0 * t) * 7.5;
|
||||||
|
let overhang = (15.0 - 2.0 * t) * o * (7.5 - 2.0 * t);
|
||||||
|
solid - lower - above - overhang
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed 83 (minimised): the overhang is 2.5 mm, and the shell opens the top box's left face.
|
||||||
|
/// The shell refused the saddle corner (`a corner whose faces don't meet the same way`).
|
||||||
|
#[test]
|
||||||
|
fn shell_box_on_box_overhanging_saddle_corner() {
|
||||||
|
let mut s = box_on_box(2.5);
|
||||||
|
run(&mut s, "solid.shell", json!({"faces": [[7.5, 17.5, 38.75]], "thickness": 1.5}));
|
||||||
|
sound(&s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same saddle corner with the bottom open: every face round the corner moves, and the
|
||||||
|
/// volume is exact.
|
||||||
|
#[test]
|
||||||
|
fn shell_saddle_corner_volume_is_exact() {
|
||||||
|
for t in [1.0, 1.5] {
|
||||||
|
let mut s = box_on_box(2.5);
|
||||||
|
run(&mut s, "solid.shell", json!({"faces": [[12.5, 8.75, 0.0]], "thickness": t}));
|
||||||
|
sound(&s);
|
||||||
|
let (got, want) = (volume(&s), open_bottom_shell_volume(2.5, t));
|
||||||
|
assert!((got - want).abs() < 1e-6 * want, "thickness {t}: shell volume {got}, want {want}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An overhang (1 mm) thinner than the wall (1.5 mm): the overhang's cavity is only 1 mm deep.
|
||||||
|
/// A corner landing outside the solid, or a wall left thin, would change the volume.
|
||||||
|
#[test]
|
||||||
|
fn shell_saddle_corner_with_an_overhang_thinner_than_the_wall() {
|
||||||
|
let mut s = box_on_box(1.0);
|
||||||
|
run(&mut s, "solid.shell", json!({"faces": [[12.5, 8.75, 0.0]], "thickness": 1.5}));
|
||||||
|
sound(&s);
|
||||||
|
let (got, want) = (volume(&s), open_bottom_shell_volume(1.0, 1.5));
|
||||||
|
assert!((got - want).abs() < 1e-6 * want, "shell volume {got}, want {want}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume(s: &Session) -> f64 {
|
||||||
|
s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).map(|m| m.volume).unwrap_or(f64::NAN)).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sound(s: &Session) {
|
||||||
|
let errors: Vec<_> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
|
||||||
|
assert!(errors.is_empty(), "{errors:?}");
|
||||||
|
let st = s.model.state();
|
||||||
|
assert_eq!(st.bodies.len(), 1, "one body");
|
||||||
|
for b in &st.bodies {
|
||||||
|
assert_eq!(b.body.shell_count(), 1, "one shell");
|
||||||
|
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-21
@@ -459,6 +459,7 @@ fn split_corner(
|
|||||||
// normal, so its inside is to the left; the edge is convex when the face beyond it falls
|
// normal, so its inside is to the left; the edge is convex when the face beyond it falls
|
||||||
// away from there.
|
// away from there.
|
||||||
let mut sign = 0.0f64;
|
let mut sign = 0.0f64;
|
||||||
|
let mut mixed = false;
|
||||||
let mut pairs: Vec<(usize, usize)> = Vec::new();
|
let mut pairs: Vec<(usize, usize)> = Vec::new();
|
||||||
for &(fi, na, ..) in &planes {
|
for &(fi, na, ..) in &planes {
|
||||||
for w in faces.get(fi)?.boundaries() {
|
for w in faces.get(fi)?.boundaries() {
|
||||||
@@ -474,7 +475,7 @@ fn split_corner(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if sign * s < 0.0 {
|
if sign * s < 0.0 {
|
||||||
return None;
|
mixed = true;
|
||||||
}
|
}
|
||||||
sign = s.signum();
|
sign = s.signum();
|
||||||
}
|
}
|
||||||
@@ -483,33 +484,55 @@ fn split_corner(
|
|||||||
if sign == 0.0 {
|
if sign == 0.0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Which triples of faces may meet at a new corner. All convex or all concave: any triple
|
||||||
|
// whose point is inside all the other moved planes. Mixed (a saddle, such as a box on a
|
||||||
|
// box that overhangs its side): the faces round the corner in order, and each new corner
|
||||||
|
// on three faces in a row (the two new edges on the middle face meet there). A saddle's
|
||||||
|
// faces bound no convex region, so a right corner can be outside another face's moved
|
||||||
|
// plane, and the inside-all-the-others test does not apply. What keeps a corner in place
|
||||||
|
// is that it is where its middle face's two new edges meet; a corner past the far end of
|
||||||
|
// one of the corner's edges turns that edge round, and a face whose loops cross after the
|
||||||
|
// move is refused (both checked in `offset_body`).
|
||||||
|
let triples: Vec<[usize; 3]> = if mixed {
|
||||||
|
let ring = face_ring(&pairs, planes.len())?;
|
||||||
|
let n = ring.len();
|
||||||
|
(0..n).map(|i| [ring[(i + n - 1) % n], ring[i], ring[(i + 1) % n]]).collect()
|
||||||
|
} else {
|
||||||
|
let mut t = Vec::new();
|
||||||
|
for ia in 0..planes.len() {
|
||||||
|
for ib in ia + 1..planes.len() {
|
||||||
|
for ic in ib + 1..planes.len() {
|
||||||
|
t.push([planes[ia].0, planes[ib].0, planes[ic].0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t
|
||||||
|
};
|
||||||
|
let plane_of = |fi: usize| planes.iter().find(|x| x.0 == fi);
|
||||||
// Convex: the body's side, n·x ≤ d; concave: the outside's, n·x ≥ d.
|
// Convex: the body's side, n·x ≤ d; concave: the outside's, n·x ≥ d.
|
||||||
let side = -sign;
|
let side = -sign;
|
||||||
let merge = size * 1e-6;
|
let merge = size * 1e-6;
|
||||||
let mut nodes: Vec<(Vec3, Vec<usize>)> = Vec::new();
|
let mut nodes: Vec<(Vec3, Vec<usize>)> = Vec::new();
|
||||||
for (ia, a) in planes.iter().enumerate() {
|
for [fa, fb, fc] in triples {
|
||||||
for (ib, b) in planes.iter().enumerate().skip(ia + 1) {
|
let (a, b, c) = (plane_of(fa)?, plane_of(fb)?, plane_of(fc)?);
|
||||||
for c in planes.iter().skip(ib + 1) {
|
let den = a.1.dot(b.1.cross(c.1));
|
||||||
let den = a.1.dot(b.1.cross(c.1));
|
if den.abs() < 1e-9 {
|
||||||
if den.abs() < 1e-9 {
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
let x = (b.1.cross(c.1) * a.3 + c.1.cross(a.1) * b.3 + a.1.cross(b.1) * c.3) * (1.0 / den);
|
||||||
let x = (b.1.cross(c.1) * a.3 + c.1.cross(a.1) * b.3 + a.1.cross(b.1) * c.3) * (1.0 / den);
|
if !x.is_finite() || x.dist(p) > size || (!mixed && planes.iter().any(|q| side * (q.1.dot(x) - q.3) > merge)) {
|
||||||
if !x.is_finite() || x.dist(p) > size || planes.iter().any(|q| side * (q.1.dot(x) - q.3) > merge) {
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
let fs = [fa, fb, fc];
|
||||||
let fs = [a.0, b.0, c.0];
|
match nodes.iter_mut().find(|(q, _)| q.dist(x) < merge) {
|
||||||
match nodes.iter_mut().find(|(q, _)| q.dist(x) < merge) {
|
Some((_, nf)) => {
|
||||||
Some((_, nf)) => {
|
for f in fs {
|
||||||
for f in fs {
|
if !nf.contains(&f) {
|
||||||
if !nf.contains(&f) {
|
nf.push(f);
|
||||||
nf.push(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => nodes.push((x, fs.to_vec())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
None => nodes.push((x, fs.to_vec())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Every face keeps a corner here, and every edge an end.
|
// Every face keeps a corner here, and every edge an end.
|
||||||
@@ -527,6 +550,39 @@ fn split_corner(
|
|||||||
Some(nodes)
|
Some(nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The faces round a corner in order, from the pairs of faces on each of its edges: `None`
|
||||||
|
/// unless they form one simple ring of `count` faces (each face meets the corner once, between
|
||||||
|
/// exactly two neighbours).
|
||||||
|
fn face_ring(pairs: &[(usize, usize)], count: usize) -> Option<Vec<usize>> {
|
||||||
|
let mut next: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||||
|
for &(a, b) in pairs {
|
||||||
|
for (x, y) in [(a, b), (b, a)] {
|
||||||
|
let ns = next.entry(x).or_default();
|
||||||
|
if !ns.contains(&y) {
|
||||||
|
ns.push(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next.len() != count || next.values().any(|ns| ns.len() != 2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let first = *next.keys().min()?;
|
||||||
|
let mut ring = vec![first];
|
||||||
|
let mut prev = first;
|
||||||
|
let mut cur = *next.get(&first)?.first()?;
|
||||||
|
while cur != first {
|
||||||
|
if ring.len() >= count {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
ring.push(cur);
|
||||||
|
let ns = next.get(&cur)?;
|
||||||
|
let to = if ns[0] == prev { ns[1] } else { ns[0] };
|
||||||
|
prev = cur;
|
||||||
|
cur = to;
|
||||||
|
}
|
||||||
|
(ring.len() == count).then_some(ring)
|
||||||
|
}
|
||||||
|
|
||||||
/// The corners of a split corner (see [`split_corner`]) on face `face`, in order from corner
|
/// The corners of a split corner (see [`split_corner`]) on face `face`, in order from corner
|
||||||
/// `from` to corner `to`, along the new edges between them.
|
/// `from` to corner `to`, along the new edges between them.
|
||||||
fn split_path(nodes: &[(Vec3, Vec<usize>)], face: usize, from: usize, to: usize) -> Option<Vec<usize>> {
|
fn split_path(nodes: &[(Vec3, Vec<usize>)], face: usize, from: usize, to: usize) -> Option<Vec<usize>> {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
"80": {"area":8652.533342,"bodies":1,"edges":20,"error":null,"faces":9,"outcome":"ok","rejected":0,"shells":1,"steps":["box","fillet","fillet"],"unsupported":0,"volume":37387.449469},
|
"80": {"area":8652.533342,"bodies":1,"edges":20,"error":null,"faces":9,"outcome":"ok","rejected":0,"shells":1,"steps":["box","fillet","fillet"],"unsupported":0,"volume":37387.449469},
|
||||||
"81": {"area":4001.847983,"bodies":2,"edges":75,"error":null,"faces":29,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut sphere","circular pattern","rectangular pattern","hole","join cylinder","join box","cut sphere"],"unsupported":2,"volume":13139.658749},
|
"81": {"area":4001.847983,"bodies":2,"edges":75,"error":null,"faces":29,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut sphere","circular pattern","rectangular pattern","hole","join cylinder","join box","cut sphere"],"unsupported":2,"volume":13139.658749},
|
||||||
"82": {"area":3195.487112,"bodies":1,"edges":30,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["box","hole","circular pattern","cut box"],"unsupported":0,"volume":5902.162954},
|
"82": {"area":3195.487112,"bodies":1,"edges":30,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["box","hole","circular pattern","cut box"],"unsupported":0,"volume":5902.162954},
|
||||||
"83": {"area":5125.0,"bodies":1,"edges":28,"error":"offset: a corner whose faces don't meet the same way after t","faces":12,"outcome":"failed","rejected":0,"shells":1,"steps":["box","join box","rectangular pattern","shell"],"unsupported":0,"volume":19250.0},
|
"83": {"area":8489.059183,"bodies":1,"edges":97,"error":null,"faces":35,"outcome":"ok","rejected":0,"shells":2,"steps":["box","join box","rectangular pattern","shell","fillet","cut sphere","join box"],"unsupported":1,"volume":6217.394627},
|
||||||
"84": {"area":4906.192237,"bodies":1,"edges":67,"error":null,"faces":25,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","fillet","hole","hole","cut cylinder","cut box","fillet"],"unsupported":0,"volume":14219.865875},
|
"84": {"area":4906.192237,"bodies":1,"edges":67,"error":null,"faces":25,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","fillet","hole","hole","cut cylinder","cut box","fillet"],"unsupported":0,"volume":14219.865875},
|
||||||
"85": {"area":3309.210936,"bodies":1,"edges":57,"error":null,"faces":24,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","fillet","cut sphere","circular pattern","join cylinder","hole","join box"],"unsupported":0,"volume":6904.455565},
|
"85": {"area":3309.210936,"bodies":1,"edges":57,"error":null,"faces":24,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","fillet","cut sphere","circular pattern","join cylinder","hole","join box"],"unsupported":0,"volume":6904.455565},
|
||||||
"86": {"area":7885.256491,"bodies":1,"edges":66,"error":"offset: a corner whose faces don't meet the same way after t","faces":28,"outcome":"failed","rejected":0,"shells":3,"steps":["extruded profile","join cylinder","join cylinder","circular pattern","rectangular pattern","shell"],"unsupported":2,"volume":21498.10695},
|
"86": {"area":7885.256491,"bodies":1,"edges":66,"error":"offset: a corner whose faces don't meet the same way after t","faces":28,"outcome":"failed","rejected":0,"shells":3,"steps":["extruded profile","join cylinder","join cylinder","circular pattern","rectangular pattern","shell"],"unsupported":2,"volume":21498.10695},
|
||||||
@@ -122,6 +122,7 @@
|
|||||||
{"reason":"#391 after merging main with #1213: seed 41 now reaches its second fillet (built since #1213), and the end-snap fix again drops a sliver edge the cuts left (94 to 93 edges); its steps, outcome, faces and shells are unchanged, volume moves by 0.005 mm³ (5185.215737 to 5185.221068) and area by 0.0014 mm²","seeds":[41]},
|
{"reason":"#391 after merging main with #1213: seed 41 now reaches its second fillet (built since #1213), and the end-snap fix again drops a sliver edge the cuts left (94 to 93 edges); its steps, outcome, faces and shells are unchanged, volume moves by 0.005 mm³ (5185.215737 to 5185.221068) and area by 0.0014 mm²","seeds":[41]},
|
||||||
{"reason":"the chamfer bug is on main too, see #1390","seeds":[57]},
|
{"reason":"the chamfer bug is on main too, see #1390","seeds":[57]},
|
||||||
{"reason":"#1390: a chamfer of a convex edge that would grow the part is refused (TooLarge). Seed 57's 0.5 mm chamfer on the sphere cut's rim, which grew the part since #391 reordered its steps, is now refused, so the run goes on to its later steps and ends ok (16017.21 mm³, 24 faces)","seeds":[57]},
|
{"reason":"#1390: a chamfer of a convex edge that would grow the part is refused (TooLarge). Seed 57's 0.5 mm chamfer on the sphere cut's rim, which grew the part since #391 reordered its steps, is now refused, so the run goes on to its later steps and ends ok (16017.21 mm³, 24 faces)","seeds":[57]},
|
||||||
|
{"reason":"Seed 83 builds: a shell splits a saddle corner (convex and concave edges in turn) into two corners","seeds":[83]},
|
||||||
{"reason":"seed 15 now cuts around a sealed void","seeds":[15]},
|
{"reason":"seed 15 now cuts around a sealed void","seeds":[15]},
|
||||||
{"reason":"#1490: when the last-resort nudge fails both ways, two more screw axes are tried; seed 63's final box join (its face 0.00005 mm off the half-ring's cut face) now builds instead of being refused with an implausible volume, and the design goes on to its fillet","seeds":[63]}
|
{"reason":"#1490: when the last-resort nudge fails both ways, two more screw axes are tried; seed 63's final box join (its face 0.00005 mm off the half-ring's cut face) now builds instead of being refused with an implausible volume, and the design goes on to its fillet","seeds":[63]}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in new issue
Block a user