saltylab-firmware/chassis/ip54_enclosure.scad
sl-mechanical a70d9a2a71 feat(mechanical): IP54 weatherproofing kit (Issue #144)
Add sealed enclosures and sensor housings for outdoor IP54 protection:
- ip54_enclosure.scad: main electronics box (Jetson/FC/ESC), O-ring lid,
  fan+filter duct, PG7/PG9 cable glands, quarter-turn latches, heat sink
  recesses; gasket DXF export
- ip54_sensor_housings.scad: IMX219 clear PC dome (O-ring + anti-fog
  pocket), D435i IR-transparent window housing (PG7 rear cap), RPLIDAR
  static clear PC dome base ring (120 mm OD, O-ring, quarter-turn clips)
- ip54_BOM.md: hardware list, thermal analysis (≤52°C at 40°C ambient),
  IP54 compliance checklist, mass ~930g total kit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 09:58:22 -05:00

540 lines
24 KiB
OpenSCAD
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// ip54_enclosure.scad — IP54 Main Electronics Enclosure
// Issue: #144 Agent: sl-mechanical Date: 2026-03-01
// ============================================================
//
// Sealed electronics bay for Jetson Orin NX + FC + ESC stack.
// IP54 rating: dust-protected, splash-proof from all directions.
//
// Protection method:
// • 4 mm PETG walls (5 perims, 40 % infill)
// • 2 mm silicone O-ring (Ø2 mm cord) in lid groove → IP54 seal
// • PG7 cable glands (Ø36 mm cables) on rear wall × 4
// • PG9 cable glands (Ø48 mm cables) on rear wall × 2
// • 40 mm axial fan + foam filter panel on lid (positive pressure)
// • Thermal: 2× Al heat sink pads on lid underside over Jetson/ESC
//
// Internal envelope: 220 × 160 × 90 mm (W × D × H, internal)
// Fits: Jetson Orin NX (58 × 49 mm), FC 30.5 × 30.5 mm,
// dual ESC (~80 × 40 mm each)
//
// Quick-release lid: 4× spring-loaded quarter-turn latches.
// Tool-free. Lid lifts straight up after 90° rotation of each latch.
//
// Coordinates: Z = 0 at box floor (internal), Z+ upward.
// Box centred on X=0, Y=0.
//
// RENDER options:
// "assembly" full box + lid + fans + glands preview
// "body_stl" box body (print 1×)
// "lid_stl" lid with fan mount (print 1×)
// "fan_duct_stl" filtered fan inlet duct (print 1×)
// "latch_stl" quarter-turn latch knob (print 4×)
// "gasket_2d" DXF — lid O-ring groove outline + cable gland panel
//
// Export:
// openscad ip54_enclosure.scad -D 'RENDER="body_stl"' -o ip54_body.stl
// openscad ip54_enclosure.scad -D 'RENDER="lid_stl"' -o ip54_lid.stl
// openscad ip54_enclosure.scad -D 'RENDER="fan_duct_stl"' -o ip54_fan_duct.stl
// openscad ip54_enclosure.scad -D 'RENDER="latch_stl"' -o ip54_latch.stl
// openscad ip54_enclosure.scad -D 'RENDER="gasket_2d"' -o ip54_gasket.dxf
// ============================================================
$fn = 64;
e = 0.01;
// ── Internal cavity ───────────────────────────────────────────────────────────
INT_W = 220.0; // internal width (X)
INT_D = 160.0; // internal depth (Y)
INT_H = 90.0; // internal height (Z)
// ── Wall / structural ─────────────────────────────────────────────────────────
WALL = 4.0; // box wall thickness
LID_T = 5.0; // lid thickness
BOX_R = 8.0; // outer corner radius (XY)
// Derived outer dims
OUT_W = INT_W + 2*WALL;
OUT_D = INT_D + 2*WALL;
OUT_H = INT_H + WALL; // wall on floor + sides; lid closes top
// ── O-ring seal ───────────────────────────────────────────────────────────────
// 2 mm cord silicone O-ring in a groove on the lid flange inner face.
// Groove: 2.2 mm wide × 1.7 mm deep (standard 70 % compression for IP54).
ORING_D = 2.0;
ORING_GROOVE_W = 2.2;
ORING_GROOVE_D = 1.7;
ORING_INSET = 6.0; // groove CL from inner box wall edge
// ── Lid flange (overlap joint) ────────────────────────────────────────────────
// Lid has a stepped rim that overlaps the box top edge.
// Seal groove is cut into the underside of this rim.
FLANGE_T = 3.0; // vertical flange depth (how far rim drops into box)
FLANGE_WALL = 3.0; // rim wall thickness
// ── Quarter-turn latches ──────────────────────────────────────────────────────
// 4× positions: one per side (front/rear/left/right centre).
// Spring-loaded bayonet latch post on box side; rotating knob on lid flange.
LATCH_POST_D = 10.0; // latch post OD
LATCH_BOSS_H = 8.0; // boss height above box top flange
LATCH_KNOB_D = 18.0; // knob OD
LATCH_SLOT_W = 2.5; // bayonet slot width
// ── Cable glands ──────────────────────────────────────────────────────────────
// All glands on rear wall (Y = -OUT_D/2).
// PG7 thread OD = 12.5 mm; PG9 thread OD = 15.2 mm.
PG7_BORE = 12.7; // drill diameter for PG7 panel hole
PG9_BORE = 15.4; // drill diameter for PG9 panel hole
PG7_COUNT = 4;
PG9_COUNT = 2;
// Gland layout on rear wall (Y = -OUT_D/2 face), evenly spaced in X.
// Z centre = 30 mm from floor (lower half of box, cable routing stays low).
GLAND_Z = 30.0;
// ── Fan ───────────────────────────────────────────────────────────────────────
// 40 mm axial fan in lid, front-left quadrant.
// Positive pressure: fan blows IN, filtered foam panel on top.
// Exit: passive vent slots on rear lid (over gland panel), IP54 labyrinth.
FAN_SZ = 40.0; // 40 mm fan
FAN_BORE_D = 36.5; // airflow bore
FAN_BOLT_SPC= 32.0; // M3 bolt square (32 × 32 mm)
FAN_BOLT_D = 3.3;
FAN_POS_X = -INT_W/2 + FAN_SZ/2 + 10; // fan X offset from centre (front-left)
FAN_POS_Y = -INT_D/2 + FAN_SZ/2 + 10; // fan Y offset
// ── Fan filter duct ───────────────────────────────────────────────────────────
DUCT_H = 20.0; // filter duct height above lid
FOAM_T = 5.0; // foam filter thickness
// ── Exhaust labyrinth slots ───────────────────────────────────────────────────
// Baffle-protected exhaust on lid rear. 2-row labyrinth prevents direct
// water ingress while maintaining IP54 (deflects splash from all angles).
EXH_SLOT_W = 3.0; // slot width
EXH_SLOT_L = 30.0; // slot length
EXH_ROWS = 2; // number of baffle rows
EXH_BAFFLE_H= 8.0; // baffle height above lid
EXH_N_SLOTS = 4; // number of exhaust slots per row
// ── Internal standoffs ────────────────────────────────────────────────────────
// Jetson Orin NX: 58 × 49 mm M3 hole pattern (4 holes), Z = WALL from floor
ORIN_HOLE_X = 58.0 / 2;
ORIN_HOLE_Y = 49.0 / 2;
ORIN_STOFF_H= 8.0; // standoff height (PCB float)
ORIN_POS_X = +INT_W/4; // offset from box centre: right half
ORIN_POS_Y = 0;
// FC: 30.5 × 30.5 mm M3 pattern
FC_HOLE_SPC = 30.5 / 2;
FC_STOFF_H = 6.0;
FC_POS_X = -INT_W/4; // left half
FC_POS_Y = -INT_D/4;
// ESC pair: 2× ESC ~80 × 40 mm; 4× M3 holes at corners
ESC_W = 80.0;
ESC_D = 40.0;
ESC_STOFF_H = 6.0;
ESC_POS_X = -INT_W/4;
ESC_POS_Y = +INT_D/4;
// ── Heat sink pads ────────────────────────────────────────────────────────────
// Recesses in lid underside that accept adhesive Al heat sink pads.
// Thermal path: board → heat sink → lid → ambient convection + fan.
// Pad size: 60 × 40 × 2 mm for Jetson, 50 × 30 × 2 mm for ESC.
HSINK_JETSON_W = 60.0;
HSINK_JETSON_D = 40.0;
HSINK_ESC_W = 50.0;
HSINK_ESC_D = 30.0;
HSINK_T = 2.2; // recess depth (pad sits flush in lid)
// Fasteners
M3_D = 3.3;
M4_D = 4.3;
M5_D = 5.3;
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") {
assembly();
} else if (RENDER == "body_stl") {
box_body();
} else if (RENDER == "lid_stl") {
box_lid();
} else if (RENDER == "fan_duct_stl") {
fan_filter_duct();
} else if (RENDER == "latch_stl") {
latch_knob();
} else if (RENDER == "gasket_2d") {
projection(cut = true)
translate([0, 0, -0.5])
linear_extrude(1)
gasket_profile_2d();
}
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly() {
// Box body
color("DarkOliveGreen", 0.85) box_body();
// Lid (lifted 5 mm to show interior)
color("OliveDrab", 0.70)
translate([0, 0, OUT_H + 5])
box_lid();
// Fan duct on lid
color("SaddleBrown", 0.80)
translate([FAN_POS_X, FAN_POS_Y, OUT_H + LID_T + 5])
fan_filter_duct();
// Ghost latch knobs
for (lpos = latch_positions())
%color("DimGray", 0.60)
translate([lpos[0], lpos[1], OUT_H + 3])
latch_knob();
// Ghost Jetson PCB
%color("Green", 0.3)
translate([ORIN_POS_X, ORIN_POS_Y, WALL + ORIN_STOFF_H])
cube([58, 49, 3], center = true);
// Ghost FC
%color("Orange", 0.3)
translate([FC_POS_X, FC_POS_Y, WALL + FC_STOFF_H])
cube([30.5, 30.5, 3], center = true);
// Ghost ESC
%color("Red", 0.3)
translate([ESC_POS_X, ESC_POS_Y, WALL + ESC_STOFF_H])
cube([ESC_W, ESC_D, 3], center = true);
// Index annotations (cable gland markers)
for (gpos = cable_gland_positions())
%color("Yellow", 0.5)
translate([gpos[0], -OUT_D/2 - 5, gpos[1]])
rotate([90, 0, 0])
cylinder(d = gpos[2], h = 3);
}
// ============================================================
// BOX BODY
// ============================================================
module box_body() {
difference() {
union() {
// ── Outer shell (rounded rect, open top) ────────────────────
_rounded_box(OUT_W, OUT_D, OUT_H, BOX_R);
// ── Latch posts on top flange ────────────────────────────────
for (lpos = latch_positions())
translate([lpos[0], lpos[1], OUT_H])
_latch_post();
// ── Cable gland boss pads (rear wall reinforcement) ──────────
translate([0, -OUT_D/2, GLAND_Z])
rotate([90, 0, 0])
_gland_boss_array();
}
// ── Internal cavity ──────────────────────────────────────────────
translate([0, 0, WALL])
cube([INT_W, INT_D, INT_H + e], center = true);
// ── Cable gland holes (rear wall) ────────────────────────────────
for (gpos = cable_gland_positions())
translate([gpos[0], -OUT_D/2 - e, GLAND_Z])
rotate([90, 0, 0])
cylinder(d = gpos[2], h = WALL + 2*e);
// ── Internal standoffs (subtracted from floor thickness) ─────────
// These are ADDED in the union; we only subtract if needed for wires.
}
// ── Internal PCB standoffs (added in separate union) ─────────────────
// Jetson Orin NX standoffs
for (sx = [-1, 1]) for (sy = [-1, 1])
translate([ORIN_POS_X + sx*ORIN_HOLE_X,
ORIN_POS_Y + sy*ORIN_HOLE_Y,
WALL])
difference() {
cylinder(d = 7, h = ORIN_STOFF_H);
cylinder(d = M3_D, h = ORIN_STOFF_H + e);
}
// FC standoffs
for (sx = [-1, 1]) for (sy = [-1, 1])
translate([FC_POS_X + sx*FC_HOLE_SPC,
FC_POS_Y + sy*FC_HOLE_SPC,
WALL])
difference() {
cylinder(d = 7, h = FC_STOFF_H);
cylinder(d = M3_D, h = FC_STOFF_H + e);
}
// ESC standoffs (4 corners of ESC_W × ESC_D)
for (sx = [-1, 1]) for (sy = [-1, 1])
translate([ESC_POS_X + sx*(ESC_W/2 - 5),
ESC_POS_Y + sy*(ESC_D/2 - 5),
WALL])
difference() {
cylinder(d = 7, h = ESC_STOFF_H);
cylinder(d = M3_D, h = ESC_STOFF_H + e);
}
}
// ── Rounded box shell (open top) ─────────────────────────────────────────────
module _rounded_box(w, d, h, r) {
linear_extrude(h)
minkowski() {
square([w - 2*r, d - 2*r], center = true);
circle(r = r);
}
}
// ── Latch post (on top rim of box) ───────────────────────────────────────────
module _latch_post() {
difference() {
cylinder(d = LATCH_POST_D + 4, h = LATCH_BOSS_H);
// Central latch bore
translate([0, 0, -e])
cylinder(d = LATCH_POST_D, h = LATCH_BOSS_H + 2*e);
// Bayonet slot (cross-slot in post top, for knob lug engagement)
for (a = [0, 90])
rotate([0, 0, a])
translate([0, 0, LATCH_BOSS_H/2])
cube([LATCH_SLOT_W, LATCH_POST_D + 2, LATCH_BOSS_H],
center = true);
}
}
// ── Cable gland boss array (pad behind gland holes on outer wall) ─────────────
module _gland_boss_array() {
for (gpos = cable_gland_positions())
translate([gpos[0], 0, 0])
cylinder(d = gpos[2] + 8, h = 2);
}
// ── Latch positions (4 sides, centred) ───────────────────────────────────────
function latch_positions() = [
[ 0, +OUT_D/2 - 3 ], // front
[ 0, -OUT_D/2 + 3 ], // rear
[ +OUT_W/2 - 3, 0 ], // right
[ -OUT_W/2 + 3, 0 ], // left
];
// ── Cable gland positions [x, z, bore_d] on rear wall ────────────────────────
// 4× PG7 + 2× PG9, arranged in a row at GLAND_Z height.
// PG7 for signal / small power; PG9 for main drive harness.
function cable_gland_positions() = [
[ -INT_W/2 + 15, GLAND_Z, PG7_BORE ], // PG7 #1
[ -INT_W/2 + 40, GLAND_Z, PG7_BORE ], // PG7 #2
[ -INT_W/2 + 65, GLAND_Z, PG7_BORE ], // PG7 #3
[ -INT_W/2 + 90, GLAND_Z, PG7_BORE ], // PG7 #4
[ INT_W/2 - 30, GLAND_Z, PG9_BORE ], // PG9 #1 (main battery harness)
[ INT_W/2 - 60, GLAND_Z, PG9_BORE ], // PG9 #2 (motor harness)
];
// ============================================================
// BOX LID
// ============================================================
module box_lid() {
difference() {
union() {
// ── Top plate ───────────────────────────────────────────────
_rounded_box(OUT_W, OUT_D, LID_T, BOX_R);
// ── Flanged rim (overlaps box top; O-ring groove cut into it) ─
difference() {
translate([0, 0, -FLANGE_T])
_rounded_box(INT_W + 2*FLANGE_WALL,
INT_D + 2*FLANGE_WALL,
FLANGE_T + e, BOX_R - 1);
// Hollow interior of flange (sits over box rim)
translate([0, 0, -FLANGE_T - e])
cube([INT_W, INT_D, FLANGE_T + 2*e], center = true);
}
// ── Exhaust labyrinth baffles on rear of lid ─────────────────
_exhaust_baffles();
}
// ── Fan bore (through lid) ────────────────────────────────────────
translate([FAN_POS_X, FAN_POS_Y, -e])
cylinder(d = FAN_BORE_D, h = LID_T + 2*e);
// ── Fan bolt holes ────────────────────────────────────────────────
for (fx = [-1, 1]) for (fy = [-1, 1])
translate([FAN_POS_X + fx*FAN_BOLT_SPC/2,
FAN_POS_Y + fy*FAN_BOLT_SPC/2, -e])
cylinder(d = FAN_BOLT_D, h = LID_T + 2*e);
// ── O-ring groove in flange underside ─────────────────────────────
// Groove runs along the inner perimeter of the flange.
translate([0, 0, -ORING_GROOVE_D])
difference() {
_rounded_box(
INT_W + 2*FLANGE_WALL - 2*(FLANGE_WALL - ORING_INSET),
INT_D + 2*FLANGE_WALL - 2*(FLANGE_WALL - ORING_INSET),
ORING_GROOVE_D + e, BOX_R - 2);
_rounded_box(
INT_W + 2*FLANGE_WALL - 2*(FLANGE_WALL - ORING_INSET) - 2*ORING_GROOVE_W,
INT_D + 2*FLANGE_WALL - 2*(FLANGE_WALL - ORING_INSET) - 2*ORING_GROOVE_W,
ORING_GROOVE_D + 2*e, BOX_R - 3);
}
// ── Latch knob counterbores in lid flange (knob sits flush) ──────
for (lpos = latch_positions())
translate([lpos[0], lpos[1], -FLANGE_T - e])
cylinder(d = LATCH_KNOB_D + 1, h = FLANGE_T + 2*e);
// ── Heat sink pad recesses (underside of lid) ─────────────────────
// Jetson pad recess
translate([ORIN_POS_X, ORIN_POS_Y, -e])
cube([HSINK_JETSON_W, HSINK_JETSON_D, HSINK_T + e], center = true);
// ESC pad recess
translate([ESC_POS_X, ESC_POS_Y, -e])
cube([HSINK_ESC_W, HSINK_ESC_D, HSINK_T + e], center = true);
// ── Exhaust labyrinth slot through-holes ──────────────────────────
for (slot = exhaust_slot_positions())
translate([slot[0], slot[1], -e])
cube([EXH_SLOT_W, EXH_SLOT_L, LID_T + 2*e], center = true);
}
// ── Latch knob receiver rings in flange ─────────────────────────────────
for (lpos = latch_positions())
translate([lpos[0], lpos[1], -FLANGE_T])
difference() {
cylinder(d = LATCH_KNOB_D, h = FLANGE_T - 0.5);
// Bayonet lugs engage latch post slot
translate([0, 0, FLANGE_T - 0.5 - 3])
cylinder(d = LATCH_POST_D + 0.4, h = 3 + e);
cylinder(d = LATCH_POST_D - 3, h = FLANGE_T + e);
}
}
// ── Exhaust baffle array ──────────────────────────────────────────────────────
// Raised wall baffles on lid rear-right quadrant provide labyrinth exhaust path.
module _exhaust_baffles() {
exh_x = INT_W/4;
exh_y = INT_D/2 - 40;
for (row = [0 : EXH_ROWS - 1])
translate([exh_x, exh_y - row * (EXH_SLOT_W + 4), LID_T])
cube([EXH_N_SLOTS * (EXH_SLOT_W + 6), EXH_BAFFLE_H/3, EXH_BAFFLE_H]);
}
// ── Exhaust slot positions [x, y] (in lid top surface) ───────────────────────
function exhaust_slot_positions() = [
let(base_x = INT_W/4, base_y = INT_D/2 - 40)
for (i = [0 : EXH_N_SLOTS - 1])
[base_x - (EXH_N_SLOTS - 1)/2 * (EXH_SLOT_W + 6) + i * (EXH_SLOT_W + 6),
base_y - EXH_SLOT_L/2]
];
// ============================================================
// FAN FILTER DUCT (Part C — print 1×)
// ============================================================
// Sits on top of lid fan bore. Contains 5 mm foam filter pad.
// Labyrinth inlet around sides prevents direct splash ingress.
module fan_filter_duct() {
duct_od_w = FAN_SZ + 2*WALL;
foam_slot = FOAM_T + 0.5; // foam insert slot depth
difference() {
// Outer duct body
cube([duct_od_w, duct_od_w, DUCT_H], center = true);
// Foam filter slot (open at top for insert/remove)
translate([0, 0, DUCT_H/2 - foam_slot - 1])
cube([FAN_SZ, FAN_SZ, foam_slot + 2*e], center = true);
// Airflow bore below foam (connects to fan bore in lid)
translate([0, 0, -DUCT_H/2 - e])
cylinder(d = FAN_BORE_D, h = DUCT_H/2 + 2*e);
// Inlet slots on all 4 sides (labyrinth — no direct top-spray path)
for (a = [0, 90, 180, 270])
rotate([0, 0, a])
translate([0, duct_od_w/2 - WALL/2, 0]) {
// Three inlet slots, staggered vertically
for (sz = [-DUCT_H/2 + 5, 0, DUCT_H/2 - 8])
translate([0, 0, sz])
cube([FAN_SZ * 0.5, WALL + 2*e, 5], center = true);
}
// Fan bolt holes (align to lid bolt holes)
for (fx = [-1, 1]) for (fy = [-1, 1])
translate([fx*FAN_BOLT_SPC/2, fy*FAN_BOLT_SPC/2,
-DUCT_H/2 - e])
cylinder(d = FAN_BOLT_D, h = DUCT_H + 2*e);
}
}
// ============================================================
// QUARTER-TURN LATCH KNOB (Part D — print 4×)
// ============================================================
// Screws onto latch post from outside. 90° rotation latches/unlatches.
// Spring washer (not printed) provides axial preload.
module latch_knob() {
knob_h = 12.0;
grip_h = 8.0;
difference() {
union() {
// Body disc
cylinder(d = LATCH_KNOB_D, h = knob_h);
// Grip ridges (6×, for finger purchase)
for (i = [0:5])
rotate([0, 0, i * 60])
translate([LATCH_KNOB_D/2 - 1, 0, 0])
cylinder(d = 2.5, h = grip_h);
}
// Latch post bore (clearance)
translate([0, 0, -e])
cylinder(d = LATCH_POST_D + 0.5, h = knob_h + 2*e);
// Bayonet lug slot (catches latch post cross-slot)
// 2 lugs at 180° — quarter-turn locks both simultaneously
for (a = [0, 180])
rotate([0, 0, a])
translate([LATCH_POST_D/2 + LATCH_SLOT_W/2, 0, 5])
cube([LATCH_SLOT_W + 0.3, LATCH_POST_D, knob_h],
center = true);
}
}
// ============================================================
// GASKET PROFILE 2D (for DXF export)
// ============================================================
// Outputs the O-ring groove path as a 2D outline suitable for
// laser-cutting a flat silicone sheet gasket (alternative to O-ring cord).
// Sheet gasket: 2 mm silicone sheet, cut to this profile.
module gasket_profile_2d() {
oring_cl_offset = FLANGE_WALL - ORING_INSET;
outer_w = INT_W + 2*FLANGE_WALL - 2*oring_cl_offset;
inner_w = outer_w - 2*ORING_GROOVE_W;
r_outer = BOX_R - 2;
r_inner = r_outer - ORING_GROOVE_W;
difference() {
minkowski() {
square([outer_w - 2*r_outer, INT_D + 2*FLANGE_WALL - 2*oring_cl_offset - 2*r_outer],
center = true);
circle(r = r_outer);
}
minkowski() {
square([inner_w - 2*r_inner,
INT_D + 2*FLANGE_WALL - 2*oring_cl_offset - 2*r_inner - 2*ORING_GROOVE_W],
center = true);
circle(r = r_inner);
}
}
}