diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 55c4232d..4b37b39a 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -527,6 +527,9 @@ mod tests { assert_eq!(def["definition"]["targets"], json!(["Plate (copy)"]), "{def}"); let st = s.world_state(); let v = |name: &str| solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume; - assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-6, "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)")); + // A spring on the wrong plate is hundreds of mm³ off. The two plates are measured apart + // (one moved 50 mm), which the boolean and the measure each round in their own way: + // a few 1e-6 mm³ on 5000. + assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-8 * v("Plate"), "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)")); } } diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index a437efdb..2355a6ff 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -959,6 +959,44 @@ fn fillet_two_edges_meeting_at_a_corner_one_running_into_the_arc_wall() { assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after); } +/// A line of text cut into a block (#391): the boolean came out empty on it after two minutes +/// of retries, at 3.5 mm deep. Two walls of a letter met the block's face 2e-7 mm apart, which +/// truck read as two vertices, and the result's shell did not close. The cut works at any +/// depth now, and takes out the same section at each (the text runs off the block's end, so a +/// little less than the letters). +#[test] +fn a_line_of_text_cuts_into_a_block_at_any_depth() { + let mut removed = Vec::new(); + let mut area = 0.0; + for depth in [1.0, 3.5] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":-40})); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":7.81})); + let state = s.world_state(); + let ss = state.sketch(s.active_sketch.unwrap()).unwrap(); + // The letters' area, counters left out (a letter's hole is its own profile too). + let sorted = |ids: &[String]| { + let mut ids = ids.to_vec(); + ids.sort(); + ids + }; + let counter = |p: &solidcraft_sketch::Profile| ss.profiles.iter().any(|q| q.hole_curves.iter().any(|h| sorted(h) == sorted(&p.outer_curves))); + area = ss.profiles.iter().filter(|p| !counter(p)).map(|p| p.area).sum(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":-depth,"operation":"cut"})); + let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap(); + removed.push((105.0 * 26.0 * 40.0 - volume) / depth); + assert_eq!(s.world_state().bodies.len(), 1); + } + let (shallow, deep) = (removed[0], removed[1]); + assert!((deep - shallow).abs() < 1e-3 * shallow, "{shallow} mm² taken out at 1 mm, {deep} mm² at 3.5 mm"); + assert!(shallow > 0.85 * area && shallow < area, "{shallow} mm² taken out, the letters are {area} mm²"); +} + /// A design saved before tapered sides were built through `taper_loop` (#1014), by the build /// before it: two tapered bosses apart from each other (now joined in one boolean), a fillet on /// a sloping side edge of one and on the top edge of the other, both saved by edge name. They diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index 30c5f15c..5312b481 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -1,7 +1,7 @@ { "about": "What each robustness seed builds (crates/engine/src/tests/robustness_goldens.rs, docs/robustness.md). Record changes with ROBUSTNESS_GOLDENS_BLESS=\"why\".", "seeds": { - "0": {"area":4795.764396,"bodies":1,"edges":79,"error":null,"faces":29,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","hole","circular pattern","circular pattern","cut sphere","join cylinder","cut cylinder","fillet"],"unsupported":1,"volume":13466.472108}, + "0": {"area":4785.573759,"bodies":1,"edges":78,"error":null,"faces":29,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","hole","circular pattern","circular pattern","cut sphere","join cylinder","cut cylinder","fillet"],"unsupported":1,"volume":13461.708496}, "1": {"area":2642.476583,"bodies":1,"edges":37,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut cylinder","join cylinder"],"unsupported":0,"volume":6991.559261}, "2": {"area":2522.922626,"bodies":1,"edges":36,"error":"boolean Cut: the operation failed: no result","faces":14,"outcome":"failed","rejected":1,"shells":1,"steps":["box","cut cylinder","fillet","cut sphere","rectangular pattern"],"unsupported":2,"volume":5580.088497}, "3": {"area":6336.456143,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut box","hole","fillet","hole"],"unsupported":0,"volume":28037.146888}, @@ -35,14 +35,14 @@ "31": {"area":10198.42294,"bodies":3,"edges":67,"error":null,"faces":29,"outcome":"ok","rejected":1,"shells":3,"steps":["box","cut box","fillet","join box","fillet","circular pattern","cut box","circular pattern"],"unsupported":0,"volume":56408.3612}, "32": {"area":3220.802769,"bodies":1,"edges":51,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","cut box","cut box","join box"],"unsupported":0,"volume":7565.955897}, "33": {"area":5286.736353,"bodies":2,"edges":59,"error":null,"faces":27,"outcome":"ok","rejected":0,"shells":3,"steps":["revolved section","fillet","join box","cut cylinder","chamfer","join box","chamfer","fillet"],"unsupported":2,"volume":13858.467983}, - "34": {"area":1086.303472,"bodies":1,"edges":30,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","hole"],"unsupported":0,"volume":2134.365275}, + "34": {"area":1086.305562,"bodies":1,"edges":30,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","hole"],"unsupported":0,"volume":2134.365275}, "35": {"area":4605.630216,"bodies":1,"edges":26,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["box","fillet","join box"],"unsupported":0,"volume":13965.526982}, "36": {"area":10408.825748,"bodies":1,"edges":60,"error":null,"faces":26,"outcome":"ok","rejected":0,"shells":2,"steps":["revolved section","cut cylinder","join box","fillet","join cylinder"],"unsupported":0,"volume":55729.313103}, "37": {"area":11714.822253,"bodies":1,"edges":44,"error":null,"faces":18,"outcome":"ok","rejected":1,"shells":1,"steps":["box","cut cylinder","join cylinder","cut sphere","circular pattern","fillet","circular pattern","rectangular pattern"],"unsupported":2,"volume":75556.602064}, "38": {"area":11769.375811,"bodies":1,"edges":30,"error":"offset: a corner whose faces don't meet the same way after t","faces":12,"outcome":"failed","rejected":1,"shells":1,"steps":["box","hole","cut cylinder","shell"],"unsupported":0,"volume":78261.020975}, - "39": {"area":1892.050815,"bodies":1,"edges":27,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","chamfer","cut sphere","rectangular pattern"],"unsupported":1,"volume":4040.62787}, + "39": {"area":1892.051593,"bodies":1,"edges":27,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","chamfer","cut sphere","rectangular pattern"],"unsupported":1,"volume":4040.619835}, "40": {"area":15548.516294,"bodies":1,"edges":49,"error":"offset: a corner whose faces don't meet the same way after t","faces":22,"outcome":"failed","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","shell","cut cylinder","join cylinder","shell"],"unsupported":0,"volume":16543.777532}, - "41": {"area":7295.281456,"bodies":1,"edges":94,"error":null,"faces":32,"outcome":"ok","rejected":1,"shells":1,"steps":["box","cut sphere","shell","fillet","cut cylinder","rectangular pattern","fillet","cut sphere"],"unsupported":0,"volume":5185.215737}, + "41": {"area":7295.282884,"bodies":1,"edges":93,"error":null,"faces":32,"outcome":"ok","rejected":1,"shells":1,"steps":["box","cut sphere","shell","fillet","cut cylinder","rectangular pattern","fillet","cut sphere"],"unsupported":0,"volume":5185.221068}, "42": {"area":5486.877622,"bodies":1,"edges":51,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut box","cut cylinder","rectangular pattern"],"unsupported":0,"volume":19095.106157}, "43": {"area":3492.988343,"bodies":1,"edges":72,"error":null,"faces":26,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","cut cylinder","rectangular pattern","fillet","hole","rectangular pattern","join cylinder"],"unsupported":1,"volume":7528.548742}, "44": {"area":7972.435566,"bodies":1,"edges":36,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","shell","cut sphere"],"unsupported":0,"volume":7622.184366}, @@ -58,7 +58,7 @@ "54": {"area":7080.555724,"bodies":4,"edges":90,"error":null,"faces":38,"outcome":"ok","rejected":0,"shells":4,"steps":["extruded profile","join box","circular pattern","cut cylinder","cut box","join cylinder","cut cylinder"],"unsupported":3,"volume":15737.224446}, "55": {"area":6796.974648,"bodies":1,"edges":60,"error":"Body","faces":23,"outcome":"invalid","rejected":0,"shells":1,"steps":["box","fillet","cut sphere","circular pattern","rectangular pattern","cut sphere"],"unsupported":0,"volume":24741.239331}, "56": {"area":4453.31728,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut box","chamfer"],"unsupported":0,"volume":11829.375}, - "57": {"area":4678.619229,"bodies":1,"edges":63,"error":null,"faces":23,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut sphere","chamfer","circular pattern","cut sphere","cut sphere","cut cylinder"],"unsupported":2,"volume":16016.156622}, + "57": {"area":4818.157176,"bodies":1,"edges":46,"error":"chamfer moved the volume the wrong way: ","faces":18,"outcome":"volume","rejected":0,"shells":1,"steps":["extruded profile","cut sphere","chamfer"],"unsupported":0,"volume":16946.593943}, "58": {"area":4197.049258,"bodies":1,"edges":42,"error":null,"faces":16,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","fillet","cut sphere"],"unsupported":0,"volume":12543.173591}, "59": {"area":7770.5632,"bodies":1,"edges":36,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["box","join box","join cylinder"],"unsupported":0,"volume":29735.209746}, "60": {"area":3443.328607,"bodies":1,"edges":39,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere"],"unsupported":0,"volume":11779.204543}, @@ -116,6 +116,10 @@ {"reason":"#952: a hole placed on a face drills into the material; seed 81's hole moves its volume by 0.1 mm³ (area by 0.04 mm²)","seeds":[81]}, {"reason":"Cuts join the straight edges they used to leave split along untouched walls (#1049). Replaying each changed seed's new commands on the build before and after gives the same volume, area and faces at every step, and the same refusals (seeds 2 and 95 end on a cut the old build refuses too), except: seed 94's final join was 1.7 mm³ too large before (the box adds 875 − 2.5 × 97.967 = 630.08 mm³ to 5270.48; now 5900.56, before 5902.27), and seed 71's hole differs only in the coarse measuring mesh (identical at 2e-4 mm). Every other volume change is the generator picking other edges by index for fillets and chamfers, some of which are now accepted; seed 71's join now builds.","seeds":[2,4,7,12,13,17,25,31,38,42,45,51,52,56,65,67,69,71,72,74,82,93,94,95]}, {"reason":"#1164: a pattern copy of a box sitting flush on the half-ring's top face (a 27 mm2 contact patch) now joins the ring instead of staying a separate shell, so the later random steps pick different edges and positions","seeds":[13]}, - {"reason":"#1213: a round or chamfer on an edge chain that runs on across a curved seam now builds (it was refused with 'a curved seam where the edge chain turns'). Seed 41's second fillet (three edges, radius 1) was one of those refusals and is now built. Replaying the seed's commands on the build before and after gives the same volume, area, faces and edges after each of steps 1 to 6; step 7, the fillet, is refused before and builds after (5292.19 to 5289.17 mm³, area 7395.26 to 7381.22 mm²); the run then reaches its eight steps one step sooner, so its last step is a cut sphere (5185.22 mm³) where it was a join cylinder (the generator draws its later steps from the new state).","seeds":[41]} + {"reason":"A point at a boundary curve's end is that end in the boolean (#391): seeds 0 and 41 lose a sliver edge (8e-5 mm on seed 41) that a cut left. Seed 0's fillet of its two edges then takes out 10.62 mm³, as its edges do alone (3.11 and 7.40 mm³), where it took 5.86. Seed 39 moves by 0.008 mm³; seed 57's chamfer and pattern come in the other order, its body valid with no edge under 10 µm","seeds":[0,39,41,57]}, + {"reason":"#391 after merging main: seed 34's area moves by 0.002 mm² (1086.303472 to 1086.305562, 2e-6 relative); its steps, outcome, volume and face, edge and shell counts are unchanged; the same small kind of move as seed 39's","seeds":[34]}, + {"reason":"#1213: a round or chamfer on an edge chain that runs on across a curved seam now builds (it was refused with 'a curved seam where the edge chain turns'). Seed 41's second fillet (three edges, radius 1) was one of those refusals and is now built. Replaying the seed's commands on the build before and after gives the same volume, area, faces and edges after each of steps 1 to 6; step 7, the fillet, is refused before and builds after (5292.19 to 5289.17 mm³, area 7395.26 to 7381.22 mm²); the run then reaches its eight steps one step sooner, so its last step is a cut sphere (5185.22 mm³) where it was a join cylinder (the generator draws its later steps from the new state).","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]} ] } diff --git a/vendor/README.md b/vendor/README.md index 71e49046..1f38c883 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,6 +17,10 @@ Changes: - `transversal/integrate/mod.rs`: the result's shells and faces keep the order of the shell they come from (truck's connected components followed hash order of face addresses, so a boolean's faces came out in a different order on every run). +- `transversal/loops_store/mod.rs`: a point at an end of a boundary curve (within the point + tolerance) is that end, whatever its parameter: a polyline's parameter counts segments, so + an intersection curve ending 2e-7 mm from another's end could read as inside it, cut it + into a sliver with a vertex of its own, and leave the result's shell open (#391). - `transversal/loops_store/mod.rs`: an intersection edge added to a face at a vertex that more than one wire visits (several curves end on it: the touching point of two equal cylinders) goes into the corner that holds its direction, swept counterclockwise about the face's normal from diff --git a/vendor/truck-shapeops/src/transversal/loops_store/mod.rs b/vendor/truck-shapeops/src/transversal/loops_store/mod.rs index a6d4decd..3d3ef375 100644 --- a/vendor/truck-shapeops/src/transversal/loops_store/mod.rs +++ b/vendor/truck-shapeops/src/transversal/loops_store/mod.rs @@ -158,7 +158,7 @@ impl ParameterKind { } } -impl Loops { +impl Loops { fn search_parameter(&self, pt: P) -> Option<(usize, usize, ParameterKind)> where C: BoundedCurve + SearchParameter { self.iter() @@ -167,7 +167,18 @@ impl Loops { .find_map(|(i, j, edge)| { let curve = edge.curve(); curve.search_parameter(pt, None, 1).and_then(|t| { - let kind = ParameterKind::try_new(t, curve.range_tuple())?; + // SolidCraft: a point at an end of the curve is that end, however far its + // parameter is from the end's. The parameter alone is no distance: on a + // polyline it counts segments, and an intersection curve ending 2e-7 mm from + // another's end (two walls of a glyph meeting a face) read 1.4e-6 before it, + // so it was cut into a sliver and the result's shell never closed (#391). + let kind = if pt.near(&curve.front()) { + ParameterKind::Front + } else if pt.near(&curve.back()) { + ParameterKind::Back + } else { + ParameterKind::try_new(t, curve.range_tuple())? + }; Some((i, j, kind)) }) })