Laser-cut box with finger joints

The example is an open box of 3 mm plywood with finger joints, made of five flat parts that are cut by a laser and slot together. Each jointed edge is divided into an odd number of fingers. One part keeps the fingers at the ends, and its partner keeps the ones in between. The laser burns away a kerf, so the cut path runs half a kerf outside each part. Lengths are in mm.

A laser-cut box with finger joints, assembled from five flat parts

[1]:
import math
import matplotlib.pyplot as plt
import netgen.occ as occ
from ngsolve_webgpu.jupyter import Draw
import planex as px

The fingers

The box is W = 120 wide, D = 80 deep and H = 60 high, of plywood t = 3 thick. Each jointed edge gets an odd number of fingers, about 12 long and at least three. notches cuts every other finger along an edge, t deep, and panel cuts the notches along the edges of a rectangular part: True keeps the end fingers of an edge, False cuts them, and None leaves the edge plain. The edges run counterclockwise, so their inside is to the left.

[3]:
W = 120.0
D = 80.0
H = 60.0
t = 3.0


def count(length):
    n = max(3, round(length / 12))
    return n if n % 2 else n + 1


def notches(start, end, ends_kept):
    n = count(math.dist(start, end))
    ux, uy = (end[0] - start[0]) / n, (end[1] - start[1]) / n
    cut = []
    for k in range(n):
        if (k % 2 == 0) != ends_kept:
            a = (start[0] + k * ux, start[1] + k * uy)
            cut.append(px.QuickSketch(a, dir=(ux, uy)).Rectangle(math.hypot(ux, uy), t).Face())
    return cut


def panel(width, height, edges):
    corners = [(0, 0), (width, 0), (width, height), (0, height)]
    cut = []
    for k, ends_kept in enumerate(edges):
        if ends_kept is not None:
            cut += notches(corners[k], corners[(k + 1) % 4], ends_kept)
    return px.Subtract([px.QuickSketch().Rectangle(width, height).Face(), *cut]).faces[0]

The parts

The bottom loses the end fingers on all four edges. The front and back keep them on the bottom edge and on both sides, and the side walls keep them on the bottom edge and lose them on the sides. Every corner cube therefore belongs to exactly one part. The top edges are plain (Figure 1).

[4]:
bottom = panel(W, D, [False, False, False, False])
front = panel(W, H, [True, True, None, True])
side = panel(D, H, [True, False, None, False])
parts = {"bottom": bottom, "front": front, "back": front, "left": side, "right": side}
The bottom
(a) The bottom
Front and back
(b) Front and back
The sides
(c) The sides
Figure 1: The three kinds of parts

Do the joints close?

Check: along each joint, the edge strips of the two parts, t deep, together fill the strip exactly once, with no gap and no overlap. Only where the bottom meets a side do the strip’s two end squares stay empty, because those corner cubes belong to the front and the back. strip cuts a part’s strip along an edge and lays it along the x axis.

[6]:
def strip(face, start, end):
    L = math.dist(start, end)
    d = ((end[0] - start[0]) / L, (end[1] - start[1]) / L)
    piece = face * px.QuickSketch(start, dir=d).Rectangle(L, t).Face()
    angle = math.degrees(math.atan2(d[1], d[0]))
    return piece.Translated((-start[0], -start[1])).Rotated((0, 0), -angle), L


joints = {
    "bottom / front": ((bottom, (0, 0), (W, 0)), (front, (0, 0), (W, 0))),
    "bottom / side": ((bottom, (W, 0), (W, D)), (side, (0, 0), (D, 0))),
    "front / side": ((front, (W, 0), (W, H)), (side, (0, H), (0, 0))),
}
owned_elsewhere = {"bottom / front": 0, "bottom / side": 2, "front / side": 0}
for name, (a, b) in joints.items():
    sa, L = strip(*a)
    sb, _ = strip(*b)
    if name == "front / side":
        sb = sb.Mirrored((L / 2, 0), (0, 1))
    expected = L * t - owned_elsewhere[name] * t * t
    print(name, "overlap", (sa * sb).mass, "filled", round((sa + sb).mass, 6), "of", round(expected, 6))
bottom / front overlap 0.0 filled 360.0 of 360.0
bottom / side overlap 0.0 filled 222.0 of 222.0
front / side overlap 0.0 filled 180.0 of 180.0

Assembled

The parts are extruded to their thickness and placed in 3D. Checks: they do not overlap anywhere, their material is their areas times t, and together they form a box of the outer size.

[7]:
def placed(face, turns, shift):
    solid = face.ToOCCShape().Extrude(t)
    for axis, angle in turns:
        solid = solid.Rotate(axis, angle)
    return solid.Move(shift)

x_axis, z_axis = occ.Axis((0, 0, 0), occ.X), occ.Axis((0, 0, 0), occ.Z)
solids = {
    "bottom": placed(bottom, [], (0, 0, 0)),
    "front": placed(front, [(x_axis, 90)], (0, t, 0)),
    "back": placed(front, [(x_axis, 90)], (0, D, 0)),
    "left": placed(side, [(x_axis, 90), (z_axis, 90)], (0, 0, 0)),
    "right": placed(side, [(x_axis, 90), (z_axis, 90)], (W - t, 0, 0)),
}
names = list(solids)
overlap = max((solids[p] * solids[q]).mass for i, p in enumerate(names) for q in names[i + 1:])
low, high = occ.Compound(list(solids.values())).bounding_box
print("largest overlap between two parts", overlap, "mm³")
print("material", round(sum(s.mass for s in solids.values()), 3), "mm³, parts' areas times t",
      round(sum(f.mass for f in parts.values()) * t, 3), "mm³")
print("outer size", round(high.x - low.x, 3), "x", round(high.y - low.y, 3), "x", round(high.z - low.z, 3))
for solid in solids.values():
    solid.faces.col = (0.82, 0.67, 0.45)
Draw(occ.Compound(list(solids.values())))
largest overlap between two parts 0.0 mm³
material 95148.0 mm³, parts' areas times t 95148.0 mm³
outer size 120.0 x 80.0 x 60.0
3D view
▶ Click to interact

Cutting

The laser burns a kerf of 0.15, so each part grows by half of it for its cut path. The parts sit on a sheet with a gap of 5 between them, and the sheet goes to the laser as DXF (Figure 2).

[8]:
layout, x = [], 5.0
for name, face in parts.items():
    cut = face + face.outer.Offset(0.15 / 2)
    (x0, y0), (x1, y1) = cut.bounding_box
    layout.append(cut.Translated((x - x0, 5 - y0)))
    x += x1 - x0 + 5
cut_length = sum(part.faces[0].outer.mass for part in layout)
print("cut path", round(cut_length), "mm,", round(cut_length / 20 / 60, 1), "min at 20 mm/s")
px.WriteDXF(px.Unify(layout), "box.dxf")
cut path 1919 mm, 1.6 min at 20 mm/s
The five parts on the sheet
Figure 2: The cut paths for the laser

The sheet for the laser: box.dxf