"""Cut list, hardware list and build guide -> PDF (reportlab)."""
import math
import os
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import LETTER
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak)
import rack_params as P
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "underdesk-rack-build-guide.pdf")
INK = colors.HexColor("#111111")
MUT = colors.HexColor("#5a5a63")
ACC = colors.HexColor("#b3261e")
HDR = colors.HexColor("#22222a")
ZEB = colors.HexColor("#f4f4f7")
LINE = colors.HexColor("#d8d8de")
ss = getSampleStyleSheet()
S = {
"h1": ParagraphStyle("h1", parent=ss["Title"], fontSize=19, leading=23,
alignment=TA_LEFT, textColor=INK, spaceAfter=2),
"sub": ParagraphStyle("sub", fontSize=9.5, leading=13.5, textColor=MUT, spaceAfter=13),
"h2": ParagraphStyle("h2", fontSize=12.5, leading=15, textColor=INK,
fontName="Helvetica-Bold", spaceBefore=15, spaceAfter=6),
"h3": ParagraphStyle("h3", fontSize=10, leading=13, textColor=ACC,
fontName="Helvetica-Bold", spaceBefore=10, spaceAfter=3),
"p": ParagraphStyle("p", fontSize=9.2, leading=13.2, textColor=INK, spaceAfter=6),
"note": ParagraphStyle("note", fontSize=8.6, leading=12.4, textColor=MUT, spaceAfter=6),
"cell": ParagraphStyle("cell", fontSize=8.4, leading=11),
"cellb": ParagraphStyle("cellb", fontSize=8.4, leading=11, fontName="Helvetica-Bold"),
"cellm": ParagraphStyle("cellm", fontSize=8.0, leading=10.6, textColor=MUT),
}
def P_(t, s="p"):
return Paragraph(t, S[s])
def table(data, widths, align=None, zebra=True, muted_col=None):
rows = [[Paragraph(c, S["cellb"] if r == 0 else
(S["cellm"] if muted_col is not None and i == muted_col else S["cell"]))
for i, c in enumerate(row)] for r, row in enumerate(data)]
t = Table(rows, colWidths=widths, repeatRows=1, hAlign="LEFT")
style = [
("BACKGROUND", (0, 0), (-1, 0), HDR),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4.5),
("BOTTOMPADDING", (0, 0), (-1, -1), 4.5),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("GRID", (0, 0), (-1, -1), 0.4, LINE),
]
if zebra:
for i in range(2, len(data), 2):
style.append(("BACKGROUND", (0, i), (-1, i), ZEB))
for col, a in (align or {}).items():
style.append(("ALIGN", (col, 0), (col, -1), a))
t.setStyle(TableStyle(style))
return t
def cut(v):
"""Round DOWN to the nearest 1/16 — a piece that must drop in must not be long."""
whole = int(math.floor(v + 1e-9))
n = int(math.floor((v - whole) * 16 + 1e-9))
if n == 0:
return f'{whole}"'
d = 16
while n % 2 == 0:
n //= 2
d //= 2
return f'{whole}-{n}/{d}"'
def fr(v):
"""Nearest 1/16, for reference dimensions."""
whole = int(math.floor(v + 1e-9))
n = int(round((v - whole) * 16))
if n == 16:
whole, n = whole + 1, 0
if n == 0:
return f'{whole}"'
d = 16
while n % 2 == 0:
n //= 2
d //= 2
return f'{whole}-{n}/{d}"' if whole else f'{n}/{d}"'
IL, SH = cut(P.INNER_LEN), cut(P.SIDE_HT)
story = []
A = story.append
# ===========================================================================
A(P_("Under-desk clamp-on homelab rack", "h1"))
A(P_(f'{fr(P.W)} W × {fr(P.D)} D × {fr(P.H)} H · '
f'¾" PureBond birch plywood (0.703" actual) · '
f'two opposite-facing U bays, {fr(P.CLEAR)} clear each · no drilling into the desk',
"sub"))
A(P_("The design in one paragraph", "h2"))
A(P_(
"A single rigid plywood box hangs under the desk on four removable C-clamps. One horizontal "
f"shelf splits it into two bays of {fr(P.CLEAR)} clear height. The lower bay is closed at the "
"wall side and open toward your chair — four mini PCs and the FireCuda live there, all in "
"one row. The upper bay is closed at the front and open toward the wall — that is where the "
"Ethernet switch, the power strips, the bricks and every spare metre of cable hide. Five 2\" "
"grommets in the shelf take each machine's cables straight up out of sight, and six 2\" "
"holes in the rear wall let anything that wants to go straight back do so. From the chair "
"you see one clean equipment bay and nothing else."))
A(P_("What changed in this revision", "h2"))
A(P_(
"The envelope came down from 36 × 10½ × 10-1/8 to 33 × 10 × 10. Home Depot lists the "
"PureBond sheet as 0.703\" actual (23/32\"), not 0.750\", so everything thickness-"
"dependent is derived from that:", "p"))
A(table(
[["", "Before", "After", "Change"],
["Overall size", "36 × 10½ × 10-1/8", f"{fr(P.W)[:-1]} × {fr(P.D)[:-1]} × {fr(P.H)[:-1]}", ""],
["Clear width, each bay", "34-19/32\"", f"{fr(P.CLEAR_W)}", "-3\""],
["Clear depth, each bay", "9-13/16\"", f"{fr(P.CLEAR_D)}", "-1/2\""],
["Clear height, each bay", "4\"", f"{fr(P.CLEAR)}", "-1/16\""],
["Usable volume per bay", "1,356 in³", f"{P.CLEAR_W * P.CLEAR_D * P.CLEAR:,.0f} in³", "-14.7%"],
["Inner panel length", "34-9/16\"", IL, ""],
["Side panel height", "8-11/16\"", SH, ""]],
[1.75 * inch, 1.35 * inch, 1.35 * inch, 0.8 * inch],
align={1: "CENTER", 2: "CENTER", 3: "CENTER"}))
A(Spacer(1, 6))
A(P_(
f"Bay height is {fr(P.CLEAR)} rather than a flat 4\" because 10\" minus three 0.703\" panels "
f"leaves {(10 - 3 * P.T) / 2:.4f}\" per bay — not a number you can mark on a tape. Rounding the "
f"walls to {fr(P.CLEAR)} puts the overall height at {P.H:.3f}\", i.e. 1/64\" under 10\".", "note"))
A(P_(
"The switch moved to the cable bay. At 33\" wide, six devices in the front row would "
"have had only 0.40\" of air between them. With five they get 1.12\", and the switch is now "
"sitting with the patch leads it belongs to. It is 1.1\" tall, so it fits the upper bay easily.",
"note"))
A(Spacer(1, 6))
A(P_(
f"The inner length works out at {P.INNER_LEN:.3f}\" exactly and is rounded down to "
f"{IL} on purpose — a piece that has to drop in between the two sides must never be long. "
"The 1/32\" of slack disappears under glue.", "note"))
A(P_(
"PureBond's face veneer is only about 1/42\" thick and tears easily, so back every hole with "
"scrap and use a sharp blade. It is a 7-ply sheet at roughly 70 lb — plan how you are getting "
"it home before you buy it.", "note"))
# ---------------------------------------------------------------------------
A(P_("1 · Cut list", "h2"))
A(P_("All pieces ¾\" nominal. All cuts are straight rectangles. Finished sizes.", "note"))
order = ["top", "doubler", "front_wall", "middle", "side_l", "rear_wall", "bottom"]
rows = [["#", "Part", "Length", "Width", "Qty", "Rip", "Holes"]]
ripof = {k: r for k, _, _, _, _, r in P.NEST}
for k in order:
p = P.PANELS_BY_KEY[k]
rows.append([str(p["part"]), p["name"], cut(p["size"][0]), cut(p["size"][1]),
"2" if k == "side_l" else "1", ripof[k],
p["note"] if p["holes"] else "none"])
A(table(rows, [0.3 * inch, 1.35 * inch, 0.8 * inch, 0.75 * inch, 0.4 * inch,
0.42 * inch, 2.2 * inch],
align={2: "CENTER", 3: "CENTER", 4: "CENTER", 5: "CENTER"}))
A(Spacer(1, 7))
A(P_("Buy: one 4' × 8' sheet of ¾\" PureBond Birch Plywood — Home Depot internet "
"#100077837, model #165921, about $75–95. You will use roughly 36% of it and keep a "
"96\" × 20¼\" offcut. See drawing sheet 8 for the layout and sheet 9 for exactly what to "
"ask the panel-saw associate for.", "note"))
# ---------------------------------------------------------------------------
NHOLES = sum(len(q["holes"]) for q in P.PANELS if q["qty"])
A(P_(f"2 · Hole schedule — {NHOLES} holes, 3 panels, 2 hole saws", "h2"))
hrows = [["Panel", "Count / size", "Positions along the piece", "Across", "Purpose"],
["Middle shelf", f'6 × {fr(P.GROMMET_DIA)}',
" · ".join(fr(x - P.X_IN_0) for x in P.GROMMET_X),
f'{fr(P.GROMMET_Y)} from the front edge',
"cables up into the hidden cable bay; rubber grommets fit these"],
["Bottom panel", f'12 × {fr(P.VENT_DIA)}',
" · ".join(fr(x) for x in P.VENT_X),
f'two rows, {fr(P.VENT_Y[0])} and {fr(P.VENT_Y[1])} from the front',
"cool air in — a pair under every device, both rows inside its footprint"],
["Rear wall", f'6 × {fr(P.REAR_HOLE_DIA)}',
" · ".join(fr(x) for x in P.REAR_HOLE_X_LOCAL),
f'{fr(P.CLEAR / 2)} up (mid-height)',
"cables straight back to the wall without going up through the shelf"]]
A(table(hrows, [0.95 * inch, 0.75 * inch, 2.35 * inch, 1.25 * inch, 1.95 * inch],
align={1: "CENTER"}, muted_col=4))
A(Spacer(1, 6))
A(P_(
f"Rear-wall spacing is a true equal pitch of {P.REAR_HOLE_PITCH:.3f}\" with symmetric "
f"{P.REAR_HOLE_X_LOCAL[0]:.3f}\" end margins, exactly as you asked. It happens to land every "
"hole within 11/8\" of a device centreline, so each one still sits fully behind its machine.",
"note"))
A(P_("Measure from the left end of the piece in front of you. The shelf sits between the "
"sides, so its own left end starts 11/16\" further in than the bottom panel's — its marks "
"are 11/16\" smaller even though the holes line up once assembled.", "note"))
A(P_("Hole saws needed: 1½\" and 2\". Drill everything before assembly, with the "
"panel clamped over sacrificial scrap.", "note"))
A(PageBreak())
# ---------------------------------------------------------------------------
A(P_("3 · Can Home Depot do the cuts and the holes?", "h2"))
A(P_("Cuts — yes. Every store has a panel saw and will break a sheet down for you. Policy "
"varies by store: many give you the first cut or two free then charge around a dollar each, "
"and some cap the total. This design needs nine cuts, which is on the high side, so call "
"ahead. Treat the panel saw as ±1/16\", not a cabinet shop.", "p"))
A(P_("Holes — no. Home Depot does not drill, bore or cut circles as a service at any "
"store. All 24 holes are yours, with a drill and the two hole saws. It is about twenty "
"minutes of work.", "p"))
A(P_("The saved-by-design trick", "h3"))
A(P_("Ask for the three long rips first. Every pair of panels that must match each other comes "
"off the same strip, so their shared dimension is identical no matter what the saw actually "
"did. That is why sloppy store cuts do not hurt this design.", "p"))
A(P_(f"Leave the two side blanks square at {fr(P.D)} × {fr(P.D)} and trim them to " + SH + " yourself "
"after you have measured your actual sheet with calipers. It is the one dimension worth "
"getting right at home.", "p"))
A(P_("Drawing sheet 9 is written as a numbered cut request you can hand straight to the "
"associate.", "note"))
# ---------------------------------------------------------------------------
A(P_("4 · Hardware list", "h2"))
hw = [["Item", "Spec", "Qty", "Notes"],
["Heavy-duty C-clamp", '4" opening, min. 2" throat, cast or malleable iron', "4",
'Must span 1" desk + 0.703" top + 0.703" doubler = 2.41" minimum. A 4" clamp gives margin.'],
["Clamp pads", '2" × 2" adhesive cork or EVA foam, 1/8" thick', "8",
"Two per clamp, one on each jaw. Stops marking and adds grip."],
["Anti-slip strip", f'¾" × {fr(P.W)} adhesive silicone or cork tape', "1 roll",
"Between the top panel and the desk underside. Kills creep and rattle."],
["Cable grommets", '2" snap-in rubber desk grommets, black', "6–12",
"6 for the shelf. Grommet the rear wall too if you want it tidy, or just sand and "
"paint those edges — they face the wall."],
["Wood screws", '#8 × 1½" flat-head, coarse thread', "60",
"About 56 used. Always into pre-drilled holes."],
["Wood glue", "Titebond II or III", "1 bottle",
"A bead on every joint. The glue does most of the structural work."],
["Hole saws", '1½" and 2", bi-metal, with arbor', "2",
"The only two you need. A 2\" Milwaukee Hole Dozer comes with the arbor."],
["Primer", "Grey or white wood primer / sanding sealer", "1 can",
"Birch takes primer well. One coat is enough."],
["Topcoat", "Flat black enamel, spray or brush", "2 cans",
"Flat reads as IT equipment. Satin starts looking like furniture."],
["Edge banding (optional)", '¾" black iron-on veneer tape', "1 roll",
"Hides the plies on the front edges. Biggest visual upgrade for the money."],
["Velcro ties + cable clips", "Black, reusable", "1 pack + 10",
"Dress the loom inside the cable bay and along the front wall."]]
A(table(hw, [1.2 * inch, 1.85 * inch, 0.55 * inch, 3.3 * inch],
align={2: "CENTER"}, muted_col=3))
A(PageBreak())
# ---------------------------------------------------------------------------
A(P_("5 · Assembly sequence", "h2"))
steps = [
("a", "Sub-assembly first", "Glue and screw the clamp doubler (2) to the underside of the "
"top panel (1), flush with the wall-side edge and centred across the width. Four screws "
"down through the top face, countersunk. Set aside to cure."),
("b", "Sides to the bottom", "Stand the two side panels (5) on the bottom panel (7), flush "
"with the outer edges. Glue, then drive #8 × 1½\" screws up through the bottom panel into "
"the side edges, four a side. Check square with a framing square before the glue grabs."),
("c", "Rear wall", "Drop the rear wall (6) in between the sides, sitting on the bottom "
"panel, flush with the wall-side edge. Screw through each side into its ends and up through "
"the bottom into its lower edge."),
("d", "Middle shelf", "Rest the middle shelf (4) on top of the rear wall — the wall's 4\" "
"height sets the shelf position for you, so there is nothing to measure. Screw through both "
"sides into the shelf edges, seven a side."),
("e", "Front wall", "Drop the front wall (3) in between the sides on top of the shelf, flush "
"with the front edge. Screw through the sides into its ends and up through the shelf."),
("f", "Cap it", "Lower the top panel sub-assembly on. Screw down through it into both sides "
"and into the top edges of the front and rear walls. The box is now a closed section and "
"should feel completely rigid — if you can rack it by hand, a joint is starved of glue or "
"short of screws."),
("g", "Finish", "Fill screw holes, sand to 180, prime, two thin coats of flat black. Iron on "
"edge banding if using. Let it harden overnight before the grommets go in or they will pick "
"up the paint."),
]
A(table([["", "Step", "What to do"]] +
[[f"{n}", f"{t}", d] for n, t, d in steps],
[0.3 * inch, 1.3 * inch, 5.4 * inch]))
A(P_("Screw pattern", "h3"))
A(P_("#8 × 1½\" at roughly 6\" centres — about seven per long joint, two per short joint, 56 in "
"total. Every screw into a pre-drilled hole: 9/64\" clearance through the outer panel, "
"3/32\" pilot into the receiving edge, then countersink. Plywood edge grain splits easily "
"and there is no recovering from it. Keep every screw at least 3/8\" from a panel end.",
"p"))
# ---------------------------------------------------------------------------
A(P_("6 · Mounting it to the desk", "h2"))
A(P_(f"Clamp centres at " + ", ".join(fr(x) for x in P.CLAMP_X) + f" across the {fr(P.W)} width — an even {fr(P.W / 4)} pitch, no "
"unsupported span over 8¼\", every clamp landing on the doubler. All four clamp at the "
"rear edge of the desk so nothing intrudes on your knee space and the clamp bodies "
"hide against the wall.", "p"))
A(P_("Lift the rack flat against the desk underside with its wall-side edge aligned to the "
"desk's rear edge. Get a helper or prop it on books — it is 25 lb empty and awkward. Fit "
"the two outer clamps first, snug not tight, check the rack is square to the desk edge, "
"then fit the inner two and bring all four up evenly.", "p"))
vol = sum((p["size"][0] * p["size"][1] -
sum(math.pi * (d / 2) ** 2 for _, _, d in p["holes"])) * P.T *
(2 if p["qty"] == 2 else 1 if p["qty"] else 0) for p in P.PANELS)
rack_lb = vol / P.T / 144.0 * P.PLY_LB_PER_SQFT
A(P_("Load check", "h3"))
A(table([["", "Weight"],
["Rack itself (¾\" birch, holes deducted)", f"{rack_lb:.1f} lb"],
["4 × Beelink mini PC + FireCuda (front bay)", "7.5 lb"],
["Switch, power strips, adapters, cabling (cable bay)", "9.0 lb"],
["Total hanging load", f"{rack_lb + 16.5:.1f} lb"],
["Per clamp", f"{(rack_lb + 16.5) / 4:.1f} lb"]],
[3.4 * inch, 1.1 * inch], align={1: "RIGHT"}))
A(Spacer(1, 5))
A(P_("Around 10 lb per clamp is trivial for a 4\" cast-iron C-clamp rated in the hundreds. The "
"real limits are your desktop's own strength and the clamps not creeping — hence the "
"anti-slip strip. Check them after the first week, then every few months.", "note"))
# ---------------------------------------------------------------------------
A(P_("7 · Fitting out the bays", "h2"))
lay = [["Item", "Approx. size", "X span", "Gap to its left"]]
prev = P.X_IN_0
for name, cx, w, d, h in P.DEVICES:
x0, x1 = cx - w / 2, cx + w / 2
lay.append([name, f'{w}" × {d}" × {h}"', f'{x0:.2f}" – {x1:.2f}"', f'{x0 - prev:.2f}"'])
prev = x1
for name, cx, w, d, h, y0 in P.CABLE_DEVICES:
lay.append([f"{name} (cable bay)", f'{w}" × {d}" × {h}"',
f'{cx - w / 2:.2f}" – {cx + w / 2:.2f}"', "—"])
A(table(lay, [1.9 * inch, 1.6 * inch, 1.3 * inch, 1.0 * inch],
align={1: "CENTER", 2: "CENTER", 3: "CENTER"}))
A(Spacer(1, 6))
KIT = sum(d[2] for d in P.DEVICES)
A(P_(f"X is measured from the left outside face. There is {fr(P.CLEAR_W)} of clear width in the "
f"front bay and the five units use {KIT:.1f}\" of it, so every one gets at least 1.1\" of air "
f"on each side — better than the 0.83\" they had at 36\" wide with the switch down here. "
f"Turn the "
"drive so its long axis runs front-to-back; that is what buys the gaps. "
"Nothing is stacked and there is at least 2\" of headroom above everything.", "note"))
A(P_("Airflow", "h3"))
A(P_("Each mini PC sits over two 1½\" floor holes, has an open front, vents up through its "
"own 2\" grommet, and has a 2\" hole in the wall directly behind it. There is no sealed "
"pocket anywhere in the box.", "p"))
A(P_("Cable bay", "h3"))
A(P_("Work from the wall side. Mount power strips flat against the underside of the top panel "
"with velcro so nothing rests on the shelf, run patch leads along the inside of the front "
"wall, coil slack toward the middle. Each machine's cables come up through the grommet "
"immediately behind it — keep that discipline and the loom never crosses itself. Use the "
"rear-wall holes for anything heading straight to a wall socket or wall port.", "p"))
A(Spacer(1, 10))
A(P_("Files in this set: underdesk-rack-cutting-viewer.html (sheet to rack 3D model with "
"cut layout, dimensions and exploded view) · underdesk-rack-viewer.html (assembled "
"rack viewer) · underdesk-rack-drawings.pdf (9 dimensioned sheets, including the "
"Home Depot cut request) · model/ (STL + GLB, whole and per-panel) · "
"rack_params.py (every dimension as one parametric source — change T and re-run "
"build_model.py, draw_plans.py and build_guide.py to regenerate everything).", "note"))
doc = SimpleDocTemplate(OUT, pagesize=LETTER,
leftMargin=0.72 * inch, rightMargin=0.72 * inch,
topMargin=0.62 * inch, bottomMargin=0.62 * inch,
title="Under-desk clamp-on homelab rack — build guide (PureBond birch)")
doc.build(story)
print("wrote", OUT)