diff --git a/hardware/wonderswan-cartridge-organizer/README.md b/hardware/wonderswan-cartridge-organizer/README.md new file mode 100644 index 0000000..e7894b3 --- /dev/null +++ b/hardware/wonderswan-cartridge-organizer/README.md @@ -0,0 +1,68 @@ +# WonderSwan 3×3 cartridge organizer + +This is a support-free, stepped display organizer for nine standard Bandai +WonderSwan cartridges. Three continuous rails hold three cartridges each, and +low divider ribs keep the cartridges aligned without covering their labels. + +## Researched cartridge envelope + +The model uses a nominal cartridge envelope of **65.2 mm wide × 41.8 mm high × +6.0 mm thick**. The dimensions come from a Japanese WonderSwan hardware listing +that specifies the cassette as 41.8 × 65.2 × 6.0 mm. The Ritsumeikan Center for +Game Studies independently catalogs a physical WonderSwan cartridge at 4.1 × +6.6 × 0.6 cm. + +- [Media World WonderSwan specifications](https://mediaworld.co.jp/products/10011140001) +- [RCGS physical-package record](https://collection.rcgs.jp/page/PACKAGE0005000) + +The 6.0 mm thickness is the critical fit dimension. The default rail is 6.8 mm +wide, providing 0.4 mm nominal clearance on each face. Because vintage shells, +reproduction shells, and individual FDM printers can vary, print the clearance +coupon before printing the full organizer. + +## Included files + +- `wonderswan-organizer-3x3.stl` — ready-to-slice nine-cartridge organizer +- `wonderswan-slot-clearance-test.stl` — small fit test with 6.4, 6.8, and + 7.2 mm channels, ordered from front to back +- `wonderswan-organizer.scad` — editable OpenSCAD source +- `generate.py` — parameterized STL and preview generator +- `wonderswan-organizer-preview.png` — rendered scale preview with cartridge + placeholders + +Default organizer dimensions: **207.6 × 57.8 × 47.0 mm**. It fits diagonally or +straight on most nominal 220 × 220 mm beds; check the slicer's printable-area +margin before starting the job. + +## First test and printing + +1. Print `wonderswan-slot-clearance-test.stl` base-down. +2. Try an expendable cartridge in the three channels. They are 6.4, 6.8, and + 7.2 mm from the coupon's front edge to its back edge. +3. Use the narrowest channel that inserts smoothly without scraping or bowing + the walls. +4. If 6.8 mm fits, print the supplied organizer STL. Otherwise change + `slot_width` in the OpenSCAD file or regenerate the STL with `--slot-width`. + +Suggested starting settings: + +- PLA or PETG +- 0.20 mm layer height +- 3 perimeters +- 15% gyroid or grid infill +- Base flat on the build plate +- No supports +- A 5–8 mm brim if the printer tends to lift long parts + +## Regenerating the STL + +The OpenSCAD source is the simplest route. For scripted generation: + +```sh +python3 -m venv .venv +.venv/bin/pip install trimesh shapely mapbox-earcut manifold3d matplotlib +.venv/bin/python generate.py --columns 3 --rows 3 --slot-width 6.8 +``` + +Changing the number of columns changes the bed-width requirement. For a common +220 mm bed, three columns is the practical maximum at full cartridge width. diff --git a/hardware/wonderswan-cartridge-organizer/generate.py b/hardware/wonderswan-cartridge-organizer/generate.py new file mode 100644 index 0000000..4679b09 --- /dev/null +++ b/hardware/wonderswan-cartridge-organizer/generate.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Generate print-ready WonderSwan cartridge organizer meshes. + +Dependencies: + python3 -m pip install trimesh shapely mapbox-earcut manifold3d matplotlib +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import trimesh +from shapely.geometry import Polygon, box as rectangle +from shapely.ops import unary_union + + +@dataclass(frozen=True) +class OrganizerParameters: + cartridge_width: float = 65.2 + cartridge_height: float = 41.8 + cartridge_thickness: float = 6.0 + slot_width: float = 6.8 + columns: int = 3 + rows: int = 3 + column_gap: float = 2.0 + side_margin: float = 4.0 + base_thickness: float = 3.0 + wall_thickness: float = 3.0 + floor_thickness: float = 3.0 + front_lip_height: float = 7.0 + back_support_height: float = 18.0 + row_pitch: float = 20.0 + row_rise: float = 13.0 + rear_margin: float = 5.0 + divider_thickness: float = 1.2 + divider_height: float = 6.0 + + @property + def width(self) -> float: + return ( + self.columns * self.cartridge_width + + (self.columns - 1) * self.column_gap + + 2 * self.side_margin + ) + + @property + def depth(self) -> float: + return ( + (self.rows - 1) * self.row_pitch + + self.wall_thickness + + self.slot_width + + self.wall_thickness + + self.rear_margin + ) + + @property + def height(self) -> float: + last_floor = self.base_thickness + (self.rows - 1) * self.row_rise + return last_floor + self.back_support_height + + +def extrude_cross_section(profile, width: float) -> trimesh.Trimesh: + """Extrude a Y/Z profile along world X.""" + mesh = trimesh.creation.extrude_polygon(profile, height=width) + local_to_world = np.array( + [ + [0.0, 0.0, 1.0, 0.0], + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + ) + mesh.apply_transform(local_to_world) + return mesh + + +def bounded_box( + x0: float, + x1: float, + y0: float, + y1: float, + z0: float, + z1: float, +) -> trimesh.Trimesh: + extents = np.array([x1 - x0, y1 - y0, z1 - z0]) + center = np.array([(x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2]) + return trimesh.creation.box( + extents=extents, + transform=trimesh.transformations.translation_matrix(center), + ) + + +def organizer_profile(parameters: OrganizerParameters): + p = parameters + pieces = [rectangle(0, 0, p.depth, p.base_thickness)] + + for row in range(p.rows): + front_y = row * p.row_pitch + slot_front = front_y + p.wall_thickness + back_y = slot_front + p.slot_width + floor_z = p.base_thickness + row * p.row_rise + + if row > 0: + shelf_bottom = floor_z - p.floor_thickness + pieces.append( + rectangle(front_y, shelf_bottom, back_y + p.wall_thickness, floor_z) + ) + lower_support_z = max(p.base_thickness, shelf_bottom - p.row_rise + p.floor_thickness) + pieces.append( + Polygon( + [ + (front_y, shelf_bottom), + (back_y, shelf_bottom), + (back_y, lower_support_z), + ] + ) + ) + + pieces.append( + rectangle( + front_y, + floor_z, + slot_front, + floor_z + p.front_lip_height, + ) + ) + pieces.append( + rectangle( + back_y, + p.base_thickness, + back_y + p.wall_thickness, + floor_z + p.back_support_height, + ) + ) + + return unary_union(pieces).buffer(0) + + +def make_organizer(parameters: OrganizerParameters) -> trimesh.Trimesh: + p = parameters + rack = extrude_cross_section(organizer_profile(p), p.width) + parts = [rack] + + for row in range(p.rows): + front_y = row * p.row_pitch + slot_front = front_y + p.wall_thickness + slot_back = slot_front + p.slot_width + floor_z = p.base_thickness + row * p.row_rise + + # Low ribs divide the continuous rail into cartridge-width bays. + for column in range(1, p.columns): + center_x = ( + p.side_margin + + column * p.cartridge_width + + (column - 0.5) * p.column_gap + ) + parts.append( + bounded_box( + center_x - p.divider_thickness / 2, + center_x + p.divider_thickness / 2, + slot_front - 0.2, + slot_back + 0.2, + floor_z, + floor_z + p.divider_height, + ) + ) + + # End stops prevent the outside cartridges from walking out sideways. + for x0, x1 in ((0.0, 2.0), (p.width - 2.0, p.width)): + parts.append( + bounded_box( + x0, + x1, + slot_front - 0.2, + slot_back + 0.2, + floor_z, + floor_z + p.divider_height, + ) + ) + + result = trimesh.boolean.union(parts, engine="manifold", check_volume=False) + result.remove_unreferenced_vertices() + result.merge_vertices() + result.fix_normals(multibody=False) + return result + + +def make_clearance_coupon() -> tuple[trimesh.Trimesh, list[float]]: + widths = [6.4, 6.8, 7.2] + coupon_width = 35.0 + edge_margin = 2.0 + wall = 3.0 + base = 3.0 + wall_height = 10.0 + + cursor = edge_margin + parts = [] + wall_positions: list[tuple[float, float]] = [] + wall_positions.append((cursor, cursor + wall)) + cursor += wall + for width in widths: + cursor += width + wall_positions.append((cursor, cursor + wall)) + cursor += wall + coupon_depth = cursor + edge_margin + + parts.append(bounded_box(0, coupon_width, 0, coupon_depth, 0, base)) + for y0, y1 in wall_positions: + parts.append( + bounded_box(0, coupon_width, y0, y1, base, base + wall_height) + ) + + result = trimesh.boolean.union(parts, engine="manifold", check_volume=False) + result.remove_unreferenced_vertices() + result.merge_vertices() + result.fix_normals(multibody=False) + return result, widths + + +def validate_mesh(mesh: trimesh.Trimesh, expected_extents: np.ndarray, name: str) -> None: + if not mesh.is_watertight: + raise RuntimeError(f"{name} is not watertight") + if not mesh.is_volume: + raise RuntimeError(f"{name} does not enclose a valid volume") + if not np.allclose(mesh.extents, expected_extents, atol=0.05): + raise RuntimeError( + f"{name} extents {mesh.extents} do not match expected {expected_extents}" + ) + + +def render_preview( + mesh: trimesh.Trimesh, + parameters: OrganizerParameters, + output_path: Path, +) -> None: + import matplotlib.pyplot as plt + from matplotlib.colors import to_rgba + from mpl_toolkits.mplot3d.art3d import Poly3DCollection + + p = parameters + figure = plt.figure(figsize=(12, 8), dpi=160) + axes = figure.add_subplot(111, projection="3d") + + triangles = [mesh.triangles] + face_colors = [ + np.tile(to_rgba("#ded8cb"), (len(mesh.triangles), 1)) + ] + + cart_colors = ["#df4d5b", "#42a7a1", "#ecb646"] + for row in range(p.rows): + floor_z = p.base_thickness + row * p.row_rise + slot_center_y = row * p.row_pitch + p.wall_thickness + p.slot_width / 2 + for column in range(p.columns): + x0 = p.side_margin + column * (p.cartridge_width + p.column_gap) + cart = bounded_box( + x0, + x0 + p.cartridge_width, + slot_center_y - p.cartridge_thickness / 2, + slot_center_y + p.cartridge_thickness / 2, + floor_z, + floor_z + p.cartridge_height, + ) + triangles.append(cart.triangles) + face_colors.append( + np.tile( + to_rgba(cart_colors[row % len(cart_colors)]), + (len(cart.triangles), 1), + ) + ) + + combined_faces = Poly3DCollection( + np.concatenate(triangles), + facecolors=np.concatenate(face_colors), + edgecolors="#343434", + linewidths=0.16, + ) + axes.add_collection3d(combined_faces) + + axes.set_xlim(0, p.width) + axes.set_ylim(-8, p.depth) + axes.set_zlim(0, p.base_thickness + (p.rows - 1) * p.row_rise + p.cartridge_height + 4) + axes.set_box_aspect((p.width, p.depth * 1.2, 70)) + axes.view_init(elev=25, azim=-62) + axes.set_axis_off() + figure.patch.set_facecolor("white") + axes.set_facecolor("white") + figure.tight_layout(pad=0) + figure.savefig(output_path, bbox_inches="tight", pad_inches=0.05) + plt.close(figure) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=Path(__file__).parent) + parser.add_argument("--columns", type=int, default=3) + parser.add_argument("--rows", type=int, default=3) + parser.add_argument("--slot-width", type=float, default=6.8) + parser.add_argument("--no-preview", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.columns < 1 or args.rows < 1: + raise SystemExit("columns and rows must be positive") + if args.slot_width <= 6.0: + raise SystemExit("slot width should be greater than the nominal 6.0 mm cartridge") + + parameters = OrganizerParameters( + columns=args.columns, + rows=args.rows, + slot_width=args.slot_width, + ) + args.output_dir.mkdir(parents=True, exist_ok=True) + + organizer = make_organizer(parameters) + organizer_name = f"wonderswan-organizer-{args.columns}x{args.rows}.stl" + organizer_path = args.output_dir / organizer_name + organizer.export(organizer_path) + validate_mesh( + organizer, + np.array([parameters.width, parameters.depth, parameters.height]), + "organizer", + ) + + coupon, coupon_widths = make_clearance_coupon() + coupon_path = args.output_dir / "wonderswan-slot-clearance-test.stl" + coupon.export(coupon_path) + validate_mesh(coupon, coupon.extents, "clearance coupon") + + if not args.no_preview: + render_preview( + organizer, + parameters, + args.output_dir / "wonderswan-organizer-preview.png", + ) + + print( + f"Wrote {organizer_path.name}: " + f"{parameters.width:.1f} x {parameters.depth:.1f} x {parameters.height:.1f} mm, " + f"watertight={organizer.is_watertight}, volume={organizer.volume / 1000:.1f} cm^3" + ) + print( + f"Wrote {coupon_path.name}: slots " + + ", ".join(f"{width:.1f} mm" for width in coupon_widths) + ) + + +if __name__ == "__main__": + main() diff --git a/hardware/wonderswan-cartridge-organizer/wonderswan-organizer-3x3.stl b/hardware/wonderswan-cartridge-organizer/wonderswan-organizer-3x3.stl new file mode 100644 index 0000000..281a8a1 Binary files /dev/null and b/hardware/wonderswan-cartridge-organizer/wonderswan-organizer-3x3.stl differ diff --git a/hardware/wonderswan-cartridge-organizer/wonderswan-organizer-preview.png b/hardware/wonderswan-cartridge-organizer/wonderswan-organizer-preview.png new file mode 100644 index 0000000..bcb1631 Binary files /dev/null and b/hardware/wonderswan-cartridge-organizer/wonderswan-organizer-preview.png differ diff --git a/hardware/wonderswan-cartridge-organizer/wonderswan-organizer.scad b/hardware/wonderswan-cartridge-organizer/wonderswan-organizer.scad new file mode 100644 index 0000000..da78305 --- /dev/null +++ b/hardware/wonderswan-cartridge-organizer/wonderswan-organizer.scad @@ -0,0 +1,160 @@ +// Parametric WonderSwan cartridge organizer and fit coupon. +// Open in OpenSCAD, set `part`, press F6, then export STL. + +part = "organizer"; // "organizer" or "coupon" + +cartridge_width = 65.2; +cartridge_height = 41.8; +cartridge_thickness = 6.0; +slot_width = 6.8; + +columns = 3; +rows = 3; +column_gap = 2.0; +side_margin = 4.0; + +base_thickness = 3.0; +wall_thickness = 3.0; +floor_thickness = 3.0; +front_lip_height = 7.0; +back_support_height = 18.0; +row_pitch = 20.0; +row_rise = 13.0; +rear_margin = 5.0; +divider_thickness = 1.2; +divider_height = 6.0; + +organizer_width = columns * cartridge_width + + (columns - 1) * column_gap + + 2 * side_margin; +organizer_depth = (rows - 1) * row_pitch + + wall_thickness + slot_width + wall_thickness + rear_margin; + +module profile_extrusion(width) { + multmatrix([ + [0, 0, 1, 0], + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 0, 1] + ]) + linear_extrude(height = width) + children(); +} + +module organizer_profile() { + union() { + square([organizer_depth, base_thickness]); + + for (row = [0 : rows - 1]) { + front_y = row * row_pitch; + slot_front = front_y + wall_thickness; + back_y = slot_front + slot_width; + floor_z = base_thickness + row * row_rise; + + if (row > 0) { + shelf_bottom = floor_z - floor_thickness; + lower_support_z = max( + base_thickness, + shelf_bottom - row_rise + floor_thickness + ); + translate([front_y, shelf_bottom]) + square([ + back_y + wall_thickness - front_y, + floor_thickness + ]); + polygon([ + [front_y, shelf_bottom], + [back_y, shelf_bottom], + [back_y, lower_support_z] + ]); + } + + translate([front_y, floor_z]) + square([wall_thickness, front_lip_height]); + translate([back_y, base_thickness]) + square([ + wall_thickness, + floor_z + back_support_height - base_thickness + ]); + } + } +} + +module organizer() { + union() { + profile_extrusion(organizer_width) + organizer_profile(); + + for (row = [0 : rows - 1]) { + slot_front = row * row_pitch + wall_thickness; + floor_z = base_thickness + row * row_rise; + + for (column = [1 : columns - 1]) { + center_x = side_margin + + column * cartridge_width + + (column - 0.5) * column_gap; + translate([ + center_x - divider_thickness / 2, + slot_front - 0.2, + floor_z + ]) + cube([ + divider_thickness, + slot_width + 0.4, + divider_height + ]); + } + + for (x0 = [0, organizer_width - 2]) { + translate([x0, slot_front - 0.2, floor_z]) + cube([2, slot_width + 0.4, divider_height]); + } + } + } +} + +module clearance_coupon() { + test_slots = [6.4, 6.8, 7.2]; + coupon_width = 35; + edge_margin = 2; + coupon_wall = 3; + coupon_base = 3; + coupon_wall_height = 10; + coupon_depth = 2 * edge_margin + + (len(test_slots) + 1) * coupon_wall + + test_slots[0] + test_slots[1] + test_slots[2]; + + union() { + cube([coupon_width, coupon_depth, coupon_base]); + translate([0, edge_margin, coupon_base]) + cube([coupon_width, coupon_wall, coupon_wall_height]); + + translate([ + 0, + edge_margin + coupon_wall + test_slots[0], + coupon_base + ]) + cube([coupon_width, coupon_wall, coupon_wall_height]); + + translate([ + 0, + edge_margin + 2 * coupon_wall + test_slots[0] + test_slots[1], + coupon_base + ]) + cube([coupon_width, coupon_wall, coupon_wall_height]); + + translate([ + 0, + edge_margin + 3 * coupon_wall + + test_slots[0] + test_slots[1] + test_slots[2], + coupon_base + ]) + cube([coupon_width, coupon_wall, coupon_wall_height]); + } +} + +if (part == "organizer") { + organizer(); +} else { + clearance_coupon(); +} diff --git a/hardware/wonderswan-cartridge-organizer/wonderswan-slot-clearance-test.stl b/hardware/wonderswan-cartridge-organizer/wonderswan-slot-clearance-test.stl new file mode 100644 index 0000000..4b5c23e Binary files /dev/null and b/hardware/wonderswan-cartridge-organizer/wonderswan-slot-clearance-test.stl differ