A wall-mountable enclosure for a Waveshare 10.1" DSI touch display, a Raspberry Pi 5, two side-firing speakers and a 5015 radial blower with an integral cooling duct — defined entirely in code with CadQuery, and verified headlessly rather than eyeballed in a GUI.
| The bare print | Assembled | Interior detail |
|---|---|---|
![]() |
![]() |
![]() |
Left: as it came off the bed — speaker grilles, waffle rib grid, bottom mounting ear, bottom vent row. Centre: blower, duct and both speakers fitted. Right: the two keyholes, the rib grid clipped around them, and the ducted blower in the top-left corner.
247.0 × 85.6 × 177.0 mm 373.5 cm³ one watertight solid
~342 g PLA 7–13 h print ~₹2,050 to have made
Built around: a Waveshare 10.1-DSI-TOUCH-A
display, a Raspberry Pi 5 with the active cooler, a 5015 12 V blower, a
CNLINKO YT-RJ45-JSX-29-001
IP67 Ethernet receptacle, a pair of
Waveshare 8Ω 5W speakers and
ten M3 heat-set inserts. Full part list with links in BOM.md.
The whole model is one ~100 KB Python script. Every dimension lives in a single
P{} dictionary at the top, so changing the bezel width re-derives the shell,
the display opening, the tab positions, the duct routing and the rib grid — and
the build asserts loudly if any of that pushes a feature into another.
That matters because this part has genuinely coupled constraints. The duct has
to reach the right wall without crossing the display's screwdriver access path
or clipping a speaker. The fan standoff is a function of wall thickness. The
display's mounting holes sit on a plane tilted 5°, so every offset up the board
is an in-plane distance whose vertical component is d × cos(5°).
In a GUI, those relationships live in your head. Here they're executable.
The screen sits at a 5° inclination, so its four fixing points lie on a plane tilted out of every axis. Each corner gets a 22 × 18 × 3.5 mm bracket with an Ø3.0 clearance hole drilled along the display's own normal, not vertically — an early version angled only the clearance hole and left the counterbore vertical, which put two different diameters inside each slot and skewed the screw axis by 10°.
The brackets were also rebuilt once. Originally a 22 × 2 mm strip, which meant the two left-hand tabs had zero side-wall contact — they hung off the front face alone. They're now full corner brackets tied into two walls each, with out-of-plane gussets (4 mm thick, 11 mm tab leg, 14 mm back leg) fusing them into the shell.
The top two corners have Ø11 mm access holes straight through the back so you can get a driver onto those screws once the Pi is in.
Sizing note: the display's captive threads are M2.5, not M3 — the drawing calls out "8-M2.5" and the STEP's tapped holes measure Ø2.04, the M2.5 tapping drill. The brackets were originally cut Ø3.4 for M3 clearance, which passes an M2.5 screw but lets it wander. Now Ø3.0: a loose M2.5 fit, snug enough to locate the screw, forgiving of print tolerance.
Two keyhole slots in the back wall, Ø11.0 opening into a Ø5.5 slot, at X = ±61.75. The Ø11 clears a No.10 pan head (~Ø9.5); the Ø5.5 takes the 4.8 mm shank with 0.35 mm per side.
Orientation matters and was got wrong the first time. The big hole sits below the slot: you pass the screw heads through, let the enclosure drop, and the screws ride up to rest at the top of each slot (Z = 143.0). Built the other way round the screw sits in the big hole with nothing trapping it, and the enclosure lifts straight off.
They're also not open holes. Each is a two-layer membrane — the cutters span ±3.5 mm about the wall centre and stop 0.5 mm short of the inner face, so the back reads as a closed surface until you pierce it. Keeps dust and fingers out of the electronics.
A third fixing hangs below the enclosure: a 44 mm wide external ear, 22 mm drop, 5 mm thick (1 mm more than the wall, for meat around the screw), with a Ø6.5 hole and a tapered R9 nose. It runs 2 mm up into the bottom wall so the joint is volumetric rather than a surface weld, and carries two gussets. Two keyholes alone let the enclosure pivot; this stops it.
A Ø21.2 mm D-shaped cutout through the top wall for a YT-RJ45-JSX-29-001 waterproof panel receptacle — Ø21.2 with a flat at 19.7 across (a 10.87 mm chord sitting 9.10 mm off centre), the flat facing the wall side. The flat is the receptacle's anti-rotation feature, so the connector can't spin when you torque the nut.
Positioned at X = −75.0, and Y = 26.0 forward of the back face — far enough forward that the retaining nut clears the back-wall rib grid, which tops out at Y = 10.
Ten M3 × 4 mm slant-knurled brass heat-set inserts: eight in Ø7.5 × 10 mm speaker bosses, two in Ø10 × 8 mm blower standoffs. Every bore is Ø4.0 × 6.0 mm, which leaves only a 1.50 mm annulus of plastic on the speaker bosses (2.75 mm on the fan ones) — that thin ring is why the print spec calls for 3 perimeters rather than 2, so it comes out solid instead of part infill. An insert pulling out of a boss is the most likely mechanical failure on this part.
A 'heat_insert' / 'self_tap' switch in the parameter dict reverts every boss
to pilot holes for No.6 self-tappers in one edit, if you'd rather not buy an
insert iron.
Angled side and bottom vent slots (sheared through the wall thickness so they aren't see-through), a perforated speaker grille of 39 holes per driver, a 150 × 90 mm rear service opening, and an internal waffle rib grid — 27 segments after automatic clipping against ten keep-out zones so it never crosses a keyhole, an access hole or a boss.
verify_enclosure.py runs the checks that caught real bugs during development.
It exists because OCC's own validity flag is not trustworthy on this model —
the build has reported valid: True while producing inverted patches with
negative volume, non-manifold edges, and a boolean that silently swallowed a
cutter.
$ python verify_enclosure.py
1. MESH INTEGRITY
PASS single connected body (found 1)
PASS watertight
PASS no non-manifold edges (found 0)
PASS bbox X/Y/Z = 247.00 / 85.56 / 177.00 mm
2. PRINT ORIENTATION
orientation height overhang bed contact
BACK face down (CORRECT) 85.6mm 2.5% 24,609mm2
side face down 247.0mm 7.1% 11,286mm2
front face down 85.6mm 14.0% 0mm2
bottom face down (upright) 177.0mm 22.6% 16mm2
3. INTERFERENCE WITH THE PLACED BODIES
PASS fan_placed.step 0.00% of 400 points inside the plastic
PASS speaker_placed.step 0.00% of 400 points inside the plastic
PASS display_placed.step 0.25% of 400 points inside the plastic
4. MATERIAL AND PRINT TIME ESTIMATE
TOTAL EXTRUDED : 275.5 cm3 = 342 g PLA (70% dense)
print time : 7-13 h on a Bambu A1
Two things worth calling out about section 3. OCC's boolean intersect()
reports the entire 80,300 mm³ left speaker as inside the enclosure — it is
open space. And axis-aligned ray casting is degenerate against this model: a +X
ray fired from inside the fan runs straight down the duct bore and grazes its
wall, flipping parity on ~1% of samples and inventing a fan clash that isn't
there. The verifier uses three oblique directions with a majority vote.
Duct aerodynamics. The blower's outlet is ducted to the right wall above the speaker. Rather than just area-matching, the loss was modelled properly: a 1D lumped-loss model with area and wetted perimeter sampled at 400 stations along the real loft, Colebrook friction, sudden-expansion / contraction / bend / exit terms, intersected with a parametric 5015 fan curve. Net penalty ~6–9%, exit kinetic energy dominant.
An early version of that model counted the 150 × 90 mm back opening as intake area. It presses against the wall, so it's closed — the real intake is 1,217 mm² of bottom vents, not 13,500 mm². The conclusion survived; the number didn't.
Printability. Designed around back-face-down printing throughout. The duct was re-grounded onto the back wall specifically to eliminate its support requirement, taking total overhang from 6,757 mm² (3.4%) to 5,337 mm² (2.5%).
Cost engineering. The first vendor quote came back at ₹5,305. Their engine reported "Material Volume 745.50 cm³" — exactly 2.00× the part's true 373.5 cm³. It doubles solid volume as a blanket support allowance and multiplies by a fixed rate; orientation is not an input, so rotating the model changes nothing. That's roughly 4× what the part actually consumes at sane settings. Same part, per-gram pricing, correct orientation: ~₹2,050.
Both vendor auto-orient tools tried to print it standing upright — 177 mm tall, balanced on 16 mm² of bed contact, generating as much support plastic as the part itself.
Fastening. Ten M3 × 4 mm brass heat-set inserts, with a reversible
'heat_insert' / 'self_tap' mode switch in the parameter dict. Boss walls are
sized so the 1.75 mm annulus around each Ø4.0 bore prints solid at 3 perimeters.
conda env create -f environment.yml # reliable on Windows
conda activate encl
python build_enclosure_cq.py # -> enclosure.step + enclosure.stl (~2 min)
python place_parts.py # -> the assembly-check bodies
python verify_enclosure.py # exit 0 = safe to slicePinned to CadQuery 2.7.0 / Python 3.10 / trimesh 5.0.0. Verified to rebuild from a bare directory containing only the three scripts.
Back face down, 85.6 mm build height. 0.2 mm layers, 3 perimeters, 20% infill,
matte black PLA. Full rationale, vendor comparison and an order template in
PRINTING.md.
| Path | |
|---|---|
build_enclosure_cq.py |
The entire model. All dimensions in the P{} dict |
place_parts.py |
Places display / speakers / fan into the build frame |
verify_enclosure.py |
Verification suite |
BOM.md |
Every part, with model numbers and buying links |
PRINTING.md |
Orientation, settings, cost analysis, vendors |
OPEN_ISSUES.md |
Unresolved items, known traps, superseded-facts table |
MANIFEST.md |
What every file is |
DESIGN_LOG.md |
93 KB chronological log — every measurement and every bug |
enclosure.step |
The deliverable |
Vendor CAD for the display, blower and speaker is not redistributed here.
build_enclosure_cq.py doesn't need it; only place_parts.py does. Download
from the vendors and drop into the repo root:
10_1-DSI-TOUCH-A-20250702.stp— Waveshare 10.1" DSI displayRadial-Cooling-Fan-5015-DC12V.STEP— 5015 12 V radial blowerspeaker_unibody.step— speaker
The design has been printed and assembled. Status from the physical part:
Confirmed good — speaker mounting-hole spacing lands correctly, and the bezel terracing reads as a deliberate finish in matte black rather than a defect.
Still open — one of the 39 grille holes in the −X wall's upper driver group is solid: a ray through Y = 41.62, Z = 98.40 crosses the wall at both faces, so the cutter did nothing there. It's a CAD defect, not a print fault, and OCC has resisted six different fixes (chained cuts, one 78-tool cut, fuzzy boolean, depth jitter, and a double pass that collapsed the model to 44 cm³). Impact is one blocked hole out of 39 — drillable with a Ø3 bit.
What makes it interesting is that nothing caught it for months. A missing
hole is still watertight, still manifold, still valid: True, and 28 mm³ out of
373,500 is invisible in a volume check. The verifier now counts grille holes per
wall specifically so it can't pass silently again.
Full detail in OPEN_ISSUES.md.
MIT for the code and the enclosure geometry. Vendor CAD excluded — see
LICENSE.



