Repository navigation
Description
The second mortise-and-tenon cut on the same board can fail with "Each body must be a closed solid; fill missing faces and remove internal or stray geometry", although the geometry is valid: each joint cuts on its own, and the same two joints cut fine when added in the other order. This is the minimal reproduction asked for under Precision in #42.
Found while an agent built a dining table with the benchup CLI: a centre rail with a tenon into each long apron. The second tenon failed with height_ratio 0.6, and worked with 0.75. The same failure happens in BenchUp itself, because the app and the CLI use the same core code to cut joints (Model::evaluate_joinery).
Minimal reproduction
A 1000 mm rail between two 20 mm sides, entering each by 10 mm. The joints use default settings.
{"tool":"create_board","arguments":{"name":"Left","origin_mm":[0,0,0],"size_mm":[20,200,100]}}
{"tool":"create_board","arguments":{"name":"Right","origin_mm":[1000,0,0],"size_mm":[20,200,100]}}
{"tool":"create_board","arguments":{"name":"Rail","origin_mm":[10,90,0],"size_mm":[1000,20,100]}}
{"tool":"add_joint","arguments":{"a":"$3.created[0]","b":"$2.created[0]","kind":"mortise_tenon"}}
{"tool":"add_joint","arguments":{"a":"$3.created[0]","b":"$1.created[0]","kind":"mortise_tenon"}}benchup new t.bup && benchup call t.bup --batch repro.jsonl --dry-run # call 5 fails- The rail into Right, then the rail into Left: fails.
- The rail into Left, then into Right: cuts.
- Either joint alone: cuts.
The same, as a core test (it fails on main; board_at is the helper in joinery.rs tests):
let mut model = Model::default();
let left = board_at(&mut model, DVec3::ZERO, DVec3::new(0.02, 0.2, 0.1), false);
let right = board_at(&mut model, DVec3::new(1.0, 0., 0.), DVec3::new(1.02, 0.2, 0.1), false);
let rail = board_at(&mut model, DVec3::new(0.01, 0.09, 0.), DVec3::new(1.01, 0.11, 0.1), false);
let settings = JointSettings { kind: JointKind::MortiseTenon, depth_axis: 0, width_axis: 1, ..Default::default() };
for b in [right, left] {
model.add_connection(Connection { members: [rail, b], entry_face: None, operation: ConnectionOperation::Joinery(settings.clone()) }).unwrap();
}
assert!(model.evaluate_joinery().errors.is_empty()); // fails: closed solid error on the second jointPattern
- It only fails on the second cut on the same board
a(the board that carries the tenon), when that board's result is validated. Boardbnever failed in any of the layouts tried. - It needs a full-section shoulder on a long board. It happens mostly with
mortise_tenon, rarely withtongue_groove, and never withdovetail,finger,half_laporrabbetin the sweeps. - Within that set the outcome is effectively chaotic. It flips with 0.1 mm moves and with absolute coordinates, but not with
width_ratioorclearance_mm. - Workaround: add the joints in the other order. Joints are cut in the order they were added. Nudging a board by 0.5 to 1 mm also helps, but less reliably.
Cases tried (about 2,000 dry-run batches)
The first reproduction was the table: aprons 2820×22×100 at [90,60,630] and [90,1118,630], a centre rail 22×1058×100 at [1489,71,630] entering each by 11 mm, mortise and tenon with width_ratio 0.33 and height_ratio 0.6, front joint first.
| What changed | Result |
|---|---|
| Table reproduction, front joint then back joint | FAIL |
| Same, back then front | PASS (0 failures in about 280 runs of that order) |
| Rail x from 1487 to 1491 in 0.1 mm steps | 11 of 41 fail (1487.1, 1488.0, 1488.2, 1489.0, 1489.6, 1490.1, 1490.3, 1490.7, 1490.9, …); not tied to centring |
| Rail x from 100 to 2880 in coarse steps | 16 of 76 fail, along the whole range |
| Apron length 2780 to 3000 | only 2820 fails |
| Entry depth 9 to 15 mm | only 11 fails |
| Thickness × height | 18×{80,100,120} and 22×{80,100} fail; 20, 25 and 22×120 pass |
| Translate the whole set | x or y shifts of 0.5 to 1000 pass; z shifts of +0.5, +1, +10 still fail; z −630 passes, so exact coordinates matter |
height_ratio 0.1 to 0.9 |
only 0.6 and 0.62 fail |
width_ratio 0.1 to 0.9 |
all fail (no effect) |
clearance_mm 0 to 1 |
all fail (no effect; clearance only changes board b) |
tongue_groove, x sweep |
2 of 41 fail |
dovetail, finger, half_lap, rabbet, x sweeps |
0 of 41 each |
mortise_tenon with other axes (depth 1, width 2); height_ratio 0.75 / 0.5 / 0.4; z = 0 |
3 / 1 / 5 / 10 / 4 of 41 fail |
| The minimal case (rail along x), joints in one order | 41 of 41 fail; robust to ratios, translation, thickness, height and clearance |
| Same, the other order | 0 of 41 |
| Rail length sweep in that layout | in the sweep, rails of 500 mm or less never failed and about half failed from 550 mm on; a 400 mm rail with the minimal case's other sizes does fail |
The minimal case rotated 90° about z with rotate |
0 of 41 |
| An apron between two legs (z, y and x sweeps) | 0 of 123 |
A shelf housed in two sides (mortise_tenon, tongue_groove) |
0 of 82 |
Shared board is b in both joints (two rails into one apron, a leg with two aprons) |
0 of 123 |
Chain: the rail is a in one joint and b in the other |
0 of 41 |
The rail is b in both joints |
0 of 82 |
Where it breaks (traced with debug prints in a copy of the core; the mechanism was observed, not just inferred)
- The first cut leaves extra vertices on the rail's edges. Faces are imported as earcut triangles (
faces.rs:244, viasolids.rs:258). When the BSP cuts at the first shoulder plane, it crosses the long faces' triangle diagonals. That leaves extra vertices in line with the rail's edges, at odd positions. In the minimal case they are at y = 90.2 and z = 1 / 99 on the x = 1000 shoulder (20 × 10 / 1000).output()→merged_surfaces(solids.rs:580,:625,:447) keeps them in the saved face rings. - The second cut adds vertices microns from the corners. The second joint re-imports the cut rail (
joinery.rs:365) and re-triangulates it, and earcut now draws long diagonals from the far corner to those odd vertices. The second shoulder plane (x = 20) crosses those diagonals 2 µm from the corner (y = 90.0020202, z = 99.98990, …), via the split incsg.rs:111. These vertices are kept again (joinery.rs:487). The shoulder face ring then has vertices 2 to 11 µm from its corners. - The validation re-import breaks the mesh.
next.solid_volume(temp)(joinery.rs:489) re-imports the result.- Earcut makes a needle triangle on the shoulder face, about 2 µm wide and 100 mm long.
Polygon::newaccepts it, because its degenerate check (csg.rs:23) works on area.- The T-junction split in
indexed()(solids.rs:124-141) uses the absolute toleranceepsilon, which is(1e-7 / scale).clamp(1e-12, 1e-8)(solids.rs:50), about 10 nm on a 1 m board. The extra vertices near the needle's tip fall within it from both long edges, so they are inserted into both. - The outline folds back on itself, some edges end up used by 4 or 6 faces, and
components()rejects the body (solids.rs:184).
In short, extra in-line vertices are kept and multiplied across successive cuts, earcut turns them into needle triangles, and the T-junction tolerance then makes those needles non-manifold. The joint parameters are not the problem, and the frame epsilon value alone is not the trigger.
Possible directions, not tried:
- Drop collinear vertices from face rings in
output/merged_surfacesbefore a solid is re-imported. - Reject or merge needle triangles when importing, comparing width to length rather than area.
- Make the T-junction test not insert a vertex into two edges of the same triangle.
How to check a fix
-
Core test: the test above, in both orders. Add the table case too (aprons 2.82 m long, rail at x = 1.489 m,
height_ratio0.6,width_ratio0.33, width axis across the rail's thickness). -
Sweeps: rebuild the CLI and rerun these cases, all as dry runs:
- the minimal batch in both orders;
- a rail x sweep of the table case (1487 to 1491 mm in 0.1 mm steps);
- the minimal layout with the rail length from 300 to 1500 mm.
Every run should cut. With the CLI from Add benchup, a CLI and MCP server for editing .bup projects #41, each case is
benchup new f.bupfollowed bybenchup call f.bup --batch case.jsonl --dry-run. The answer hasok,failed_callanderror. -
Existing tests: run
cargo test -p benchup-coreand the joinery and router tests, to confirm that removing collinear vertices does not change the results of other cuts.
Activity