diff --git a/CHANGELOG.md b/CHANGELOG.md index d19b7130..24acf1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,12 @@ All notable changes to Patternflow will be documented in this file, newest first ### Hardware - **Besoiobiy's printed case is the second enclosure remix** (`hardware/case/remixes/besoiobiy-printed/`). Two long pieces glued side by side, a frame round the panel and a box for the board and the power bank, closed at the back by four screwed-on covers, with the panel bolted through the case into its own M3 sockets. Its README takes the place of the build guide's sections 4 and 6 and sets the order of 7 to 9; check your panel against it first, because the BOM's panel takes M4 screws on another hole pattern. The build guide's printing section and the assembly map point at it. +- **Mykyta Bilous' horizontal desktop case is the third enclosure remix** (`hardware/case/remixes/mbchars-horizontal-desktop-printed/`, [#488](https://github.com/engmung/Patternflow/pull/488) by [@mbchars](https://github.com/mbchars)). The panel lies the long way across on two removable stands, with the four controls on the right. Three front sections print face down, two white frames and an orange control section, and four joint bars on brass inserts hold them together without glue; two rear covers open onto the electronics and carry wall-mount openings. There is no power-bank bay: a separate PDSink 303PDSink01 trigger module in the control section feeds J4, the screw terminal, at 5 V. One Bambu Studio project holds all nine plates, with illustrated assembly instructions and the BOM changes beside it. ### Web - **/build lets you pick the case, and the 3D view shows the one you pick.** The Build panel's switch (the official v3.9 case, Besoiobiy's printed case, SimonePDA's laser-cut one; `?case=` links to one) brings up that case's card, a remix's opening with its README on GitHub, and swaps the device in the preview for that case, with the same v3.9 board, DevKit and LED panel inside (and, on Besoiobiy's case, its own knob caps). SimonePDA's case comes in its two finishes, acrylic and MDF. Each model is made by `tools/case-models` from the case's own files in this repository (the guide's v3.9 case, Besoiobiy's STLs, SimonePDA's cutting layout) and sits beside the case's photos, `web/public/cases//model.glb`. The model the page loads first is 0.32 MB, where the old v3.0 one (`3dforweb.glb`, now removed) was 1.15 MB; a remix's model loads when it is picked, and the guide takes its LED panel from the official one. +- **/build has a fourth case, mbchars' horizontal desktop one, in the switch and in the 3D view.** Its card is its README's facts and the author's photos, and the switch is four tabs, two by two on a phone. `tools/case-models/mbchars.py` puts its model together from the remix's STLs, since no assembled file exists, and checks it against the README; in it the panel lies the long way (`ledTurn` in `placement.json`), the official knob is printed white (`knobLook`), and the orange is the one in the author's 3MF, `#F46B16` (`pla_orange`). The model is 0.38 MB and loads when the case is picked; the other three rebuild byte for byte. The case is 41 cm across, so on a desktop's tall view it is drawn smaller, by as little as keeps it clear of the arrows; on a phone all four cases, and on a desktop the three upright ones, are still drawn at the one scale. The build map's pin for the same build goes by the maker's name now, mbchars (it was extraplojo). - **The preview's case is white, not beige, and the rest of it looks more like the photos.** The printed parts are a neutral white PLA (`#f4f4f2`, matte), where the guide's warm white had come along; the case front measures b\* +0.4 and L\* 89.8 where it was +1.7 and 82.5. The frame goes through the shoulder of the Neutral tone curve, which leaves what is under its knee as lit and rolls off what is brighter with its channels together, so a bright light no longer clips red and green first and leaves the white yellow. The light is made on the page from light panels, as on the guide's stage, instead of an environment map fetched from a CDN at run time. The LED panel is a grid of round LEDs on a black mask where it was solid blocks on grey (each LED becomes its whole cell once it is under about three pixels across, as on a phone), its sides and back are the panel's black frame, and what glowed before (the whites, and the bright yellows and cyans of a pattern from the editor) glows as before. The official knob is the printed knob's own profile drawn round, where it was a 32-sided prism. SimonePDA's clear acrylic is clear: the canvas is see-through, and every partly covered pixel used to reach the page far too bright, which drew the sheet frosted white; its three feet, which no photo shows on it, now come out only with the parts and on Assemble. Every case is drawn at one scale, so the LED panel is the same size in all three (the remixes drew it 3.5 to 4% smaller). The three models were rebuilt with `tools/case-models/build.sh`; the official one grows by 0.8 KB. ### Docs diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md b/hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md new file mode 100644 index 00000000..0d2619ac --- /dev/null +++ b/hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md @@ -0,0 +1,79 @@ +Author: Mykyta Bilous (mbchars), https://github.com/mbchars +License: CC-BY-SA-4.0 +Based on: Patternflow enclosure by engmung / SeungHun Lee; +Fits: Patternflow v3.0 controller PCB; 320 × 160 mm LED panel with the tested hole pattern +Material: PLA; Bambu P2S; 0.4 mm nozzle; 0.20 mm layers; textured plate. +Verified: 2026-10-07; assembled by the author; photographs below + +# Horizontal desktop printed enclosure + +This enclosure holds Patternflow horizontally on two removable stands. There are also screw openings for wall-mounting. The four controls are on the right in the front view. + +The release has one control section with a holder for the **PDSink 303PDSink01 USB Trigger**. The separate module connects to **J4**, the controller power terminal. + +![Completed enclosure with white frames and orange controls](images/photo_front.jpg) + +## Features + +- The three front sections print with their front faces on the plate. +- The power bank compartment is removed. +- Four joint bars connect the front sections independently of the rear covers. +- Two rear covers give access to the electronics. +- Four small alignment pins are optional. They can guide the parts during glue. + +**Glue is optional. The joint bars hold the enclosure without glue.** + +The enclosure dimensions are approximately 411 × 176 × 42 mm. These dimensions exclude the knobs and stands. + +![Front surface, controls, and cable](images/photo_front_angle.jpg) + +## Print files + +Open [print_layout.3mf](print_layout.3mf) as a project in Bambu Studio. This single project contains all nine plates. + +Select your printer and PLA profile. Keep the supplied part orientations and support blockers. + +Each plate uses one color. + +| Plate | Part | Quantity | Color | +|---|---|---:|---| +| 01 | [Outer frame](stl/white_outer_frame.stl) | 1 | White | +| 02 | [Inner frame](stl/white_inner_frame.stl) | 1 | White | +| 03 | [Control section](stl/control_section.stl) | 1 | Orange | +| 04 | [Display rear cover](stl/rear_display_cover.stl) | 1 | White | +| 05 | [Control rear cover](stl/rear_control_cover.stl) | 1 | White | +| 06 | [Joint bar](stl/joint_bar.stl) | 4 | White | +| 06 | [Optional alignment pin](stl/alignment_pin_2mm.stl) | 4 | White | +| 07 | [USB retainer](stl/usb_retainer.stl) | 1 | Orange | +| 08 | [Stand](stl/stand.stl) | 2 | Orange | +| 09 | [Knob](stl/knob.stl) | 4 | White | + +Each STL contains one part. The 3MF contains the quantities in the table. The colors are optional. + +The front sections and USB retainer use configured supports. The covers, joint bars, pins, stands, and knobs do not use supports. + +Keep the USB retainer screw face upward. Remove its supports before assembly. Do not put supports inside the PCB guides or alignment holes. + +An STL does not contain print settings. For another slicer, use the 3MF orientations and support locations as references. + +The estimated total is 24 hours 08 minutes and 714 g of PLA. + +## Parts and assembly + +Use the [BOM changes](bom.md) with the original v3.0 BOM. Follow the [illustrated assembly instructions](assembly.md). + +Make sure that your panel hole pattern agrees with the enclosure before you print. Panel dimensions alone do not establish compatibility. + +The tested module PCB is 27.8 × 11.1 × 1.5 mm, excluding the socket overhang. The metal socket is 8.97 mm wide. + +The USB retainer accepts PCB lengths from 27.5 to 28.3 mm. Other modules with the same product name can have different dimensions. + +**J4 is the controller's only power input.** Do not use its onboard USB footprint or DevKit USB port for LED panel power. + +Set the separate trigger output to 5 V. Measure its output before connection to J4. Follow the original [power connection instructions](../../../../BUILD_GUIDE.md#2-power-input--use-the-screw-terminal). + +## Attribution and license + +This is a community remix of [Patternflow by engmung / SeungHun Lee](https://github.com/engmung/Patternflow). + +Mykyta Bilous designed the enclosure changes and supplied the build photographs. The files in this folder use [CC BY-SA 4.0](../../../../LICENSE-CC-BY-SA). diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md b/hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md new file mode 100644 index 00000000..534bb67e --- /dev/null +++ b/hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md @@ -0,0 +1,141 @@ +# Assembly instructions + +Use the parts in [bom.md](bom.md). Disconnect the supply before mechanical assembly or wire installation. + +The **joint bar** connects two front sections. The **USB retainer** holds the separate trigger PCB against its socket location. + +The controls are on the right in the front view. They are on the left in the rear view. + +## 1. Prepare the printed parts + +1. Remove all supports and brims. +2. Remove loose material from the joint channels, PCB guides, and screw holes. +3. Put the three front sections face down on a clean, soft, flat surface. +4. Put the concealed laps into their recesses. +5. Make sure that the front faces are flush. +6. If a joint does not close, remove the obstruction before you continue. + +Do not use screws to pull a distorted frame flat. + +## 2. Install the brass inserts + +![Insert dimensions and joint screw section](images/02_joint_section.png) + +1. Put the four joint bars on a heat-resistant surface, away from the enclosure. +2. Use a temperature-controlled insert tool to install two M3 inserts in each bar. +3. Make each insert top flush with the bar surface. +4. Let the bars cool on a flat surface. +5. Make sure that each bar is straight. +6. Install the 16 rear cover inserts in their case bosses. +7. Install one insert in the USB retainer screw boss. +8. Let all inserts cool before screw installation. + +Use the insert tool supplier's procedure for PLA. Keep the tool away from visible surfaces. + +## 3. Connect the front sections + +![Four joint bars in the rear view](images/01_frame_connections.png) + +1. Keep the three front sections face down. +2. Slide one joint bar beneath each pair of joint lugs. +3. Point the insert openings toward the rear of the enclosure. +4. Install two M3 × 8 mm countersunk screws through each lug pair into its bar. +5. Leave the screws loose initially. +6. Hold the joint closed with the front faces flush. +7. Tighten the two screws alternately until the joint is secure. +8. Repeat the procedure for all four bars. +9. Make sure that all three sections remain connected with the rear covers removed. + +The joint lug holes and rear cover insert holes are separate. Do not put joint screws into the cover insert holes. + +### Optional alignment pins and glue + +![Optional alignment pins at a joint](images/03_optional_pins.png) + +The four small printed pins are optional. They locate the parts and can guide them during glue application. + +The joint bars provide the mechanical connections. Glue is not required. + +1. If you use the pins, put them in the four alignment locations before you close the joints. +2. Make sure that each pin permits the joint to close fully. +3. If a pin holds a joint open, remove it. +4. If you use glue, first assemble the joints without glue to confirm the fit. +5. Apply a small quantity of PLA-compatible glue to the mating faces as specified by its manufacturer. +6. Keep glue away from the visible faces, inserts, PCB guides, and electronics. +7. Align the parts with the pins during glue application. +8. Keep the joints aligned until the glue has cured. + +## 4. Install the panel and controller + +1. Put the LED panel in the frame with its display face toward the front. +2. Make sure that its power and data connectors face the internal cable passage. +3. Make sure that the rear components do not contact the joint bars. +4. Install the panel with its correct mounting screws. +5. Tighten the panel screws gradually. +6. Make sure that the frame joints stay closed. +7. Install the controller with the encoder shafts through the control section. +8. Install the encoder washers and nuts. +9. Tighten the nuts until the controller is secure. + +If a joint opens, stop before you tighten the screws further. Correct the cause before you continue. + +## 5. Install the separate USB module + +![USB module and short retainer in the rear view](images/04_usb_installation.png) + +1. Put the module into its guides, with the socket first. +2. Keep the components toward the open rear of the enclosure. +3. Seat the socket behind the side opening. +4. Put the USB retainer against the rear PCB edge. +5. Keep the output wires above the low retainer tongue. +6. Install the M3 × 8 mm countersunk retainer screw. +7. Tighten the screw only until the module is secure. +8. Make sure that the retainer does not contact the adjacent joint bar. +9. With the supply disconnected, insert and remove the USB-C cable gently. +10. Make sure that the plug seats fully and the PCB stays in position. + +Do not use the retainer to force the module into position. Do not clamp wires beneath the retainer. + + +![Actual socket installation](images/photo_socket_detail.jpg) + +## 6. Connect power and data + +![Electrical connection diagram](images/05_connections.png) + +**CAUTION: The trigger output must be 5 V. A higher voltage can damage the controller and panel.** + +1. With the supply disconnected, set the trigger to 5 V according to the module manufacturer's instructions. +2. Keep the trigger output disconnected from J4. +3. Connect the USB-C supply to the trigger. +4. Measure the trigger output voltage and polarity with a multimeter. +5. If the output is not 5 V with the correct polarity, stop. +6. Disconnect the supply. +7. Connect the module's positive output to J4 +5 V. +8. Connect the module's negative output to J4 GND. +9. Connect the panel power cable to J3. +10. Connect the HUB75 ribbon cable between J1 and the panel input. +11. Make sure that the ribbon cable orientation agrees with the connector keys and panel labels. +12. Make sure that all terminals are secure and no bare conductor can contact another connection. +13. Keep the wires clear of screws, joint bars, and cover edges. + +**J4 is the controller's only power input.** Do not use the controller USB footprint or DevKit USB port for panel power. + +The diagram shows the connections, not the physical terminal order. Use the labels on the actual boards. + +![Actual rear assembly with controller, module, and connections](images/photo_rear_assembly.jpg) + +## 7. Close the enclosure + +1. Make sure that all supports and loose objects are removed from the enclosure. +2. Put the two rear covers in position. +3. Make sure that the covers seat without pressure on wires or components. +4. Install the 16 rear cover screws. +5. Tighten the screws until the covers are secure. +6. Install the four knobs on the encoder shafts. +7. Put one stand at each lower end of the enclosure. +8. Put the enclosure on a stable, flat surface. +9. Connect the supply. +10. Make sure that the display and all four controls operate correctly. + +Follow the original [build guide](../../../../BUILD_GUIDE.md) for controller setup and operation. diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md b/hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md new file mode 100644 index 00000000..ee9b034d --- /dev/null +++ b/hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md @@ -0,0 +1,46 @@ +# Bill of materials changes + +Use these changes with the original [v3.0 BOM](../../../bom/bom_v3.0.csv). The controller PCB components and LED panel remain unchanged. + +## Replacement parts + +| Original item | Replacement for this enclosure | +|---|---| +| Original printed enclosure | Parts in the [print table](README.md#print-files) | +| Power bank | External USB-C supply with a 5 V output compatible with the trigger | +| USB cable cut for connection to J4 | Complete USB-C cable, separate trigger module, and two output wires to J4 | + + +## Additional hardware + +| Part | Quantity | Specification or location | +|---|---:|---| +| PDSink 303PDSink01 USB Trigger | 1 | Separate module; output set to 5 V | +| Brass heat-set insert | 25 | M3 thread; 5 mm outside diameter; 5 mm length; 4.5 mm printed pilot | +| Joint bar screw | 8 | M3 × 8 mm, countersunk head; two per joint bar | +| USB retainer screw | 1 | M3 × 8 mm, countersunk head | +| Rear cover screw | 16 | M3 × 8 mm, countersunk head | +| Output wire pair | 1 pair | Module +5 V and GND to controller J4 | + +The 25 inserts comprise 16 cover inserts, eight joint bar inserts, and one USB retainer insert. + +The enclosure uses 25 M3 screws in total. + +Do not substitute the enclosure screws for the panel screws. + +## Printed quantities + +| Part | Quantity | +|---|---:| +| Outer frame | 1 | +| Inner frame | 1 | +| Control section | 1 | +| Display rear cover | 1 | +| Control rear cover | 1 | +| Joint bar | 4 | +| USB retainer | 1 | +| Stand | 2 | +| Knob | 4 | +| Optional 2 mm alignment pin | 4 | + +The four small pins can guide the parts during glue application. Glue and pins are optional; the joint bars provide the connections. diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/01_frame_connections.png b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/01_frame_connections.png new file mode 100644 index 00000000..d5cc3ef5 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/01_frame_connections.png differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/02_joint_section.png b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/02_joint_section.png new file mode 100644 index 00000000..67142028 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/02_joint_section.png differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/03_optional_pins.png b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/03_optional_pins.png new file mode 100644 index 00000000..049d326f Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/03_optional_pins.png differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/04_usb_installation.png b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/04_usb_installation.png new file mode 100644 index 00000000..99fc4569 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/04_usb_installation.png differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/05_connections.png b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/05_connections.png new file mode 100644 index 00000000..1c7f703f Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/05_connections.png differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_front.jpg b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_front.jpg new file mode 100644 index 00000000..c32b5dab Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_front.jpg differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_front_angle.jpg b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_front_angle.jpg new file mode 100644 index 00000000..a289e7b6 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_front_angle.jpg differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_rear_assembly.jpg b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_rear_assembly.jpg new file mode 100644 index 00000000..0ec29ee0 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_rear_assembly.jpg differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_socket_detail.jpg b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_socket_detail.jpg new file mode 100644 index 00000000..5d3b1aa8 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_socket_detail.jpg differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/print_layout.3mf b/hardware/case/remixes/mbchars-horizontal-desktop-printed/print_layout.3mf new file mode 100644 index 00000000..fce4d25a Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/print_layout.3mf differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/alignment_pin_2mm.stl b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/alignment_pin_2mm.stl new file mode 100644 index 00000000..d6279610 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/alignment_pin_2mm.stl differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/control_section.stl b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/control_section.stl new file mode 100644 index 00000000..97c93828 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/control_section.stl differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/joint_bar.stl b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/joint_bar.stl new file mode 100644 index 00000000..c027b9b4 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/joint_bar.stl differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/knob.stl b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/knob.stl new file mode 100644 index 00000000..ce1909ba Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/knob.stl differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/rear_control_cover.stl b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/rear_control_cover.stl new file mode 100644 index 00000000..7e0d0907 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/rear_control_cover.stl differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/rear_display_cover.stl b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/rear_display_cover.stl new file mode 100644 index 00000000..e7474403 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/rear_display_cover.stl differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/stand.stl b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/stand.stl new file mode 100644 index 00000000..6cf17d11 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/stand.stl differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/usb_retainer.stl b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/usb_retainer.stl new file mode 100644 index 00000000..6bbbdf7a Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/usb_retainer.stl differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/white_inner_frame.stl b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/white_inner_frame.stl new file mode 100644 index 00000000..deb195f7 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/white_inner_frame.stl differ diff --git a/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/white_outer_frame.stl b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/white_outer_frame.stl new file mode 100644 index 00000000..6a9368d8 Binary files /dev/null and b/hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/white_outer_frame.stl differ diff --git a/tools/case-models/README.md b/tools/case-models/README.md index 2f371518..b983c041 100644 --- a/tools/case-models/README.md +++ b/tools/case-models/README.md @@ -7,14 +7,16 @@ The 3D models the [/build](https://patternflow.work/build) page's preview shows, | Official | `web/public/cases/official/model.glb` | `web/public/guide/case-v39.glb`, the guide's v3.9 case (itself exported from `hardware/case/source/patternflow_case.blend`) | `assemble.mjs official` | | Besoiobiy | `web/public/cases/besoiobiy-printed/model.glb` | `hardware/case/remixes/besoiobiy-printed/source/patternbox.stl` | `besoiobiy.py`, then `assemble.mjs` | | SimonePDA | `web/public/cases/simonepda-lasercut/model.glb` | `hardware/case/remixes/simonepda-lasercut/lasercut_layout.pdf` | `simonepda.py`, then `assemble.mjs` | +| mbchars | `web/public/cases/mbchars-horizontal-desktop-printed/model.glb` | `hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/` | `mbchars.py`, then `assemble.mjs` | -Run `./build.sh` to make all three again (or `./build.sh ` for one). The models are committed; nothing here runs when the site builds. Every step is deterministic: the same files in give the same bytes out. +Run `./build.sh` to make all four again (or `./build.sh ` for one). The models are committed; nothing here runs when the site builds. Every step is deterministic: the same files in give the same bytes out. | Model | Size | Loads | | :--- | ---: | :--- | | `official/model.glb` | 317 KB | with the page (the guide uses it too) | | `besoiobiy-printed/model.glb` | 345 KB | when its tab is hovered or picked | | `simonepda-lasercut/model.glb` | 332 KB | when its tab is hovered or picked | +| `mbchars-horizontal-desktop-printed/model.glb` | 380 KB | when its tab is hovered or picked | The landing page's model before these, `web/public/3dforweb.glb` (a v3.0 device), was 1,155 KB and loaded with the page. @@ -33,10 +35,10 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements.txt # Python 3. A case script makes the case's *shell*: one mesh per part, assembled, in the model frame below, with material names from `lib/looks.mjs`, and a `placement.json` saying where this case holds the board and the LED panel: ```text -{ "board": [dx, dy, dz], "led": [x, y, z], "knobs": "official" | "shell", "knobBaseZ": z } +{ "board": [dx, dy, dz], "led": [x, y, z], "ledTurn": degrees, "knobs": "official" | "shell", "knobBaseZ": z, "knobLook": material } ``` -`board` is added to where the official case has the board, the DevKit and the knobs; `led` is the panel node's translation, its z the LED face; `knobs` says whether the shell has its own `c1` … `c4` or takes the official ones (standing at `knobBaseZ`). `assemble.mjs` then adds what every case has, from the files the guide uses and the knob's STL: +`board` is added to where the official case has the board, the DevKit and the knobs; `led` is the panel node's translation, its z the LED face, and `ledTurn` turns the panel about its own middle (anticlockwise seen from the front), for a case that holds it on its side; `knobs` says whether the shell has its own `c1` … `c4` or takes the official ones (standing at `knobBaseZ`, in `knobLook`, black PLA unless it says otherwise). Everything but `board` and `knobs` can be left out. `assemble.mjs` then adds what every case has, from the files the guide uses and the knob's STL: - the LED panel, `source/led_panel.glb`: the node `l` of the landing page's old model, its name, transform, geometry and UVs unchanged. `extract_led.mjs` took it out; the old file is at `git show v3.11.0:web/public/3dforweb.glb`; - the v3.9 board, `web/public/guide/pcb-v39.glb`, and the DevKit, `web/public/guide/devkit.glb`, simplified with meshoptimizer — the preview shows them small, through a case; @@ -105,13 +107,27 @@ The drawing does not say which way the box walls face along their length. The ph The plate, strips and feet use `sheet_face`/`sheet_edge`; the page shows them as acrylic or MDF (the finishes in `web/src/components/3d/cases/simonepda-lasercut.ts`). The box uses `acrylic_face`/`acrylic_edge` in both finishes. Run it with no arguments; `--pdf` and `--out` override the defaults. +## mbchars: `mbchars.py` + +Mykyta Bilous' horizontal desktop case has no assembled file, only one STL per part in `hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/`, each as it prints. The three front sections print face down, so their STLs share one frame, the case's own: the front on the bed at z = 0, the depth up +z, seen from behind with the control section at the left (the README's rear view). The script puts the parts together in that frame and checks every step: + +1. **The front sections side by side** along x: control section, inner frame, outer frame. Each joint is a joint bar under a pair of lugs with an M3 screw through each lug into the bar's insert, so the lug holes either side of a joint are as far apart as the bar's two inserts (36 mm). That places each section (at 0, 74 and 242.14 mm); the faces that meet at each joint are then checked to touch. +2. **The joint bars** under the lug pairs, inserts toward the back as the bar prints, their back on the lugs. +3. **The two rear covers** face up, their 3 mm plates on the sections' back rim, each with its eight countersunk holes on eight of the sixteen inserts in the sections' bosses. Which half turn brings a cover face up, and where it goes, is found by trying both turns and every offset that puts a hole on an insert: exactly one fits all eight. +4. **The USB retainer** on its boss in the control section, the one insert that is neither a cover's nor a bar's: its slot over the insert, its tongue on the boss. The slot lets it slide 0.8 mm for the module's length; the model has it in the middle. +5. **The stands** at each end, the case's bottom in their slot (as wide as the case is deep), the short lip in front, the case's back against the tall side. The slot is tilted, so on a table the case leans back 10°. The README says only "at each lower end"; the author's front photo has each stand's middle about 30 mm in from the end. + +Then it checks the result: 410.28 × 176.16 × 42 mm against the README's "approximately 411 × 176 × 42 mm", the encoder holes on the board's 31 × 30.5 mm grid, a 320.8 × 160.8 mm opening for the 320 × 160 mm panel, every insert under a cover hole, and no two parts overlapping (manifold3d). The knob in `stl/` is checked to be the official knob, so the model takes the official one, white as the README prints it (`knobLook`); the four optional alignment pins sit inside the joints, where nothing shows them, and are left out. + +Into the model frame it is a half turn about y, 10 mm to the unit, the outline centred and the front face set where the official case's are. The panel lies the long way across here, a quarter turn from the other three, so `ledTurn` turns it a quarter turn clockwise, which puts the top of the pattern at the top as the author mounts the panel (its arrows point up in the rear photo). The board is centred on the encoder holes and bears on the inside of the control section's 3 mm front, as in the official case; the panel's face is flush with the front, on tabs 17.8 mm behind it. Each part is one node: `outer_frame`, `inner_frame`, `rear_display_cover`, `rear_control_cover` and the four `joint_bar_*` in `pla_white`; `control_section`, `usb_retainer`, `stand_left` and `stand_right` in `pla_orange`, the orange of the author's 3MF. Normals as on Besoiobiy's case (`crease_shaded`, imported from `besoiobiy.py`). + ## How the preview uses a model `web/src/components/3d/HeroScene.tsx` with `heroCase.ts` draws whichever model the Build panel's case switch is on. - **Loading.** Only the official model is preloaded with the page. A remix's model loads when it is picked, or when its tab is hovered (`preloadCaseModel.ts`). The current case stays on screen until the new model has arrived. A model that fails to load is caught, and the official case stays up. - **Its own copy.** The preview works on a deep clone of drei's cached scene, with its own copy of each material, so it never changes the cached scene, which the guide may be given too. It finds the parts by the node names above; every other top-level node is treated as a piece of the case. -- **Placement.** The model is measured once when it arrives and centred on the preview's orbit target. Every case is drawn at the same scale: the LED panel is the same part in each (and the knobs, except Besoiobiy's own caps), so it is the same size on screen, and what differs is the case round it (up to 4% across the front's diagonal; Besoiobiy's case is 8% wider than the official one). A script does not need to match the official case's origin, only the frame's axes and units. +- **Placement.** The model is measured once when it arrives and centred on the preview's orbit target. Every case is drawn at the same scale: the LED panel is the same part in each (and the knobs, except Besoiobiy's own caps), so it is the same size on screen, and what differs is the case round it (up to 4% across the front's diagonal among the three that stand upright; Besoiobiy's case is 8% wider than the official one). mbchars' case lies the long way and is two thirds wider than the official one: on a view too narrow for it, a desktop's tall one, it is drawn smaller, by as little as keeps it within 72% of the view's width, clear of the arrows (`fitAcross` in `buildPose.ts`). On a phone all four fit at the one scale, and on a desktop the other three do. A script does not need to match the official case's origin, only the frame's axes and units. - **The Build steps** use what `web/src/components/3d/cases/.ts` gives: step 1 shows the case pieces and knobs at 40% of their explode vectors; step 2 shows the board and DevKit, framed on their box; step 3 moves every top-level node by its explode vector times the panel's slider. - **Finishes.** A case's alternative finishes recolour materials by name over the file's own. A look below full opacity is drawn see-through: blended, not writing depth, and casting no shadow. - **Loose pieces.** Top-level nodes a case's entry lists as `loose` come with the case but are not on it as it stands: SimonePDA's three feet, for laying it flat. They are drawn with the parts (step 1) and on Assemble (step 3), not on the device standing and lit. diff --git a/tools/case-models/assemble.mjs b/tools/case-models/assemble.mjs index c86d4dac..66e567b3 100644 --- a/tools/case-models/assemble.mjs +++ b/tools/case-models/assemble.mjs @@ -6,18 +6,21 @@ // A case model is the case itself (its "shell": every printed, cut or glued // part) with the same Patternflow inside it, whichever case it is: the LED // panel, the v3.9 board, the ESP32 DevKit on its sockets, and four knobs. The -// shell comes from the case's own script (besoiobiy.py, simonepda.py; for the -// official case, the guide's case-v39.glb); everything else comes from the -// files the guide uses, and the official knob from the STL people print -// (lib/knob.mjs), moved to where this case holds them. placement.json says -// where that is: +// shell comes from the case's own script (besoiobiy.py, simonepda.py, +// mbchars.py; for the official case, the guide's case-v39.glb); everything +// else comes from the files the guide uses, and the official knob from the +// STL people print (lib/knob.mjs), moved to where this case holds them. +// placement.json says where that is: // // { // "board": [dx, dy, dz], // added to the official case's board, DevKit and knobs // "led": [x, y, z], // the LED panel node's translation (z is its LED face); // // left out, it stays where the official case has it +// "ledTurn": -90, // optional: degrees about z, anticlockwise seen from +// // the front, for a case that holds the panel on its side // "knobs": "official", // or "shell": the shell has its own c1..c4 -// "knobBaseZ": 1.6437 // optional: the knobs' base, when "official" +// "knobBaseZ": 1.6437, // optional: the knobs' base, when "official" +// "knobLook": "pla_white" // optional: the official knobs' material (pla_black) // } // // The result is written to web/public/cases//model.glb, Draco @@ -72,6 +75,16 @@ async function readSimplified(file, opts) { return doc; } +/** The quaternion product a·b ([x, y, z, w]): b turned, then a. */ +function mulQuat([ax, ay, az, aw], [bx, by, bz, bw]) { + return [ + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz, + ]; +} + /** Moves a source document's scene roots into the target's scene; returns them by name. */ function adopt(target, source) { const map = mergeDocuments(target, source); @@ -125,12 +138,18 @@ const board = placement.board ?? [0, 0, 0]; // holds it (on the official case it stays where it was). const ledRoots = adopt(doc, await io.read(path.join(HERE, 'source/led_panel.glb'))); if (placement.led) ledRoots.l.setTranslation(placement.led); +// Turned about its own origin, the middle of its LED face, so the turn keeps +// it where `led` puts it; the pattern on it turns with it, as on the device. +if (placement.ledTurn) { + const half = (placement.ledTurn * Math.PI) / 360; + ledRoots.l.setRotation(mulQuat([0, 0, Math.sin(half), Math.cos(half)], ledRoots.l.getRotation())); +} // The knobs: the official ones, on the encoder axes of the board as placed. // The printed knob's own profile (knobs_20mm.stl) turned round, one mesh for // all four: case-v39.glb has the same knob as a 32-sided prism. if (placement.knobs === 'official') { - const knob = knobMesh(doc, KNOB_STL, doc.createMaterial('pla_black')); + const knob = knobMesh(doc, KNOB_STL, doc.createMaterial(placement.knobLook ?? 'pla_black')); const scene = doc.getRoot().listScenes()[0]; for (const [name, base] of Object.entries(KNOB_BASES)) { const t = add3(base, board); diff --git a/tools/case-models/build.sh b/tools/case-models/build.sh index 559eaf78..82e840dc 100755 --- a/tools/case-models/build.sh +++ b/tools/case-models/build.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Makes every case model again: web/public/cases//model.glb. # -# tools/case-models/build.sh # all three +# tools/case-models/build.sh # all four # tools/case-models/build.sh official # one # # Needs `npm ci` here and the Python packages in requirements.txt (set PYTHON @@ -16,7 +16,7 @@ if [[ -z "$PYTHON" ]]; then fi cases=("$@") -if [[ ${#cases[@]} -eq 0 ]]; then cases=(official besoiobiy-printed simonepda-lasercut); fi +if [[ ${#cases[@]} -eq 0 ]]; then cases=(official besoiobiy-printed simonepda-lasercut mbchars-horizontal-desktop-printed); fi for id in "${cases[@]}"; do echo "== $id" @@ -32,8 +32,12 @@ for id in "${cases[@]}"; do "$PYTHON" simonepda.py node assemble.mjs simonepda-lasercut build/simonepda-lasercut/shell.glb build/simonepda-lasercut/placement.json ;; + mbchars-horizontal-desktop-printed) + "$PYTHON" mbchars.py + node assemble.mjs mbchars-horizontal-desktop-printed build/mbchars-horizontal-desktop-printed/shell.glb build/mbchars-horizontal-desktop-printed/placement.json + ;; *) - echo "unknown case: $id (official, besoiobiy-printed, simonepda-lasercut)" >&2 + echo "unknown case: $id (official, besoiobiy-printed, simonepda-lasercut, mbchars-horizontal-desktop-printed)" >&2 exit 1 ;; esac diff --git a/tools/case-models/lib/looks.mjs b/tools/case-models/lib/looks.mjs index aaa016ee..31897f51 100644 --- a/tools/case-models/lib/looks.mjs +++ b/tools/case-models/lib/looks.mjs @@ -4,7 +4,10 @@ // // Colours are sRGB hex, as a designer writes them; glTF wants linear factors. // -// pla_white printed case parts (the official case and Besoiobiy's) +// pla_white printed case parts (the official case, Besoiobiy's, and the +// frames, covers and knobs of mbchars') +// pla_orange printed case parts in orange (mbchars' control section, +// stands and USB retainer) // pla_black printed knobs // sheet_face laser-cut sheet, its two faces: the plate, the border strips // and the feet of SimonePDA's case. Clear acrylic here, as the @@ -35,6 +38,9 @@ export const LOOKS = { // filament itself is drawn without a cast, and matte: a warmer white read // as beige once the page's light got bright. pla_white: { color: hex('#f4f4f2'), roughness: 0.85 }, + // The orange of mbchars' build: the filament colour in the remix's own + // print_layout.3mf (#F46B16), matte like the white. + pla_orange: { color: hex('#f46b16'), roughness: 0.85 }, pla_black: { color: hex('#151515'), roughness: 0.55 }, sheet_face: ACRYLIC_FACE, sheet_edge: ACRYLIC_EDGE, diff --git a/tools/case-models/mbchars.py b/tools/case-models/mbchars.py new file mode 100644 index 00000000..01b39493 --- /dev/null +++ b/tools/case-models/mbchars.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 +"""mbchars' horizontal desktop case, put together, as the shell of its preview model. + + python mbchars.py + node assemble.mjs mbchars-horizontal-desktop-printed build/mbchars-horizontal-desktop-printed/shell.glb build/mbchars-horizontal-desktop-printed/placement.json + +MIT, like the rest of tools/. The model it makes is a drawing of Mykyta +Bilous' (mbchars') case and takes the case's license (CC BY-SA 4.0, README.md). + +hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/ has one STL per +part, each as it prints, and nothing assembled: no scene file, no step. The +three front sections (outer frame, inner frame, control section) print face +down, so their STLs share one frame, the case's own: the front face on the +bed at z = 0, the depth up +z, and seen from above, from behind, the control +section at the left (the README's rear view, images/01_frame_connections.png). +In that frame: + +1. The front sections go side by side along x, the control section, then the + inner frame, then the outer frame, all three with y and z as they print. + Each joint is held by a joint bar under a pair of lugs, one lug on each + section, and an M3 screw through each lug into the bar's insert, so the + lug holes either side of a joint are as far apart as the bar's two + inserts (36 mm). That sets where each section goes; the faces that meet at + each joint are then checked to touch. + +2. The joint bars go under the lug pairs, inserts toward the back (as the + bar prints, so translations only), their back face on the lugs' front. + +3. The rear covers turn face up (they print face down) and close the back: + their 3 mm plate on the sections' back rim, each with its eight + countersunk holes over eight of the sixteen inserts in the sections' + bosses. Which way a cover turns, and where it goes, is found by trying + both half turns that bring its face up and every offset that puts a hole + on an insert; one of each fits all eight holes. + +4. The USB retainer goes on its boss in the control section (the one insert + that is neither a cover's nor a joint bar's), its slot over the insert + and its tongue on the boss, as it prints (screw face up, toward the back). + The slot lets it slide 0.8 mm to suit the module's length (README); the + model has it in the middle. + +5. The stands hold the case by its bottom edge in a slot as wide as the case + is deep, the short lip in front and the tall side behind, which the case + leans back on: the slot is tilted, so the case stands leaning back at that + angle on a table. A stand goes "at each lower end" (README); the author's + front photo has each one's middle about 30 mm in from the end. + +Then the result is checked against the remix README and the board: about +411 × 176 × 42 mm without the knobs and stands, the four encoder holes on the +board's 31 × 30.5 mm grid, an opening a 320 × 160 mm panel fits, every cover +hole on an insert, and no two parts overlapping (boolean intersections, by +manifold3d). + +The knob in stl/ is the official knob (hardware/case/knobs/knobs_20mm.stl), +which is checked; so the model takes the official knob, as assemble.mjs makes +it, in white, the colour the README prints it in. The four optional alignment +pins sit inside the joints, where nothing shows them; they are left out. + +Into the model frame (README.md): one unit is 10 mm, the device faces +z +with the knobs at the top and the panel on the left. The case frame looks at +the case from behind, so that is a half turn about y. The case's outline is +centred where the official case's is and its front face is set where the +official's is. This case holds the panel the long way across, a quarter turn +from the other three, so placement.json turns the panel too (ledTurn): a +quarter turn clockwise seen from the front, which puts the top of the +pattern's 128 × 64 frame at the top, as the author mounts the panel (its +arrows point up in images/photo_rear_assembly.jpg). + +Each part is one node, white PLA or orange PLA as the README's print table +colours it, with normals that keep a printed part's edges hard and its round +surfaces smooth (besoiobiy.py's crease_shaded). placement.json says where +this case holds the board, the panel and the knobs: + board added to the official board's placement, so that the + encoders are centred on the four holes and bear on the inside + of the control section's 3 mm front, as in the official case; + led the panel node's translation: centred in the frame's opening, + its LED face flush with the front (the panel sits in the frame + on tabs 17.8 mm behind the front, 0.8 mm deeper than the panel + model); + ledTurn the quarter turn above; + knobs "official", standing 0.8 mm off the front as on the official + case (knobBaseZ), in "pla_white" (knobLook). + +Deterministic: the same input files give the same output bytes. +""" + +from __future__ import annotations + +import argparse +import itertools +import json +import sys +from pathlib import Path + +import numpy as np +import shapely +import trimesh + +from besoiobiy import CREASE_DEG, crease_shaded, official_numbers, with_material + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] +REMIX = REPO / "hardware/case/remixes/mbchars-horizontal-desktop-printed" +OFFICIAL_KNOBS = REPO / "hardware/case/knobs/knobs_20mm.stl" + +# The front sections, in order from the control end (the case frame's left). +FRONT = { + "control_section": "control_section.stl", + "inner_frame": "white_inner_frame.stl", + "outer_frame": "white_outer_frame.stl", +} +COVERS = { + "rear_control_cover": "rear_control_cover.stl", + "rear_display_cover": "rear_display_cover.stl", +} +JOINT_BAR = "joint_bar.stl" +RETAINER = "usb_retainer.stl" +STAND = "stand.stl" +KNOB = "knob.stl" + +# The README's print table: plates 03, 07 and 08 orange, the rest white. +# lib/looks.mjs gives each its look. +ORANGE = {"control_section", "usb_retainer", "stand_left", "stand_right"} +WHITE_PLA = "pla_white" +ORANGE_PLA = "pla_orange" +KNOB_LOOK = "pla_white" + +# What the remix README says, in mm: "approximately 411 × 176 × 42 mm" +# without the knobs and stands, and where the stands go. +README_SIZE = (411.0, 176.0, 42.0) +README_SIZE_TOLERANCE = 1.0 +# The panel the case holds, 320 × 160 mm (lib/frame.mjs, LED_SIZE): the +# opening has to take it, with at most this much to spare each way. +PANEL = (320.0, 160.0) +OPENING_PLAY = 2.0 +# The middle of each stand from its end of the case, read off +# images/photo_front.jpg (the README says only "at each lower end"). +STAND_INSET = 30.0 + +# Hole sizes, mm: the 3.4 mm clearance holes for the M3 screws (the lugs, the +# covers' countersinks below their heads) and the 4.5 mm pilots the README +# gives for the inserts. +CLEARANCE_HOLE = 3.4 +INSERT_HOLE = 4.5 +ENCODER_HOLE = 7.4 + +# Model units per mm, and the half turn about y from the case frame (seen +# from behind, front at z = 0) to the model frame (seen from the front). +MM = 0.1 +CASE_TO_MODEL = np.diag([-1.0, 1.0, -1.0]) + +# The official case in the model frame (besoiobiy.py has the same): the +# centre of its outline and the inside of its 3 mm front. +OFFICIAL_OUTLINE_CENTRE = (-0.1817, 16.2446) +OFFICIAL_INSIDE_FRONT_Z = 1.2628 + +# Pieces closer than this (mm³) count as not overlapping: faces that touch +# exactly give a sliver of rounding error. +OVERLAP_TOLERANCE = 1.0 +# Two positions this close (mm) are the same. +SAME = 0.05 + + +def require(ok: bool, message: str) -> None: + """Stops the build when the files are not what this script expects.""" + if not ok: + sys.exit(f"mbchars.py: {message}") + + +def load(path: Path) -> trimesh.Trimesh: + mesh = trimesh.load_mesh(path) + require(mesh.is_watertight and len(mesh.split(only_watertight=False)) == 1, f"{path.name} is not one closed part") + return mesh + + +def signature(mesh: trimesh.Trimesh) -> np.ndarray: + """Numbers that do not change when a part is moved or turned.""" + return np.concatenate( + [[len(mesh.faces), mesh.area, abs(mesh.volume)], np.sort(np.abs(mesh.principal_inertia_components))] + ) + + +def is_official_knob(knob: trimesh.Trimesh) -> bool: + want = signature(knob) + return any( + np.allclose(signature(piece), want, rtol=1e-4, atol=1e-3) + for piece in trimesh.load_mesh(OFFICIAL_KNOBS).split(only_watertight=False) + ) + + +def planes(mesh: trimesh.Trimesh, axis: int, sign: int) -> list[tuple[float, float]]: + """The flat faces facing along ±axis: (level, area) per level, mm.""" + facing = mesh.face_normals[:, axis] * sign > 0.9999 + levels = np.round(mesh.triangles_center[facing, axis], 2) + out: dict[float, float] = {} + for level, area in zip(levels, mesh.area_faces[facing]): + out[level] = out.get(level, 0.0) + area + return sorted((float(k), float(v)) for k, v in out.items()) + + +def largest_plane(mesh: trimesh.Trimesh, axis: int, sign: int) -> float: + return max((area, level) for level, area in planes(mesh, axis, sign))[1] + + +def circles(mesh: trimesh.Trimesh, z: float, diameter: float) -> list[np.ndarray]: + """The round holes of a given size in a slice across z, by their centres.""" + section = mesh.section(plane_origin=[0, 0, z], plane_normal=[0, 0, 1]) + if section is None: + return [] + out = [] + for loop in section.discrete: + ring = shapely.Polygon(loop[:, :2]) + x0, y0, x1, y1 = ring.bounds + if abs(x1 - x0 - diameter) < SAME and abs(y1 - y0 - diameter) < SAME: + out.append(np.array([ring.centroid.x, ring.centroid.y])) + return out + + +def holes(mesh: trimesh.Trimesh, diameter: float, step: float = 0.25) -> list[dict]: + """ + Every round hole of a given size along z: its centre and the depths it + runs between (to the nearest step), found by slicing the part every step. + """ + found: list[dict] = [] + for z in np.arange(mesh.bounds[0, 2] + step / 2, mesh.bounds[1, 2], step): + for c in circles(mesh, float(z), diameter): + hole = next((h for h in found if np.allclose(h["at"], c, atol=SAME) and z - h["to"] <= step * 1.5), None) + if hole is None: + found.append({"at": c, "from": float(z), "to": float(z)}) + else: + hole["to"] = float(z) + for h in found: + h["from"] -= step / 2 + h["to"] += step / 2 + return sorted(found, key=lambda h: (round(h["at"][0], 2), round(h["at"][1], 2))) + + +def level_around(mesh: trimesh.Trimesh, at, radius: float, sign: int, near: float) -> float: + """ + The flat face facing ±z within radius of a point (xy) whose level is + nearest `near`: the exact depth of a face a slice only brackets. + """ + facing = mesh.face_normals[:, 2] * sign > 0.9999 + close = np.linalg.norm(mesh.triangles_center[:, :2] - np.asarray(at), axis=1) <= radius + levels = np.unique(np.round(mesh.triangles_center[facing & close, 2], 2)) + require(len(levels) > 0, f"no flat face within {radius} mm of {np.round(at, 2).tolist()}") + return float(levels[np.argmin(np.abs(levels - near))]) + + +def material_at(mesh: trimesh.Trimesh, z: float) -> shapely.Geometry: + """A slice through a part at depth z, as the area that is solid (mm).""" + section = mesh.section(plane_origin=[0, 0, z], plane_normal=[0, 0, 1]) + solid = shapely.Polygon() + for loop in section.discrete: + solid = solid.symmetric_difference(shapely.Polygon(loop[:, :2])) + return solid + + +def moved(mesh: trimesh.Trimesh, matrix: np.ndarray) -> trimesh.Trimesh: + out = mesh.copy() + out.apply_transform(matrix) + return out + + +def translation(v) -> np.ndarray: + return trimesh.transformations.translation_matrix(np.asarray(v, dtype=float)) + + +def front_sections(parts: dict[str, trimesh.Trimesh], bar: trimesh.Trimesh) -> tuple[dict, dict]: + """ + Where each front section goes along x (step 1), and the joint bars' places + (step 2): {name: 4×4}, and the joints as {name: (left, right, lug holes)}. + """ + inserts = holes(bar, INSERT_HOLE) + require(len(inserts) == 2, f"{JOINT_BAR}: {len(inserts)} insert holes, expected two") + pitch = inserts[1]["at"][0] - inserts[0]["at"][0] + require(abs(inserts[0]["at"][1] - inserts[1]["at"][1]) < SAME, "the joint bar's inserts are not along its length") + # The inserts open on the bar's back (its top as it prints), toward the lugs. + require(all(abs(h["to"] - bar.bounds[1, 2]) < 0.2 for h in inserts), "the joint bar's inserts do not open on its top") + + # Each section's lug holes: at its far end (from the control section) on + # the left of a joint, at its near end on the right. + lugs = {name: holes(mesh, CLEARANCE_HOLE) for name, mesh in parts.items()} + names = list(FRONT) + offsets = {names[0]: 0.0} + joints = {} + for left, right in zip(names, names[1:]): + far = [h for h in lugs[left] if h["at"][0] > parts[left].centroid[0]] + near = [h for h in lugs[right] if h["at"][0] < parts[right].centroid[0]] + require(len(far) == 2 and len(near) == 2, f"{left}/{right}: {len(far)} and {len(near)} lug holes, expected two each") + require( + np.allclose(sorted(h["at"][1] for h in far), sorted(h["at"][1] for h in near), atol=SAME), + f"{left}/{right}: the lugs are not level", + ) + offsets[right] = offsets[left] + far[0]["at"][0] + pitch - near[0]["at"][0] + joints[f"{left}/{right}"] = (left, right, far, near) + placed = {name: translation([offsets[name], 0, 0]) for name in names} + + # The bars: inserts under the left section's lug holes, back face on the lugs. + bars = {} + insert_at = inserts[0]["at"] + for key, (left, right, far, near) in joints.items(): + for hole in far: + lug_front = level_around(parts[left], hole["at"], CLEARANCE_HOLE, -1, hole["from"]) + at = [offsets[left] + hole["at"][0] - insert_at[0], hole["at"][1] - insert_at[1], lug_front - bar.bounds[1, 2]] + # The other insert lands under the right section's lug. + other = np.array(at[:2]) + inserts[1]["at"] + require( + any(np.allclose(other, [offsets[right] + h["at"][0], h["at"][1]], atol=SAME) for h in near), + f"{key}: the bar's second insert is not under the lug", + ) + side = "top" if hole["at"][1] > parts[left].bounds.mean(axis=0)[1] else "bottom" + # In the model the control section is on the right: name the + # joints as a reader sees them, from the front. + joint = "right" if left == "control_section" else "left" + bars[f"joint_bar_{joint}_{side}"] = translation(at) + return placed, {"bars": bars, "pitch": pitch, "offsets": offsets} + + +def covers( + covers_raw: dict[str, trimesh.Trimesh], case: dict[str, trimesh.Trimesh], insert_holes: list[np.ndarray] +) -> dict[str, np.ndarray]: + """Each cover's 4×4 (step 3): face up, plate on the rim, every hole on an insert.""" + rim = max(mesh.bounds[1, 2] for mesh in case.values()) + out = {} + for name, cover in covers_raw.items(): + fits = [] + # The two half turns that bring a face-down cover face up. + for turn_name, axis in (("about x", [1, 0, 0]), ("about y", [0, 1, 0])): + turn = trimesh.transformations.rotation_matrix(np.pi, axis) + turned = moved(cover, turn) + # Its plate's inside face (the largest facing the front) on the rim. + dz = rim - largest_plane(turned, 2, -1) + turned.apply_translation([0, 0, dz]) + # The screw holes, through the middle of the plate. + mine = circles(turned, rim + 1.5, CLEARANCE_HOLE) + require(len(mine) == 8, f"{name}: {len(mine)} screw holes, expected eight") + for a, b in itertools.product(insert_holes, mine): + d = a - b + if all(any(np.allclose(m + d, i, atol=SAME) for i in insert_holes) for m in mine): + fits.append((turn_name, translation([d[0], d[1], dz]) @ turn, d)) + unique = {(t, round(d[0], 2), round(d[1], 2)): m for t, m, d in fits} + require(len(unique) == 1, f"{name}: {len(unique)} ways to put its holes on the inserts, expected one") + out[name] = next(iter(unique.values())) + return out + + +def retainer_place(control: trimesh.Trimesh, retainer: trimesh.Trimesh, used: list[np.ndarray]) -> np.ndarray: + """The USB retainer's 4×4 (step 4): slot over its insert, tongue on the boss.""" + others = [ + h for h in holes(control, INSERT_HOLE) if not any(np.linalg.norm(h["at"] - u) < 1.0 for u in used) + ] + require(len(others) == 1, f"{len(others)} inserts in the control section besides the covers', expected one") + boss = others[0] + boss_top = level_around(control, boss["at"], 1.5 * INSERT_HOLE, +1, boss["to"]) + # The slot through the tongue: the one closed loop in a slice across it + # that is not the outline. + top = retainer.bounds[1, 2] + section = retainer.section(plane_origin=[0, 0, top - 1], plane_normal=[0, 0, 1]) + loops = sorted((shapely.Polygon(loop[:, :2]) for loop in section.discrete), key=lambda p: p.area) + require(len(loops) == 2, "the retainer's tongue has no slot") + x0, y0, x1, y1 = loops[0].bounds + require(x1 - x0 > y1 - y0, "the retainer's slot does not run along x") + slot = ((x0 + x1) / 2, (y0 + y1) / 2) + # The tongue's underside: the face round the slot that faces the front. + underside = level_around(retainer, slot, x1 - x0, -1, top) + return translation([boss["at"][0] - slot[0], boss["at"][1] - slot[1], boss_top - underside]) + + +def stand_place(stand: trimesh.Trimesh, depth: float, x_mid: float) -> tuple[np.ndarray, float]: + """ + A stand's 4×4 (step 5), its slot's middle at x_mid along the case, and the + angle the case leans back at. The stand prints on its side: its outline in + xy, extruded along z. + """ + outline = shapely.Polygon( + max(stand.section(plane_origin=[0, 0, stand.bounds[1, 2] / 2], plane_normal=[0, 0, 1]).discrete, key=len)[:, :2] + ).simplify(0.05) + outline = shapely.geometry.polygon.orient(outline, 1.0) # anticlockwise: solid on the left + pts = np.array(outline.exterior.coords)[:-1] + n = len(pts) + found = [] + for i in range(n): + a, b = pts[i], pts[(i + 1) % n] + prev, nxt = pts[i - 1], pts[(i + 2) % n] + length = np.linalg.norm(b - a) + if not depth <= length <= depth + 1.5: + continue + u = (b - a) / length + side_in, side_out = a - prev, nxt - b + # A slot: both sides square to the floor. + if any(abs(np.dot(side, u)) > 0.01 * np.linalg.norm(side) for side in (side_in, side_out)): + continue + found.append((a, b, u, np.linalg.norm(side_in), np.linalg.norm(side_out))) + require(len(found) == 1, f"{STAND}: {len(found)} slots as wide as the case is deep, expected one") + a, b, u, h_in, h_out = found[0] + # Anticlockwise, the solid is on the left of each step, so the open slot + # is on the right of its floor: that way is up, out of the slot. + up = np.array([u[1], -u[0]]) + require(up[1] > 0, "the stand's slot does not open upward") + # The short side is the lip in front, the tall side the back the case + # leans on. Order the floor from the front lip to the back. + lip, back = (a, b) if h_in < h_out else (b, a) + require(min(h_in, h_out) < max(h_in, h_out) / 2, "the stand's slot has no short lip and tall back") + along = (back - lip) / np.linalg.norm(back - lip) + # How far the case leans back on a table: its up, along the slot's sides, + # from the vertical, toward the back. + lean = float(np.degrees(np.arccos(up[1]))) + require(up[0] * along[0] > 0, "the stand leans the case forward") + # Stand frame → case frame: `along` → +z (front to back), `up` → +y, + # and the extrusion (z) → -x, to keep it a turn rather than a mirror. + m = np.eye(4) + rot = np.zeros((3, 3)) + rot[2, :2] = along + rot[1, :2] = up + rot[0, 2] = -1.0 + require(abs(np.linalg.det(rot) - 1) < 1e-9, "the stand's turn is a mirror") + m[:3, :3] = rot + # The case's bottom on the floor, its back against the tall side, the + # stand's middle at x_mid. + back_xy = rot[:, :2] @ back + m[:3, 3] = [x_mid + stand.bounds[1, 2] / 2, -back_xy[1], depth - back_xy[2]] + return m, lean + + +def check(case: dict[str, trimesh.Trimesh], body: list[str]) -> dict[str, object]: + """The assembled case against the README and the board; returns what the model needs.""" + every = trimesh.util.concatenate([case[n] for n in body]) + size = every.extents + require( + np.allclose(size, README_SIZE, atol=README_SIZE_TOLERANCE), + f"assembled size {np.round(size, 2).tolist()}, README says about {README_SIZE}", + ) + + for a, b in itertools.combinations(case, 2): + lo = np.maximum(case[a].bounds[0], case[b].bounds[0]) + hi = np.minimum(case[a].bounds[1], case[b].bounds[1]) + if np.any(hi - lo <= 0): + continue + overlap = trimesh.boolean.intersection([case[a], case[b]], engine="manifold") + # Faces that only touch leave a flat sliver of no volume, whose centre + # of mass trimesh divides by that zero to find on the way. + with np.errstate(divide="ignore", invalid="ignore"): + volume = overlap.volume if len(overlap.faces) else 0.0 + require(volume < OVERLAP_TOLERANCE, f"{a} and {b} overlap by {volume:.1f} mm³") + + # The encoder holes through the control section's front, on the board's grid. + control = case["control_section"] + inside = largest_plane(control, 2, +1) + encoders = [c for c in circles(control, inside / 2, ENCODER_HOLE)] + require(len(encoders) == 4, f"{len(encoders)} encoder holes, expected four") + centres = np.array(sorted((c[0], c[1]) for c in encoders)) + pitch = (float(np.ptp(centres[:, 0])), float(np.ptp(centres[:, 1]))) + + # The panel's opening: the hole in a slice through the two frames just + # behind the front, closing the hairline at their joint first. + frames = [material_at(case[n], 1.0).buffer(0.05) for n in ("inner_frame", "outer_frame")] + walls = shapely.unary_union(frames).buffer(-0.05) + rings = [shapely.Polygon(r) for g in getattr(walls, "geoms", [walls]) for r in g.interiors] + require(bool(rings), "no opening in the frames") + x0, y0, x1, y1 = max(rings, key=lambda p: p.area).bounds + opening = (x1 - x0, y1 - y0) + require( + all(PANEL[k] <= opening[k] <= PANEL[k] + OPENING_PLAY for k in range(2)), + f"opening {np.round(opening, 2).tolist()} for a {PANEL} panel", + ) + # The tabs the panel's back stands on: the largest flat faces facing the + # front in the frames, behind the front face itself. + tabs = min( + max((area, level) for level, area in planes(case[n], 2, -1) if level > 1.0)[1] + for n in ("inner_frame", "outer_frame") + ) + + return { + "outline": every.bounds, + "inside_front": inside, + "hole_centroid": centres.mean(axis=0), + "pitch": pitch, + "opening": opening, + "opening_centre": np.array([(x0 + x1) / 2, (y0 + y1) / 2]), + "tabs": tabs, + "overall": size, + } + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--parts", type=Path, default=REMIX / "stl", help="the remix's stl/ folder") + ap.add_argument( + "--out", + type=Path, + default=HERE / "build/mbchars-horizontal-desktop-printed", + help="where shell.glb and placement.json go", + ) + args = ap.parse_args() + + knob_bases, official_front, _ = official_numbers() + knob_pitch = ( + abs(knob_bases["c2"][0] - knob_bases["c1"][0]) / MM, + abs(knob_bases["c1"][1] - knob_bases["c3"][1]) / MM, + ) + + raw = {name: load(args.parts / file) for name, file in {**FRONT, **COVERS}.items()} + bar = load(args.parts / JOINT_BAR) + retainer = load(args.parts / RETAINER) + stand = load(args.parts / STAND) + require(is_official_knob(load(args.parts / KNOB)), f"{KNOB} is not the official knob") + + # 1, 2: the front sections and the joint bars. + front_raw = {name: raw[name] for name in FRONT} + placed, joints = front_sections(front_raw, bar) + case = {name: moved(front_raw[name], m) for name, m in placed.items()} + for name, m in joints["bars"].items(): + case[name] = moved(bar, m) + # The faces that meet at each joint touch. + names = list(FRONT) + for left, right in zip(names, names[1:]): + ends = [level for level, area in planes(case[left], 0, +1) if area > 50] + starts = [level for level, area in planes(case[right], 0, -1) if area > 50] + require( + any(abs(a - b) < 0.01 for a in ends for b in starts), f"{left} and {right} have no faces that meet" + ) + + # 3: the covers, on the inserts by the rim. + rim = max(case[n].bounds[1, 2] for n in FRONT) + inserts = [] + for n in FRONT: + for h in holes(case[n], INSERT_HOLE): + if abs(h["to"] - rim) < 0.2: + inserts.append(h["at"]) + require(len(inserts) == 16, f"{len(inserts)} cover inserts in the sections' bosses, expected 16") + cover_places = covers({n: raw[n] for n in COVERS}, {n: case[n] for n in FRONT}, inserts) + for name, m in cover_places.items(): + case[name] = moved(raw[name], m) + covered = [c for n in COVERS for c in circles(case[n], rim + 1.5, CLEARANCE_HOLE)] + for i in inserts: + require(sum(np.allclose(i, c, atol=SAME) for c in covered) == 1, f"insert at {np.round(i, 2)} has no cover hole") + require( + case["rear_control_cover"].bounds[0, 0] <= case["control_section"].bounds[0, 0] + SAME, + "the control cover is not at the control end", + ) + + # 4: the USB retainer, on the control section's other insert. + control_inserts = [i for i in inserts if i[0] < case["control_section"].bounds[1, 0]] + case["usb_retainer"] = moved(retainer, retainer_place(case["control_section"], retainer, control_inserts)) + + # 5: the stands, at each end. + body = list(FRONT) + list(COVERS) + lo = min(case[n].bounds[0, 0] for n in body) + hi = max(case[n].bounds[1, 0] for n in body) + depth = max(case[n].bounds[1, 2] for n in body) - min(case[n].bounds[0, 2] for n in body) + for name, x_mid in (("stand_right", lo + STAND_INSET), ("stand_left", hi - STAND_INSET)): + m, lean = stand_place(stand, depth, x_mid) + case[name] = moved(stand, m) + + found = check(case, body) + require( + np.allclose(found["pitch"], knob_pitch, atol=SAME), + f"encoder holes {np.round(found['pitch'], 2).tolist()} apart, the board's are {np.round(knob_pitch, 2).tolist()}", + ) + + # Into the model frame: the outline centred on the official case's, the + # front face on the official's. + lo3, hi3 = found["outline"] + centre = CASE_TO_MODEL @ ((lo3 + hi3) / 2) * MM + # The front face is at z = 0 in the case frame. + front_z = 0.0 + offset = np.array([OFFICIAL_OUTLINE_CENTRE[0] - centre[0], OFFICIAL_OUTLINE_CENTRE[1] - centre[1], official_front]) + to_model = np.eye(4) + to_model[:3, :3] = CASE_TO_MODEL * MM + to_model[:3, 3] = offset + + def model_point(p) -> np.ndarray: + return CASE_TO_MODEL @ np.asarray(p, dtype=float) * MM + offset + + holes_at = model_point([*found["hole_centroid"], 0.0]) + knob_centroid = np.mean(list(knob_bases.values()), axis=0) + inside_front = model_point([0, 0, found["inside_front"]])[2] + board = np.array( + [ + holes_at[0] - knob_centroid[0], + holes_at[1] - knob_centroid[1], + inside_front - OFFICIAL_INSIDE_FRONT_Z, + ] + ) + led = model_point([*found["opening_centre"], front_z]) + knob_z = official_front + (knob_bases["c1"][2] - official_front) + + out = trimesh.Scene() + for name in case: + mesh = crease_shaded(moved(case[name], to_model), CREASE_DEG) + look = ORANGE_PLA if name in ORANGE else WHITE_PLA + out.add_geometry(with_material(mesh, look), node_name=name, geom_name=name) + + args.out.mkdir(parents=True, exist_ok=True) + (args.out / "shell.glb").write_bytes(out.export(file_type="glb", include_normals=True)) + placement = { + "board": [round(float(v), 4) for v in board], + "led": [round(float(v), 4) for v in led], + "ledTurn": -90, + "knobs": "official", + "knobBaseZ": round(float(knob_z), 4), + "knobLook": KNOB_LOOK, + } + (args.out / "placement.json").write_text(json.dumps(placement, indent=2) + "\n") + + # Report, in mm unless it says units. + size = found["overall"] + offsets = joints["offsets"] + print(f"front sections along x: {', '.join(f'{n} {offsets[n]:.2f}' for n in FRONT)} (lug holes {joints['pitch']:.2f} apart across each joint)") + print(f"joint bars: {', '.join(joints['bars'])}") + for name in COVERS: + print(f" {name:20s} x {case[name].bounds[0, 0]:.2f} … {case[name].bounds[1, 0]:.2f}, all eight holes on inserts") + print(f"assembled {size[0]:.2f} × {size[1]:.2f} × {size[2]:.2f} (README: about 411 × 176 × 42), no parts overlapping") + print(f"encoder holes {found['pitch'][0]:.2f} × {found['pitch'][1]:.2f} apart; front {found['inside_front']:.2f} thick") + print( + f"opening {found['opening'][0]:.2f} × {found['opening'][1]:.2f} for the 320 × 160 panel; tabs {found['tabs']:.2f} behind the front" + ) + print(f"stands {STAND_INSET:.0f} from each end, the case leaning back {lean:.2f}°") + print(f"model units: {json.dumps(placement)}") + print(f"wrote {args.out / 'shell.glb'} ({len(case)} parts) and placement.json") + + +if __name__ == "__main__": + main() diff --git a/web/content/build.md b/web/content/build.md index 2904894c..f834d123 100644 --- a/web/content/build.md +++ b/web/content/build.md @@ -3,4 +3,4 @@ title: Build your own. subtitle: Around US$100–200 and an hour of hands-on work. --- -Every case here holds the same v3 board, soldered, wired and flashed the same way; what changes is the body around it. The official case is printed, and it is the one the build guide walks through. The two community remixes have each been built by their authors: another printed case, and one laser-cut from acrylic or MDF sheet. Pick one and the 3D view shows the device in it. +Every case here holds the same v3 board, soldered, wired and flashed the same way; what changes is the body around it. The official case is printed, and it is the one the build guide walks through. The three community remixes have each been built by their authors: another printed case, one laser-cut from acrylic or MDF sheet, and a printed one that holds the panel the long way across, on stands for a desk. Pick one and the 3D view shows the device in it. diff --git a/web/public/builds/extraplojo/angle.jpg b/web/public/builds/mbchars/angle.jpg similarity index 100% rename from web/public/builds/extraplojo/angle.jpg rename to web/public/builds/mbchars/angle.jpg diff --git a/web/public/builds/extraplojo/front.jpg b/web/public/builds/mbchars/front.jpg similarity index 100% rename from web/public/builds/extraplojo/front.jpg rename to web/public/builds/mbchars/front.jpg diff --git a/web/public/builds/extraplojo/inside.jpg b/web/public/builds/mbchars/inside.jpg similarity index 100% rename from web/public/builds/extraplojo/inside.jpg rename to web/public/builds/mbchars/inside.jpg diff --git a/web/public/cases/mbchars-horizontal-desktop-printed/angle.jpg b/web/public/cases/mbchars-horizontal-desktop-printed/angle.jpg new file mode 100644 index 00000000..0ac77f0f Binary files /dev/null and b/web/public/cases/mbchars-horizontal-desktop-printed/angle.jpg differ diff --git a/web/public/cases/mbchars-horizontal-desktop-printed/front.jpg b/web/public/cases/mbchars-horizontal-desktop-printed/front.jpg new file mode 100644 index 00000000..8aa77af3 Binary files /dev/null and b/web/public/cases/mbchars-horizontal-desktop-printed/front.jpg differ diff --git a/web/public/cases/mbchars-horizontal-desktop-printed/model.glb b/web/public/cases/mbchars-horizontal-desktop-printed/model.glb new file mode 100644 index 00000000..b2ec1785 Binary files /dev/null and b/web/public/cases/mbchars-horizontal-desktop-printed/model.glb differ diff --git a/web/public/cases/mbchars-horizontal-desktop-printed/rear_assembly.jpg b/web/public/cases/mbchars-horizontal-desktop-printed/rear_assembly.jpg new file mode 100644 index 00000000..20a99760 Binary files /dev/null and b/web/public/cases/mbchars-horizontal-desktop-printed/rear_assembly.jpg differ diff --git a/web/public/llms.txt b/web/public/llms.txt index dc12883d..0d3c7a66 100644 --- a/web/public/llms.txt +++ b/web/public/llms.txt @@ -12,7 +12,7 @@ It is not a single luminous object but an open system. The hardware files, firmw - **Mission:** Democratizing interactive media art — making something that felt expensive and exclusive into something anyone can make. - **Creator:** Seung Hun Lee (artist). - **Lineage:** A reinterpretation of Nam June Paik's *Participation TV* (1963), extending participation into creation. -- **Hardware:** ESP32-S3 microcontroller, HUB75 RGB LED matrix, four rotary encoders, artist-designed PCB, 3D-printed enclosure; two community case remixes, a second printed case and a laser-cut acrylic or MDF one, sit beside it. +- **Hardware:** ESP32-S3 microcontroller, HUB75 RGB LED matrix, four rotary encoders, artist-designed PCB, 3D-printed enclosure; three community case remixes, two more printed cases (one holds the panel the long way across, on desk stands) and a laser-cut acrylic or MDF one, sit beside it. - **Software:** Arduino-compatible C++ firmware with a modular pattern architecture; browser-based Live Editor and flasher built with Next.js and Three.js. - **Make your own patterns:** A prompt template lets AI coding assistants (Claude, ChatGPT, etc.) generate new patterns from a plain-language description — no GLSL or rendering-pipeline knowledge required. - **Licensing:** Firmware and web are MIT; hardware, designs, and patterns are CC-BY-SA 4.0. Community pattern submissions are inbound = outbound (no CLA). diff --git a/web/src/components/3d/HeroScene.tsx b/web/src/components/3d/HeroScene.tsx index e71b56bc..4790b7a1 100644 --- a/web/src/components/3d/HeroScene.tsx +++ b/web/src/components/3d/HeroScene.tsx @@ -40,7 +40,7 @@ import { type KnobRig, type PreparedCase, } from './heroCase'; -import { poseFor, shows } from './buildPose'; +import { fitAcross, poseFor, shows } from './buildPose'; import CaseFinishSwitch from './CaseFinishSwitch'; import { PageAlpha, StraightAlpha } from './canvasAlpha'; import { NeutralToeBack } from './neutralToe'; @@ -100,11 +100,22 @@ const MESH_TO_KNOB: Record = { const TARGET = new THREE.Vector3(0, 1.7, 0); const Y_AXIS = new THREE.Vector3(0, 1, 0); +/** The camera as the page opens: where it stands, and its vertical field of view. */ +const CAMERA_POSITION: [number, number, number] = [0.0, 6.0, 10.3]; +const CAMERA_FOV = 28; +/** + * How tall the view is where the device stands, with the camera at rest, in + * world units; times the canvas's aspect, how wide (buildPose.ts, fitAcross). + */ +const REST_VIEW_HEIGHT = + 2 * Math.tan(THREE.MathUtils.degToRad(CAMERA_FOV / 2)) * TARGET.distanceTo(new THREE.Vector3(...CAMERA_POSITION)); + /** * Solder (step 2) shows the board alone, filling the view: the diagonal of * the board-and-DevKit's box, seen from the front, is drawn this many world * units across. The official device as a whole is 4.09 at the resting scale - * (the remixes, up to 4% more). + * (Besoiobiy's and SimonePDA's up to 4% more; mbchars', lying the long way + * on its stands, 11% more, and two thirds wider). */ const BOARD_SPAN = 3.1; @@ -438,6 +449,10 @@ function ProductPreview({ if (pose.board) { const size = device.board.getSize(tmp.v); scale = BOARD_SPAN / Math.hypot(size.x, size.y); + } else { + // A device too wide for a tall view at the one scale is drawn smaller, + // so it stays clear of the arrows (fitAcross); any other, as it is. + scale *= fitAcross(device.width, REST_VIEW_HEIGHT * (state.size.width / state.size.height)); } group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, pose.turn, kView); @@ -584,7 +599,7 @@ export default function HeroScene() { /> )} - + { expect(Object.values(STEPS).filter((step) => poseFor(step, 1, 0).lit)).toEqual([STEPS.idle, STEPS.power]); }); }); + +describe('fitAcross', () => { + // The view's height at rest where the device stands (HeroScene: fov 28°, + // the camera at (0, 6, 10.3) on the target (0, 1.7, 0)), and its width on + // the two layouts the screenshots were taken on: 1440 × 900, the preview + // 690 × 900 beside the panel, and a 390 px phone, 390 × 372 over it. + const VIEW_HEIGHT = 2 * Math.tan((14 * Math.PI) / 180) * Math.hypot(6 - 1.7, 10.3); + const DESKTOP = VIEW_HEIGHT * (690 / 900); + const PHONE = VIEW_HEIGHT * (390 / 372); + // Each case's device across, model units, as tools/case-models builds it. + const UPRIGHT = { official: 24.679, 'besoiobiy-printed': 26.701, 'simonepda-lasercut': 25.599 }; + const HORIZONTAL = 41.028; + + it('keeps every case at the one scale wherever it fits', () => { + for (const width of [...Object.values(UPRIGHT), HORIZONTAL]) { + expect(fitAcross(width, PHONE), `${width} on a phone`).toBe(1); + } + for (const [id, width] of Object.entries(UPRIGHT)) { + expect(fitAcross(width, DESKTOP), `${id} on a desktop`).toBe(1); + } + }); + + it('draws the long case smaller on a tall view, by as little as keeps it clear of the arrows', () => { + const fit = fitAcross(HORIZONTAL, DESKTOP); + expect(fit).toBeLessThan(1); + expect(fit).toBeGreaterThan(0.7); + // At rest it takes 72% of the view's width, no more and no less. + expect((HORIZONTAL * REST_SCALE * fit) / DESKTOP).toBeCloseTo(0.72, 9); + }); +}); diff --git a/web/src/components/3d/buildPose.ts b/web/src/components/3d/buildPose.ts index 89a6dc4d..9a69c309 100644 --- a/web/src/components/3d/buildPose.ts +++ b/web/src/components/3d/buildPose.ts @@ -12,6 +12,15 @@ import type { CasePart, PartRole } from './heroCase'; */ const BOARD_TURN = Math.PI - 0.8; +/** The scale the device rests at: idle, and lit on Flash. */ +export const REST_SCALE = 0.1; + +/** + * At most this much of the view's width, the device at rest: what keeps it + * clear of the arrows either side (HeroScene). + */ +const FIT_ACROSS = 0.72; + /** What each Build step shows. */ export interface Pose { /** The device's turn about the vertical, radians, and its scale. */ @@ -61,12 +70,12 @@ export function poseFor(step: number, explode: number, t: number): Pose { }; // 4. Flash and power on: together, lit. case 4: - return { turn: -0.5, scale: 0.1, lift: 0, spread: 0, show: ALL, loose: false, board: false, lit: true }; + return { turn: -0.5, scale: REST_SCALE, lift: 0, spread: 0, show: ALL, loose: false, board: false, lit: true }; // Idle: together, lit, swaying slowly. default: return { turn: Math.sin(t * 0.15) * 0.45, - scale: 0.1, + scale: REST_SCALE, lift: Math.sin(t * 0.3) * 0.03, spread: 0, show: ALL, @@ -77,6 +86,24 @@ export function poseFor(step: number, explode: number, t: number): Pose { } } +/** + * How much smaller than its pose's scale a device is drawn, so it fits across + * the view: 1 for a device that already does. Every case is drawn at the one + * scale, so the LED panel is the same size in each, wherever that fits: on a + * phone all four do, and on a desktop's tall view the three that stand + * upright do, 25 to 27 cm across. mbchars' case lies the long way, 41 cm + * across, and on a tall view it would run off both sides, under the arrows; + * there it is drawn smaller, by as little as keeps it clear of them. + * + * `width` is the device's, assembled, in model units (PreparedCase.width); + * `viewWidth` the view's width at rest where the device stands, in world + * units: at rest, not as zoomed, since zooming in on a device must not + * shrink it. + */ +export function fitAcross(width: number, viewWidth: number): number { + return Math.min(1, (FIT_ACROSS * viewWidth) / (width * REST_SCALE)); +} + /** Whether a pose has a part out: its kind of part, and if it is a loose piece, loose pieces too. */ export function shows(pose: Pick, part: Pick): boolean { return pose.show[part.role] && (pose.loose || !part.loose); diff --git a/web/src/components/3d/caseModels.ts b/web/src/components/3d/caseModels.ts index 16906d85..d54825fc 100644 --- a/web/src/components/3d/caseModels.ts +++ b/web/src/components/3d/caseModels.ts @@ -2,6 +2,7 @@ import type { CaseId } from '@/components/sections/build-cases-data'; import { OFFICIAL_MODEL } from './cases/official'; import { BESOIOBIY_MODEL } from './cases/besoiobiy-printed'; import { SIMONEPDA_MODEL } from './cases/simonepda-lasercut'; +import { MBCHARS_MODEL } from './cases/mbchars-horizontal-desktop-printed'; // The device the product preview shows, one model per case on the Build // panel's switch. Each model is a single GLB built by tools/case-models from @@ -69,6 +70,7 @@ export const CASE_MODELS: Record = { official: OFFICIAL_MODEL, 'besoiobiy-printed': BESOIOBIY_MODEL, 'simonepda-lasercut': SIMONEPDA_MODEL, + 'mbchars-horizontal-desktop-printed': MBCHARS_MODEL, }; /** Where the Draco decoder comes from: the same copy the guide's models use. */ diff --git a/web/src/components/3d/cases/mbchars-horizontal-desktop-printed.ts b/web/src/components/3d/cases/mbchars-horizontal-desktop-printed.ts new file mode 100644 index 00000000..1e1d90fd --- /dev/null +++ b/web/src/components/3d/cases/mbchars-horizontal-desktop-printed.ts @@ -0,0 +1,42 @@ +import type { CaseModel } from '../caseModels'; + +// Mykyta Bilous' (mbchars') horizontal desktop case: tools/case-models/mbchars.py, +// from the parts in hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/ +// put together. The panel lies the long way across, a quarter turn from the +// other cases, in two white frames; the board is in the orange control section +// on the right. Four joint bars hold the three front sections together from +// behind, two covers close the back, and two orange stands hold it by its +// bottom edge. The knobs are the official ones, printed white. +export const MBCHARS_MODEL: CaseModel = { + id: 'mbchars-horizontal-desktop-printed', + url: '/cases/mbchars-horizontal-desktop-printed/model.glb', + // Drawn apart the way it goes together, told from the inner frame, which + // stays. The outer frame and the control section open a little at their + // joints, so the joints show, and the joint bars come out behind them, each + // still over its joint; whatever the control section holds (the knobs, the + // board, the DevKit, the module's retainer) moves with it. The panel goes + // into the frames from the front and the knobs onto the shafts from the + // front, so both come off that way. Behind, the board comes out, the DevKit + // comes off its sockets, and the two covers come off last, as the back of + // the case. The stands drop off the bottom at each end. + explode: { + outer_frame: [-1, 0, 0], + control_section: [1, 0, 0], + joint_bar_left_top: [-0.5, 0, -3.5], + joint_bar_left_bottom: [-0.5, 0, -3.5], + joint_bar_right_top: [0.5, 0, -3.5], + joint_bar_right_bottom: [0.5, 0, -3.5], + l: [0, 0, 6.4], + c1: [1, 0, 4.2], + c2: [1, 0, 4.2], + c3: [1, 0, 4.2], + c4: [1, 0, 4.2], + pcb_v39: [1, 0, -6.8], + devkit: [1, 0, -11.2], + usb_retainer: [1, 0, -4.5], + rear_control_cover: [0, 0, -15.5], + rear_display_cover: [0, 0, -15.5], + stand_left: [-1, -2.5, 0], + stand_right: [1, -2.5, 0], + }, +}; diff --git a/web/src/components/3d/heroCase.test.ts b/web/src/components/3d/heroCase.test.ts index b161d590..94de6c92 100644 --- a/web/src/components/3d/heroCase.test.ts +++ b/web/src/components/3d/heroCase.test.ts @@ -123,6 +123,8 @@ describe('prepareCase', () => { prepared.parts.forEach((p) => box.union(p.box)); const centre = box.getCenter(new THREE.Vector3()).add(prepared.fitPosition); expect(centre.distanceTo(FRAME_CENTRE)).toBeLessThan(1e-9); + // Its width, what the preview fits across the view (buildPose.ts, fitAcross). + expect(prepared.width).toBeCloseTo(box.max.x - box.min.x, 9); }); it('draws every case at one scale: a bigger case does not shrink the panel in it', () => { diff --git a/web/src/components/3d/heroCase.ts b/web/src/components/3d/heroCase.ts index d6e8a9a6..81d781c8 100644 --- a/web/src/components/3d/heroCase.ts +++ b/web/src/components/3d/heroCase.ts @@ -73,6 +73,8 @@ export interface PreparedCase { led: THREE.Mesh[]; /** Centres the model on FRAME_CENTRE: where the model's frame sits in the preview's. */ fitPosition: THREE.Vector3; + /** The device's width, assembled, in model units: what the view has to fit across. */ + width: number; /** The board with the DevKit on it, assembled, in the model's frame. */ board: THREE.Box3; looks: OwnLook[]; @@ -197,11 +199,15 @@ export function prepareCase(scene: THREE.Object3D, model: CaseModel): PreparedCa // Every case at the one scale, only centred: the LED panel is the same part // in each (and the knobs, but on Besoiobiy's case, which has its own caps), // so it is drawn the same size, and what differs is what really does, the - // case round it (up to 4% across the front's diagonal; Besoiobiy's case is - // 8% wider than the official one). + // case round it (up to 4% across the front's diagonal among the three that + // stand upright; Besoiobiy's case is 8% wider than the official one, and + // mbchars', lying the long way, two thirds wider). Only a view too narrow + // for a case draws it smaller, and that is the preview's to do, by the + // width recorded here (buildPose.ts, fitAcross). const device = new THREE.Box3(); parts.forEach((p) => device.union(p.box)); const fitPosition = FRAME_CENTRE.clone().sub(device.getCenter(new THREE.Vector3())); + const width = device.max.x - device.min.x; const board = new THREE.Box3(); parts.filter((p) => p.role === 'pcb' || p.role === 'devkit').forEach((p) => board.union(p.box)); @@ -283,7 +289,7 @@ export function prepareCase(scene: THREE.Object3D, model: CaseModel): PreparedCa return [{ node, ring, tints, height: box.max.z }]; }); - return { model, root, parts, knobs, led, fitPosition, board, looks, owned }; + return { model, root, parts, knobs, led, fitPosition, width, board, looks, owned }; } /** The knob a pointer is on, if it is on one: the knob itself, its ring or its hit disc. */ diff --git a/web/src/components/sections/BuildPanel.test.tsx b/web/src/components/sections/BuildPanel.test.tsx index ef3247f5..c5005fc0 100644 --- a/web/src/components/sections/BuildPanel.test.tsx +++ b/web/src/components/sections/BuildPanel.test.tsx @@ -5,7 +5,7 @@ import { useAppStore } from '@/store/useAppStore'; import type { SectionContent } from '@/lib/content'; import BuildPanel from './BuildPanel'; -// The Build panel's case switch: three tabs over one card, the official case +// The Build panel's case switch: four tabs over one card, the official case // first; switching changes the card, step 01 and the Start here row, writes // ?case= while /build is on screen, and a ?case= link opens on that case. The // case lives in the app store, where the 3D preview reads it, so the switch @@ -77,7 +77,7 @@ const startRow = (name: RegExp) => describe('the case switch', () => { it('opens on the official case, with the build.md lead above it', () => { render(); - expect(screen.getAllByRole('tab')).toHaveLength(3); + expect(screen.getAllByRole('tab')).toHaveLength(4); expect(tab(/Official/)).toHaveAttribute('aria-selected', 'true'); expect(tab(/Besoiobiy/)).toHaveAttribute('aria-selected', 'false'); expect(screen.getByText('Every case here holds the same v3 board.')).toBeInTheDocument(); @@ -169,8 +169,7 @@ describe('the case switch', () => { fireEvent.keyDown(official, { key: 'ArrowRight' }); expect(tab(/Besoiobiy/)).toHaveAttribute('aria-selected', 'true'); expect(tab(/Besoiobiy/)).toHaveFocus(); - // false: the key was consumed, so End does not also scroll the panel. - expect(fireEvent.keyDown(tab(/Besoiobiy/), { key: 'End' })).toBe(false); + fireEvent.keyDown(tab(/Besoiobiy/), { key: 'ArrowRight' }); expect(tab(/SimonePDA/)).toHaveAttribute('aria-selected', 'true'); expect(tab(/SimonePDA/)).toHaveFocus(); expect(screen.getByRole('tabpanel')).toHaveAccessibleName(/SimonePDA/); @@ -189,11 +188,34 @@ describe('the case switch', () => { 'href', `${GH}/tree/main/hardware/case/remixes/simonepda-lasercut`, ); - fireEvent.keyDown(tab(/SimonePDA/), { key: 'ArrowRight' }); + // false: the key was consumed, so End does not also scroll the panel. + expect(fireEvent.keyDown(tab(/SimonePDA/), { key: 'End' })).toBe(false); + expect(tab(/mbchars/)).toHaveAttribute('aria-selected', 'true'); + expect(tab(/mbchars/)).toHaveFocus(); + expect(screen.getByRole('tabpanel')).toHaveAccessibleName(/mbchars/); + expect(card().getByRole('heading', { name: 'Mykyta Bilous’s horizontal desktop case' })).toBeInTheDocument(); + expect(card().getByRole('link', { name: 'Mykyta Bilous' })).toHaveAttribute('href', 'https://github.com/mbchars'); + expect(card().getByText(/Bambu Lab P2S/)).toBeInTheDocument(); + expect(card().getByRole('link', { name: /assembly\.md/ })).toHaveAttribute( + 'href', + `${GH}/blob/main/hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md`, + ); + expect(readme()).toHaveAttribute( + 'href', + `${GH}/blob/main/hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md`, + ); + expect(card().getByText(/puts it together from its STL files/)).toBeInTheDocument(); + expect(useAppStore.getState().buildCase).toBe('mbchars-horizontal-desktop-printed'); + expect(stepOne()).toHaveTextContent('Print the nine plates of the 3MF'); + expect(startRow(/^Print the case\s*mbchars-horizontal-desktop-printed\//)).toHaveAttribute( + 'href', + `${GH}/tree/main/hardware/case/remixes/mbchars-horizontal-desktop-printed`, + ); + fireEvent.keyDown(tab(/mbchars/), { key: 'ArrowRight' }); expect(tab(/Official/)).toHaveAttribute('aria-selected', 'true'); fireEvent.keyDown(tab(/Official/), { key: 'ArrowLeft' }); - expect(tab(/SimonePDA/)).toHaveAttribute('aria-selected', 'true'); - expect(fireEvent.keyDown(tab(/SimonePDA/), { key: 'Home' })).toBe(false); + expect(tab(/mbchars/)).toHaveAttribute('aria-selected', 'true'); + expect(fireEvent.keyDown(tab(/mbchars/), { key: 'Home' })).toBe(false); expect(tab(/Official/)).toHaveAttribute('aria-selected', 'true'); expect(tab(/Official/)).toHaveFocus(); // Home on the first tab picks nothing new, so it sends nothing: like a diff --git a/web/src/components/sections/CasePicker.module.css b/web/src/components/sections/CasePicker.module.css index 323ca5f9..3a8ecc09 100644 --- a/web/src/components/sections/CasePicker.module.css +++ b/web/src/components/sections/CasePicker.module.css @@ -22,11 +22,12 @@ margin: 0; } -/* ── The switch ── three cells over the card, the selected one in ink, so the - tab and the card it opened read as one piece. */ +/* ── The switch ── four cells over the card (two rows of two on a phone), + the selected one in ink, so the tab and the card it opened read as one + piece. */ .tabs { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); border: 1px solid var(--pf-rule); } @@ -384,10 +385,23 @@ /* A phone: the label goes over its value, and the padding comes in. */ @container (max-width: 420px) { + /* Four across, a name like "SimonePDA" no longer fits its cell. */ + .tabs { + grid-template-columns: repeat(2, 1fr); + } + .tab { padding: 10px 10px; } + .tab:nth-child(2n) { + border-right: 0; + } + + .tab:nth-child(-n + 2) { + border-bottom: 1px solid var(--pf-rule); + } + .photoMeta { padding: 10px 14px 0; } diff --git a/web/src/components/sections/CasePicker.tsx b/web/src/components/sections/CasePicker.tsx index e52fd841..21081bfe 100644 --- a/web/src/components/sections/CasePicker.tsx +++ b/web/src/components/sections/CasePicker.tsx @@ -25,8 +25,8 @@ interface CasePickerProps { // both: a build, and a render or a view from behind. const STRIP = 2; -// The case, three ways. A tablist rather than a row of toggles: the switch -// replaces the whole card below it, and arrow keys move along the three the +// The case, four ways. A tablist rather than a row of toggles: the switch +// replaces the whole card below it, and arrow keys move along the four the // way they do in any other tab set. export default function CasePicker({ cases, @@ -64,7 +64,7 @@ export default function CasePicker({ return (
- The case — three ways to make it + The case — four ways to make it {lead && lead.trim().length > 0 && (
{lead} diff --git a/web/src/components/sections/InsideGlobe/builds.ts b/web/src/components/sections/InsideGlobe/builds.ts index 0317b011..4ad1e110 100644 --- a/web/src/components/sections/InsideGlobe/builds.ts +++ b/web/src/components/sections/InsideGlobe/builds.ts @@ -338,20 +338,20 @@ export const builds: Build[] = [ ], }, { - id: 'ukraine-extraplojo', + id: 'ukraine-mbchars', title: 'Horizontal build', category: 'builds', - slug: 'extraplojo-ukraine', + slug: 'mbchars-ukraine', kind: 'build', location: { lat: 50.4501, lng: 30.5234, label: 'Kyiv, Ukraine' }, - maker: 'extraplojo', + maker: 'mbchars', date: '2026-10', description: "A horizontal Patternflow in orange and white, for a desk or a wall. The case is held together with brass inserts instead of glue. It is also the maker's first 3D design, first PCB order and first soldering job, and it came out beautifully.", images: [ - { src: '/builds/extraplojo/front.jpg', alt: "extraplojo's horizontal Patternflow from the front: a rainbow gradient on the panel, an orange side panel with four white knobs, and orange feet" }, - { src: '/builds/extraplojo/angle.jpg', alt: "extraplojo's Patternflow at an angle, blue and orange waves on the panel and a braided orange cable running off to the side" }, - { src: '/builds/extraplojo/inside.jpg', alt: "Inside extraplojo's case: the board in the orange control box beside the panel, with brass inserts at the corners" }, + { src: '/builds/mbchars/front.jpg', alt: "mbchars's horizontal Patternflow from the front: a rainbow gradient on the panel, an orange side panel with four white knobs, and orange feet" }, + { src: '/builds/mbchars/angle.jpg', alt: "mbchars's Patternflow at an angle, blue and orange waves on the panel and a braided orange cable running off to the side" }, + { src: '/builds/mbchars/inside.jpg', alt: "Inside mbchars's case: the board in the orange control box beside the panel, with brass inserts at the corners" }, ], }, ]; diff --git a/web/src/components/sections/build-cases-data.test.ts b/web/src/components/sections/build-cases-data.test.ts index 205f84d0..7752a6fd 100644 --- a/web/src/components/sections/build-cases-data.test.ts +++ b/web/src/components/sections/build-cases-data.test.ts @@ -60,12 +60,27 @@ function readmeHeader(folder: string): Record { return fields; } +// How each remix's README takes over from the build guide, and how its card +// says so. Besoiobiy's and SimonePDA's replace the printing and case-assembly +// sections by number; mbchars' sends the reader to its own illustrated +// assembly instructions and BOM changes, and back to the build guide for the +// controller's setup and operation. +const GUIDE_BACKING: Record = { + 'besoiobiy-printed': { readme: /\(4 and 6\)/, card: /sections 4 and 6/ }, + 'simonepda-lasercut': { readme: /\(4 and 6\)/, card: /sections 4 and 6/ }, + 'mbchars-horizontal-desktop-printed': { + readme: /\[BOM changes\]\(bom\.md\)[^\n]*\[illustrated assembly instructions\]\(assembly\.md\)/, + card: /assembly instructions \(assembly\.md\) and BOM changes \(bom\.md\)/, + }, +}; + describe('the case cards', () => { - it('are the official case and the two remixes, official first and the default', () => { + it('are the official case and the three remixes, official first and the default', () => { expect(BUILD_CASES.map((item) => item.id)).toEqual([ 'official', 'besoiobiy-printed', 'simonepda-lasercut', + 'mbchars-horizontal-desktop-printed', ]); expect(DEFAULT_CASE).toBe('official'); expect(BUILD_CASES[0].kind).toBe('official'); @@ -133,10 +148,13 @@ describe('the case cards', () => { const date = header.Verified.match(/\d{4}-\d{2}-\d{2}/)?.[0]; expect(date).toBeTruthy(); expect(item.verified).toContain(date as string); - // Both READMEs replace the printing and case-assembly sections. + // What the card says the README does to the build guide is what the + // README says, in its own words. const readme = fs.readFileSync(path.join(REPO_ROOT, folder, 'README.md'), 'utf8'); - expect(readme).toMatch(/\(4 and 6\)/); - expect(item.guide).toMatch(/sections 4 and 6/); + const backing = GUIDE_BACKING[item.id]; + expect(backing, `${item.id}: no entry in GUIDE_BACKING`).toBeDefined(); + expect(readme).toMatch(backing.readme); + expect(item.guide).toMatch(backing.card); }, ); diff --git a/web/src/components/sections/build-cases-data.ts b/web/src/components/sections/build-cases-data.ts index c62bddc2..8426909f 100644 --- a/web/src/components/sections/build-cases-data.ts +++ b/web/src/components/sections/build-cases-data.ts @@ -1,5 +1,5 @@ -// The three cases the Build panel lets a reader switch between: the official -// printed case and the two community remixes in hardware/case/remixes/. +// The four cases the Build panel lets a reader switch between: the official +// printed case and the three community remixes in hardware/case/remixes/. // // Every claim here is copied from a file in the repo, not written fresh — the // official case from hardware/case/README.md and BUILD_GUIDE.md §1, §4 and §6, @@ -20,7 +20,7 @@ export const blob = (path: string) => `${REPO}/blob/main/${path}`; /** A folder on main. */ export const tree = (path: string) => `${REPO}/tree/main/${path}`; -export type CaseId = 'official' | 'besoiobiy-printed' | 'simonepda-lasercut'; +export type CaseId = 'official' | 'besoiobiy-printed' | 'simonepda-lasercut' | 'mbchars-horizontal-desktop-printed'; export interface CasePhoto { src: string; @@ -80,6 +80,7 @@ export interface BuildCase { const OFFICIAL = 'hardware/case'; const BESOIOBIY = 'hardware/case/remixes/besoiobiy-printed'; const SIMONE = 'hardware/case/remixes/simonepda-lasercut'; +const MBCHARS = 'hardware/case/remixes/mbchars-horizontal-desktop-printed'; export const BUILD_CASES: BuildCase[] = [ { @@ -291,6 +292,71 @@ export const BUILD_CASES: BuildCase[] = [ desc: 'Laser-cut the plate, the border strips, the box and the feet, and print the knobs.', }, }, + { + id: 'mbchars-horizontal-desktop-printed', + tab: 'mbchars', + method: 'Printed remix', + kind: 'remix', + name: 'Mykyta Bilous’s horizontal desktop case', + summary: + 'The panel the long way across, on two removable stands, with the four controls on the right. Three front sections print face down, two white frames and an orange control section, and four joint bars on brass inserts hold them together without glue. Two rear covers open onto the electronics and have screw openings for hanging it on a wall. The power-bank compartment is gone: the control section holds a separate trigger module that feeds J4, the screw terminal, at 5 V.', + author: { + name: 'Mykyta Bilous', + note: 'mbchars', + href: 'https://github.com/mbchars', + }, + material: + 'PLA, set up for a Bambu Lab P2S with a 0.4 mm nozzle, 0.20 mm layers and a textured plate. White frames, covers, joint bars and knobs; orange control section, stands and module retainer. The colours are optional.', + fits: 'v3.9 and v3.0 boards (built on v3.0; v3.9 has the same outline), and a 320 × 160 mm panel with the hole pattern it was tested on.', + make: 'Nine plates in one Bambu Studio project, one colour each: about 24 hours and 714 g of PLA. The front sections and the module retainer print with the supports set up in the 3MF; the rest need none.', + parts: + '25 M3 heat-set inserts (5 mm across, 5 mm long) and 25 M3 × 8 countersunk screws; a PDSink 303PDSink01 trigger module set to 5 V, two wires from it to J4, and a supply it takes, in place of the power bank. No glue.', + verified: 'Built by its author, and photographed (2026-10-07).', + license: 'CC BY-SA 4.0', + caution: + 'Check that your panel’s hole pattern agrees with the frame before you print: a panel’s size alone does not say it fits. Set the trigger module to 5 V and measure its output before you connect it to J4: a higher voltage can damage the board and the panel.', + cautionHref: `${blob(`${MBCHARS}/README.md`)}#parts-and-assembly`, + guide: + 'Its own illustrated assembly instructions (assembly.md) and BOM changes (bom.md) take the place of the build guide’s printing, case assembly and power bank. The build guide still covers the controller’s setup and operation.', + readme: blob(`${MBCHARS}/README.md`), + modelNote: 'The 3D view puts it together from its STL files.', + photos: [ + { + src: '/cases/mbchars-horizontal-desktop-printed/front.jpg', + alt: 'The case on its two orange stands: the lit LED panel the long way across in a white frame, and the orange control section on the right with four white knobs near the top', + width: 1200, + height: 1200, + }, + { + src: '/cases/mbchars-horizontal-desktop-printed/angle.jpg', + alt: 'The case at an angle: blue and orange waves on the panel, the white frame in two sections, and the orange control section with its power cable leaving low on its side', + width: 1200, + height: 1200, + }, + { + src: '/cases/mbchars-horizontal-desktop-printed/rear_assembly.jpg', + alt: 'The case open from behind, covers off: the board in the orange control section, the trigger module below it wired to J4, the ribbon cable across to the panel, and brass inserts at the corners', + width: 900, + height: 1200, + }, + ], + credit: 'Photos by Mykyta Bilous.', + links: [ + { label: 'README and every file', target: 'mbchars-horizontal-desktop-printed/', href: tree(MBCHARS) }, + { + label: 'Bambu Studio project, nine plates', + target: 'print_layout.3mf', + href: blob(`${MBCHARS}/print_layout.3mf`), + }, + { label: 'Illustrated assembly', target: 'assembly.md', href: blob(`${MBCHARS}/assembly.md`) }, + { label: 'What changes in the BOM', target: 'bom.md', href: blob(`${MBCHARS}/bom.md`) }, + { label: 'One STL per part', target: 'stl/', href: tree(`${MBCHARS}/stl`) }, + ], + step: { + title: 'Print the case', + desc: 'Print the nine plates of the 3MF, about 24 hours, set the brass inserts, and join the three front sections with the joint bars.', + }, + }, ]; export const DEFAULT_CASE: CaseId = 'official';