From cb802ee76f9ea449564fda55a0984e2512fe876f Mon Sep 17 00:00:00 2001 From: sl-mechanical Date: Sun, 15 Mar 2026 14:33:49 -0400 Subject: [PATCH] feat: Cable management tray (Issue #628) --- chassis/cable_tray.scad | 410 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 chassis/cable_tray.scad diff --git a/chassis/cable_tray.scad b/chassis/cable_tray.scad new file mode 100644 index 0000000..e730b0d --- /dev/null +++ b/chassis/cable_tray.scad @@ -0,0 +1,410 @@ +// ============================================================ +// Cable Management Tray — Issue #628 +// Agent : sl-mechanical +// Date : 2026-03-15 +// Part catalogue: +// 1. tray_body — under-plate tray with snap-in cable channels, Velcro +// tie-down slots every 40 mm, pass-through holes, label slots +// 2. tnut_bracket — 2020 T-nut rail mount bracket (×2, slide under tray) +// 3. channel_clip — snap-in divider clip separating power / signal / servo zones +// 4. cover_panel — hinged snap-on lid (living-hinge PETG flexure strip) +// 5. cable_saddle — individual cable saddle / strain-relief clip (×n) +// +// BOM: +// 4 × M5×10 BHCS + M5 T-nuts (tnut_bracket × 2 to rail) +// 4 × M3×8 SHCS (tnut_bracket to tray body) +// n × 100 mm Velcro tie-down strips (through 6×2 mm slots, every 40 mm) +// +// Cable channel layout (X axis, inside tray): +// Zone A — Power (2S–6S LiPo, XT60/XT30): 20 mm wide, 14 mm deep +// Zone B — Signal (JST-SH, PWM, I2C, UART): 14 mm wide, 10 mm deep +// Zone C — Servo (JST-PH, thick servo leads): 14 mm wide, 12 mm deep +// Divider walls: 2.5 mm thick between zones +// +// Print settings (PETG): +// tray_body / tnut_bracket / channel_clip : 5 perimeters, 40 % gyroid, no supports +// cover_panel : 3 perimeters, 20 % gyroid, no supports +// (living-hinge — print flat, thin strip flexes) +// cable_saddle : 3 perimeters, 30 % gyroid, no supports +// +// Export commands: +// openscad -D 'RENDER="tray_body"' -o tray_body.stl cable_tray.scad +// openscad -D 'RENDER="tnut_bracket"' -o tnut_bracket.stl cable_tray.scad +// openscad -D 'RENDER="channel_clip"' -o channel_clip.stl cable_tray.scad +// openscad -D 'RENDER="cover_panel"' -o cover_panel.stl cable_tray.scad +// openscad -D 'RENDER="cable_saddle"' -o cable_saddle.stl cable_tray.scad +// openscad -D 'RENDER="assembly"' -o assembly.png cable_tray.scad +// ============================================================ + +RENDER = "assembly"; // tray_body | tnut_bracket | channel_clip | cover_panel | cable_saddle | assembly + +$fn = 48; +EPS = 0.01; + +// ── 2020 rail constants ────────────────────────────────────── +RAIL_W = 20.0; +TNUT_W = 9.8; +TNUT_H = 5.5; +TNUT_L = 12.0; +SLOT_NECK_H = 3.2; +M5_D = 5.2; +M5_HEAD_D = 9.5; +M5_HEAD_H = 4.0; + +// ── Tray geometry ──────────────────────────────────────────── +TRAY_L = 280.0; // length along rail (7 × 40 mm tie-down pitch) +TRAY_W = 60.0; // width across rail (covers standard 40 mm rail pair) +TRAY_WALL = 2.5; // side / floor wall thickness +TRAY_DEPTH = 18.0; // interior depth (tallest zone + wall) + +// Cable channel zones (widths must sum to TRAY_W - 2*TRAY_WALL - 2*DIV_T) +DIV_T = 2.5; // divider wall thickness +ZONE_A_W = 20.0; // Power +ZONE_A_D = 14.0; +ZONE_B_W = 14.0; // Signal +ZONE_B_D = 10.0; +ZONE_C_W = 14.0; // Servo +ZONE_C_D = 12.0; +// Total inner width used: ZONE_A_W + ZONE_B_W + ZONE_C_W + 2*DIV_T = 55 mm < TRAY_W - 2*TRAY_WALL = 55 mm ✓ + +// Tie-down slots (Velcro strips) +TIEDOWN_PITCH = 40.0; +TIEDOWN_W = 6.0; // slot width (fits 6 mm wide Velcro) +TIEDOWN_T = 2.2; // slot through-thickness (floor) +TIEDOWN_CNT = 7; // 7 positions along tray + +// Pass-through holes in floor +PASSTHRU_D = 12.0; // circular grommet-compatible pass-through +PASSTHRU_CNT = 3; // one per zone, at tray mid-length + +// Label slots (rear outer wall) +LABEL_W = 24.0; +LABEL_H = 8.0; +LABEL_T = 1.0; // depth from outer face + +// Snap ledge for cover +SNAP_LEDGE_H = 2.5; +SNAP_LEDGE_D = 1.5; + +// ── T-nut bracket ──────────────────────────────────────────── +BKT_L = 60.0; +BKT_W = TRAY_W; +BKT_T = 6.0; +BOLT_PITCH = 40.0; +M3_D = 3.2; +M3_HEAD_D = 6.0; +M3_HEAD_H = 3.0; +M3_NUT_W = 5.5; +M3_NUT_H = 2.4; + +// ── Cover panel ────────────────────────────────────────────── +CVR_T = 1.8; // panel thickness +HINGE_T = 0.6; // living-hinge strip thickness (printed in PETG) +HINGE_W = 3.0; // hinge strip width (flexes easily) +SNAP_HOOK_H = 3.5; // snap hook height +SNAP_HOOK_T = 2.2; + +// ── Cable saddle ───────────────────────────────────────────── +SAD_W = 12.0; +SAD_H = 8.0; +SAD_T = 2.5; +SAD_BORE_D = 7.0; // cable bundle bore +SAD_CLIP_T = 1.6; // snap arm thickness + +// ── Utilities ──────────────────────────────────────────────── +module chamfer_cube(size, ch=1.0) { + hull() { + translate([ch, ch, 0]) cube([size[0]-2*ch, size[1]-2*ch, EPS]); + translate([0, 0, ch]) cube(size - [0, 0, ch]); + } +} + +module hex_pocket(af, depth) { + cylinder(d=af/cos(30), h=depth, $fn=6); +} + +// ── Part 1: tray_body ─────────────────────────────────────── +module tray_body() { + difference() { + // Outer shell + union() { + chamfer_cube([TRAY_L, TRAY_W, TRAY_DEPTH + TRAY_WALL], ch=1.5); + + // Snap ledge along top of both long walls (for cover_panel) + for (y = [-SNAP_LEDGE_D, TRAY_W]) + translate([0, y, TRAY_DEPTH]) + cube([TRAY_L, TRAY_WALL + SNAP_LEDGE_D, SNAP_LEDGE_H]); + } + + // Interior cavity + translate([TRAY_WALL, TRAY_WALL, TRAY_WALL]) + cube([TRAY_L - 2*TRAY_WALL, TRAY_W - 2*TRAY_WALL, + TRAY_DEPTH + EPS]); + + // ── Zone dividers (subtract from solid to leave walls) ── + // Zone A (Power) inner floor cut — full depth A + translate([TRAY_WALL, TRAY_WALL, TRAY_WALL + (TRAY_DEPTH - ZONE_A_D)]) + cube([TRAY_L - 2*TRAY_WALL, ZONE_A_W, ZONE_A_D + EPS]); + + // Zone B (Signal) inner floor cut + translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T, + TRAY_WALL + (TRAY_DEPTH - ZONE_B_D)]) + cube([TRAY_L - 2*TRAY_WALL, ZONE_B_W, ZONE_B_D + EPS]); + + // Zone C (Servo) inner floor cut + translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T, + TRAY_WALL + (TRAY_DEPTH - ZONE_C_D)]) + cube([TRAY_L - 2*TRAY_WALL, ZONE_C_W, ZONE_C_D + EPS]); + + // ── Velcro tie-down slots (floor, every 40 mm) ────────── + for (i = [0:TIEDOWN_CNT-1]) { + x = TRAY_WALL + 20 + i * TIEDOWN_PITCH - TIEDOWN_W/2; + // Zone A slot + translate([x, TRAY_WALL + 2, -EPS]) + cube([TIEDOWN_W, ZONE_A_W - 4, TRAY_WALL + 2*EPS]); + // Zone B slot + translate([x, TRAY_WALL + ZONE_A_W + DIV_T + 2, -EPS]) + cube([TIEDOWN_W, ZONE_B_W - 4, TRAY_WALL + 2*EPS]); + // Zone C slot + translate([x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + 2, -EPS]) + cube([TIEDOWN_W, ZONE_C_W - 4, TRAY_WALL + 2*EPS]); + } + + // ── Pass-through holes in floor (centre of each zone at mid-length) ── + mid_x = TRAY_L / 2; + // Zone A + translate([mid_x, TRAY_WALL + ZONE_A_W/2, -EPS]) + cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS); + // Zone B + translate([mid_x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2, -EPS]) + cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS); + // Zone C + translate([mid_x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2, -EPS]) + cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS); + + // ── Label slots on front wall (y = 0) — one per zone ──── + zone_ctrs = [TRAY_WALL + ZONE_A_W/2, + TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2, + TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2]; + label_z = TRAY_WALL + 2; + for (yc = zone_ctrs) + translate([TRAY_L/2 - LABEL_W/2, -EPS, label_z]) + cube([LABEL_W, LABEL_T + EPS, LABEL_H]); + + // ── M3 bracket bolt holes in floor (4 corners) ────────── + for (x = [20, TRAY_L - 20]) + for (y = [TRAY_W/4, 3*TRAY_W/4]) + translate([x, y, -EPS]) + cylinder(d=M3_D, h=TRAY_WALL + 2*EPS); + + // ── Channel clip snap sockets (top of each divider, every 80 mm) ── + for (i = [0:2]) { + cx = 40 + i * 80; + for (dy = [ZONE_A_W, ZONE_A_W + DIV_T + ZONE_B_W]) + translate([cx - 3, TRAY_WALL + dy - 1, TRAY_DEPTH - 4]) + cube([6, DIV_T + 2, 4 + EPS]); + } + } + + // ── Divider walls (positive geometry) ─────────────────── + // Wall between Zone A and Zone B + translate([TRAY_WALL, TRAY_WALL + ZONE_A_W, TRAY_WALL]) + cube([TRAY_L - 2*TRAY_WALL, DIV_T, + TRAY_DEPTH - ZONE_A_D]); // partial height — lower in A zone + + // Wall between Zone B and Zone C + translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W, TRAY_WALL]) + cube([TRAY_L - 2*TRAY_WALL, DIV_T, + TRAY_DEPTH - ZONE_B_D]); +} + +// ── Part 2: tnut_bracket ──────────────────────────────────── +module tnut_bracket() { + difference() { + chamfer_cube([BKT_L, BKT_W, BKT_T], ch=1.5); + + // M5 T-nut holes (2 per bracket, on rail centreline) + for (x = [BKT_L/2 - BOLT_PITCH/2, BKT_L/2 + BOLT_PITCH/2]) { + translate([x, BKT_W/2, -EPS]) { + cylinder(d=M5_D, h=BKT_T + 2*EPS); + cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS); + } + translate([x - TNUT_L/2, BKT_W/2 - TNUT_W/2, BKT_T - TNUT_H]) + cube([TNUT_L, TNUT_W, TNUT_H + EPS]); + } + + // M3 tray-attachment holes (4 corners) + for (x = [10, BKT_L - 10]) + for (y = [10, BKT_W - 10]) { + translate([x, y, -EPS]) + cylinder(d=M3_D, h=BKT_T + 2*EPS); + // M3 hex nut captured pocket (from top) + translate([x, y, BKT_T - M3_NUT_H - 0.2]) + hex_pocket(M3_NUT_W + 0.3, M3_NUT_H + 0.3); + } + + // Weight relief + translate([15, 8, -EPS]) + cube([BKT_L - 30, BKT_W - 16, BKT_T/2]); + } +} + +// ── Part 3: channel_clip ──────────────────────────────────── +// Snap-in clip that locks into divider-wall snap sockets; +// holds individual bundles in their zone and acts as colour-coded zone marker. +module channel_clip() { + clip_body_w = 6.0; + clip_body_h = DIV_T + 4.0; + clip_body_t = 8.0; + tab_h = 3.5; + tab_w = 2.5; + + difference() { + union() { + // Body spanning divider + cube([clip_body_t, clip_body_w, clip_body_h]); + + // Snap tabs (bottom, straddle divider) + for (s = [0, clip_body_w - tab_w]) + translate([clip_body_t/2 - 1, s, -tab_h]) + cube([2, tab_w, tab_h + 1]); + } + + // Cable radius slot on each face + translate([-EPS, clip_body_w/2, clip_body_h * 0.6]) + rotate([0, 90, 0]) + cylinder(d=5.0, h=clip_body_t + 2*EPS); + + // Snap tab undercut for flex + for (s = [0, clip_body_w - tab_w]) + translate([clip_body_t/2 - 2, s - EPS, -tab_h + 1.5]) + cube([4, tab_w + 2*EPS, 1.5]); + } +} + +// ── Part 4: cover_panel ───────────────────────────────────── +// Flat snap-on lid with living-hinge along one long edge. +// Print flat; PETG living hinge flexes ~90° to snap onto tray. +module cover_panel() { + total_w = TRAY_W + 2 * SNAP_HOOK_T; + + difference() { + union() { + // Main panel + cube([TRAY_L, TRAY_W, CVR_T]); + + // Living hinge strip along back edge (thin, flexes) + translate([0, TRAY_W - EPS, 0]) + cube([TRAY_L, HINGE_W, HINGE_T]); + + // Snap hooks along front edge (clips under tray snap ledge) + for (x = [20, TRAY_L/2 - 20, TRAY_L/2 + 20, TRAY_L - 20]) + translate([x - SNAP_HOOK_T/2, -SNAP_HOOK_H + EPS, 0]) + difference() { + cube([SNAP_HOOK_T, SNAP_HOOK_H, CVR_T + 1.5]); + // Hook nose chamfer + translate([-EPS, -EPS, CVR_T]) + rotate([0, 0, 0]) + cube([SNAP_HOOK_T + 2*EPS, 1.5, 1.5]); + } + } + + // Ventilation slots (3 rows × 6 slots) + for (row = [0:2]) + for (col = [0:5]) { + sx = 20 + col * 40 + row * 10; + sy = 10 + row * 12; + if (sx + 25 < TRAY_L && sy + 6 < TRAY_W) + translate([sx, sy, -EPS]) + cube([25, 6, CVR_T + 2*EPS]); + } + + // Zone label windows (align with tray label slots) + zone_ctrs = [TRAY_WALL + ZONE_A_W/2, + TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2, + TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2]; + for (yc = zone_ctrs) + translate([TRAY_L/2 - LABEL_W/2, yc - LABEL_H/2, -EPS]) + cube([LABEL_W, LABEL_H, CVR_T + 2*EPS]); + } +} + +// ── Part 5: cable_saddle ──────────────────────────────────── +// Snap-in cable saddle / strain-relief clip; press-fits onto tray top edge. +module cable_saddle() { + arm_gap = TRAY_WALL + 0.4; // fits over tray wall + arm_len = 8.0; + + difference() { + union() { + // Body + chamfer_cube([SAD_W, SAD_T * 2 + arm_gap, SAD_H], ch=1.0); + + // Cable retaining arch + translate([SAD_W/2, SAD_T + arm_gap/2, SAD_H]) + scale([1, 0.6, 1]) + difference() { + cylinder(d=SAD_BORE_D + SAD_CLIP_T * 2, h=SAD_T); + translate([0, 0, -EPS]) + cylinder(d=SAD_BORE_D, h=SAD_T + 2*EPS); + translate([-SAD_BORE_D, 0, -EPS]) + cube([SAD_BORE_D * 2, SAD_BORE_D, SAD_T + 2*EPS]); + } + } + + // Slot for tray wall (negative) + translate([0, SAD_T, -EPS]) + cube([SAD_W, arm_gap, arm_len + EPS]); + + // M3 tie-down hole + translate([SAD_W/2, SAD_T + arm_gap/2, -EPS]) + cylinder(d=M3_D, h=SAD_H + 2*EPS); + } +} + +// ── Assembly ──────────────────────────────────────────────── +module assembly() { + // Tray body (open face up for visibility) + color("SteelBlue") + tray_body(); + + // Two T-nut brackets underneath at 1/4 and 3/4 length + for (bx = [TRAY_L/4 - BKT_L/2, 3*TRAY_L/4 - BKT_L/2]) + color("DodgerBlue") + translate([bx, 0, -BKT_T]) + tnut_bracket(); + + // Channel clips (3 per divider position, every 80 mm) + for (i = [0:2]) { + cx = 40 + i * 80; + // Divider A/B + color("Tomato", 0.8) + translate([cx - 4, TRAY_WALL + ZONE_A_W - 2, TRAY_DEPTH - 3]) + channel_clip(); + // Divider B/C + color("Orange", 0.8) + translate([cx - 4, + TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W - 2, + TRAY_DEPTH - 3]) + channel_clip(); + } + + // Cover panel (raised above tray to show interior) + color("LightSteelBlue", 0.5) + translate([0, 0, TRAY_DEPTH + SNAP_LEDGE_H + 4]) + cover_panel(); + + // Cable saddles along front tray edge + for (x = [40, 120, 200]) + color("SlateGray") + translate([x - SAD_W/2, -SAD_T * 2 - TRAY_WALL, 0]) + cable_saddle(); +} + +// ── Dispatch ──────────────────────────────────────────────── +if (RENDER == "tray_body") tray_body(); +else if (RENDER == "tnut_bracket") tnut_bracket(); +else if (RENDER == "channel_clip") channel_clip(); +else if (RENDER == "cover_panel") cover_panel(); +else if (RENDER == "cable_saddle") cable_saddle(); +else assembly();