saltylab-firmware/chassis/sensor_rail_brackets.scad
sl-mechanical 3992b80bcb feat(mechanical): universal sensor mount rail system (Issue #138)
Add 2020 T-slot quick-swap sensor rail for SaltyLab/Rover/Tank variants:
- sensor_rail.scad: 2020 T-slot profile, T-nut, thumbscrew, end cap, index
  pins, stem/post/tank clamp adapters
- sensor_rail_brackets.scad: universal T-nut base + RPLIDAR A1M8, D435i,
  IMX219, UWB anchor, cable clip brackets (tool-free M3 thumbscrew retention)
- sensor_rail_BOM.md: purchased hardware, print settings, export commands

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

534 lines
22 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.

// ============================================================
// sensor_rail_brackets.scad — Quick-Swap Sensor Brackets
// Issue: #138 Agent: sl-mechanical Date: 2026-03-01
// ============================================================
//
// Slide-on T-slot brackets for the 2020 sensor rail defined in
// sensor_rail.scad. All brackets share a common T-nut base that
// clamps to the rail with a single M3 thumbscrew (tool-free, ¼ turn).
//
// Bracket catalogue:
// Part 1 — universal_tnut_base() shared base for all brackets
// Part 2 — rplidar_bracket() RPLIDAR A1M8 Ø70 mm scanner
// Part 3 — d435i_bracket() Intel RealSense D435i (90×25×25 mm)
// Part 4 — imx219_bracket() IMX219 CSI camera (32×32 mm PCB)
// Part 5 — uwb_bracket() MaUWB ESP32-S3 anchor (~50×25 mm)
// Part 6 — cable_clip() Cable management clip
// Part 7 — assembly_preview() Full rail + all brackets
//
// Sensor interface dimensions (caliper-verified):
// RPLIDAR A1M8 : body Ø70 mm, bolt circle Ø58 mm, 4× M3 at 45°
// D435i : 90×25×25 mm body, 1/4-20 UNC female bottom port
// IMX219 : 32×32 mm PCB, M2 holes 24×24 mm (±12 mm grid)
// UWB ESP32-S3 : 50×25 mm PCB, M3 holes at corners (42×17 mm)
//
// T-nut base interface (matches sensor_rail.scad constants):
// TNUT_W = 9.8 mm, TNUT_H = 5.5 mm, TNUT_L = 12 mm
// M3 thumbscrew (M3×16 SHCS + printed thumbwheel)
//
// Coordinate convention (same as sensor_rail.scad):
// Rail runs along Z (vertical); front sensor face faces +Y.
// Bracket Z=0 at the bottom of the bracket body.
//
// RENDER options:
// "assembly" full preview — rail + all brackets
// "base_stl" T-nut base only (universal — print ×N)
// "rplidar_stl" RPLIDAR bracket arm + platform
// "d435i_stl" D435i bracket arm + plate
// "imx219_stl" IMX219 bracket arm + PCB cradle
// "uwb_stl" UWB bracket arm + PCB cradle
// "cable_clip_stl" cable management clip (print ×610)
//
// Export commands:
// openscad sensor_rail_brackets.scad -D 'RENDER="base_stl"' -o srb_tnut_base.stl
// openscad sensor_rail_brackets.scad -D 'RENDER="rplidar_stl"' -o srb_rplidar.stl
// openscad sensor_rail_brackets.scad -D 'RENDER="d435i_stl"' -o srb_d435i.stl
// openscad sensor_rail_brackets.scad -D 'RENDER="imx219_stl"' -o srb_imx219.stl
// openscad sensor_rail_brackets.scad -D 'RENDER="uwb_stl"' -o srb_uwb.stl
// openscad sensor_rail_brackets.scad -D 'RENDER="cable_clip_stl"' -o srb_cable_clip.stl
// ============================================================
$fn = 64;
e = 0.01;
// ── Rail geometry constants (must match sensor_rail.scad) ────────────────────
RAIL_W = 20.0;
SLOT_OPEN = 6.0;
SLOT_INNER_W = 10.2;
SLOT_INNER_H = 5.8;
SLOT_NECK_H = 3.2;
INDEX_PITCH = 25.0;
INDEX_HOLE_D = 5.3;
// ── T-nut constants (must match sensor_rail.scad) ────────────────────────────
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
TNUT_M3_NUT_AF = 5.5;
TNUT_M3_NUT_H = 2.5;
TNUT_BOLT_D = 3.3;
// ── Bracket base geometry ────────────────────────────────────────────────────
BASE_FACE_W = 30.0; // width of base body on rail face
BASE_FACE_H = 20.0; // height of base body (along rail Z axis)
BASE_FACE_T = SLOT_NECK_H + 1.5; // depth: neck + small flange overstand
// ── Bracket arm geometry ─────────────────────────────────────────────────────
ARM_T = 4.0; // arm wall thickness
ARM_OUT = 30.0; // arm reach out from rail face (+Y)
// ── Sensor interface constants ───────────────────────────────────────────────
// RPLIDAR A1M8
RPL_BODY_D = 70.0; // scanner body OD
RPL_BC_D = 58.0; // bolt circle diameter
RPL_BOLT_D = 3.3; // M3 clearance
RPL_PLAT_T = 4.0; // platform plate thickness
RPL_PLAT_D = 76.0; // platform OD (6 mm clearance around body)
// D435i RealSense
D4_BODY_W = 90.0; // body width (X)
D4_BODY_D = 25.0; // body depth (Y)
D4_BODY_H = 25.0; // body height (Z)
D4_MOUNT_D = 6.5; // 1/4-20 UNC clearance bore (6.35 mm + 0.15)
D4_PLATE_W = 96.0; // mounting plate width
D4_PLATE_T = 3.0; // mounting plate thickness
D4_TILT_DEG = 8.0; // nose-down tilt (matches rover/tank D435i mounts)
// IMX219 CSI camera
IMX_PCB_W = 32.0;
IMX_PCB_H = 32.0;
IMX_HOLE_SPC = 24.0; // M2 hole pattern (±12 mm, square)
IMX_BOLT_D = 2.4; // M2 clearance
IMX_TILT_DEG = 10.0; // slight downward tilt for terrain view
IMX_CRADLE_T = 3.0; // cradle plate thickness
// UWB anchor (MaUWB ESP32-S3 ~50×25 mm)
UWB_PCB_W = 50.0;
UWB_PCB_H = 25.0;
UWB_HOLE_X = 42.0; // hole pattern X span (±21 mm)
UWB_HOLE_Y = 17.0; // hole pattern Y span (±8.5 mm)
UWB_BOLT_D = 3.3; // M3 clearance
UWB_CRADLE_T = 3.0;
// Cable clip
CLIP_CABLE_D = 6.5; // max cable bundle OD (6 mm typ)
CLIP_T = 2.5; // clip wall thickness
// Fasteners
M2_D = 2.4;
M3_D = 3.3;
M4_D = 4.3;
M5_D = 5.3;
// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";
if (RENDER == "assembly") {
assembly_preview();
} else if (RENDER == "base_stl") {
universal_tnut_base();
} else if (RENDER == "rplidar_stl") {
rplidar_bracket();
} else if (RENDER == "d435i_stl") {
d435i_bracket();
} else if (RENDER == "imx219_stl") {
imx219_bracket();
} else if (RENDER == "uwb_stl") {
uwb_bracket();
} else if (RENDER == "cable_clip_stl") {
cable_clip();
}
// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly_preview() {
// Ghost rail section
%color("Silver", 0.35) {
linear_extrude(250)
square([RAIL_W, RAIL_W], center = true);
}
// RPLIDAR bracket at top (Z=180)
color("OliveDrab", 0.85)
translate([0, 0, 180])
rplidar_bracket();
// D435i bracket (Z=120)
color("DarkSlateGray", 0.85)
translate([0, 0, 120])
d435i_bracket();
// IMX219 bracket (Z=75)
color("Teal", 0.85)
translate([0, 0, 75])
imx219_bracket();
// UWB bracket (Z=30)
color("SaddleBrown", 0.85)
translate([0, 0, 30])
uwb_bracket();
// Cable clips
for (cz = [50, 100, 150, 200])
color("DimGray", 0.70)
translate([RAIL_W/2, 0, cz])
rotate([0, -90, 0])
cable_clip();
}
// ============================================================
// PART 1 — UNIVERSAL T-NUT BASE
// ============================================================
// Common base used by all sensor brackets.
// Slides into the 2020 rail T-groove from the end.
// M3×16 SHCS + thumbwheel clamps the T-nut from outside the rail.
//
// Print: PETG, 5 perims, 60 % infill, flat face (face plate) down.
// Orientation: face plate is the -Y face (against rail), T-nut protrudes +Y.
//
// The face plate provides a flat surface for bracket arm attachment via
// 2× M3 bolts (inset into the bracket arm from the +Y side).
module universal_tnut_base() {
difference() {
union() {
// ── Face plate (sits flush against rail outer face) ──────────
// Width = BASE_FACE_W, height = BASE_FACE_H, thin (BASE_FACE_T)
translate([-BASE_FACE_W/2, -BASE_FACE_T, 0])
cube([BASE_FACE_W, BASE_FACE_T, BASE_FACE_H]);
// ── T-nut tongue (protrudes into rail T-groove) ──────────────
// Centred on face plate; sized to TNUT_W × TNUT_H
translate([-TNUT_W/2, 0, (BASE_FACE_H - TNUT_L)/2])
cube([TNUT_W, SLOT_NECK_H + e, TNUT_L]);
// ── T-nut inner body (wider, inside T-groove) ────────────────
translate([-TNUT_W/2, SLOT_NECK_H - e, (BASE_FACE_H - TNUT_L)/2])
cube([TNUT_W, TNUT_H - SLOT_NECK_H + e, TNUT_L]);
}
// ── M3 thumbscrew bore (centre of T-nut, through face plate) ────
translate([0, -BASE_FACE_T - e, BASE_FACE_H/2])
rotate([-90, 0, 0])
cylinder(d = TNUT_BOLT_D, h = BASE_FACE_T + TNUT_H + 2*e);
// ── M3 hex nut pocket (inside T-nut body, for thumbscrew) ────────
translate([0, SLOT_NECK_H + 0.3, BASE_FACE_H/2])
rotate([-90, 0, 0])
cylinder(d = TNUT_M3_NUT_AF / cos(30),
h = TNUT_M3_NUT_H + 0.3,
$fn = 6);
// ── 2× M3 bolt holes in face plate (bracket arm attachment) ─────
for (hz = [BASE_FACE_H * 0.28, BASE_FACE_H * 0.72])
translate([0, -BASE_FACE_T - e, hz])
rotate([-90, 0, 0])
cylinder(d = M3_D, h = BASE_FACE_T + 2*e);
// ── Index pin pocket (optional 25 mm grid lock) ──────────────────
// Shallow Ø5 mm pocket on right side — aligns with rail index hole
translate([BASE_FACE_W/2 - 3, -BASE_FACE_T/2, BASE_FACE_H/2])
rotate([0, 90, 0])
cylinder(d = 5.1, h = 3 + e);
}
}
// ── Internal helper: base with bolt holes for arm attachment ─────────────────
// Used by all sensor brackets — arms bolt to this face plate.
// Returns the face plate at Y=0 (rail face), arm extends in +Y.
module _base_with_arm_holes() {
universal_tnut_base();
}
// ── Internal helper: arm stem from rail face to sensor platform ───────────────
// arm_len: reach in +Y from rail face
// arm_w : arm width in X
// arm_h : arm height in Z (same as BASE_FACE_H unless overridden)
module _arm(arm_len, arm_w, arm_h = BASE_FACE_H) {
// Chamfered arm block
hull() {
translate([-arm_w/2, 0, 0])
cube([arm_w, ARM_T, arm_h]);
translate([-arm_w/2, arm_len - ARM_T, (arm_h - arm_h*0.6)/2])
cube([arm_w, ARM_T, arm_h * 0.6]);
}
}
// ============================================================
// PART 2 — RPLIDAR A1M8 BRACKET
// ============================================================
// Circular platform that holds the RPLIDAR A1M8 scanner on top
// of the sensor rail. The scanner sits with its scan plane
// perpendicular to the rail (horizontal plane).
//
// RPLIDAR bolt pattern: 4× M3 on Ø58 mm BC at 45°/135°/225°/315°.
// Motor connector exits through a slot in the rear of the platform.
//
// Print: PETG, 4 perims, 40 % infill. Arm + platform in one piece.
module rplidar_bracket() {
union() {
// T-nut base
_base_with_arm_holes();
// Vertical arm rising above rail centre
translate([0, 0, 0])
difference() {
// Arm + top platform merge via hull
union() {
// Arm
translate([-BASE_FACE_W/2, 0, 0])
cube([BASE_FACE_W, ARM_OUT, ARM_T]);
// Platform disc at arm end
translate([0, ARM_OUT, BASE_FACE_H/2 + 5])
cylinder(d = RPL_PLAT_D, h = RPL_PLAT_T);
}
// ── Central bore (RPLIDAR body clears through) ───────────
// Not needed — scanner sits ON platform, not through it.
// Slot for motor/USB connector exit at rear
translate([0, ARM_OUT - RPL_PLAT_D/2 - e,
BASE_FACE_H/2 + 5 - e])
cube([20, 15, RPL_PLAT_T + 2*e], center = true);
}
// ── Bolt holes subtracted from platform (separate difference) ────
translate([0, ARM_OUT, BASE_FACE_H/2 + 5])
difference() {
cylinder(d = RPL_PLAT_D, h = RPL_PLAT_T);
// 4× M3 bolt holes on Ø58 mm bolt circle at 45°
for (a = [45, 135, 225, 315])
translate([RPL_BC_D/2 * cos(a),
RPL_BC_D/2 * sin(a), -e])
cylinder(d = RPL_BOLT_D, h = RPL_PLAT_T + 2*e);
// Lightening pockets (reduces print time/weight)
for (a = [0, 90, 180, 270])
translate([RPL_PLAT_D/2 * 0.5 * cos(a),
RPL_PLAT_D/2 * 0.5 * sin(a), 1])
cylinder(d = 14, h = RPL_PLAT_T + e);
// Connector slot
translate([0, -RPL_PLAT_D/2 + 5, -e])
cube([20, 15, RPL_PLAT_T + 2*e], center = true);
}
}
}
// ============================================================
// PART 3 — D435i REALSENSE BRACKET
// ============================================================
// Angled mounting plate for the Intel RealSense D435i.
// Camera attaches via captured 1/4-20 UNC hex nut (standard tripod).
// 8° nose-down tilt for forward terrain view (matches rover/tank mounts).
//
// Print: PETG, 4 perims, 40 % infill.
module d435i_bracket() {
union() {
// T-nut base
_base_with_arm_holes();
// Horizontal arm + tilted mounting plate
difference() {
union() {
// Arm from rail face to camera plate
translate([-D4_PLATE_W/2, 0, 0])
cube([D4_PLATE_W, ARM_OUT, ARM_T]);
// Camera mounting plate (tilted 8° nose-down)
translate([0, ARM_OUT, BASE_FACE_H/2])
rotate([D4_TILT_DEG, 0, 0])
translate([-D4_PLATE_W/2, 0, -D4_PLATE_T/2])
cube([D4_PLATE_W, D4_PLATE_T, BASE_FACE_H]);
}
// 1/4-20 UNC clearance bore (6.5 mm through mounting plate)
translate([0, ARM_OUT + D4_PLATE_T/2, BASE_FACE_H/2])
rotate([D4_TILT_DEG + 90, 0, 0])
cylinder(d = D4_MOUNT_D, h = D4_PLATE_T + 2*e,
center = true);
// 1/4-20 hex nut pocket (rear of plate — 11.1 mm AF UNC)
translate([0, ARM_OUT - D4_PLATE_T/2 - 3, BASE_FACE_H/2])
rotate([D4_TILT_DEG + 90, 0, 0])
cylinder(d = 11.4 / cos(30), h = 5,
$fn = 6, center = true);
// Cable routing notch at plate edge
translate([D4_PLATE_W/2 - 8, ARM_OUT,
BASE_FACE_H/2 - BASE_FACE_H * 0.3])
cube([10, ARM_T + 2*e, 8], center = true);
}
}
}
// ============================================================
// PART 4 — IMX219 CSI CAMERA BRACKET
// ============================================================
// Small cradle for 32×32 mm IMX219 CSI camera PCB.
// 10° downward tilt for terrain view.
// PCB attaches with 4× M2 bolts (24×24 mm pattern, countersunk).
// FFC cable exits through open bottom of cradle.
//
// Print: PETG, 4 perims, 40 % infill.
module imx219_bracket() {
pcb_h = IMX_PCB_H + 4; // cradle height (PCB + rim)
pcb_w = IMX_PCB_W + 4; // cradle width
union() {
// T-nut base
_base_with_arm_holes();
difference() {
union() {
// Short arm (IMX219 is compact, less reach needed)
translate([-pcb_w/2, 0, 0])
cube([pcb_w, ARM_OUT * 0.7, ARM_T]);
// Tilted PCB cradle
translate([0, ARM_OUT * 0.7, BASE_FACE_H/2])
rotate([IMX_TILT_DEG, 0, 0])
translate([-pcb_w/2, 0, -pcb_h/2])
cube([pcb_w, IMX_CRADLE_T + 2, pcb_h]);
}
// 4× M2 clearance bores (24×24 mm pattern)
for (hx = [-IMX_HOLE_SPC/2, IMX_HOLE_SPC/2])
for (hz = [-IMX_HOLE_SPC/2, IMX_HOLE_SPC/2])
translate([hx,
ARM_OUT * 0.7 + IMX_CRADLE_T + 2 + e,
BASE_FACE_H/2 + hz])
rotate([90 - IMX_TILT_DEG, 0, 0])
cylinder(d = IMX_BOLT_D,
h = IMX_CRADLE_T + 4, center = true);
// FFC cable exit slot (bottom of cradle)
translate([0, ARM_OUT * 0.7 + (IMX_CRADLE_T + 2)/2,
BASE_FACE_H/2 - pcb_h/2 - e])
rotate([IMX_TILT_DEG, 0, 0])
cube([12, IMX_CRADLE_T + 4, 8], center = true);
// Lens window (open centre of cradle face)
translate([0, ARM_OUT * 0.7 - e, BASE_FACE_H/2])
rotate([IMX_TILT_DEG, 0, 0])
cube([20, IMX_CRADLE_T + 4 + 2*e, 18], center = true);
}
}
}
// ============================================================
// PART 5 — UWB ANCHOR BRACKET
// ============================================================
// Open cradle for MaUWB ESP32-S3 PCB (~50×25 mm).
// Antenna patch faces forward (+Y); PCB attaches with 4× M3 bolts.
// Bracket angled 0° (vertical face) for panoramic UWB coverage.
//
// Print: PETG, 4 perims, 40 % infill.
module uwb_bracket() {
rim = 3.0; // cradle rim thickness
crd_w = UWB_PCB_W + 2*rim;
crd_h = UWB_PCB_H + 2*rim;
union() {
// T-nut base
_base_with_arm_holes();
difference() {
union() {
// Arm
translate([-crd_w/2, 0, 0])
cube([crd_w, ARM_OUT, ARM_T]);
// Vertical cradle plate at arm end
translate([-crd_w/2, ARM_OUT, 0])
cube([crd_w, UWB_CRADLE_T, crd_h]);
}
// 4× M3 clearance bores (UWB_HOLE_X × UWB_HOLE_Y pattern)
for (hx = [-UWB_HOLE_X/2, UWB_HOLE_X/2])
for (hz = [-UWB_HOLE_Y/2 + crd_h/2, UWB_HOLE_Y/2 + crd_h/2])
translate([hx, ARM_OUT - e, hz])
rotate([-90, 0, 0])
cylinder(d = UWB_BOLT_D,
h = UWB_CRADLE_T + 2*e);
// M3 nut pockets (rear of cradle)
for (hx = [-UWB_HOLE_X/2, UWB_HOLE_X/2])
for (hz = [-UWB_HOLE_Y/2 + crd_h/2, UWB_HOLE_Y/2 + crd_h/2])
translate([hx, ARM_OUT + UWB_CRADLE_T - 3, hz])
rotate([-90, 0, 0])
cylinder(d = 6.4 / cos(30), h = 3 + e, $fn = 6);
// Open centre window (antenna / component clearance)
translate([0, ARM_OUT - e,
crd_h/2])
cube([UWB_PCB_W - 2*rim,
UWB_CRADLE_T + 2*e,
UWB_PCB_H - 2*rim], center = true);
// USB / wire exit slot at bottom
translate([0, ARM_OUT + UWB_CRADLE_T/2, rim/2])
cube([12, UWB_CRADLE_T + 2*e, rim + 2*e], center = true);
}
}
}
// ============================================================
// PART 6 — CABLE CLIP
// ============================================================
// Tool-free push-in cable retainer that snaps into the 2020 rail
// T-groove. Accepts a single cable bundle up to CLIP_CABLE_D mm.
// Snap-fit tongue grips the T-groove without fasteners.
// Print in PETG for flexibility (snap-fit requires some elasticity).
//
// Print: PETG, 3 perims, 20 % infill (flexibility matters).
// Orientation: flat face (channel face) down.
module cable_clip() {
snap_t = 1.4; // snap tongue thickness (springy)
snap_h = SLOT_INNER_H - 0.3;
snap_w = SLOT_OPEN - 0.4; // narrow enough to enter slot
body_w = 18.0;
body_h = 14.0;
ch_d = CLIP_CABLE_D;
difference() {
union() {
// ── Body plate (sits on rail face) ──────────────────────────
translate([-body_w/2, 0, 0])
cube([body_w, CLIP_T, body_h]);
// ── Snap tongue (inserts into T-groove) ─────────────────────
// Centred, protrudes into rail slot
translate([-snap_w/2, CLIP_T - e, (body_h - TNUT_L)/2])
cube([snap_w, SLOT_NECK_H + e, TNUT_L]);
// Barb: slightly wider than slot opening — snaps into T-groove
translate([-TNUT_W/2 + 0.4, CLIP_T + SLOT_NECK_H - e,
(body_h - TNUT_L)/2])
cube([TNUT_W - 0.8, snap_h + e, TNUT_L]);
// ── Cable channel (C-clip shape) ─────────────────────────────
translate([0, CLIP_T + SLOT_NECK_H + snap_h + 2, body_h/2])
rotate([0, 90, 0])
difference() {
cylinder(d = ch_d + 2*CLIP_T, h = body_w,
center = true);
cylinder(d = ch_d, h = body_w + 2*e,
center = true);
// Open front for push-in insertion
translate([0, -(ch_d/2 + CLIP_T + e), 0])
cube([ch_d * 0.8,
ch_d + 2*CLIP_T + 2*e,
body_w + 2*e], center = true);
}
}
// ── Lightening slot in body plate ───────────────────────────────
translate([0, -e, body_h/2])
cube([body_w - 8, CLIP_T + 2*e, body_h - 8], center = true);
}
}