// ============================================================
// ip54_sensor_housings.scad — IP54 Sensor Housings
// Issue: #144  Agent: sl-mechanical  Date: 2026-03-01
// ============================================================
//
// Weatherproof housings for sensors exposed to outdoor conditions:
//
//   Part A — imx219_dome()     Clear PC dome for IMX219 CSI camera
//             IP54, anti-fog element pocket, gasket-sealed base ring
//   Part B — d435i_housing()   Sealed D435i RealSense housing
//             IP54, IR-transparent PC window, O-ring sealed front frame
//   Part C — rplidar_dome()    Spinning dome for RPLIDAR A1M8 scanner
//             Clear PC hemisphere, static base + rotary lip seal
//
// Window/dome materials:
//   Camera domes : 2 mm clear polycarbonate (Covestro Makrolon)
//   D435i window : 3 mm IR-transparent PC (transmits 850 nm / 930 nm IR)
//                  e.g. Evonik PLEXIGLAS IR (Altuglas 8N, 85% IR850 T)
//   RPLIDAR dome : 1.5 mm clear PC hemisphere, Ø120 mm OD
//                  (off-the-shelf: plastic Easter egg halves or custom vac-form)
//
// Anti-fog provision:
//   Each camera dome has a pocket for a 1 g silica gel packet (demountable)
//   and an anti-fog coating groove (optionally apply Rain-X or similar).
//
// Coordinate convention:
//   Sensor faces +Y (forward).  Z = 0 at housing base / mounting face.
//
// All housings mount to sensor_rail_brackets.scad T-nut arm ends.
// Bracket arm interfaces re-used from existing rail brackets.
//
// RENDER options:
//   "assembly"           all 3 housings side by side
//   "imx219_dome_stl"    IMX219 dome base ring (print 1× per camera)
//   "imx219_dome_2d"     DXF — dome polycarbonate disc profile
//   "d435i_body_stl"     D435i housing body (print 1×)
//   "d435i_window_2d"    DXF — IR-transparent PC window profile
//   "d435i_frame_stl"    D435i window retention frame (print 1×)
//   "rplidar_base_stl"   RPLIDAR dome base ring (print 1×)
//   "rplidar_dome_2d"    DXF — RPLIDAR clear PC dome spec
//
// Export commands:
//   openscad ip54_sensor_housings.scad -D 'RENDER="imx219_dome_stl"'  -o imx219_dome.stl
//   openscad ip54_sensor_housings.scad -D 'RENDER="imx219_dome_2d"'   -o imx219_dome_profile.dxf
//   openscad ip54_sensor_housings.scad -D 'RENDER="d435i_body_stl"'   -o d435i_body.stl
//   openscad ip54_sensor_housings.scad -D 'RENDER="d435i_window_2d"'  -o d435i_window.dxf
//   openscad ip54_sensor_housings.scad -D 'RENDER="d435i_frame_stl"'  -o d435i_frame.stl
//   openscad ip54_sensor_housings.scad -D 'RENDER="rplidar_base_stl"' -o rplidar_base.stl
//   openscad ip54_sensor_housings.scad -D 'RENDER="rplidar_dome_2d"'  -o rplidar_dome_spec.dxf
// ============================================================

$fn = 64;
e   = 0.01;

// ── Fasteners ─────────────────────────────────────────────────────────────────
M2_D = 2.4;
M3_D = 3.3;
M4_D = 4.3;

// ── O-ring groove standard (2 mm cord, 70 % compression) ─────────────────────
ORING_W = 2.2;
ORING_D = 1.7;

// ── IMX219 sensor interface (matches sensor_rail_brackets.scad) ───────────────
IMX_PCB_W      = 32.0;
IMX_PCB_H      = 32.0;
IMX_HOLE_SPC   = 24.0;

// ── D435i sensor interface ────────────────────────────────────────────────────
D4_BODY_W      = 90.0;
D4_BODY_D      = 25.0;
D4_BODY_H      = 25.0;
D4_MOUNT_D     =  6.5;

// ── RPLIDAR A1M8 sensor interface ─────────────────────────────────────────────
RPL_BODY_D     = 70.0;
RPL_BODY_H     = 40.0;    // approximate scan head height
RPL_MOTOR_D    = 30.0;    // motor spindle OD (approximate, lower portion)
RPL_BC_D       = 58.0;
RPL_BOLT_D     =  3.3;

// ============================================================
// ── PART A: IMX219 CLEAR DOME ─────────────────────────────────────────────────
// ============================================================
//
// Base ring: printed PETG, gasket-sealed, mounts to sensor rail bracket.
// Dome: 2 mm clear PC disc or hemisphere (separate purchase / fabrication).
//
// Design overview:
//   • Circular base ring sized to accept the dome OD (snap or screw)
//   • O-ring groove at dome seating face → IP54
//   • Camera PCB attaches inside on 4× M2 standoffs
//   • Silica gel pocket on inner wall (1 g sachet, removable via dome)
//   • Anti-fog groove (optional coating during assembly)
//   • Base flange: M3 bolt pattern matching IMX219 bracket arm

IMX_DOME_OD    = 55.0;   // clear PC dome outer diameter
IMX_DOME_RIM_T =  4.0;   // base ring wall thickness
IMX_DOME_H     = 35.0;   // inner cavity height (lens to dome apex clearance)
IMX_RING_H     = 18.0;   // base ring height (below dome seating face)
IMX_PCB_STOFF  =  4.0;   // PCB standoff height inside dome
IMX_TILT_DEG   = 10.0;   // dome tilt (same as imx219 bracket)

module imx219_dome() {
    base_od = IMX_DOME_OD + 2*IMX_DOME_RIM_T;
    base_id = IMX_DOME_OD - 2*IMX_DOME_RIM_T;  // internal cavity OD

    difference() {
        union() {
            // ── Base ring cylinder ─────────────────────────────────────
            cylinder(d = base_od, h = IMX_RING_H);

            // ── Dome retention lip (retains dome from below) ───────────
            // Dome rests on this lip; screw-on retainer ring holds it.
            translate([0, 0, IMX_RING_H])
                difference() {
                    cylinder(d = base_od, h = 4);
                    // Dome inner bore (dome drops in)
                    translate([0, 0, -e])
                        cylinder(d = IMX_DOME_OD + 0.4, h = 4 + 2*e);
                }

            // ── Base flange (mounts to bracket arm) ───────────────────
            // Square flange extending below ring
            translate([-base_od/2, -base_od/2, -8])
                cube([base_od, base_od, 8]);
        }

        // ── Internal cavity ────────────────────────────────────────────
        translate([0, 0, -e])
            cylinder(d = IMX_DOME_OD - 2*IMX_DOME_RIM_T + 0.4,
                     h = IMX_RING_H + 4 + 2*e);

        // ── O-ring groove at dome seating face ─────────────────────────
        translate([0, 0, IMX_RING_H - ORING_D])
            difference() {
                cylinder(d = IMX_DOME_OD - 2 + ORING_W,
                         h = ORING_D + e);
                cylinder(d = IMX_DOME_OD - 2 - ORING_W,
                         h = ORING_D + e);
            }

        // ── 4× M2 PCB standoff bores (square 24×24 mm pattern) ────────
        for (sx = [-1, 1]) for (sy = [-1, 1])
            translate([sx*IMX_HOLE_SPC/2, sy*IMX_HOLE_SPC/2, -e])
                cylinder(d = M2_D, h = IMX_RING_H + 4 + 2*e);

        // ── Base flange M3 bolt holes (4 corners, match bracket arm) ──
        for (bx = [-1, 1]) for (by = [-1, 1])
            translate([bx*(base_od/2 - 5),
                       by*(base_od/2 - 5), -8 - e])
                cylinder(d = M3_D, h = 8 + 2*e);

        // ── Silica gel pocket (inside ring wall, rear) ─────────────────
        // Small pocket ~25×20×5 mm accessible when dome removed
        translate([0, -(base_od/2 - 3), IMX_RING_H/2])
            cube([20, 6, 12], center = true);

        // ── FFC cable exit slot (bottom of ring) ──────────────────────
        translate([0, 0, -8 - e])
            cube([10, base_od + 2*e, 5], center = true);
    }

    // ── M2 standoffs (inside ring) ──────────────────────────────────────
    for (sx = [-1, 1]) for (sy = [-1, 1])
        translate([sx*IMX_HOLE_SPC/2, sy*IMX_HOLE_SPC/2, 0])
            difference() {
                cylinder(d = 5, h = IMX_PCB_STOFF);
                cylinder(d = M2_D, h = IMX_PCB_STOFF + e);
            }
}

// ── IMX219 dome PC disc profile (DXF) ────────────────────────────────────────
// 2D profile for laser-cutting / ordering the clear PC dome disc.
// Material: 2 mm clear polycarbonate.
module imx219_dome_profile_2d() {
    circle(d = IMX_DOME_OD);
}

// ── IMX219 retainer ring (screw-on, holds dome in base) ──────────────────────
// M24 metric thread (modelled as cylindrical press-fit with snap grooves).
module imx219_retainer_ring() {
    base_od = IMX_DOME_OD + 2*IMX_DOME_RIM_T;
    ring_h  = 8.0;

    difference() {
        cylinder(d = base_od, h = ring_h);
        translate([0, 0, -e])
            cylinder(d = IMX_DOME_OD + 0.6, h = ring_h + 2*e);
        // Grip notches
        for (i = [0:5])
            rotate([0, 0, i*60])
                translate([base_od/2 - 1, 0, ring_h/2])
                    cylinder(d = 2, h = ring_h + e, center = true);
    }
}

// ============================================================
// ── PART B: D435i REALSENSE SEALED HOUSING ───────────────────────────────────
// ============================================================
//
// Body: PETG printed U-channel wraps D435i body.
//   Camera inserts from front; front window frame retains it.
// Window: 3 mm IR-transparent PC (88 × 22 mm), O-ring sealed.
//   Transmits >85% at 850 nm (IR stereo projector + receiver).
// Rear cap: snap-on / screw-on PETG cap, O-ring sealed.
//   Cable exits via PG7 gland on rear cap.
// Top: flat, M3 holes for mounting to sensor rail D435i bracket.
//
// Internal clearance: 94 × 29 × 29 mm (D4 body + 2 mm each side).

D4H_INT_W  = D4_BODY_W + 4;   // internal width clearance
D4H_INT_D  = D4_BODY_D + 4;   // internal depth
D4H_INT_H  = D4_BODY_H + 4;   // internal height
D4H_WALL   = 3.5;              // housing wall thickness
D4H_WIN_W  = D4_BODY_W - 2;   // window aperture width
D4H_WIN_H  = D4_BODY_H - 2;   // window aperture height
D4H_WIN_T  = 3.0;              // window thickness (PC)
D4H_WIN_REC= 1.5;              // window recess depth (sits into frame)
D4H_TILT   = 8.0;              // nose-down tilt, matches existing mount

module d435i_housing_body() {
    out_w = D4H_INT_W + 2*D4H_WALL;
    out_d = D4H_INT_D + 2*D4H_WALL;
    out_h = D4H_INT_H + 2*D4H_WALL;

    difference() {
        // ── Outer shell ──────────────────────────────────────────────
        cube([out_w, out_d, out_h], center = true);

        // ── Internal cavity ─────────────────────────────────────────
        cube([D4H_INT_W, D4H_INT_D, D4H_INT_H + 2*e], center = true);

        // ── Front window aperture (sensor-facing face, +Y) ──────────
        translate([0, out_d/2 - D4H_WALL - e, 0])
            cube([D4H_WIN_W, D4H_WALL + 2*e, D4H_WIN_H], center = true);

        // ── Window recess (window sits flush in face) ────────────────
        translate([0, out_d/2 - D4H_WIN_REC, 0])
            cube([D4H_WIN_W + 2*D4H_WIN_REC,
                  D4H_WIN_REC + e,
                  D4H_WIN_H + 2*D4H_WIN_REC], center = true);

        // ── O-ring groove around front aperture ─────────────────────
        // Groove on front face, surrounds window aperture
        translate([0, out_d/2 - ORING_D - 0.5, 0])
            difference() {
                cube([D4H_WIN_W + 2*(D4H_WIN_REC + ORING_W),
                      ORING_D + e,
                      D4H_WIN_H + 2*(D4H_WIN_REC + ORING_W)], center = true);
                cube([D4H_WIN_W + 2*D4H_WIN_REC,
                      ORING_D + 2*e,
                      D4H_WIN_H + 2*D4H_WIN_REC], center = true);
            }

        // ── Rear cap opening (camera inserted from rear, cap closes it) ─
        translate([0, -out_d/2 - e, 0])
            cube([D4H_INT_W - 2, out_d/4, D4H_INT_H - 2], center = true);

        // ── PG7 gland hole in rear cap mating face ───────────────────
        translate([0, -out_d/2 - e, 0])
            rotate([90, 0, 0])
                cylinder(d = 12.7, h = D4H_WALL + 2*e);

        // ── 1/4-20 captured nut for tripod/bracket mount (bottom) ────
        translate([0, 0, -out_h/2 - e])
            rotate([180, 0, 0])
                cylinder(d = 6.5, h = D4H_WALL + 2*e);
        translate([0, 0, -out_h/2 + 4])
            cylinder(d = 11.4/cos(30), h = 5, $fn = 6);

        // ── M3 mounting holes on top (sensor rail bracket interface) ──
        for (mx = [-20, 0, 20])
            translate([mx, 0, out_h/2 - e])
                cylinder(d = M3_D, h = D4H_WALL + 2*e);
    }
}

// ── D435i window retention frame ─────────────────────────────────────────────
// Screws onto front of housing body, sandwiches the IR-transparent PC window.
// 4× M2.5 screws at corners pull frame against O-ring seal.
module d435i_window_frame() {
    out_w = D4H_INT_W + 2*D4H_WALL;
    out_h = D4H_INT_H + 2*D4H_WALL;
    frame_t = 5.0;
    frame_w = out_w + 2;
    frame_h = out_h + 2;

    difference() {
        // Frame plate
        cube([frame_w, frame_t, frame_h], center = true);

        // Central window reveal (slightly smaller than aperture — shows window)
        cube([D4H_WIN_W - 4, frame_t + 2*e, D4H_WIN_H - 4], center = true);

        // Window recess (PC sits in this pocket on rear face)
        translate([0, -frame_t/2 + D4H_WIN_REC/2, 0])
            cube([D4H_WIN_W + 0.4, D4H_WIN_REC + e, D4H_WIN_H + 0.4],
                 center = true);

        // 4× M2.5 mounting screws at corners
        for (fx = [-1, 1]) for (fz = [-1, 1])
            translate([fx*(frame_w/2 - 5), -frame_t/2 - e,
                       fz*(frame_h/2 - 5)])
                rotate([90, 0, 0])
                    cylinder(d = 2.8, h = frame_t + 2*e);
    }
}

// ── D435i window profile (DXF) ───────────────────────────────────────────────
// Profile for laser-cutting 3 mm IR-transparent PC.
module d435i_window_profile_2d() {
    square([D4H_WIN_W, D4H_WIN_H], center = true);
}

// ── D435i rear cap ────────────────────────────────────────────────────────────
// O-ring sealed rear cap. Snap-over with 2× M3 retention screws.
module d435i_rear_cap() {
    out_w = D4H_INT_W + 2*D4H_WALL;
    out_h = D4H_INT_H + 2*D4H_WALL;
    cap_t = 5.0;

    difference() {
        union() {
            cube([out_w, cap_t, out_h], center = true);
            // Lip that wraps inside housing rear opening
            translate([0, cap_t/2 - e, 0])
                cube([D4H_INT_W - 2 - 0.4,
                      6,
                      D4H_INT_H - 2 - 0.4], center = true);
        }

        // PG7 cable gland
        rotate([90, 0, 0])
            cylinder(d = 12.7, h = cap_t + 6 + 2*e, center = true);

        // O-ring groove on lip perimeter
        translate([0, cap_t/2 + 3, 0])
            difference() {
                cube([D4H_INT_W - 2 + ORING_W,
                      ORING_D,
                      D4H_INT_H - 2 + ORING_W], center = true);
                cube([D4H_INT_W - 2 - ORING_W,
                      ORING_D + e,
                      D4H_INT_H - 2 - ORING_W], center = true);
            }

        // M3 retention screw holes
        for (mz = [-1, 1])
            translate([0, -cap_t/2 - e, mz*(out_h/2 - 8)])
                rotate([90, 0, 0])
                    cylinder(d = M3_D, h = cap_t + 2*e);
    }
}

// ============================================================
// ── PART C: RPLIDAR SPINNING DOME ────────────────────────────────────────────
// ============================================================
//
// The RPLIDAR A1M8 scan head spins continuously.  A transparent PC dome
// covers the entire scanner, protecting it from water and debris.
//
// Architecture:
//   • Static base ring: mounts to robot deck; RPLIDAR mounts inside on its
//     standard 4× M3 bolt circle (Ø58 mm BC).
//   • Spinning clear dome: rotates with scan head OR is statically mounted
//     with sufficient clearance for the scan head to spin inside.
//     ★ Design choice here: STATIC dome, sized Ø120 mm OD × 95 mm tall.
//     The scanner spins inside the static dome.  No rotary seal needed.
//     Scan laser exits through dome walls at all angles (clear PC transmits
//     the 785 nm laser with <5 % absorption at 1.5 mm wall thickness).
//   • Dome lip sits in O-ring groove in base ring → IP54 seal.
//   • Dome retention: 3× M3 captive-nut clips, quarter-turn removal.
//
// Dome spec: custom vac-form, OR cut top from clear PC tube Ø120 mm OD.
//   Off-the-shelf option: clear plastic cylinder Ø120×120 mm (party supply).
//   Top cap: 1.5–2 mm clear PC disc, Ø120 mm.

RPL_DOME_OD    = 120.0;   // dome outer diameter
RPL_DOME_H     = 95.0;    // dome total height (covers scanner + motor)
RPL_DOME_T     =   2.0;   // dome wall thickness (clear PC)
RPL_BASE_H     =  20.0;   // base ring height
RPL_BASE_WALL  =   5.0;   // base ring wall thickness
RPL_CLEAR      =   5.0;   // radial clearance between scanner and dome wall

// Base ring inner bore must clear RPLIDAR motor (Ø70 mm body + clearance)
RPL_BASE_BORE  = RPL_BODY_D + 2*RPL_CLEAR;  // = 80 mm

module rplidar_dome_base() {
    base_od = RPL_DOME_OD + 2*RPL_BASE_WALL;

    difference() {
        union() {
            // ── Outer base cylinder ──────────────────────────────────
            cylinder(d = base_od, h = RPL_BASE_H);

            // ── Dome seat lip (raised inner lip, dome rests on top) ──
            translate([0, 0, RPL_BASE_H])
                difference() {
                    cylinder(d = base_od, h = 4);
                    // Dome drops over this; 0.5 mm radial clearance
                    translate([0, 0, -e])
                        cylinder(d = RPL_DOME_OD + 1, h = 4 + 2*e);
                }
        }

        // ── RPLIDAR body bore (scanner sits inside base ring) ────────
        translate([0, 0, -e])
            cylinder(d = RPL_BASE_BORE, h = RPL_BASE_H + 4 + 2*e);

        // ── 4× M3 bolt holes — RPLIDAR mounting (Ø58 mm BC, 45° off) ─
        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_BASE_H + 4 + 2*e);

        // ── O-ring groove at dome seating face ───────────────────────
        // Groove in top face of base ring; dome rim presses onto O-ring.
        translate([0, 0, RPL_BASE_H + 4 - ORING_D])
            difference() {
                cylinder(d = RPL_DOME_OD + ORING_W, h = ORING_D + e);
                cylinder(d = RPL_DOME_OD - ORING_W, h = ORING_D + e);
            }

        // ── 3× M3 dome retention clip pockets (quarter-turn inserts) ─
        for (a = [0, 120, 240])
            translate([RPL_DOME_OD/2 * cos(a),
                       RPL_DOME_OD/2 * sin(a),
                       RPL_BASE_H + 1])
                rotate([0, 0, a]) {
                    // Clip slot: L-shaped slot for quarter-turn retention
                    translate([0, 0, 0])
                        cube([M3_D + 0.5, 8, 8], center = true);
                    translate([3, 0, -3])
                        cube([M3_D + 0.5 + 6, 8, 3 + e], center = true);
                }

        // ── Cable pass-through (motor USB + power) ───────────────────
        // Slot in base ring floor for cable routing
        translate([0, RPL_BASE_BORE/2, -e])
            cube([12, RPL_BASE_BORE/2, 6], center = true);

        // ── Deck mounting holes (4× M4 on standard bolt circle) ──────
        for (a = [0, 90, 180, 270])
            translate([(base_od/2 - 8) * cos(a),
                       (base_od/2 - 8) * sin(a), -e])
                cylinder(d = M4_D, h = RPL_BASE_H + 4 + 2*e);
    }
}

// ── RPLIDAR dome profile (DXF — cylindrical tube spec) ───────────────────────
// 2D cross-section profile for clear PC dome cylinder purchase/fabrication.
// Cut a length of Ø120 mm clear PC tube; add a disc cap.
module rplidar_dome_profile_2d() {
    // Cross-section annulus: OD = RPL_DOME_OD, wall = RPL_DOME_T
    difference() {
        circle(d = RPL_DOME_OD);
        circle(d = RPL_DOME_OD - 2*RPL_DOME_T);
    }
}

// ── RPLIDAR dome top cap (clear PC disc — DXF profile only) ──────────────────
module rplidar_dome_cap_2d() {
    circle(d = RPL_DOME_OD - RPL_DOME_T);
}

// ── RPLIDAR dome retention clip ────────────────────────────────────────────────
// Printed clip slides into quarter-turn slot on base ring.
// Captive M3 bolt tip engages hole drilled in dome wall.
// Print: PETG, 5 perims, 60% infill.  3× per dome.
module rplidar_dome_clip() {
    difference() {
        union() {
            // T-body (fits in L-slot)
            cube([M3_D + 2, 8, 8], center = true);
            // Engagement lug
            translate([3, 0, -3])
                cube([M3_D + 8, 7, 3], center = true);
        }
        // M3 bore (bolt presses against dome wall)
        rotate([0, 90, 0])
            cylinder(d = M3_D, h = M3_D + 10, center = true);
    }
}

// ============================================================
// RENDER DISPATCH
// ============================================================
RENDER = "assembly";

if (RENDER == "assembly") {
    assembly();
} else if (RENDER == "imx219_dome_stl") {
    imx219_dome();
} else if (RENDER == "imx219_dome_2d") {
    projection(cut = true) translate([0, 0, -0.5])
        linear_extrude(1) imx219_dome_profile_2d();
} else if (RENDER == "d435i_body_stl") {
    d435i_housing_body();
} else if (RENDER == "d435i_window_2d") {
    projection(cut = true) translate([0, 0, -0.5])
        linear_extrude(1) d435i_window_profile_2d();
} else if (RENDER == "d435i_frame_stl") {
    d435i_window_frame();
} else if (RENDER == "rplidar_base_stl") {
    rplidar_dome_base();
} else if (RENDER == "rplidar_dome_2d") {
    projection(cut = true) translate([0, 0, -0.5])
        linear_extrude(1) rplidar_dome_profile_2d();
}

// ============================================================
// ASSEMBLY PREVIEW
// ============================================================
module assembly() {
    // IMX219 dome (left)
    color("DodgerBlue", 0.80)
        translate([-120, 0, 0])
            imx219_dome();

    // IMX219 clear dome ghost
    %color("LightCyan", 0.25)
        translate([-120, 0, 18])
            cylinder(d = IMX_DOME_OD + 0.4, h = IMX_DOME_H);

    // D435i housing (centre)
    color("DarkSlateGray", 0.85)
        translate([0, 0, 0])
            d435i_housing_body();

    // D435i window frame ghost
    %color("LightBlue", 0.30)
        translate([0, (D4H_INT_D + 2*D4H_WALL)/2, 0])
            rotate([90, 0, 0])
                d435i_window_frame();

    // RPLIDAR dome base (right)
    color("OliveDrab", 0.85)
        translate([160, 0, 0])
            rplidar_dome_base();

    // RPLIDAR clear dome ghost
    %color("LightCyan", 0.25)
        translate([160, 0, RPL_BASE_H + 4])
            cylinder(d = RPL_DOME_OD, h = RPL_DOME_H - RPL_BASE_H - 4);

    // Ghost scanner inside dome
    %color("Black", 0.35)
        translate([160, 0, RPL_BASE_H/2])
            cylinder(d = RPL_BODY_D, h = RPL_BODY_H, center = true);
}
