feat: Cable management tray (Issue #628) #635

Merged
sl-jetson merged 1 commits from sl-mechanical/issue-628-cable-tray into main 2026-03-15 16:29:53 -04:00
Showing only changes of commit cb802ee76f - Show all commits

410
chassis/cable_tray.scad Normal file
View File

@ -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 (2S6S 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();