Skip to content

Joinery: second mortise-and-tenon on the same board fails with "Each body must be a closed solid" (precision) #61

Open

Description

@furilo

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 joint

Pattern

  • 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. Board b never failed in any of the layouts tried.
  • It needs a full-section shoulder on a long board. It happens mostly with mortise_tenon, rarely with tongue_groove, and never with dovetail, finger, half_lap or rabbet in 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_ratio or clearance_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)
  1. The first cut leaves extra vertices on the rail's edges. Faces are imported as earcut triangles (faces.rs:244, via solids.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.
  2. 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 in csg.rs:111. These vertices are kept again (joinery.rs:487). The shoulder face ring then has vertices 2 to 11 µm from its corners.
  3. 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::new accepts it, because its degenerate check (csg.rs:23) works on area.
    • The T-junction split in indexed() (solids.rs:124-141) uses the absolute tolerance epsilon, 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_surfaces before 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_ratio 0.6, width_ratio 0.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.bup followed by benchup call f.bup --batch case.jsonl --dry-run. The answer has ok, failed_call and error.

  • Existing tests: run cargo test -p benchup-core and the joinery and router tests, to confirm that removing collinear vertices does not change the results of other cuts.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions