--- jupyter: description: 'An open plywood box with finger joints for a laser cutter: five flat parts, odd finger counts, the kerf compensated by offsets, and the cut path as DXF.' --- # 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](../_static/gallery/laser-cut-box.png){.result} ```python import math import matplotlib.pyplot as plt import netgen.occ as occ from ngsolve_webgpu.jupyter import Draw import planex as px ``` ```python nbsphinx="hidden" from docs_figures import Row ``` ## 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. ```python 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). ```python 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} ``` ```python nbsphinx="hide-input" Row((bottom, "The bottom"), (front, "Front and back"), (side, "The sides"), caption="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. ```python 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)) ``` ## 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. ```python 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()))) ``` ## 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). ```python 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") ``` ```python nbsphinx="hide-input" Row((px.Compound(layout), "The five parts on the sheet"), caption="The cut paths for the laser") ``` The sheet for the laser: [box.dxf](box.dxf) ```python nbsphinx="hidden" from ngsolve.webgui import Draw as WebguiDraw import sys sys.path.insert(0, "..") from gallery import save_scene, save_figure, save_animation, HIDDEN save_scene(WebguiDraw(occ.Compound(list(solids.values())), euler_angles=[-55, 0, -30], settings=HIDDEN, show=False), "laser-cut-box") ```