diff --git a/.vscode/mcp.json b/.vscode/mcp.json
new file mode 100644
index 0000000..7fe973b
--- /dev/null
+++ b/.vscode/mcp.json
@@ -0,0 +1,13 @@
+{
+ "servers": {
+ "openscad": {
+ "command": "uv",
+ "args": [
+ "run",
+ "--with",
+ "git+https://github.com/quellant/openscad-mcp.git",
+ "openscad-mcp"
+ ]
+ }
+ }
+}
diff --git a/hardware/transmitter/enclosure/.gitignore b/hardware/transmitter/enclosure/.gitignore
new file mode 100644
index 0000000..68cd221
--- /dev/null
+++ b/hardware/transmitter/enclosure/.gitignore
@@ -0,0 +1,2 @@
+renders/
+.deps/
diff --git a/hardware/transmitter/enclosure/Makefile b/hardware/transmitter/enclosure/Makefile
new file mode 100644
index 0000000..c8f01d1
--- /dev/null
+++ b/hardware/transmitter/enclosure/Makefile
@@ -0,0 +1,236 @@
+# Copyright (C) 2026 Peter Buchegger
+#
+# This file is part of OpenDriveHub.
+#
+# OpenDriveHub is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# OpenDriveHub is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with OpenDriveHub. If not, see .
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# =============================================================================
+# OpenDriveHub Transmitter Enclosure – Build System
+# =============================================================================
+#
+# Auto-discovers .scad part files and generates PNG render / STL export targets.
+# Adding a new .scad file automatically makes it available – no edits needed.
+#
+# Prerequisites:
+# - OpenSCAD (command-line)
+# - DISPLAY environment variable (Xvfb for headless systems)
+#
+# Quick start:
+# make help Show all targets and options
+# make renders Render every part in every view
+# make -j$(nproc) renders Parallel rendering
+# make stl Export all parts as STL
+# =============================================================================
+
+# Disable built-in rules for faster parsing
+MAKEFLAGS += --no-builtin-rules
+.SUFFIXES:
+
+# ─── Configuration (override via command line or environment) ─────────────────
+
+OPENSCAD ?= openscad
+IMG_WIDTH ?= 1920
+IMG_HEIGHT ?= 1080
+COLORSCHEME ?= Cornfield
+FN ?= 60
+RENDER_DIR ?= renders
+EXPLODE ?= 30
+PNG_MODE ?= --render
+
+# ─── Views & Camera Angles ───────────────────────────────────────────────────
+#
+# Each view is defined by a camera rotation (rot_x,rot_y,rot_z) and a
+# projection type (p=perspective, o=orthographic).
+# --autocenter and --viewall handle translation and distance automatically.
+#
+# To add a custom view, define CAM_ and PROJ_, then add
+# to the VIEWS list.
+
+VIEWS ?= iso_front iso_back top bottom front back left right
+
+CAM_iso_front := 55,0,25
+CAM_iso_back := 55,0,205
+CAM_top := 0,0,0
+CAM_bottom := 180,0,0
+CAM_front := 90,0,0
+CAM_back := 90,0,180
+CAM_left := 90,0,90
+CAM_right := 90,0,270
+
+PROJ_iso_front := p
+PROJ_iso_back := p
+PROJ_top := o
+PROJ_bottom := o
+PROJ_front := o
+PROJ_back := o
+PROJ_left := o
+PROJ_right := o
+
+# ─── Auto-Discovery ──────────────────────────────────────────────────────────
+#
+# Library files are included/used by other files and produce no geometry on
+# their own. Everything else is treated as a renderable part.
+# Stems (filename without .scad) must be unique across subdirectories.
+
+SCAD_EXCLUDE := parameters.scad utils.scad
+
+SCAD_PARTS := $(shell find . -name '*.scad' \
+ $(foreach x,$(SCAD_EXCLUDE),! -name '$(x)') \
+ ! -path './$(RENDER_DIR)/*' \
+ -printf '%P\n' | sort)
+
+# Common dependencies – a change here rebuilds everything
+SCAD_DEPS := $(wildcard parameters.scad utils.scad)
+
+# Unique part stems (filename without extension)
+STEMS := $(sort $(foreach p,$(SCAD_PARTS),$(basename $(notdir $(p)))))
+
+# Source map: SRCMAP_ → relative path to .scad file
+$(foreach p,$(SCAD_PARTS),$(eval SRCMAP_$(basename $(notdir $(p))) := $(p)))
+
+# ─── Generated Target Lists ──────────────────────────────────────────────────
+
+ALL_PNGS := $(foreach s,$(STEMS),$(foreach v,$(VIEWS),$(RENDER_DIR)/$(s)/$(v).png))
+ALL_STLS := $(foreach s,$(STEMS),$(RENDER_DIR)/$(s)/$(s).stl)
+
+EXPLODED_VIEWS := iso_front iso_back
+EXPLODED_PNGS := $(foreach v,$(EXPLODED_VIEWS),$(RENDER_DIR)/assembly/exploded_$(v).png)
+
+# ─── Phony Targets ───────────────────────────────────────────────────────────
+
+.PHONY: all renders stl assembly-exploded \
+ clean clean-all list-parts help \
+ $(foreach s,$(STEMS),render-$(s) stl-$(s))
+
+# ─── Main Targets ────────────────────────────────────────────────────────────
+
+all: renders
+
+renders: $(ALL_PNGS)
+ @echo ""
+ @echo "✓ All $(words $(ALL_PNGS)) renders complete in $(RENDER_DIR)/."
+
+stl: $(ALL_STLS)
+ @echo ""
+ @echo "✓ All $(words $(ALL_STLS)) STL exports complete in $(RENDER_DIR)/."
+
+assembly-exploded: $(EXPLODED_PNGS)
+ @echo ""
+ @echo "✓ Exploded assembly views complete."
+
+# ─── Per-Part Convenience Targets ─────────────────────────────────────────────
+# Usage: make render-assembly, make stl-joystick_zone, etc.
+
+$(foreach s,$(STEMS),$(eval render-$(s): $(foreach v,$(VIEWS),$(RENDER_DIR)/$(s)/$(v).png)))
+$(foreach s,$(STEMS),$(eval stl-$(s): $(RENDER_DIR)/$(s)/$(s).stl))
+
+# ─── Build Rules ──────────────────────────────────────────────────────────────
+#
+# Uses .SECONDEXPANSION so that the implicit rule can resolve the source .scad
+# file from the target path at prerequisite-expansion time:
+#
+# Target: renders//.png
+# $* = /
+# word 1 = → SRCMAP lookup → source .scad path
+# word 2 = → CAM / PROJ lookup
+
+.SECONDEXPANSION:
+
+# --- PNG render rule ---
+$(RENDER_DIR)/%.png: $$(SRCMAP_$$(word 1,$$(subst /, ,$$*))) $$(SCAD_DEPS)
+ @mkdir -p $(dir $@)
+ @echo "[RENDER] $*"
+ @$(OPENSCAD) $(PNG_MODE) --autocenter --viewall \
+ --imgsize=$(IMG_WIDTH),$(IMG_HEIGHT) \
+ --colorscheme=$(COLORSCHEME) \
+ --camera=0,0,0,$(CAM_$(word 2,$(subst /, ,$*))),0 \
+ --projection=$(PROJ_$(word 2,$(subst /, ,$*))) \
+ -D '$$fn=$(FN)' \
+ -o $@ $<
+
+# --- STL export rule ---
+$(RENDER_DIR)/%.stl: $$(SRCMAP_$$(word 1,$$(subst /, ,$$*))) $$(SCAD_DEPS)
+ @mkdir -p $(dir $@)
+ @echo "[STL] $(word 1,$(subst /, ,$*))"
+ @$(OPENSCAD) -D '$$fn=$(FN)' -o $@ $<
+
+# --- Exploded assembly (special case with explode parameter) ---
+$(RENDER_DIR)/assembly/exploded_%.png: assembly.scad $$(SCAD_DEPS)
+ @mkdir -p $(dir $@)
+ @echo "[RENDER] assembly/exploded_$*"
+ @$(OPENSCAD) $(PNG_MODE) --autocenter --viewall \
+ --imgsize=$(IMG_WIDTH),$(IMG_HEIGHT) \
+ --colorscheme=$(COLORSCHEME) \
+ --camera=0,0,0,$(CAM_$*),0 \
+ --projection=$(PROJ_$*) \
+ -D '$$fn=$(FN)' -D 'explode=$(EXPLODE)' \
+ -o $@ $<
+
+# ─── Cleanup ─────────────────────────────────────────────────────────────────
+
+clean:
+ rm -rf $(foreach s,$(STEMS),$(RENDER_DIR)/$(s))
+
+clean-all:
+ rm -rf $(RENDER_DIR)
+
+# ─── Information ──────────────────────────────────────────────────────────────
+
+list-parts:
+ @echo "Discovered $(words $(STEMS)) part(s):"
+ @$(foreach s,$(STEMS),echo " $(s) ← $(SRCMAP_$(s))";)
+ @echo ""
+ @echo "Library files (not rendered):"
+ @$(foreach x,$(SCAD_EXCLUDE),echo " $(x)";)
+
+help:
+ @echo "OpenDriveHub Transmitter Enclosure – Build System"
+ @echo ""
+ @echo "Usage: make [TARGET] [OPTION=value ...]"
+ @echo ""
+ @echo "Main targets:"
+ @echo " all / renders Render all parts in all views ($(words $(ALL_PNGS)) images)"
+ @echo " stl Export all parts as STL ($(words $(ALL_STLS)) files)"
+ @echo " assembly-exploded Exploded assembly renders"
+ @echo " clean Remove per-part render directories"
+ @echo " clean-all Remove entire $(RENDER_DIR)/ directory"
+ @echo " list-parts Show auto-discovered parts"
+ @echo " help Show this help"
+ @echo ""
+ @echo "Per-part targets:"
+ @$(foreach s,$(STEMS),echo " render-$(s) / stl-$(s)";)
+ @echo ""
+ @echo "Options (override on command line):"
+ @echo " IMG_WIDTH=1920 Render width in pixels"
+ @echo " IMG_HEIGHT=1080 Render height in pixels"
+ @echo " FN=60 Curve resolution (OpenSCAD \$$fn)"
+ @echo " COLORSCHEME=Cornfield Color scheme (Cornfield|Metallic|Sunset|...)"
+ @echo " EXPLODE=30 Explosion distance for exploded views"
+ @echo " PNG_MODE=--render Use --preview to show transparent components"
+ @echo " VIEWS='iso_front ...' Space-separated list of views to render"
+ @echo ""
+ @echo "Adding views:"
+ @echo " Define CAM_ (rot_x,rot_y,rot_z) and PROJ_ (p|o),"
+ @echo " then add to VIEWS."
+ @echo ""
+ @echo "Examples:"
+ @echo " make renders # All parts, all views"
+ @echo " make -j\$$(nproc) renders # Parallel rendering"
+ @echo " make render-assembly # Single part, all views"
+ @echo " make render-assembly VIEWS='iso_front top' # Single part, 2 views"
+ @echo " make assembly-exploded # Exploded assembly"
+ @echo " make stl-joystick_zone # Single STL export"
+ @echo " make renders IMG_WIDTH=3840 IMG_HEIGHT=2160 # 4K resolution"
diff --git a/hardware/transmitter/enclosure/README.md b/hardware/transmitter/enclosure/README.md
new file mode 100644
index 0000000..76fbe1a
--- /dev/null
+++ b/hardware/transmitter/enclosure/README.md
@@ -0,0 +1,125 @@
+# Transmitter Enclosure
+
+3D-printable modular enclosure for the OpenDriveHub transmitter, designed in
+[OpenSCAD](https://openscad.org/).
+
+---
+
+## Zone Architecture
+
+The enclosure is built from three zones arranged **horizontally** (front to
+back), connected via tongue-and-groove joints and M3 screws. As held by the
+user (body → away):
+
+```
+[Joystick Zone] → [Module Zone] → [Display Zone]
+ front middle rear
+```
+
+| # | Zone | File | Width | Depth |
+|---|------|------|-------|-------|
+| 1 | Joystick (2× D400-R4 + grips) | `joystick/joystick_zone.scad` | 195 mm | 70 mm |
+| 2 | Module grid (5×3 × 30 mm bays) | `modules/module_zone.scad` | 195 mm | 110 mm |
+| 3 | Display (ILI9341 2.8″ landscape) | `display/display_zone.scad` | 195 mm | 65 mm |
+
+All zones share the same **width (~195 mm)** and **height (~42.5 mm)**.
+Each zone fits on a **200×200 mm print bed** individually.
+
+Open `assembly.scad` in OpenSCAD to see all zones together. Set
+`explode = 30;` in `parameters.scad` for an exploded view.
+
+---
+
+## File Structure
+
+```
+enclosure/
+├── assembly.scad ← Full assembly view
+├── parameters.scad ← All dimensions (edit this!)
+├── utils.scad ← Helper modules + zone connectors
+├── joystick/
+│ └── joystick_zone.scad ← Joystick zone with grips + battery
+├── modules/
+│ └── module_zone.scad ← Module grid + electronics underneath
+├── display/
+│ └── display_zone.scad ← Display bezel (LCD landscape)
+└── renders/ ← Pre-rendered PNG images
+```
+
+---
+
+## Key Parameters
+
+All dimensions are defined in `parameters.scad`. Key values:
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `wall_thickness` | 2.5 mm | Outer wall thickness |
+| `zone_width` | ~195 mm | Uniform width for all zones |
+| `zone_height` | ~42.5 mm | Zone shell height |
+| `bay_unit_size` | 30 mm | Module bay size |
+| `bay_grid_cols` × `bay_grid_rows` | 5 × 3 | Module grid layout |
+| `joy_center_distance` | 125 mm | Joystick center-to-center |
+| `tongue_width` | 15 mm | Tongue-and-groove joint width |
+| `print_bed_x/y` | 200 mm | Max print bed dimensions |
+| `explode` | 0 | Exploded view distance |
+
+---
+
+## Bill of Materials (Hardware)
+
+| Qty | Item | Purpose |
+|-----|------|---------|
+| ~12 | M3×8 socket head cap screws | Zone-to-zone connection |
+| ~12 | M3×5×5 brass heat-set inserts | Threaded mounting points |
+| 15 | M3 screws (short) | Module retention (rear) |
+| 4 | M3 screws (various lengths) | Display PCB mounting |
+| 1 | Power switch (12×8 mm) | Battery on/off |
+| 1 | XT30 connector pair | Battery connection |
+
+---
+
+## Printing Guidelines
+
+- **Material:** PLA or PETG (PETG recommended for durability)
+- **Layer height:** 0.2 mm
+- **Infill:** 20–30% (gyroid or grid)
+- **Walls:** 3 perimeters minimum
+- **Supports:** Only needed for grip sections
+- **Print bed:** Each zone fits on 200×200 mm individually
+- **Orientation:** Print each zone upside-down (open top facing build plate)
+
+---
+
+## Assembly
+
+1. Print all three zones
+2. Install heat-set inserts into mounting points
+3. Mount ESP32 and TCA9548A into the module zone (underneath grid)
+4. Slide zones together via tongue-and-groove joints
+5. Secure with M3 screws through the joint edges
+6. Insert LCD into the display zone bezel
+7. Insert battery into the joystick zone compartment
+8. Mount joysticks from the top (panel-mount)
+
+---
+
+## Customization
+
+Edit `parameters.scad` to adapt the design:
+
+- **Different joysticks:** Adjust `joy_*` parameters
+- **Fewer/more module bays:** Change `bay_grid_cols` and `bay_grid_rows`
+- **Larger display:** Adjust `display_*` parameters
+- **Different battery:** Adjust `battery_*` parameters
+- **Thicker walls:** Increase `wall_thickness`
+
+After changing parameters, open `assembly.scad` to verify the design and
+check the console output for dimension/fit warnings.
+
+---
+
+## License
+
+This hardware design is licensed under GPL-3.0-or-later, consistent with the
+OpenDriveHub project. See [LICENSE](../../../LICENSE).
diff --git a/hardware/transmitter/enclosure/assembly.scad b/hardware/transmitter/enclosure/assembly.scad
new file mode 100644
index 0000000..4c2aef9
--- /dev/null
+++ b/hardware/transmitter/enclosure/assembly.scad
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2026 Peter Buchegger
+ *
+ * This file is part of OpenDriveHub.
+ *
+ * OpenDriveHub is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenDriveHub is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenDriveHub. If not, see .
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// =============================================================================
+// OpenDriveHub Transmitter Enclosure – Full Assembly
+// =============================================================================
+// Three zones arranged horizontally (front to back along Y axis):
+//
+// Front (body) Back
+// [Joystick Zone] → [Module Zone] → [Display Zone]
+// Y=0 Y=joy_d Y=joy_d+mod_d
+//
+// Set 'explode' in parameters.scad to spread zones apart for visibility.
+
+use
+use
+use
+
+include
+
+// Zone Y positions (front to back)
+y_joystick = 0;
+y_modules = joy_zone_depth + explode;
+y_display = joy_zone_depth + module_zone_depth + 2 * explode;
+
+module full_assembly() {
+ // Zone 1: Joystick Zone (front)
+ color(color_joystick)
+ translate([0, y_joystick, 0])
+ joystick_zone();
+
+ // Zone 2: Module Zone (middle)
+ color(color_modules)
+ translate([0, y_modules, 0])
+ module_zone();
+
+ // Zone 3: Display Zone (rear)
+ color(color_display)
+ translate([0, y_display, 0])
+ display_zone();
+}
+
+// Render
+full_assembly();
+
+// Dimension report
+echo(str("=== OpenDriveHub Transmitter Enclosure ==="));
+echo(str(" Zone width (uniform): ", zone_width, " mm"));
+echo(str(" Joystick zone depth: ", joy_zone_depth, " mm"));
+echo(str(" Module zone depth: ", module_zone_depth, " mm"));
+echo(str(" Display zone depth: ", display_zone_depth, " mm"));
+echo(str(" Total depth: ", total_depth, " mm"));
+echo(str(" Zone height: ", zone_height, " mm"));
+echo(str(" Module grid: ", bay_grid_cols, "x", bay_grid_rows,
+ " = ", bay_grid_width, "x", bay_grid_depth, " mm"));
+echo(str(" Print bed: ", print_bed_x, "x", print_bed_y, " mm"));
+echo(str(" Each zone fits bed: ",
+ zone_width <= print_bed_x && joy_zone_depth <= print_bed_y &&
+ module_zone_depth <= print_bed_y && display_zone_depth <= print_bed_y
+ ? "YES" : "NO"));
+echo(str(" Explode distance: ", explode, " mm"));
diff --git a/hardware/transmitter/enclosure/display/display_zone.scad b/hardware/transmitter/enclosure/display/display_zone.scad
new file mode 100644
index 0000000..22cab61
--- /dev/null
+++ b/hardware/transmitter/enclosure/display/display_zone.scad
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2026 Peter Buchegger
+ *
+ * This file is part of OpenDriveHub.
+ *
+ * OpenDriveHub is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenDriveHub is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenDriveHub. If not, see .
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// =============================================================================
+// OpenDriveHub Transmitter Enclosure – Display Zone
+// =============================================================================
+// Rear zone (farthest from the user's body). Contains the ILI9341 2.8" LCD
+// mounted in landscape orientation with a bezel/viewing window.
+//
+// Front edge has grooves to receive module zone tongues.
+
+use <../utils.scad>
+include <../parameters.scad>
+
+module display_zone() {
+ w = zone_width;
+ d = display_zone_depth;
+ h = zone_height;
+
+ // LCD position (centered on X and Y within this zone)
+ lcd_x = (w - display_pcb_width) / 2;
+ lcd_y = (d - display_pcb_depth) / 2;
+
+ // LCD sits near the top surface
+ lcd_z = h - display_module_thick - 2;
+
+ bezel_overlap = 2.0; // Bezel overlaps the viewing area
+
+ difference() {
+ union() {
+ // Zone shell
+ zone_shell(w, d, h);
+
+ // LCD support ledge (inside, around the LCD)
+ ledge_h = 1.5;
+ translate([lcd_x - 1, lcd_y - 1, lcd_z - ledge_h])
+ difference() {
+ cube([display_pcb_width + 2, display_pcb_depth + 2,
+ ledge_h]);
+ translate([2, 2, -0.1])
+ cube([display_pcb_width - 2, display_pcb_depth - 2,
+ ledge_h + 0.2]);
+ }
+
+ // Mounting posts at front edge (through-holes for module zone screws)
+ front_mounts = zone_mount_positions(w, d);
+ for (pos = front_mounts) {
+ translate([pos[0], pos[1], 0])
+ mounting_post_through(h - 1);
+ }
+ }
+
+ // Grooves on front edge (receive module zone tongues)
+ t_positions = tongue_positions(w);
+ for (tx = t_positions) {
+ groove(tx);
+ }
+
+ // LCD PCB pocket (slightly larger than PCB for insertion)
+ translate([lcd_x - fit_tolerance, lcd_y - fit_tolerance, lcd_z])
+ cube([display_pcb_width + 2 * fit_tolerance,
+ display_pcb_depth + 2 * fit_tolerance,
+ display_module_thick + 3]);
+
+ // Display viewing window (through the top wall, reduced by bezel
+ // overlap so the LCD is held in place by the frame)
+ translate([lcd_x + display_view_offset_x + bezel_overlap,
+ lcd_y + display_view_offset_y + bezel_overlap,
+ -0.1])
+ cube([display_view_width - 2 * bezel_overlap,
+ display_view_depth - 2 * bezel_overlap,
+ h + 0.2]);
+
+ // LCD mounting screw holes
+ hole_positions = [
+ [lcd_x + display_mount_inset,
+ lcd_y + display_mount_inset],
+ [lcd_x + display_pcb_width - display_mount_inset,
+ lcd_y + display_mount_inset],
+ [lcd_x + display_mount_inset,
+ lcd_y + display_pcb_depth - display_mount_inset],
+ [lcd_x + display_pcb_width - display_mount_inset,
+ lcd_y + display_pcb_depth - display_mount_inset],
+ ];
+ for (pos = hole_positions) {
+ translate([pos[0], pos[1], lcd_z - 5])
+ cylinder(d = display_mount_hole_dia, h = 10);
+ }
+
+ // Cable slot at front wall
+ translate([w / 2, 0, h / 2])
+ cable_slot();
+ }
+}
+
+// LCD placeholder for visualization
+module _lcd_placeholder() {
+ // PCB
+ color([0.1, 0.3, 0.6, 0.8])
+ cube([display_pcb_width, display_pcb_depth, display_pcb_thick]);
+ // Active display area
+ color([0.05, 0.05, 0.05])
+ translate([display_view_offset_x, display_view_offset_y,
+ display_pcb_thick])
+ cube([display_view_width, display_view_depth, 1.5]);
+}
+
+// Preview
+display_zone();
+
+if (show_components) {
+ w = zone_width;
+ d = display_zone_depth;
+ lcd_x = (w - display_pcb_width) / 2;
+ lcd_y = (d - display_pcb_depth) / 2;
+ lcd_z = zone_height - display_module_thick - 2;
+
+ translate([lcd_x, lcd_y, lcd_z])
+ %_lcd_placeholder();
+}
diff --git a/hardware/transmitter/enclosure/joystick/joystick_zone.scad b/hardware/transmitter/enclosure/joystick/joystick_zone.scad
new file mode 100644
index 0000000..418ca28
--- /dev/null
+++ b/hardware/transmitter/enclosure/joystick/joystick_zone.scad
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) 2026 Peter Buchegger
+ *
+ * This file is part of OpenDriveHub.
+ *
+ * OpenDriveHub is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenDriveHub is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenDriveHub. If not, see .
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// =============================================================================
+// OpenDriveHub Transmitter Enclosure – Joystick Zone
+// =============================================================================
+// Front zone (closest to the user's body). Contains two D400-R4 joysticks
+// with ergonomic grips on the left and right sides.
+// Battery compartment is integrated underneath.
+//
+// Rear edge has tongues for connecting to the module zone.
+
+use <../utils.scad>
+include <../parameters.scad>
+
+module joystick_zone() {
+ w = zone_width;
+ d = joy_zone_depth;
+ h = zone_height;
+
+ // Joystick positions (centered on X, centered on Y within this zone)
+ joy_y = d / 2;
+ joy_left_x = w / 2 - joy_center_distance / 2;
+ joy_right_x = w / 2 + joy_center_distance / 2;
+
+ difference() {
+ union() {
+ // Zone shell (open-top box)
+ zone_shell(w, d, h);
+
+ // Tongues on rear edge (connect to module zone)
+ t_positions = tongue_positions(w);
+ for (tx = t_positions) {
+ translate([0, d, 0])
+ tongue(tx);
+ }
+
+ // Grips (hanging down from sides)
+ // Left grip
+ translate([0, d * 0.15, 0])
+ _grip_shape(false);
+ // Right grip
+ translate([w, d * 0.15, 0])
+ _grip_shape(true);
+
+ // Internal reinforcement around joystick holes
+ for (jx = [joy_left_x, joy_right_x]) {
+ translate([jx, joy_y, wall_thickness])
+ difference() {
+ cylinder(d = joy_panel_cutout + 8, h = 4);
+ translate([0, 0, -0.1])
+ cylinder(d = joy_panel_cutout, h = 4.2);
+ }
+ }
+
+ // Mounting posts at rear edge (heat-set inserts for module zone)
+ mount_positions = zone_mount_positions(w, d);
+ for (pos = mount_positions) {
+ translate([pos[0], pos[1], 0])
+ mounting_post(h - 1);
+ }
+ }
+
+ // Joystick panel cutouts (through the top wall)
+ for (jx = [joy_left_x, joy_right_x]) {
+ // Shaft hole through top
+ translate([jx, joy_y, -0.1])
+ cylinder(d = joy_panel_cutout, h = h + 0.2);
+
+ // Mounting screw holes
+ for (i = [0:3]) {
+ angle = i * 90 + 45;
+ translate([
+ jx + cos(angle) * joy_mounting_hole_pcd / 2,
+ joy_y + sin(angle) * joy_mounting_hole_pcd / 2,
+ -0.1
+ ])
+ cylinder(d = joy_mounting_hole_dia, h = h + 0.2);
+ }
+ }
+
+ // Battery compartment cavity (underneath, accessible from bottom/side)
+ bat_cavity_w = battery_width + 2 * battery_clearance;
+ bat_cavity_l = battery_length + 2 * battery_clearance;
+ bat_cavity_h = battery_height + battery_clearance;
+ translate([w / 2 - bat_cavity_w / 2,
+ d / 2 - bat_cavity_l / 2,
+ wall_thickness])
+ cube([bat_cavity_w, bat_cavity_l, bat_cavity_h]);
+
+ // Battery access opening (bottom)
+ translate([w / 2 - battery_width / 2,
+ d / 2 - battery_length / 2,
+ -0.1])
+ cube([battery_width, battery_length, wall_thickness + 0.2]);
+
+ // Cable slot at rear wall (to module zone)
+ translate([w / 2, d, h / 2])
+ cable_slot();
+
+ // Side cable slots
+ for (x = [-0.1, w - wall_thickness]) {
+ translate([x, d / 2 - cable_slot_width / 2, h / 2])
+ cube([wall_thickness + 0.2, cable_slot_width, cable_slot_height]);
+ }
+ }
+}
+
+// Grip shape: extends downward from the zone side
+module _grip_shape(is_right) {
+ mirror_x = is_right ? 1 : 0;
+ mirror([mirror_x, 0, 0])
+ translate([0, 0, 0]) {
+ hull() {
+ // Top attachment (flush with zone side)
+ translate([-wall_thickness, 0, -5])
+ cube([wall_thickness, grip_depth, 10]);
+
+ // Bottom grip section
+ translate([-grip_width, grip_depth * 0.1, -grip_length + 10])
+ rounded_cube_3d([grip_width, grip_depth * 0.8, 15],
+ grip_fillet);
+ }
+ }
+}
+
+// Joystick placeholder for visualization
+module _joy_placeholder() {
+ // Shaft above panel
+ color([0.2, 0.2, 0.2])
+ cylinder(d = 8, h = joy_shaft_height);
+ color([0.1, 0.1, 0.1])
+ translate([0, 0, joy_shaft_height - 15])
+ sphere(d = 20);
+ // Body below panel (inside the zone)
+ color([0.5, 0.5, 0.5, 0.5])
+ translate([0, 0, -joy_body_height])
+ cylinder(d = joy_body_width * 0.8, h = joy_body_height);
+}
+
+// Battery cover: slides or clips into the bottom opening of the joystick zone.
+// Print this part separately.
+module battery_cover() {
+ cover_tol = 0.3; // Fit tolerance on each side
+ lip = 1.5; // Lip that sits inside the cavity to retain the cover
+ cover_w = battery_width - 2 * cover_tol;
+ cover_l = battery_length - 2 * cover_tol;
+ cover_h = wall_thickness;
+
+ difference() {
+ union() {
+ // Main plate (flush with bottom of joystick zone)
+ rounded_cube([cover_w, cover_l, cover_h], corner_radius);
+
+ // Retention lip (sits inside the battery opening)
+ translate([lip, lip, cover_h])
+ cube([cover_w - 2 * lip, cover_l - 2 * lip, lip]);
+ }
+
+ // Finger-pull recess for easy removal
+ translate([cover_w / 2, cover_l / 2, -0.1])
+ cylinder(d = 15, h = cover_h * 0.6);
+ }
+}
+
+// Preview
+joystick_zone();
+
+if (show_components) {
+ w = zone_width;
+ d = joy_zone_depth;
+ joy_y = d / 2;
+ joy_left_x = w / 2 - joy_center_distance / 2;
+ joy_right_x = w / 2 + joy_center_distance / 2;
+
+ translate([joy_left_x, joy_y, zone_height])
+ %_joy_placeholder();
+ translate([joy_right_x, joy_y, zone_height])
+ %_joy_placeholder();
+}
diff --git a/hardware/transmitter/enclosure/modules/module_zone.scad b/hardware/transmitter/enclosure/modules/module_zone.scad
new file mode 100644
index 0000000..6b3155b
--- /dev/null
+++ b/hardware/transmitter/enclosure/modules/module_zone.scad
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2026 Peter Buchegger
+ *
+ * This file is part of OpenDriveHub.
+ *
+ * OpenDriveHub is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenDriveHub is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenDriveHub. If not, see .
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// =============================================================================
+// OpenDriveHub Transmitter Enclosure – Module Zone
+// =============================================================================
+// Middle zone containing the 5×3 module grid (30×30mm bays).
+// Modules are inserted from the top, secured with screws from behind.
+// Electronics (ESP32 + TCA9548A mux) are mounted underneath the grid.
+//
+// Front edge has grooves to receive joystick zone tongues.
+// Rear edge has tongues for connecting to the display zone.
+
+use <../utils.scad>
+include <../parameters.scad>
+
+module module_zone() {
+ w = zone_width;
+ d = module_zone_depth;
+ h = zone_height;
+
+ // Grid position (centered on X, centered on Y)
+ grid_w = bay_grid_width;
+ grid_d = bay_grid_depth;
+ grid_x = (w - grid_w) / 2;
+ grid_y = (d - grid_d) / 2;
+
+ difference() {
+ union() {
+ // Zone shell
+ zone_shell(w, d, h);
+
+ // Module grid structure (raised from floor)
+ translate([grid_x, grid_y, 0])
+ _module_grid_structure(grid_w, grid_d);
+
+ // Tongues on rear edge (connect to display zone)
+ t_positions = tongue_positions(w);
+ for (tx = t_positions) {
+ translate([0, d, 0])
+ tongue(tx);
+ }
+
+ // Mounting posts at front edge (through-holes for joystick zone screws)
+ front_mounts = zone_mount_positions(w, d);
+ for (pos = front_mounts) {
+ translate([pos[0], pos[1], 0])
+ mounting_post_through(h - 1);
+ }
+
+ // Mounting posts at rear edge (heat-set inserts for display zone)
+ rear_mounts = zone_mount_positions(w, d);
+ for (pos = rear_mounts) {
+ translate([pos[0], d - pos[1], 0])
+ mounting_post(h - 1);
+ }
+ }
+
+ // Grooves on front edge (receive joystick zone tongues)
+ t_positions = tongue_positions(w);
+ for (tx = t_positions) {
+ groove(tx);
+ }
+
+ // Cable slots front and rear walls
+ translate([w / 2, 0, h / 2])
+ cable_slot();
+ translate([w / 2, d, h / 2])
+ cable_slot();
+
+ // USB port opening (side wall, for ESP32 access)
+ usb_x = w - wall_thickness - 0.1;
+ translate([usb_x, d / 2 - esp32_usb_width / 2 - 2,
+ wall_thickness + 3])
+ cube([wall_thickness + 0.2, esp32_usb_width + 4,
+ esp32_usb_height + 2]);
+ }
+
+ // ESP32 mount (underneath grid, on the floor)
+ esp32_x = grid_x + grid_w / 2 - esp32_pcb_width / 2;
+ esp32_y = grid_y + 5;
+ translate([esp32_x, esp32_y, wall_thickness])
+ _esp32_standoffs();
+
+ // TCA9548A mount (next to ESP32)
+ mux_x = grid_x + grid_w / 2 - mux_pcb_width / 2;
+ mux_y = grid_y + grid_d - mux_pcb_length - 5;
+ translate([mux_x, mux_y, wall_thickness])
+ _mux_standoffs();
+}
+
+// Module grid: walls forming the 5×3 bay layout
+module _module_grid_structure(grid_w, grid_d) {
+ bay_pitch = bay_unit_size + bay_wall;
+ grid_h = bay_depth + wall_thickness;
+
+ // Grid sits at the top of the zone (bays open upward)
+ z_offset = zone_height - grid_h;
+
+ translate([0, 0, z_offset]) {
+ difference() {
+ // Solid grid block
+ cube([grid_w, grid_d, grid_h]);
+
+ // Bay openings (from top)
+ for (col = [0:bay_grid_cols - 1]) {
+ for (row = [0:bay_grid_rows - 1]) {
+ x = bay_wall + col * bay_pitch;
+ y = bay_wall + row * bay_pitch;
+ translate([x, y, wall_thickness])
+ cube([bay_unit_size, bay_unit_size, bay_depth + 0.1]);
+ }
+ }
+ }
+
+ // Screw bosses for module retention (rear face of each bay)
+ for (col = [0:bay_grid_cols - 1]) {
+ for (row = [0:bay_grid_rows - 1]) {
+ x = bay_wall + col * bay_pitch + bay_unit_size / 2;
+ y = bay_wall + (row + 1) * bay_pitch - bay_wall / 2;
+ translate([x, y, wall_thickness + bay_depth / 2])
+ rotate([-90, 0, 0])
+ difference() {
+ cylinder(d = m3_screw_dia + 4, h = bay_wall);
+ translate([0, 0, -0.1])
+ cylinder(d = m3_screw_dia,
+ h = bay_wall + 0.2);
+ }
+ }
+ }
+ }
+}
+
+// Simple standoffs for ESP32
+module _esp32_standoffs() {
+ standoff_h = 3;
+ for (x = [2, esp32_pcb_width - 2])
+ for (y = [2, esp32_pcb_length - 2])
+ translate([x, y, 0])
+ cylinder(d = 4, h = standoff_h);
+}
+
+// Simple standoffs for TCA9548A mux
+module _mux_standoffs() {
+ standoff_h = 3;
+ for (x = [2, mux_pcb_width - 2])
+ for (y = [2, mux_pcb_length - 2])
+ translate([x, y, 0])
+ cylinder(d = 4, h = standoff_h);
+}
+
+// Preview
+module_zone();
diff --git a/hardware/transmitter/enclosure/parameters.scad b/hardware/transmitter/enclosure/parameters.scad
new file mode 100644
index 0000000..115770f
--- /dev/null
+++ b/hardware/transmitter/enclosure/parameters.scad
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2026 Peter Buchegger
+ *
+ * This file is part of OpenDriveHub.
+ *
+ * OpenDriveHub is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenDriveHub is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenDriveHub. If not, see .
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// =============================================================================
+// OpenDriveHub Transmitter Enclosure – Central Parameters
+// =============================================================================
+// All dimensions in millimeters. Change values here to adjust the entire design.
+//
+// LAYOUT: Three zones arranged horizontally (front to back, Y axis):
+// [Joystick Zone] → [Module Zone] → [Display Zone]
+// All zones share the same width (X). Each zone fits on the print bed.
+
+// --- Print Bed Constraints ---------------------------------------------------
+print_bed_x = 200;
+print_bed_y = 200;
+
+// --- General Construction ----------------------------------------------------
+wall_thickness = 2.5; // Outer wall thickness
+inner_wall = 1.6; // Internal divider walls
+corner_radius = 3.0; // Outer corner rounding
+fit_tolerance = 0.3; // General 3D-print fit tolerance
+
+// --- Screw & Fastener Dimensions ---------------------------------------------
+// M3 Heat-Set Insert (standard M3×5×5 brass insert)
+m3_screw_dia = 3.2; // M3 clearance hole
+m3_screw_head_dia = 5.8; // M3 socket head cap screw head
+m3_screw_head_h = 3.0; // M3 head height
+m3_insert_dia = 4.6; // Heat-set insert outer diameter
+m3_insert_depth = 5.0; // Heat-set insert depth
+m3_insert_hole_dia = 4.8; // Hole diameter for heat-set insert (slightly larger)
+
+// --- Zone Connection (tongue-and-groove between zones) -----------------------
+tongue_width = 15.0; // Width of each tongue
+tongue_height = 8.0; // Height of tongue (Z)
+tongue_depth = 5.0; // How far the tongue protrudes (Y)
+tongue_tolerance = 0.3; // Gap for fit
+tongue_count = 3; // Number of tongues per joint edge
+
+// --- Joystick D400-R4 --------------------------------------------------------
+// JH-D400X-R4 3-axis joystick (panel-mount)
+joy_body_width = 49.6; // Joystick body width (X)
+joy_body_length = 94.5; // Joystick body length (Y)
+joy_body_height = 35.0; // Body height below panel
+joy_shaft_height = 40.0; // Shaft height above panel
+joy_panel_cutout = 28.0; // Circular panel cutout diameter
+joy_mounting_dia = 36.0; // Mounting ring outer diameter
+joy_mounting_hole_dia = 3.2; // Mounting screw holes
+joy_mounting_hole_pcd = 40.0; // Mounting hole pitch circle diameter
+
+// Ergonomic spacing
+joy_center_distance = 125; // Center-to-center distance between joysticks
+
+// --- Module Bay --------------------------------------------------------------
+bay_unit_size = 30.0; // Single bay unit (30×30mm)
+bay_depth = 25.0; // Bay depth (how deep the module sits)
+bay_wall = 1.2; // Wall between adjacent bays
+bay_screw_inset = 4.0; // Screw hole distance from bay edge (rear mounting)
+
+// Grid configuration
+bay_grid_cols = 5; // Number of columns (along X)
+bay_grid_rows = 3; // Number of rows (along Y)
+
+// Computed grid dimensions
+bay_grid_width = bay_grid_cols * bay_unit_size + (bay_grid_cols + 1) * bay_wall;
+bay_grid_depth = bay_grid_rows * bay_unit_size + (bay_grid_rows + 1) * bay_wall;
+
+// --- Display (ILI9341 2.8" TFT LCD, mounted LANDSCAPE) ----------------------
+// In landscape orientation: long edge = X (width), short edge = Y (depth)
+display_pcb_width = 86.0; // PCB along X (landscape)
+display_pcb_depth = 50.0; // PCB along Y (landscape)
+display_pcb_thick = 1.6; // PCB thickness
+display_module_thick = 5.0; // Total module thickness (PCB + components)
+
+display_view_width = 57.6; // Visible area along X (landscape)
+display_view_depth = 43.2; // Visible area along Y (landscape)
+display_view_offset_x = 5.0; // View area offset from PCB left edge
+display_view_offset_y = 3.4; // View area offset from PCB front edge
+
+display_mount_hole_dia = 3.0; // Mounting hole diameter
+display_mount_inset = 2.5; // Mounting hole distance from PCB edge
+
+// --- ESP32 DevKitC (38-pin) --------------------------------------------------
+esp32_pcb_width = 28.0;
+esp32_pcb_length = 55.0;
+esp32_pcb_thick = 1.6;
+esp32_total_height = 10.0; // Including components underneath
+esp32_usb_width = 8.0; // Micro-USB port width
+esp32_usb_height = 3.5; // Micro-USB port height
+
+// --- TCA9548A I²C Mux Breakout -----------------------------------------------
+mux_pcb_width = 18.0;
+mux_pcb_length = 25.0;
+mux_pcb_thick = 1.6;
+
+// --- Battery (2S LiPo) ------------------------------------------------------
+battery_width = 35.0; // Typical 2S 1000-1500mAh LiPo
+battery_length = 68.0;
+battery_height = 18.0;
+battery_clearance = 2.0; // Extra space around battery
+
+// --- Ergonomic Grip ----------------------------------------------------------
+grip_length = 80.0; // Grip length (Z, downward from joystick zone)
+grip_width = 35.0; // Grip cross-section width
+grip_depth = 30.0; // Grip cross-section depth
+grip_fillet = 8.0; // Grip edge rounding
+
+// --- Zone Dimensions ---------------------------------------------------------
+// Uniform width for all zones (X axis) – driven by the widest content
+zone_width = max(
+ joy_center_distance + joy_body_width + 20, // Joystick spacing + margin
+ bay_grid_width + 20, // Module grid + margin
+ display_pcb_width + 20 // Display PCB + margin
+);
+
+// Zone heights (Z axis) – the enclosure shell height
+zone_height = joy_body_height + wall_thickness + 5; // ~42.5mm
+
+// Zone depths (Y axis, front-to-back for each zone)
+joy_zone_depth = joy_body_width + 20; // ~70mm
+module_zone_depth = bay_grid_depth + 15; // ~110mm
+display_zone_depth = display_pcb_depth + 15; // ~65mm
+
+// Total transmitter depth
+total_depth = joy_zone_depth + module_zone_depth + display_zone_depth;
+
+// --- Mounting Posts -----------------------------------------------------------
+mount_post_dia = 8.0; // Outer diameter of mounting post
+mount_post_inset_x = 8.0; // Inset from zone edge (X)
+mount_post_inset_y = 8.0; // Inset from zone edge (Y)
+
+// --- Alignment Pins ----------------------------------------------------------
+alignment_pin_dia = 3.0; // Diameter of alignment pins
+alignment_pin_height = 4.0; // Height of alignment pins
+alignment_hole_dia = 3.4; // Diameter of alignment holes (with tolerance)
+alignment_hole_depth = 4.5; // Depth of alignment holes
+
+// --- Cable Routing -----------------------------------------------------------
+cable_slot_width = 10.0; // Width of cable routing slots between zones
+cable_slot_height = 4.0; // Height of cable routing slots
+
+// --- Colors (for assembly visualization) -------------------------------------
+color_joystick = [0.2, 0.5, 0.8, 0.9];
+color_modules = [0.8, 0.5, 0.2, 0.9];
+color_display = [0.2, 0.8, 0.3, 0.9];
+color_electronics = [0.6, 0.2, 0.6, 0.9];
+color_battery = [0.8, 0.2, 0.2, 0.9];
+color_grip = [0.4, 0.4, 0.4, 0.9];
+
+// --- Debug / Visualization ---------------------------------------------------
+$fn = 60; // Default facet count for curves
+explode = 0; // Explosion distance (0 = assembled, >0 = exploded view)
+show_components = true; // Show placeholder components (joysticks, display, etc.)
diff --git a/hardware/transmitter/enclosure/utils.scad b/hardware/transmitter/enclosure/utils.scad
new file mode 100644
index 0000000..83d37a9
--- /dev/null
+++ b/hardware/transmitter/enclosure/utils.scad
@@ -0,0 +1,215 @@
+/*
+ * Copyright (C) 2026 Peter Buchegger
+ *
+ * This file is part of OpenDriveHub.
+ *
+ * OpenDriveHub is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenDriveHub is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenDriveHub. If not, see .
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// =============================================================================
+// OpenDriveHub Transmitter Enclosure – Utility Modules
+// =============================================================================
+
+include
+
+// --- Rounded Cube ------------------------------------------------------------
+// A cube with rounded edges on the XY plane.
+// size: [x, y, z], radius: corner radius
+module rounded_cube(size, radius) {
+ r = min(radius, min(size[0], size[1]) / 2);
+ hull() {
+ for (x = [r, size[0] - r])
+ for (y = [r, size[1] - r])
+ translate([x, y, 0])
+ cylinder(h = size[2], r = r);
+ }
+}
+
+// --- Fully Rounded Cube (all edges) ------------------------------------------
+module rounded_cube_3d(size, radius) {
+ r = min(radius, min(size[0], min(size[1], size[2])) / 2);
+ hull() {
+ for (x = [r, size[0] - r])
+ for (y = [r, size[1] - r])
+ for (z = [r, size[2] - r])
+ translate([x, y, z])
+ sphere(r = r);
+ }
+}
+
+// --- Screw Clearance Hole ----------------------------------------------------
+// Vertical through-hole for a screw (clearance fit).
+// dia: screw diameter, depth: hole depth, head_dia/head_h: counterbore
+module screw_hole(dia, depth, head_dia = 0, head_h = 0) {
+ cylinder(d = dia, h = depth);
+ if (head_dia > 0 && head_h > 0) {
+ translate([0, 0, depth - head_h])
+ cylinder(d = head_dia, h = head_h + 0.1);
+ }
+}
+
+// --- M3 Clearance Hole (convenience) -----------------------------------------
+module m3_clearance_hole(depth) {
+ screw_hole(
+ dia = m3_screw_dia,
+ depth = depth,
+ head_dia = m3_screw_head_dia,
+ head_h = m3_screw_head_h
+ );
+}
+
+// --- Heat-Set Insert Hole ----------------------------------------------------
+// Hole designed for pressing in a brass heat-set insert from the top.
+module heat_insert_hole(dia = m3_insert_hole_dia, depth = m3_insert_depth) {
+ // Slightly tapered entry for easier insertion
+ cylinder(d1 = dia + 0.4, d2 = dia, h = 0.8);
+ cylinder(d = dia, h = depth);
+}
+
+// --- Mounting Post -----------------------------------------------------------
+// Cylindrical post with a heat-set insert hole at the top.
+module mounting_post(height, outer_dia = mount_post_dia,
+ insert_dia = m3_insert_hole_dia,
+ insert_depth = m3_insert_depth) {
+ difference() {
+ cylinder(d = outer_dia, h = height);
+ translate([0, 0, height - insert_depth])
+ heat_insert_hole(dia = insert_dia, depth = insert_depth + 0.1);
+ }
+}
+
+// --- Mounting Post with Through-Hole -----------------------------------------
+// Post with a clearance hole going all the way through (for bolting layers).
+module mounting_post_through(height, outer_dia = mount_post_dia,
+ hole_dia = m3_screw_dia) {
+ difference() {
+ cylinder(d = outer_dia, h = height);
+ translate([0, 0, -0.1])
+ cylinder(d = hole_dia, h = height + 0.2);
+ }
+}
+
+// --- Alignment Pin -----------------------------------------------------------
+module alignment_pin(dia = alignment_pin_dia, height = alignment_pin_height) {
+ // Chamfered tip for easier insertion
+ cylinder(d = dia, h = height - 1);
+ translate([0, 0, height - 1])
+ cylinder(d1 = dia, d2 = dia - 1, h = 1);
+}
+
+// --- Alignment Hole ----------------------------------------------------------
+module alignment_hole(dia = alignment_hole_dia, depth = alignment_hole_depth) {
+ // Chamfered entry
+ cylinder(d1 = dia + 0.5, d2 = dia, h = 0.5);
+ cylinder(d = dia, h = depth);
+}
+
+// --- Cable Slot --------------------------------------------------------------
+// Rectangular slot for routing cables between layers.
+module cable_slot(width = cable_slot_width, height = cable_slot_height,
+ wall = wall_thickness) {
+ translate([-width / 2, -wall / 2 - 0.1, 0])
+ cube([width, wall + 0.2, height]);
+}
+
+// --- Rubber Foot Recess ------------------------------------------------------
+module rubber_foot_recess(dia = 10, depth = 1.5) {
+ cylinder(d = dia, h = depth);
+}
+
+// --- Plate with Mounting Holes -----------------------------------------------
+// Generates mounting hole positions for a rectangular plate.
+// Returns a list of [x, y] positions for use with for() loops.
+// Usage: for (pos = plate_mount_positions(w, d)) translate(pos) ...
+function plate_mount_positions(width, depth,
+ inset_x = mount_post_inset_x,
+ inset_y = mount_post_inset_y) =
+ [
+ [inset_x, inset_y], // Front-left
+ [width - inset_x, inset_y], // Front-right
+ [inset_x, depth - inset_y], // Rear-left
+ [width - inset_x, depth - inset_y], // Rear-right
+ [width / 2, inset_y], // Front-center
+ [width / 2, depth - inset_y], // Rear-center
+ ];
+
+// --- Text Label (debossed) ---------------------------------------------------
+module text_label(txt, size = 5, depth = 0.4) {
+ linear_extrude(height = depth)
+ text(txt, size = size, halign = "center", valign = "center",
+ font = "Liberation Sans:style=Bold");
+}
+
+// --- Fillet (2D profile for hull operations) ---------------------------------
+module fillet_2d(r) {
+ offset(r = r) offset(delta = -r) children();
+}
+
+// --- Mirror Copy (creates original + mirrored copy) --------------------------
+module mirror_copy(axis = [1, 0, 0]) {
+ children();
+ mirror(axis) children();
+}
+
+// --- Tongue (male half of tongue-and-groove joint) ---------------------------
+// Placed on the rear edge of a zone (protrudes in +Y).
+// x_pos: X position of tongue center on the zone edge.
+module tongue(x_pos, z_pos = wall_thickness, w = tongue_width,
+ h = tongue_height, d = tongue_depth) {
+ translate([x_pos - w / 2, 0, z_pos])
+ cube([w, d, h]);
+}
+
+// --- Groove (female half of tongue-and-groove joint) -------------------------
+// Cut into the front edge of the next zone (receives tongue from -Y).
+// x_pos: X position of groove center on the zone edge.
+module groove(x_pos, z_pos = wall_thickness, w = tongue_width,
+ h = tongue_height, d = tongue_depth,
+ tol = tongue_tolerance) {
+ translate([x_pos - w / 2 - tol, -0.1, z_pos - tol])
+ cube([w + 2 * tol, d + tol + 0.1, h + 2 * tol]);
+}
+
+// --- Tongue positions for a given zone width ---------------------------------
+// Evenly distributes tongue_count tongues across the width.
+function tongue_positions(width, count = tongue_count) =
+ [for (i = [0:count - 1]) width / (count + 1) * (i + 1)];
+
+// --- Zone Shell (open-top box) -----------------------------------------------
+// Creates a box shell for a zone: walls + floor, open on top.
+module zone_shell(width, depth, height, radius = corner_radius) {
+ difference() {
+ rounded_cube([width, depth, height], radius);
+ translate([wall_thickness, wall_thickness, wall_thickness])
+ rounded_cube([width - 2 * wall_thickness,
+ depth - 2 * wall_thickness,
+ height + 0.1],
+ max(0.1, radius - wall_thickness));
+ }
+}
+
+// --- Zone Screw Bosses -------------------------------------------------------
+// Mounting bosses at corners and mid-points for screwing zones together or
+// attaching top panels.
+function zone_mount_positions(width, depth,
+ inset_x = mount_post_inset_x,
+ inset_y = mount_post_inset_y) =
+ [
+ [inset_x, inset_y],
+ [width - inset_x, inset_y],
+ [inset_x, depth - inset_y],
+ [width - inset_x, depth - inset_y],
+ ];