diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 99365f3ed..87717e892 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -1,16 +1,20 @@ name: Web CI # Lint + build the Next.js site so broken code can't reach main. -# Only runs when something under web/ (or this workflow) changes. +# Only runs when something under web/ (or this workflow) changes — and on +# hardware/case/, because the Build panel's case cards link into it and +# build-cases-data.test.ts checks those paths and the remix READMEs. on: pull_request: paths: - "web/**" + - "hardware/case/**" - ".github/workflows/web-ci.yml" push: branches: [main] paths: - "web/**" + - "hardware/case/**" - ".github/workflows/web-ci.yml" # Cancel an in-progress run if newer commits are pushed to the same ref. diff --git a/AGENTS.md b/AGENTS.md index 2641084dc..a66463721 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ It is standalone (not Eurorack), powered from a 5 V power bank through a screw t - `firmware/` — PlatformIO/Arduino code for the ESP32-S3. Main sketch folder: `firmware/patternflow/` with `patternflow.ino`, `config.h` (pin mappings, brightness, limits, LED calibration), `net_config.h` (Wi-Fi / Improv / OTA / self-update defaults and the lane scale — core only; a feature's own tunables live in `features//_config.h`, and settings TUNE a feature, they never add one; per-device secrets in gitignored `patternflow_secrets.h`), and `pattern_registry.h` (function-pointer pattern table; compiles in Origin alone — the rest of the curated set ships as the Basics pack). Shared engine code lives in `firmware/patternflow/src/` (`core_display.h` HUB75 driver, `core_encoders.h`, canvas/color/math/noise helpers, Wi-Fi/OTA/web-console modules, the hotspot in `core_hotspot.h`) — core only: OSC, audio, MQTT, weather, shows, MIDI and the clock all live in `features/`. `firmware/patternflow/abi/` is the frozen host ⇄ module contract. Curated patterns live in `firmware/patternflow/presets/` (`preset_origin.h`, `preset_wave_saw.h`, dated presets), each using its own pattern namespace; user patterns are `.pfm` modules uploaded to the device over Wi-Fi, built from `firmware/modules/` via `firmware/toolchain/build_module.py` (the old `custom1.h`–`custom3.h` root slots are gone). Features attach through `firmware/patternflow/features/` (named `addons/` until 2026-08-30; legacy spellings are shimmed for out-of-tree bundles — see docs/EDITIONS.md) — a directory and a descriptor of function pointers, with the core naming none of them (see its README). `firmware/bundles/` names firmwares built from the same tree (the shelf editions `audio` and `performance`, plus `clock` and `midi`, compositions the tree keeps buildable but does not publish; the default build has no bundle): two files each, saying which features compile in and what the build calls itself; `firmware/bundles/build.sh` builds the default or a named one, `shelf.sh` stages a publishable image. Device console pages are plain HTML in `firmware/patternflow/console/`, spliced into `*_index.h` by `firmware/toolchain/console_pages.py`. Tooling lives in `firmware/toolchain/` (repo-level scripts) and `firmware/patternflow/toolchain/` (PlatformIO `extra_scripts`, which must stay sketch-relative). Also: `firmware/encoder_test/` (standalone encoder diagnostic) and `firmware/CUSTOM_PATTERNS.md` (pattern authoring guide). - `hardware/` — Hardware designs. `case/` (Blender source in `source/`, STLs by printer bed size, `legacy_v2/` for the v2.x board), `pcb/` (KiCad 10.0 source, Gerbers per revision, schematic PDF), `bom/` (`bom_v3.9.csv` — **the BOM source of truth**; the guide's table is derived from it. `bom_v3.0.csv` is kept for the previous board). - `web/` — Next.js site at patternflow.work: landing page (`/`, `/pattern`, `/build`, `/inside` are tabs of one view), the interactive guide (`/guide` is a hub over `/guide/build`, `/guide/play`, `/guide/make` and `/guide/audio`, each with a `/ko` twin; one 3D stage in `components/guide/`, the firmware simulator in `lib/guide/`), Pattern Lab (`/pattern-lab`), community (`/community/**`, SQLite + Drizzle + Better Auth, only on the Pi deployment), browser flasher (`/flash`), the edition shelf (`/editions`; `/variants` redirects there), the feature catalogue (`/features`, reels per feature), device update handoff (`/update`), journal, roadmap. `lib/pattern/` is the pattern runtime and annotations, `lib/lab/` the Pattern Lab, `lib/community/` the community, `lib/ai/` the Gemini client. Architecture doc: `web/ARCHITECTURE.md`. The JS presets in `web/src/lib/presets/` are the source of truth for firmware preset headers. -- `tools/` — desktop-side helpers: `patternflow-audio-extension` (the browser audio-react extension — also the authoring source the device's `/audio-in` console page is assembled from), `patternflow-audio-android` (phone capture app), `rtpmidi-probe`. +- `tools/` — desktop-side helpers: `patternflow-audio-extension` (the browser audio-react extension — also the authoring source the device's `/audio-in` console page is assembled from), `patternflow-audio-android` (phone capture app), `rtpmidi-probe`, and `case-models` (builds the /build preview's per-case 3D models, `web/public/cases//model.glb`, from the case files in the repo; run by hand, not by the site build). - `integrations/` — host-software integrations, each with its own README. `integrations/ableton/` is the Max for Live bridge (knobs → Live parameters over OSC). The Home Assistant integration left this repository on 2026-09-03; its author maintains it separately. Integrations are built against the contracts in `docs/` (`rest-api.md`, `osc-spec.md`, `midi-spec.md`), not against the firmware source. `docs/rest-api.md` also has the table for choosing between HTTP, OSC and MQTT, and the rules that make the device's single-connection web server easy to knock over. ## Hard rules (do not violate) diff --git a/BUILD_GUIDE_v2.md b/BUILD_GUIDE_v2.md index 5131e802b..ae0b0dbed 100644 --- a/BUILD_GUIDE_v2.md +++ b/BUILD_GUIDE_v2.md @@ -2,7 +2,7 @@ This guide walks you through building a Patternflow v2.0.0 from scratch. It assumes basic familiarity with through-hole soldering and 3D printing. -This is the current detailed path for a hand-soldered official PCB plus a PLA 3D printed enclosure. For the broader assembly map, including the laser-cut path still in preparation, see [docs/assembly/README.md](docs/assembly/README.md). +This is the current detailed path for a hand-soldered official PCB plus a PLA 3D printed enclosure. For the broader assembly map, see [docs/assembly/README.md](docs/assembly/README.md); the community cases it lists, laser-cut and printed, are for the v3 board. > **No PCB? Try the breadboard build.** If you don't want to order or solder a custom PCB, there's a complete **PCB-free path** that wires the same parts together with a snapped-off breadboard power rail and jumper wires — no soldering iron required. See the **[Breadboard Build Guide](https://patternflow.work/build/breadboard)**. You can always move up to this PCB build later. @@ -10,7 +10,7 @@ This is the current detailed path for a hand-soldered official PCB plus a PLA 3D **Skill level:** Beginner-to-intermediate. If you've assembled a mechanical keyboard or built a basic Arduino project, you're ready — this is an all through-hole build. -> 🚀 **This is the legacy v2.x guide.** The current build is **v3.0** — see the main **[Build Guide](BUILD_GUIDE.md)**. Keep following this page only if you already have v2.x parts (v2 and v3 boards/cases are not interchangeable). +> 🚀 **This is the legacy v2.x guide.** The current build is the **v3.9** board — see the main **[Build Guide](BUILD_GUIDE.md)**. Keep following this page only if you already have v2.x parts (v2 and v3 boards/cases are not interchangeable). > > Photos in this guide were shot on an early build; where a photo looks slightly off, follow the written steps. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f17ab150..d19b7130d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to Patternflow will be documented in this file, newest first ## [Unreleased] +### 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. + +### 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. +- **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 + +- **The build pages say what is true now.** Laser cut is no longer "being prepared" on /build, in the README, the v2 guide or `llms.txt`: SimonePDA's case is it. The printing time is about 10 hours for the official case on a 256 mm bed (/build said 10–12, the breadboard guide 11), the v2 guide and the assembly map name the v3.9 board, and the breadboard guide flashes through the DevKit's native USB port from the Pattern tab and links the sections that exist. + ## [3.11.0] - 2026-10-04 ### Firmware diff --git a/README.md b/README.md index 06268f780..2208549a1 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Each of these lives in an **edition** — a firmware you install from [the shelf ## How it's built -Patternflow is built around a standalone ESP32-S3 driving a HUB75 RGB LED matrix at low resolution, where each pixel reads as a discrete point of light with its own brightness and color. Four rotary encoders feed firmware written in Arduino-compatible C++ around a modular pattern architecture: each pattern is a self-contained module with its own setup, update, and draw routines, while the shared framework handles input, LED rendering, mode transitions, and color calibration. I designed the PCB myself; the enclosure is 3D-printed by default, with stainless steel, transparent acrylic, and laser-cut variations in progress. +Patternflow is built around a standalone ESP32-S3 driving a HUB75 RGB LED matrix at low resolution, where each pixel reads as a discrete point of light with its own brightness and color. Four rotary encoders feed firmware written in Arduino-compatible C++ around a modular pattern architecture: each pattern is a self-contained module with its own setup, update, and draw routines, while the shared framework handles input, LED rendering, mode transitions, and color calibration. I designed the PCB myself; the enclosure is 3D-printed by default. Two community remixes sit beside it in [`hardware/case/remixes/`](hardware/case/remixes/README.md), another printed case and a laser-cut one in clear acrylic or MDF, and a stainless steel version is still in progress. ## Repository & documentation diff --git a/docs/LICENSE-SUMMARY.md b/docs/LICENSE-SUMMARY.md index d66612990..a00b6dde4 100644 --- a/docs/LICENSE-SUMMARY.md +++ b/docs/LICENSE-SUMMARY.md @@ -17,6 +17,7 @@ override it. | Journal (`web/content/journal/`) | CC BY-SA 4.0 | Same as above. | | Enclosure remixes (`hardware/case/remixes/`) | Stated in each remix's README (CC BY-SA 4.0 by default) | The README is the license header for files that cannot carry one. | | Build-map photos (`web/public/builds/`) | CC BY-SA 4.0 unless the build's entry says otherwise | Credit the maker. | +| Case photos and models (`web/public/cases/`) | CC BY-SA 4.0 — resized copies of the images in `hardware/case/remixes//` and `docs/build-guide/`, and each case's 3D model for the /build preview (`model.glb`), made by `tools/case-models` from the case's own files | Credit the author the remix's README names (its /build card names them too). | | **Bundled patterns** (`web/src/lib/presets/`, `firmware/patternflow/presets/`) | CC BY-SA 4.0 | Per-file SPDX headers. They sit in code folders but they are artwork, not code. | | **Community patterns** | **Chosen by their author** — see below | Read the header in the pattern itself. | diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md index a889d1229..e22c6b03d 100644 --- a/docs/REPOSITORY.md +++ b/docs/REPOSITORY.md @@ -10,7 +10,7 @@ A map for people. (AI coding agents get the same map with more rules in [`../AGE | `hardware/` | `pcb/` (KiCad source, Gerbers per revision, schematic PDF), `case/` (STLs by printer bed size, knob plates, the Blender source, legacy v2 cases, and `remixes/` for community variants), `bom/` (the CSV that is the BOM source of truth) | [`hardware/README.md`](../hardware/README.md) | | `web/` | The Next.js site at patternflow.work: landing page, the interactive guide (`/guide`, with `/guide/build`, `/guide/play`, `/guide/make` and `/guide/audio`), Live Editor, Pattern Lab, the community (only on the community host), browser flasher, edition shelf, device update handoff, journal, roadmap. The JS presets in `src/lib/presets/` are the source of truth for the firmware's preset headers. The breadboard build guide is a React page here, `src/app/build/breadboard/` | [`web/README.md`](../web/README.md), then [`web/ARCHITECTURE.md`](../web/ARCHITECTURE.md) | | `docs/` | Contracts other software is built against, how the firmware is put together, walk-throughs, the assembly map, project records and media | [`README.md`](README.md) | -| `tools/` | Clients Patternflow ships that run on their own: the audio-react browser extension (also the authoring source of the device's `/audio-in` page), the Android capture app, an RTP-MIDI probe | [`tools/README.md`](../tools/README.md) | +| `tools/` | Clients Patternflow ships that run on their own: the audio-react browser extension (also the authoring source of the device's `/audio-in` page), the Android capture app, an RTP-MIDI probe. Also `case-models/`, the scripts that make the /build page's 3D model of each case | [`tools/README.md`](../tools/README.md) | | `integrations/` | Bridges that run inside or beside someone else's software (Ableton / Max for Live today) and talk to the panel only through the contracts in `docs/` | [`integrations/README.md`](../integrations/README.md) | | `.github/` | Issue and pull-request templates, the CI workflows (web, firmware editions, doc links, console-page sync, firmware release assets, Discord dev-log) and their helper scripts | the header comment of each workflow | | root | `README.md`, the five guides (`BUILD_GUIDE.md`, `BUILD_GUIDE_v2.md`, `PATTERN_GUIDE.md`, `FEATURE_GUIDE.md`, `AUDIO_GUIDE.md`), `CHANGELOG.md`, `CONTRIBUTING.md`, `SUPPORT.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `CITATION.cff`, the two license texts and the `LICENSE` pointer, `AGENTS.md` (+ `CLAUDE.md`, a one-line shim) | | diff --git a/docs/assembly/README.md b/docs/assembly/README.md index ff3823217..07010a0d1 100644 --- a/docs/assembly/README.md +++ b/docs/assembly/README.md @@ -12,7 +12,7 @@ Patternflow is not a single, rigid kit. It is a modular system divided into two Build those two, flash the firmware, and your Patternflow is alive. -## The main route (v3.0.0) +## The main route (v3.9 board) | Enclosure | Electronics | Firmware | Status | | --- | --- | --- | --- | diff --git a/hardware/case/remixes/README.md b/hardware/case/remixes/README.md index f660c97e7..1526e7a00 100644 --- a/hardware/case/remixes/README.md +++ b/hardware/case/remixes/README.md @@ -15,6 +15,8 @@ hardware/case/remixes/-/ Folder name: your maker name, a dash, a short variant name, all lowercase (`nath-acrylic`, `day-paper-diffuser`). +The [/build](https://patternflow.work/build) page can show a remix beside the official case, in its 3D preview too: [`tools/case-models`](../../../tools/case-models/README.md) makes that model from the folder's own files. + ## The README is the license header STL and DXF files cannot carry an SPDX line, so the README carries the license for the whole folder. Its first lines are these fields, one per line, in this order; the first two are checked by CI on every pull request: diff --git a/tools/README.md b/tools/README.md index 2ba259960..57fad5205 100644 --- a/tools/README.md +++ b/tools/README.md @@ -7,6 +7,7 @@ Clients that Patternflow ships and that run on their own — a browser extension | [`patternflow-audio-extension/`](patternflow-audio-extension/README.md) | Chrome/Edge extension: captures the current tab's audio, splits it into four bands, one per knob, that you shape as boxes on the live spectrum, and streams the four knob values to the panel | [`audio-ws-spec.md`](../docs/audio-ws-spec.md) | | [`patternflow-audio-android/`](patternflow-audio-android/README.md) | The phone-side twin: captures what the phone is playing and drives the knobs with it, for filming. It takes its mapping from the panel | [`audio-ws-spec.md`](../docs/audio-ws-spec.md), and the microphone endpoints of [`rest-api.md`](../docs/rest-api.md#microphone-audio-edition) | | [`rtpmidi-probe/`](rtpmidi-probe/README.md) | A plain-Python RTP-MIDI session initiator that walks the MIDI contract against a panel from a machine with no MIDI driver | [`midi-spec.md`](../docs/midi-spec.md) | +| [`case-models/`](case-models/README.md) | Not a client of the panel: the scripts that make the 3D model of each case the /build page's preview shows (`web/public/cases//model.glb`), from the case's own files in `hardware/case/`. Run by hand; the site's build does not run them | The node names `web/src/components/3d/caseModels.ts` finds in every model, listed in its README | ## The extension is also firmware source diff --git a/tools/case-models/.gitignore b/tools/case-models/.gitignore new file mode 100644 index 000000000..ea46dfbfc --- /dev/null +++ b/tools/case-models/.gitignore @@ -0,0 +1,2 @@ +# The Python environment README.md sets up. +.venv/ diff --git a/tools/case-models/README.md b/tools/case-models/README.md new file mode 100644 index 000000000..2f3715188 --- /dev/null +++ b/tools/case-models/README.md @@ -0,0 +1,126 @@ +# Case models + +The 3D models the [/build](https://patternflow.work/build) page's preview shows, one per case on its case switch, and the scripts that make them from files in this repository. Pick a case on the switch and the device in the preview is that case, with the same Patternflow inside it: the LED panel, the v3.9 board and the ESP32 DevKit, and four knobs (the official ones, or the case's own). + +| Case | Model | Made from | Script | +| :--- | :--- | :--- | :--- | +| 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` | + +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. + +| 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 | + +The landing page's model before these, `web/public/3dforweb.glb` (a v3.0 device), was 1,155 KB and loaded with the page. + +## Setup + +```sh +cd tools/case-models +npm ci # glTF-Transform, Draco, meshoptimizer +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt # Python 3.12 or newer +``` + +`build.sh` uses `.venv/bin/python` when it is there (or `$PYTHON`). `preview.mjs` also needs the web app's dependencies (`npm ci` in `web/`) and Playwright with a Chromium. + +## How a model is put together + +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` 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: + +- 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; +- the official knob, unless the case has its own: the knob people print, `hardware/case/knobs/knobs_20mm.stl`, read and turned round (`lib/knob.mjs`, below), one mesh for all four; + +sets every material's look by name (`lib/looks.mjs`; materials merge only when their names match, since the page swaps looks by name), compresses the meshes with Draco like the site's other models, and writes `web/public/cases//model.glb`. + +`preview.mjs [--explode]` renders a model from four sides without starting the site. + +### The model frame + +One unit is 10 mm. The device stands facing +z, +y up with the knobs at the top, +x to the reader's right: the LED panel on the left, the knob column on the right. It is the frame the guide's models share (`web/src/components/guide/stage/geometry.ts`, `parts.ts`); `lib/frame.mjs` copies the numbers this folder needs from there. + +### The nodes the page relies on + +`web/src/components/3d/caseModels.ts` finds these by name in every model, and `caseModels.test.ts` checks that they are there: + +| Node | What | +| :--- | :--- | +| `l` | The LED panel. The pattern shaders draw on its front face, with its UVs. | +| `c1` … `c4` | The knobs: origin on the encoder axis at the knob's base, axis +z, no rotation. Named after the encoder nets as `case-v39.glb` names them: seen from the front, `c1` top-left, `c2` top-right, `c3` bottom-left, `c4` bottom-right. | +| `pcb_v39` | The board; its children keep `pcb-v39.glb`'s names. | +| `devkit` | The DevKit; its `module` is the ESP32 module, its `board` is renamed `devkit_board`. | + +Every other top-level node is a part of the case, named by its script. Where each one goes when the preview draws the device apart is in `web/src/components/3d/cases/.ts`. + +## Official: `assemble.mjs official` + +The guide's v3.9 case (`body`, `back_plate`, `back_slider`, `top_lid`) in white PLA, with the knobs, the board and the DevKit where the guide seats them. There is no shell script: the case is already in the model frame. + +### The official knob: `lib/knob.mjs` + +`case-v39.glb` has the knob as exported from the STL, a 32-sided prism, and at the size the preview draws it its flats and corners show. `lib/knob.mjs` reads `hardware/case/knobs/knobs_20mm.stl` instead: it takes one of the file's four knobs, measures its radius at every height the STL has vertices at, and turns that profile about the knob's axis with 96 sides. It keeps the outside (20 mm tall, 16.26 mm across the base, 12.89 mm across the top, a little convex), the open recess under it and the bore; it leaves out the 16 flutes round the top half, 0.14 mm deep, and the D's flat in the bore, and rounds the top edge by 0.4 mm so it catches the light (a choice for the preview: the knob prints on its top, and that edge comes off the bed sharp). Bends under 35° are smooth, the rest are edges. It stops if the STL is no longer that knob (its height and both diameters are checked). The knob's base stands where `case-v39.glb`'s does, 0.8 mm off the front. + +## Besoiobiy: `besoiobiy.py` + +`hardware/case/remixes/besoiobiy-printed/source/patternbox.stl` is Besoiobiy's whole Blender scene in one STL. The case lies face up, front toward +z, and every part faces the way it does assembled, but the parts are pulled apart: the variant box halves are stacked above the main ones, the covers lie below, the knob cap stands above it all, and 45 modelling helpers lie around them. Each file in the remix's `stl/` is one connected piece of that scene, moved to where it prints. The script: + +1. **Finds the parts.** It splits the scene into connected pieces and matches each `stl/` file on numbers a move cannot change: triangle count, surface area, volume and principal moments of inertia. It takes the two frame halves, the lettered box halves with the bridge (`box_top.stl`, `box_bottom.stl`, as in Besoiobiy's photo), the four covers and the knob cap, and leaves out the variants and the helpers. +2. **Puts them together with translations only**, each worked out from the faces that meet, not from the scene's spacing: + - each bottom half's joint face goes onto its top half's, with its 2 mm keys in the top half's pockets; + - the box's side goes onto the frame's outer side, with the box's keys in the frame's pockets and the fronts flush; + - each cover keeps its half's x and y, and its back sits flush with the case's back, which puts it on its 2 mm ledge. +3. **Checks the result against the remix README** and stops if any of it is off: 267 × 329 × 30.85 mm overall, the 161 × 321 mm panel opening, the tabs 14.35 mm behind the front edge, four 7.2 mm encoder holes 30.8 × 30.1 mm apart, nothing behind the front where an encoder sits, and no two parts overlapping (boolean intersections, manifold3d). +4. **Moves it into the model frame** with a half turn about z, 10 mm to the unit. The outline is centred where the official case's is and the front face is set at the official's z. Each part is one node (`frame_top`, `frame_bottom`, `box_top`, `box_bottom`, `cover_frame_top`, `cover_frame_bottom`, `cover_box_top`, `cover_box_bottom`, in `pla_white`), and Besoiobiy's knob cap is `c1` … `c4` (`pla_black`). Normals keep every edge sharper than 35° hard and smooth the round surfaces. + +In `placement.json`, `board` centres the board's encoders on the four holes: the board's 31 × 30.5 mm grid against the holes' 30.8 × 30.1 mm leaves half the difference on each side, which the build absorbs in play. The board sits 1 mm further back than in the official case, because the box's front is 4 mm thick where the official's is 3 mm. The knob caps stand on the board's encoder axes where the 20 mm shaft bottoms out in their 11.8 mm bore, 3.7 mm off the front (the official knobs, with a deeper bore, sit 0.8 mm off theirs); `lib/frame.mjs` has the shafts' tips. `led` centres the panel in the frame's opening with its face flush with the frame's front edge, as a panel with Besoiobiy's 14.35 mm socket depth sits. The panel model is a 17 mm slab, so its back passes 2.6 mm into the frame's tabs; a real panel's sockets stop on them. + +## SimonePDA: `simonepda.py` + +There is no 3D file for this case, only Simone's drawing. `simonepda.py` builds the shell from `hardware/case/remixes/simonepda-lasercut/lasercut_layout.pdf`. It reads every cut path with PyMuPDF (the drawing is 1:1, 1 pt = 25.4/72 mm) and stops if the sheet does not come out at 256 × 340 mm. Every other size comes from the drawing: + +| Page | What the script takes from it | +| :--- | :--- | +| 3 | The plate: its rounded outline, 14 screw holes (5 mm), 4 knob holes (8 mm, on 30 × 30 mm), 4 connector windows (60 × 40 mm) and the 3 slots for the box (2.7 mm wide) | +| 2 | Where the panel sits: the 160 × 320 mm outline drawn on the plate | +| 4 | Where the box sits: its lid drawn on the plate. The script checks it is the outline page 5 cuts | +| 1 | The four border strips, 16 mm wide: 322 and 162 mm long | +| 5 | The box (a lid and four finger-jointed walls, keys on three of them, two cable notches) and a foot (an open cube of five 30 mm squares) | + +Each part is its drawn outline, holes and fingers included, extruded and stood where it goes. Page 3 is the plate seen from the front, so the panel ends up on the left and the knobs at the top right. + +The plate and strips are 2.75 mm thick, the acrylic the remix README names. The box and the feet are 2.7 mm thick, the depth their finger joints are drawn at, so they interlock exactly. The plate takes the place of the official case's front face; the board is centred under the knob holes, its encoders bearing on the plate's back. The strips stand on edge around the panel, each flush at one corner and 2 mm past the next. + +The drawing does not say which way the box walls face along their length. The photos do: the ribbon-cable notch is towards the panel, a little below the middle, and the power notch is at the bottom right. Nor does it place the three feet: since the box is as deep as a cube is tall, they go in the three back corners the box leaves free. + +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. + +## 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. +- **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. +- **Light and colour.** The preview's light is its own, made on the page from Lightformers (no environment map is downloaded), and its frame goes through the shoulder of the Neutral tone curve: the curve's toe, which would crush dark colours, is given back first (`neutralToe.tsx`), so below the knee a look is drawn as lit, and only what is brighter rolls off, its channels together. So the white PLA is the white `lib/looks.mjs` gives it, under a light without a cast. + +## Adding a case + +A remix with 3D files (or a drawing exact enough to stand up) gets a model the same way: a script in this folder that writes a shell and a `placement.json` in the frame above, a line in `build.sh`, an entry in `web/src/components/3d/cases/`, and the case's card in `web/src/components/sections/build-cases-data.ts`. + +## License + +The scripts are MIT, like the rest of `tools/`. The models they make are drawings of the cases, and take the cases' license: CC BY-SA 4.0, crediting each remix's author as its README names them. diff --git a/tools/case-models/assemble.mjs b/tools/case-models/assemble.mjs new file mode 100644 index 000000000..c86d4dac6 --- /dev/null +++ b/tools/case-models/assemble.mjs @@ -0,0 +1,195 @@ +// Puts one case's model together for the /build page's 3D preview. +// +// node assemble.mjs official +// node assemble.mjs +// +// 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: +// +// { +// "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 +// "knobs": "official", // or "shell": the shell has its own c1..c4 +// "knobBaseZ": 1.6437 // optional: the knobs' base, when "official" +// } +// +// The result is written to web/public/cases//model.glb, Draco +// compressed like the site's other models (the page already loads the +// decoder). The board and the DevKit are simplified on the way: the preview +// shows them from a distance, through a case. +// +// The node contract the page relies on (web/src/components/3d/caseModels.ts): +// l the LED panel; the pattern is drawn on its front face +// c1..c4 the knobs, origin on the encoder axis at the knob's base, +// axis +z; named by the encoder under them, as case-v39.glb +// pcb_v39 the board (its children keep pcb-v39.glb's names) +// devkit the DevKit (its "board" is renamed "devkit_board") +// anything else is the shell: one node per part, named by the script +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Document, PropertyType } from '@gltf-transform/core'; +import { KHRDracoMeshCompression } from '@gltf-transform/extensions'; +import { dedup, draco, mergeDocuments, prune, simplify, weld } from '@gltf-transform/functions'; +import { MeshoptSimplifier } from 'meshoptimizer'; +import { getIO, worldBounds, fmt } from './lib/io.mjs'; +import { DEVKIT_SEAT, KNOB_BASES, PCB_PLACEMENT, add3 } from './lib/frame.mjs'; +import { applyLooks } from './lib/looks.mjs'; +import { knobMesh } from './lib/knob.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, '../..'); +const GUIDE = path.join(REPO, 'web/public/guide'); +/** The official knob, as printed (the BOM's 20 mm shaft). */ +const KNOB_STL = path.join(REPO, 'hardware/case/knobs/knobs_20mm.stl'); +const OUT_DIR = path.join(REPO, 'web/public/cases'); + +/** How far the board and the DevKit are simplified (meshoptimizer, fraction of triangles kept, error bound relative to the mesh). */ +const SIMPLIFY = { + pcb: { ratio: 0.25, error: 0.01 }, + devkit: { ratio: 0.1, error: 0.03 }, +}; + +const [caseId, shellArg, placementArg] = process.argv.slice(2); +if (!caseId || (caseId !== 'official' && (!shellArg || !placementArg))) { + console.error('usage: node assemble.mjs official | node assemble.mjs '); + process.exit(1); +} + +const io = await getIO(); + +async function readSimplified(file, opts) { + const doc = await io.read(file); + await MeshoptSimplifier.ready; + await doc.transform(weld(), simplify({ simplifier: MeshoptSimplifier, ...opts }), dedup(), prune({ keepAttributes: true })); + return doc; +} + +/** 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); + const scene = target.getRoot().listScenes()[0]; + const roots = {}; + for (const srcScene of source.getRoot().listScenes()) { + for (const srcNode of srcScene.listChildren()) { + const node = map.get(srcNode); + scene.addChild(node); + roots[node.getName()] = node; + } + } + // mergeDocuments brings the source's scenes and buffers along: drop the extra + // scenes (their nodes now live in ours) and fold every buffer into one. + for (const s of target.getRoot().listScenes()) if (s !== scene) s.dispose(); + const [buffer, ...rest] = target.getRoot().listBuffers(); + for (const b of rest) { + for (const a of target.getRoot().listAccessors()) if (a.getBuffer() === b) a.setBuffer(buffer); + b.dispose(); + } + return roots; +} + +let shellDoc; +let placement; +if (caseId === 'official') { + // The v3.9 case, as the guide has it, without its knobs: the official knob + // is made below from knobs_20mm.stl, on the same bases. + shellDoc = await io.read(path.join(GUIDE, 'case-v39.glb')); + // No "led": the panel stays exactly where the source file has it. + placement = { board: [0, 0, 0], knobs: 'official' }; + for (const n of shellDoc.getRoot().listNodes()) { + if (/^c[1-4]$/.test(n.getName())) n.dispose(); + else if (n.getMesh()) n.getMesh().listPrimitives().forEach((p) => p.setMaterial(null)); + } + // The guide's case has no materials: white PLA, the same for every part. + const pla = shellDoc.createMaterial('pla_white'); + for (const n of shellDoc.getRoot().listNodes()) n.getMesh()?.listPrimitives().forEach((p) => p.setMaterial(pla)); +} else { + shellDoc = await io.read(path.resolve(shellArg)); + placement = JSON.parse(fs.readFileSync(path.resolve(placementArg), 'utf8')); +} + +const doc = new Document(); +doc.createScene('Scene'); +doc.createBuffer(); +const shellRoots = adopt(doc, shellDoc); +const board = placement.board ?? [0, 0, 0]; + +// The LED panel: the old landing model's own node, moved where this case +// 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); + +// 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 scene = doc.getRoot().listScenes()[0]; + for (const [name, base] of Object.entries(KNOB_BASES)) { + const t = add3(base, board); + if (placement.knobBaseZ !== undefined) t[2] = placement.knobBaseZ; + scene.addChild(doc.createNode(name).setMesh(knob).setTranslation(t)); + } +} else { + for (const name of Object.keys(KNOB_BASES)) { + if (!shellRoots[name]) throw new Error(`placement says the shell has its own knobs, but it has no "${name}"`); + } +} + +// The board and the DevKit, simplified, where this case holds them. +const pcb = adopt(doc, await readSimplified(path.join(GUIDE, 'pcb-v39.glb'), SIMPLIFY.pcb)).pcb_v39; +pcb.setTranslation(add3(PCB_PLACEMENT.translation, board)).setRotation(PCB_PLACEMENT.rotation).setScale(PCB_PLACEMENT.scale); +const kit = adopt(doc, await readSimplified(path.join(GUIDE, 'devkit.glb'), SIMPLIFY.devkit)).devkit; +kit.setTranslation(add3(DEVKIT_SEAT.translation, board)).setRotation(DEVKIT_SEAT.rotation).setScale(DEVKIT_SEAT.scale); +kit.listChildren().find((c) => c.getName() === 'board')?.setName('devkit_board'); + +// One look for every case's materials, by name (lib/looks.mjs). +applyLooks(doc); + +await doc.transform( + weld(), + dedup({ propertyTypes: [PropertyType.ACCESSOR, PropertyType.MESH, PropertyType.TEXTURE] }), + // Materials merge only when their names match as well: two names can share + // a look (SimonePDA's sheet and its box are both clear acrylic as built), + // and the page swaps looks by name (a case's finishes, caseModels.ts). + dedup({ propertyTypes: [PropertyType.MATERIAL], keepUniqueNames: true }), + // keepAttributes: prune() drops a UV set no texture uses, and the LED + // panel's UVs are what the page's pattern shaders draw with. + prune({ keepLeaves: false, keepAttributes: true }), + draco({ method: 'edgebreaker', quantizePosition: 14, quantizeNormal: 10, quantizeTexcoord: 14 }), +); +doc.createExtension(KHRDracoMeshCompression).setRequired(true); +doc.getRoot().getAsset().generator = 'patternflow tools/case-models/assemble.mjs'; + +const out = path.join(OUT_DIR, caseId, 'model.glb'); +fs.mkdirSync(path.dirname(out), { recursive: true }); +await io.write(out, doc); + +// Report: size, triangles per top-level node, and the bounds. +const written = await io.read(out); +const scene = written.getRoot().listScenes()[0]; +let total = 0; +const rows = []; +for (const node of scene.listChildren()) { + let tris = 0; + node.traverse((n) => { + for (const p of n.getMesh()?.listPrimitives() ?? []) { + const idx = p.getIndices(); + tris += (idx ? idx.getCount() : p.getAttribute('POSITION').getCount()) / 3; + } + }); + total += tris; + const b = worldBounds(node); + rows.push(` ${node.getName().padEnd(20)} ${String(tris).padStart(7)} tris ${fmt(b.min)} … ${fmt(b.max)}`); +} +const all = worldBounds(scene); +console.log(`${path.relative(REPO, out)}: ${fs.statSync(out).size} bytes, ${total} triangles`); +console.log(rows.join('\n')); +console.log(` bounds ${fmt(all.min)} … ${fmt(all.max)}`); diff --git a/tools/case-models/besoiobiy.py b/tools/case-models/besoiobiy.py new file mode 100755 index 000000000..e18cb4dc7 --- /dev/null +++ b/tools/case-models/besoiobiy.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +"""Besoiobiy's printed case, put together, as the shell of its preview model. + + python besoiobiy.py + node assemble.mjs besoiobiy-printed build/besoiobiy-printed/shell.glb build/besoiobiy-printed/placement.json + +MIT, like the rest of tools/. The model it makes is a drawing of Besoiobiy's +case and takes the case's license (CC BY-SA 4.0, README.md). + +hardware/case/remixes/besoiobiy-printed/source/patternbox.stl is Besoiobiy's +whole Blender scene in one STL. The case lies in it face up, its front toward +the scene's +z, and every part faces the way it does assembled, but they are +pulled apart: the variant box halves are stacked above the main ones, the +covers lie below them, the knob cap stands above it all, and 45 modelling +helpers lie around them (README, "Files" and "The parts"). Each file in the +remix's stl/ folder is one connected piece of that scene, unchanged except for +its placement (turned to its print orientation, set on the bed, centred). So: + +1. Split the scene into connected pieces and find the nine that are parts by + matching each STL file against them on signatures a placement cannot + change: triangle count, surface area, volume and the principal moments of + inertia. The halves are the lettered ones with the bridge, box_top.stl and + box_bottom.stl, as in Besoiobiy's photo; the variants and the helpers are + left out. + +2. Put them together. Since the scene already turns every part the way it + faces assembled, the assembly is translations only, each one worked out + from the faces that meet, not from the scene's spacing: + - frame_top stays where it is: everything else is placed against it. + - The bottom halves slide along y until their joint face meets the top + half's. Each bottom half carries the 2 mm keys of that joint, which go + into pockets in the top half. + - The box halves slide along x until the box's side face meets the + frame's outer side (the box carries those keys), with the box's joint + in line with the frame's and the fronts flush. + - Each cover keeps its half's x and y (the scene centres it over its + opening, 0.2 mm short of every edge) and moves along z until its back + is flush with the back of the case. That puts its front face on the + 2 mm ledge the half has for it, which is checked. + Then the result is checked against the remix README: 267 × 329 × 30.85 mm + overall, the 161 × 321 mm panel opening, the panel tabs 14.35 mm behind the + front edge, the four 7.2 mm encoder holes 30.8 × 30.1 mm apart, and no two + parts overlapping (boolean intersections, by manifold3d). + +3. Move it into the model frame (tools/case-models/README.md): one unit is + 10 mm, the device faces +z with the knobs at the top and the panel on the + left. In the scene the knob end of the case lies toward -y and the box, + seen from the front, toward -x, so that is a half turn about z. The case's + outline is centred where the official case's is, and its front face is set + where the official's is, so the knobs stand at the same depth. + +4. Write the shell, one node per part (frame_top, frame_bottom, box_top, + box_bottom, cover_frame_top, cover_frame_bottom, cover_box_top, + cover_box_bottom, in white PLA) and the four knob caps as c1..c4 (black + PLA), with normals that keep a printed part's edges hard and its round + surfaces smooth; and placement.json, which tells assemble.mjs where this + case holds the board and the LED panel: + board added to the official board's placement, so that the encoders + (the official knob axes plus this) are centred on the four holes + and bear on the inside of the box's front, which is 4 mm thick + where the official case's is 3 mm; + led the panel node's translation: centred in the frame's opening, + its LED face flush with the frame's front edge, as a panel with + Besoiobiy's 14.35 mm socket depth sits (README, "Check your + panel first"); + knobs "shell": the knobs are Besoiobiy's caps, not the official ones. + +The board's encoders are 31 × 30.5 mm apart and the holes 30.8 × 30.1 mm, so +they cannot all be concentric: the board is centred on the holes, as the +README says the build relies on the play in both. The knob caps go on the +encoder shafts, so they follow the board, and stand where the shaft bottoms +out in their bore (it is shallower than the official knobs', and the box's +front 1 mm thicker), not 0.8 mm off the front face as the official knobs do. The LED panel in the model is a simplified 17 mm slab, 2.6 mm +deeper than the real panel in front of its sockets, so its back passes +through the frame's tabs; the real panel's sockets stop on them. + +Deterministic: the same input files give the same output bytes. +""" + +from __future__ import annotations + +import argparse +import itertools +import json +import re +import sys +from pathlib import Path + +import numpy as np +import shapely +import trimesh +from scipy.sparse import coo_matrix +from scipy.sparse.csgraph import connected_components + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] +REMIX = REPO / "hardware/case/remixes/besoiobiy-printed" + +# The case's parts, by the file in the remix's stl/ folder that is each of +# them, and the materials lib/looks.mjs gives the case and the knobs. +CASE_PARTS = { + "frame_top": "frame_top.stl", + "frame_bottom": "frame_bottom.stl", + "box_top": "box_top.stl", + "box_bottom": "box_bottom.stl", + "cover_frame_top": "cover_frame_top.stl", + "cover_frame_bottom": "cover_frame_bottom.stl", + "cover_box_top": "cover_box_top.stl", + "cover_box_bottom": "cover_box_bottom.stl", +} +KNOB_FILE = "knob_cap.stl" +CASE_MATERIAL = "pla_white" +KNOB_MATERIAL = "pla_black" + +# The covers, by the half whose back each one closes. +COVER_OF = { + "cover_frame_top": "frame_top", + "cover_frame_bottom": "frame_bottom", + "cover_box_top": "box_top", + "cover_box_bottom": "box_bottom", +} + +# What the remix README says the assembled case measures, in mm. +README_SIZE = (267.0, 329.0, 30.85) +README_OPENING = (161.0, 321.0) +README_SOCKET_DEPTH = 14.35 +README_HOLE = 7.2 +README_HOLE_PITCH = (30.8, 30.1) +COVER_THICKNESS = 2.0 + +# How deep the LED panel model is (lib/frame.mjs, LED_SIZE), for the report. +LED_DEPTH = 16.99 + +# Where the official case sits in the model frame, as `node assemble.mjs +# official` reports it: the centre of its outline (its "body" node, x +# -12.5184..12.155, y -0.0693..32.5584) and the inside of its front, 3 mm +# behind the front face (case-v39.glb), which its encoders bear on. +OFFICIAL_OUTLINE_CENTRE = (-0.1817, 16.2446) +OFFICIAL_INSIDE_FRONT_Z = 1.2628 + +# The rest of the official numbers come from lib/frame.mjs, so the two +# cannot drift apart: the knob bases (on the encoder axes) and the front face. +FRAME_MJS = HERE / "lib/frame.mjs" + +# Model units per mm, and the half turn about z from the scene to the model +# frame: the scene's +z is already the front, its -y is up, its -x is right. +MM = 0.1 +SCENE_TO_MODEL = np.diag([-1.0, -1.0, 1.0]) + +# Edges sharper than this stay hard in the shading; flatter ones are smoothed +# over, so the holes and the knob read round and every chamfer, lettering +# wall and corner stays crisp. +CREASE_DEG = 35.0 + +# Pieces closer than this (mm³) count as not overlapping: faces that touch +# exactly, as the glued joints do, give a sliver of rounding error. +OVERLAP_TOLERANCE = 1.0 + + +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"besoiobiy.py: {message}") + + +def official_numbers() -> tuple[dict[str, np.ndarray], float, float]: + """KNOB_BASES, OFFICIAL_FRONT_Z and SHAFT_TIP_Z, read from lib/frame.mjs.""" + text = FRAME_MJS.read_text() + bases = { + f"c{n}": np.array([float(v) for v in vec.split(",")]) + for n, vec in re.findall(r"\bc([1-4]):\s*\[([^\]]+)\]", text) + } + front = re.search(r"OFFICIAL_FRONT_Z\s*=\s*([-\d.]+)", text) + tip = re.search(r"SHAFT_TIP_Z\s*=\s*([-\d.]+)", text) + require( + sorted(bases) == ["c1", "c2", "c3", "c4"] and front is not None and tip is not None, + f"{FRAME_MJS}: no KNOB_BASES c1..c4, OFFICIAL_FRONT_Z or SHAFT_TIP_Z", + ) + return bases, float(front.group(1)), float(tip.group(1)) + + +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 find_parts(scene: list[trimesh.Trimesh], stl_dir: Path) -> dict[str, trimesh.Trimesh]: + """The scene piece each STL file is, by signature; exactly one each.""" + sigs = [signature(piece) for piece in scene] + found = {} + for name, file in {**CASE_PARTS, "knob": KNOB_FILE}.items(): + want = signature(trimesh.load_mesh(stl_dir / file)) + matches = [ + i + for i, have in enumerate(sigs) + if have[0] == want[0] and np.allclose(have[1:], want[1:], rtol=1e-4, atol=1e-3) + ] + require(len(matches) == 1, f"{file}: {len(matches)} pieces of the scene match it, expected one") + found[name] = scene[matches[0]] + return found + + +def planes(mesh: trimesh.Trimesh, axis: int, sign: int) -> list[tuple[float, float]]: + """The flat faces facing along ±axis: (level, area) per level, mm.""" + normals, areas, centres = mesh.face_normals, mesh.area_faces, mesh.triangles_center + facing = normals[:, axis] * sign > 0.9999 + levels = np.round(centres[facing, axis], 2) + out = {} + for level, area in zip(levels, areas[facing]): + out[level] = out.get(level, 0.0) + area + return sorted((float(k), float(v)) for k, v in out.items()) + + +def outer_face(mesh: trimesh.Trimesh, axis: int, sign: int, within: float = 3.0) -> float: + """ + The level of a part's outer face on one side: of the flat faces facing + that way near the part's extreme, the largest. A joint's key is small and + stands 2 mm proud of the face it belongs to; this skips it. + """ + extreme = mesh.bounds[1 if sign > 0 else 0, axis] + near = [(area, level) for level, area in planes(mesh, axis, sign) if abs(level - extreme) <= within] + return max(near)[1] + + +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 assemble(parts: dict[str, trimesh.Trimesh]) -> dict[str, np.ndarray]: + """Each case part's translation in the scene (mm) that puts it in place.""" + X, Y, Z = 0, 1, 2 + p = parts + t = {"frame_top": np.zeros(3)} + + # The frame's joint: the bottom half's joint face onto the top half's. + t["frame_bottom"] = np.array([0.0, outer_face(p["frame_top"], Y, +1) - outer_face(p["frame_bottom"], Y, -1), 0.0]) + # The box beside the frame: its side face onto the frame's outer side, + # its joint in line with the frame's, the fronts flush. + t["box_top"] = np.array( + [ + outer_face(p["frame_top"], X, -1) - outer_face(p["box_top"], X, +1), + outer_face(p["frame_top"], Y, +1) - outer_face(p["box_top"], Y, +1), + p["frame_top"].bounds[1, Z] - p["box_top"].bounds[1, Z], + ] + ) + # The box's joint, the same way as the frame's. + t["box_bottom"] = t["box_top"] + [ + 0.0, + outer_face(p["box_top"], Y, +1) - outer_face(p["box_bottom"], Y, -1), + 0.0, + ] + for top, bottom in (("frame_top", "frame_bottom"), ("box_top", "box_bottom")): + sides = [p[half].bounds[0, X] + t[half][X] for half in (top, bottom)] + require(abs(sides[0] - sides[1]) < 0.01, f"{bottom} is not in line with {top}") + # Each cover: its half's x and y, its back flush with the case's back. + back = p["frame_top"].bounds[0, Z] + for cover, half in COVER_OF.items(): + t[cover] = np.array([t[half][X], t[half][Y], back - p[cover].bounds[0, Z]]) + ledges = [level + t[half][Z] for level, _ in planes(p[half], Z, -1)] + front = p[cover].bounds[1, Z] + t[cover][Z] + require(abs(p[cover].extents[Z] - COVER_THICKNESS) < 0.01, f"{cover} is not a 2 mm plate") + require(min(abs(np.array(ledges) - front)) < 0.01, f"{half} has no ledge under {cover}") + return t + + +def placed(parts: dict[str, trimesh.Trimesh], moves: dict[str, np.ndarray]) -> dict[str, trimesh.Trimesh]: + return {name: parts[name].copy().apply_translation(moves[name]) for name in CASE_PARTS} + + +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 check(case: dict[str, trimesh.Trimesh]) -> dict[str, object]: + """The assembled case against the README's measurements; returns what the model needs.""" + Z = 2 + every = trimesh.util.concatenate(list(case.values())) + size = every.extents + require(np.allclose(size, README_SIZE, atol=0.01), f"assembled size {size}, README says {README_SIZE}") + + # No two parts overlap (keys in their pockets, covers on their ledges). + 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") + volume = overlap.volume if len(overlap.faces) else 0.0 + require(volume < OVERLAP_TOLERANCE, f"{a} and {b} overlap by {volume:.1f} mm³") + + # The panel's opening: the hole in a slice through the two frame halves + # between their front edge and the tabs, where only the walls are. + top, bottom = case["frame_top"], case["frame_bottom"] + front = top.bounds[1, Z] + tabs = largest_plane(top, Z, +1) + require(abs(front - tabs - README_SOCKET_DEPTH) < 0.01, f"tabs {front - tabs:.2f} mm behind the front") + # (Closing the hairline at the joint first: the halves touch there, and + # their slices meet only to within rounding.) + slices = [material_at(half, front - 5).buffer(0.05) for half in (top, bottom)] + walls = shapely.unary_union(slices).buffer(-0.05) + rings = [shapely.Polygon(ring) for g in getattr(walls, "geoms", [walls]) for ring in g.interiors] + require(bool(rings), "no opening in the frame") + x0, y0, x1, y1 = max(rings, key=lambda pg: pg.area).bounds + opening = (x1 - x0, y1 - y0) + require(np.allclose(opening, README_OPENING, atol=0.01), f"opening {opening}, README says {README_OPENING}") + + # The encoder holes, through the box's front, and the inside of that + # front, which the encoders bear on. + box = case["box_top"] + inside = largest_plane(box, Z, -1) + hole_area = np.pi * (README_HOLE / 2) ** 2 + section = box.section(plane_origin=[0, 0, (inside + front) / 2], plane_normal=[0, 0, 1]) + holes = [ + shapely.Polygon(loop[:, :2]) + for loop in section.discrete + if abs(shapely.Polygon(loop[:, :2]).area - hole_area) < 0.1 * hole_area + ] + require(len(holes) == 4, f"{len(holes)} encoder holes, expected four") + centres = np.array(sorted((h.centroid.x, h.centroid.y) for h in holes)) + pitch = (np.ptp(centres[:, 0]), np.ptp(centres[:, 1])) + require(np.allclose(pitch, README_HOLE_PITCH, atol=0.06), f"holes {pitch} apart") + width = np.mean([h.bounds[2] - h.bounds[0] for h in holes]) + require(abs(width - README_HOLE) < 0.01, f"holes {width:.2f} mm across") + # Nothing behind the front where an encoder's 12.4 mm body stands. + behind = material_at(box, inside - 0.3) + for cx, cy in centres: + body = shapely.box(cx - 6.2, cy - 6.2, cx + 6.2, cy + 6.2) + require(behind.intersection(body).area < 0.5, "something behind the front where an encoder sits") + + return { + "outline": every.bounds, + "front": front, + "inside_front": inside, + "tabs": tabs, + "opening_centre": np.array([(x0 + x1) / 2, (y0 + y1) / 2]), + "hole_centroid": centres.mean(axis=0), + "pitch": pitch, + "overall": size, + } + + +def knob_cap(knob: trimesh.Trimesh) -> trimesh.Trimesh: + """ + The cap with its origin on its axis at its base, in model units. In the + scene it already stands the way it goes on, its dished top toward the + front (+z): checked by the bore's recess opening at the base. + """ + base = knob.bounds[0, 2] + loops = knob.section(plane_origin=[0, 0, base + 0.5], plane_normal=[0, 0, 1]).discrete + rings = sorted((shapely.Polygon(loop[:, :2]) for loop in loops), key=lambda pg: pg.area) + require(len(rings) == 2 and rings[0].area < rings[1].area / 2, "the knob's recess is not at its base") + # The recess under the cap is round, centred on its axis. + axis = np.array([rings[0].centroid.x, rings[0].centroid.y, base]) + cap = knob.copy() + cap.apply_translation(-axis) + return to_model_units(cap, np.zeros(3)) + + +def bore_depth(cap: trimesh.Trimesh) -> float: + """ + How far up the cap (model units, from its base) the hole for the shaft + goes: the highest cross-section that still has a hole in it. The README + measures a 2 mm recess and a bore about 9.8 mm deep above it. + """ + step = 0.005 # 0.05 mm + z = step + top = cap.bounds[1, 2] + deepest = 0.0 + while z < top: + loops = cap.section(plane_origin=[0, 0, z], plane_normal=[0, 0, 1]) + if loops is None or len(loops.discrete) < 2: + break + deepest = z + z += step + require(deepest > 0.5, "the knob cap has no bore") + return deepest + + +def to_model_units(mesh: trimesh.Trimesh, offset: np.ndarray) -> trimesh.Trimesh: + """Scene mm → model frame: the half turn about z, 10 mm to the unit, then offset.""" + out = mesh.copy() + matrix = np.eye(4) + matrix[:3, :3] = SCENE_TO_MODEL * MM + matrix[:3, 3] = offset + out.apply_transform(matrix) + return out + + +def crease_shaded(mesh: trimesh.Trimesh, crease_deg: float) -> trimesh.Trimesh: + """ + The mesh with a vertex per smooth patch around each corner: a vertex is + shared between the faces round it that meet at flatter than crease_deg + and split where they meet sharper. Each copy's normal is its patch's, + weighted by the corner angles, as CAD shading does it. + """ + faces = mesh.faces + corners = len(faces) * 3 + # Corners joined across every edge that is not a crease. + smooth = mesh.face_adjacency_angles < np.radians(crease_deg) + pairs = mesh.face_adjacency[smooth] + edges = mesh.face_adjacency_edges[smooth] + rows, cols = [], [] + for k in range(2): + v = edges[:, k] + ka = np.argmax(faces[pairs[:, 0]] == v[:, None], axis=1) + kb = np.argmax(faces[pairs[:, 1]] == v[:, None], axis=1) + rows.append(pairs[:, 0] * 3 + ka) + cols.append(pairs[:, 1] * 3 + kb) + rows, cols = np.concatenate(rows), np.concatenate(cols) + graph = coo_matrix((np.ones(len(rows)), (rows, cols)), shape=(corners, corners)) + count, label = connected_components(graph, directed=False) + # Number the new vertices in order of first use, so the output is stable. + _, first = np.unique(label, return_index=True) + order = np.argsort(np.argsort(first)) + vertex_of = order[label] + face_normals = np.repeat(mesh.face_normals, 3, axis=0) + normals = np.zeros((count, 3)) + np.add.at(normals, vertex_of, face_normals * mesh.face_angles.reshape(-1, 1)) + # A corner of a needle triangle can stand alone with a zero angle (a few + # in the case's files): it takes its own face's normal, unweighted. + lone = np.linalg.norm(normals, axis=1) < 1e-12 + if lone.any(): + fallback = np.zeros((count, 3)) + np.add.at(fallback, vertex_of, face_normals) + normals[lone] = fallback[lone] + lengths = np.linalg.norm(normals, axis=1, keepdims=True) + normals = np.divide(normals, lengths, out=np.tile([0.0, 0.0, 1.0], (count, 1)), where=lengths > 0) + positions = np.zeros((count, 3)) + positions[vertex_of] = mesh.vertices[faces.reshape(-1)] + return trimesh.Trimesh( + vertices=positions, + faces=vertex_of.reshape(-1, 3), + vertex_normals=normals, + process=False, + ) + + +def with_material(mesh: trimesh.Trimesh, name: str) -> trimesh.Trimesh: + mesh.visual = trimesh.visual.TextureVisuals(material=trimesh.visual.material.PBRMaterial(name=name)) + return mesh + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--scene", type=Path, default=REMIX / "source/patternbox.stl", help="Besoiobiy's scene STL") + ap.add_argument("--parts", type=Path, default=REMIX / "stl", help="the remix's stl/ folder") + ap.add_argument( + "--out", type=Path, default=HERE / "build/besoiobiy-printed", help="where shell.glb and placement.json go" + ) + args = ap.parse_args() + + knob_bases, official_front, shaft_tip = official_numbers() + + scene = trimesh.load_mesh(args.scene).split(only_watertight=False) + parts = find_parts(scene, args.parts) + moves = assemble(parts) + case = placed(parts, moves) + found = check(case) + + # Into the model frame: the outline centred on the official case's, the + # front face on the official's. + lo, hi = found["outline"] + centre = SCENE_TO_MODEL @ ((lo + hi) / 2) * MM + offset = np.array( + [ + OFFICIAL_OUTLINE_CENTRE[0] - centre[0], + OFFICIAL_OUTLINE_CENTRE[1] - centre[1], + official_front - found["front"] * MM, + ] + ) + + def model_point(p) -> np.ndarray: + return SCENE_TO_MODEL @ np.asarray(p, dtype=float) * MM + offset + + front = found["front"] * MM + offset[2] + holes = model_point([*found["hole_centroid"], 0.0]) + knob_centroid = np.mean(list(knob_bases.values()), axis=0) + board = np.array( + [ + holes[0] - knob_centroid[0], + holes[1] - knob_centroid[1], + found["inside_front"] * MM + offset[2] - OFFICIAL_INSIDE_FRONT_Z, + ] + ) + led = model_point([*found["opening_centre"], found["front"]]) + knob_standoff = knob_bases["c1"][2] - official_front + + out = trimesh.Scene() + for name, part in case.items(): + mesh = crease_shaded(to_model_units(part, offset), CREASE_DEG) + out.add_geometry(with_material(mesh, CASE_MATERIAL), node_name=name, geom_name=name) + cap = knob_cap(parts["knob"]) + # The cap goes onto the shaft until the shaft bottoms out in its bore, or + # down to the official knobs' 0.8 mm off the front if the bore is deeper: + # the box's front is 1 mm thicker than the official case's, so less of the + # shaft stands out of it. + depth = bore_depth(cap) + knob_z = max(front + knob_standoff, shaft_tip + board[2] - depth) + cap = with_material(crease_shaded(cap, CREASE_DEG), KNOB_MATERIAL) + knobs = {} + for name, base in sorted(knob_bases.items()): + at = np.array([base[0] + board[0], base[1] + board[1], knob_z]) + knobs[name] = at + out.add_geometry( + cap, node_name=name, geom_name="knob_cap", transform=trimesh.transformations.translation_matrix(at) + ) + + 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], + "knobs": "shell", + } + (args.out / "placement.json").write_text(json.dumps(placement, indent=2) + "\n") + + # Report, in mm unless it says units. + size = found["overall"] + print(f"parts: {', '.join(CASE_PARTS)} and the knob cap, matched in {args.scene.name}") + for name, move in moves.items(): + print(f" {name:20s} moved {np.round(move, 2).tolist()}") + print(f"assembled {size[0]:.2f} × {size[1]:.2f} × {size[2]:.2f}, no parts overlapping") + print( + f"holes {found['pitch'][0]:.2f} × {found['pitch'][1]:.2f} apart; front {found['front'] - found['inside_front']:.2f} thick" + ) + into_tabs = found["tabs"] - (found["front"] - LED_DEPTH) + print( + f"panel tabs {found['front'] - found['tabs']:.2f} behind the front; the {LED_DEPTH} mm panel model passes {into_tabs:.2f} into them" + ) + print(f"model units: board {placement['board']}, led {placement['led']}") + print( + f"knob caps {(knob_z - front) / MM:.2f} mm off the front, where the shaft bottoms out in their {depth / MM:.2f} mm bore:" + ) + for name, at in knobs.items(): + print(f" {name} {np.round(at, 4).tolist()}") + print(f"wrote {args.out / 'shell.glb'} and placement.json") + + +if __name__ == "__main__": + main() diff --git a/tools/case-models/build.sh b/tools/case-models/build.sh new file mode 100755 index 000000000..559eaf781 --- /dev/null +++ b/tools/case-models/build.sh @@ -0,0 +1,40 @@ +#!/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 official # one +# +# Needs `npm ci` here and the Python packages in requirements.txt (set PYTHON +# to the interpreter that has them; it defaults to .venv/bin/python, then +# python3). See README.md. +set -euo pipefail +cd "$(dirname "$0")" + +PYTHON="${PYTHON:-}" +if [[ -z "$PYTHON" ]]; then + if [[ -x .venv/bin/python ]]; then PYTHON=.venv/bin/python; else PYTHON=python3; fi +fi + +cases=("$@") +if [[ ${#cases[@]} -eq 0 ]]; then cases=(official besoiobiy-printed simonepda-lasercut); fi + +for id in "${cases[@]}"; do + echo "== $id" + case "$id" in + official) + node assemble.mjs official + ;; + besoiobiy-printed) + "$PYTHON" besoiobiy.py + node assemble.mjs besoiobiy-printed build/besoiobiy-printed/shell.glb build/besoiobiy-printed/placement.json + ;; + simonepda-lasercut) + "$PYTHON" simonepda.py + node assemble.mjs simonepda-lasercut build/simonepda-lasercut/shell.glb build/simonepda-lasercut/placement.json + ;; + *) + echo "unknown case: $id (official, besoiobiy-printed, simonepda-lasercut)" >&2 + exit 1 + ;; + esac +done diff --git a/tools/case-models/extract_led.mjs b/tools/case-models/extract_led.mjs new file mode 100644 index 000000000..37a4ab88d --- /dev/null +++ b/tools/case-models/extract_led.mjs @@ -0,0 +1,39 @@ +// Takes the LED panel out of the landing page's first model. +// +// node extract_led.mjs <3dforweb.glb> [source/led_panel.glb] +// +// The old landing model (web/public/3dforweb.glb until the case models +// replaced it) was a v3.0 device; of all its parts only the LED panel, the +// node "l", is still used: its front face carries the UVs the pattern shaders +// draw on, and the guide places its own LED panel by it. This keeps that one +// node, unchanged (same name, transform, geometry and UVs), in a file of its +// own, so the case models and the guide can be rebuilt without the rest. +// It has been run once; source/led_panel.glb is its output. The old model is +// in the repository's history, for running it again: +// git show v3.11.0:web/public/3dforweb.glb > /tmp/3dforweb.glb +import { fileURLToPath } from 'node:url'; +import { prune } from '@gltf-transform/functions'; +import { getIO, worldBounds, fmt } from './lib/io.mjs'; + +const [src, out = fileURLToPath(new URL('./source/led_panel.glb', import.meta.url))] = process.argv.slice(2); +if (!src) { + console.error('usage: node extract_led.mjs <3dforweb.glb> [out.glb]'); + process.exit(1); +} + +const io = await getIO(); +const doc = await io.read(src); +const root = doc.getRoot(); +const scene = root.getDefaultScene() ?? root.listScenes()[0]; +for (const node of scene.listChildren()) { + if (node.getName() !== 'l') node.dispose(); +} +const led = root.listNodes().find((n) => n.getName() === 'l'); +if (!led) throw new Error(`no node "l" in ${src}`); +// keepAttributes: the panel's UVs are what the pattern shaders draw with, though no +// texture in the file uses them — prune() would drop them otherwise. +await doc.transform(prune({ keepAttributes: true })); +doc.getRoot().getAsset().generator = 'patternflow tools/case-models/extract_led.mjs'; +await io.write(out, doc); +const b = worldBounds(led); +console.log(`${out}: "l" world bounds ${fmt(b.min)} … ${fmt(b.max)}`); diff --git a/tools/case-models/lib/frame.mjs b/tools/case-models/lib/frame.mjs new file mode 100644 index 000000000..fafc5e39f --- /dev/null +++ b/tools/case-models/lib/frame.mjs @@ -0,0 +1,59 @@ +// The model frame every case model is built in, and where the official +// device's parts sit in it. These are the guide's numbers +// (web/src/components/guide/stage/parts.ts and geometry.ts), copied here so +// this folder runs without the web app; if those move, move these with them. +// +// One unit is 10 mm. The device stands facing +z: +y up with the knobs at the +// top, +x to the reader's right, the LED panel on the left and the knob +// column on the right — the frame of the landing page's first model, which +// the guide's v3.9 case was exported into. + +/** Metres (pcb-v39.glb, devkit.glb) → model units. */ +export const M_TO_MODEL = 100; + +/** pcb-v39.glb's root, seated so SW1..SW4's shafts land on the knob axes. */ +export const PCB_PLACEMENT = { + translation: [8.0515, 24.9577, 0.6572], + rotation: [0, Math.SQRT1_2, -Math.SQRT1_2, 0], + scale: [M_TO_MODEL, M_TO_MODEL, M_TO_MODEL], +}; + +/** devkit.glb's root, on U1's sockets on the back of the board. */ +export const DEVKIT_SEAT = { + translation: [8.0565, 22.4732, -0.8323], + rotation: [0, 1, 0, 0], + scale: [M_TO_MODEL, M_TO_MODEL, M_TO_MODEL], +}; + +/** + * The knobs' bases on the official case: on the encoder axes, 0.8 mm off the + * front face. Named after the PCB encoder nets as case-v39.glb names them; + * seen from the front c1 is top-left, c2 top-right, c3 bottom-left, c4 + * bottom-right. + */ +export const KNOB_BASES = { + c1: [6.5265, 29.6952, 1.6437], + c2: [9.6265, 29.6952, 1.6437], + c3: [6.5265, 26.6452, 1.6437], + c4: [9.6265, 26.6452, 1.6437], +}; + +/** The official case's front face, which the encoders bear on from inside. */ +export const OFFICIAL_FRONT_Z = 1.5635; + +/** + * The tips of the encoders' shafts with the board where the official case + * holds it: SW1..SW4 of pcb-v39.glb, placed as above, end at this z. A knob + * cap whose bore is shallower than the shaft stands where the shaft bottoms + * out in it. + */ +export const SHAFT_TIP_Z = 3.2172; + +/** Where the LED panel's node ("l") sits on the official case. */ +export const LED_TRANSLATION = [-4.1908, 16.2677, 1.4977]; + +/** The LED panel's size in model units: 160 × 320 mm, 16.99 mm deep. */ +export const LED_SIZE = [16, 32, 1.699]; + +/** Adds two 3-vectors. */ +export const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; diff --git a/tools/case-models/lib/io.mjs b/tools/case-models/lib/io.mjs new file mode 100644 index 000000000..8cfa4ca69 --- /dev/null +++ b/tools/case-models/lib/io.mjs @@ -0,0 +1,48 @@ +// Reading and writing glTF with the extensions and codecs this folder uses. +import { NodeIO } from '@gltf-transform/core'; +import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; +import draco3d from 'draco3dgltf'; + +let io; + +/** One NodeIO that reads and writes Draco-compressed GLB. */ +export async function getIO() { + if (!io) { + io = new NodeIO() + .registerExtensions(ALL_EXTENSIONS) + .registerDependencies({ + 'draco3d.decoder': await draco3d.createDecoderModule(), + 'draco3d.encoder': await draco3d.createEncoderModule(), + }); + } + return io; +} + +/** The world-space bounding box of a node's meshes, in its document's units. */ +export function worldBounds(node) { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + node.traverse((n) => { + const mesh = n.getMesh(); + if (!mesh) return; + const m = n.getWorldMatrix(); + for (const prim of mesh.listPrimitives()) { + const pos = prim.getAttribute('POSITION'); + const v = [0, 0, 0]; + for (let i = 0; i < pos.getCount(); i++) { + pos.getElement(i, v); + const x = m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12]; + const y = m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13]; + const z = m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]; + const w = [x, y, z]; + for (let k = 0; k < 3; k++) { + if (w[k] < min[k]) min[k] = w[k]; + if (w[k] > max[k]) max[k] = w[k]; + } + } + } + }); + return { min, max }; +} + +export const fmt = (v) => v.map((x) => +x.toFixed(4)); diff --git a/tools/case-models/lib/knob.mjs b/tools/case-models/lib/knob.mjs new file mode 100644 index 000000000..d861055fe --- /dev/null +++ b/tools/case-models/lib/knob.mjs @@ -0,0 +1,207 @@ +// The official knob, drawn round. hardware/case/knobs/knobs_20mm.stl is the +// knob people print (the BOM names it), and case-v39.glb carries it as +// exported: a 32-sided prism, whose flats and corners show at the size the +// preview draws it. Here its profile — its radius at each height, read from +// the STL — is turned about its axis with enough sides to read as round. +// +// What the STL has, and this keeps: a 20 mm knob that tapers from 16.26 mm +// across at its base to 12.89 mm at its top, a little convex; an open recess +// under it that clears the encoder's nut; and the bore for the shaft. What it +// does not keep: the 16 flutes round the top half, 0.14 mm deep, too fine to +// see here, and the D's flat in the bore, which nothing shows. The top edge +// gets a 0.4 mm round so it catches the light: a choice for the preview, as +// the knob prints standing on its top and that edge comes off the bed sharp. +// +// The knob's frame is the case models' contract (caseModels.ts): the origin +// on its axis at its base, the axis +z, one unit 10 mm. +import fs from 'node:fs'; + +/** Sides of the turned knob: as round as the eye needs at the preview's sizes. */ +const SIDES = 96; +/** The top edge's round, mm. */ +const TOP_ROUND = 0.4; +/** Two faces meeting at more than this are an edge; under it, one smooth surface. */ +const CREASE_DEG = 35; +const MM = 0.1; + +/** A binary STL's vertices, three per triangle, as one flat array of x, y, z. */ +function readStl(file) { + const buf = fs.readFileSync(file); + const count = buf.readUInt32LE(80); + if (buf.length !== 84 + count * 50) throw new Error(`${file}: not a binary STL`); + const out = new Float64Array(count * 9); + for (let t = 0; t < count; t++) { + for (let k = 0; k < 9; k++) out[t * 9 + k] = buf.readFloatLE(84 + t * 50 + 12 + k * 4); + } + return out; +} + +const near = (a, b, tol, what) => { + if (Math.abs(a - b) > tol) throw new Error(`knob: ${what} is ${a.toFixed(3)}, expected ${b} ± ${tol}`); +}; + +/** + * The profile of one knob of the STL, in mm: [r, h] points with h up from the + * base, as one outline of the solid's section from the axis round to the + * axis, clockwise with r to the right and h up, so the solid is on the right + * of each step and its outward normal is the step's left normal: [-dh, dr]. + */ +export function knobProfile(stlFile) { + const v = readStl(stlFile); + // The file lays four knobs out in a row along y: take the first. + const ys = []; + for (let i = 1; i < v.length; i += 3) ys.push(v[i]); + ys.sort((a, b) => a - b); + let end = ys[0]; + for (const y of ys) { + if (y - end > 1) break; + end = y; + } + const pts = []; + for (let i = 0; i < v.length; i += 3) if (v[i + 1] <= end) pts.push([v[i], v[i + 1], v[i + 2]]); + const lo = [0, 1, 2].map((k) => Math.min(...pts.map((p) => p[k]))); + const hi = [0, 1, 2].map((k) => Math.max(...pts.map((p) => p[k]))); + const cx = (lo[0] + hi[0]) / 2; + const cy = (lo[1] + hi[1]) / 2; + const height = hi[2] - lo[2]; + + // Every height the STL has vertices at, with the radii there. + const levels = new Map(); + for (const [x, y, z] of pts) { + const key = Math.round((z - lo[2]) * 1000); + const r = Math.hypot(x - cx, y - cy); + const l = levels.get(key) ?? { z: key / 1000, min: Infinity, max: -Infinity }; + l.min = Math.min(l.min, r); + l.max = Math.max(l.max, r); + levels.set(key, l); + } + const byZ = [...levels.values()].sort((a, b) => a.z - b.z); + // The STL stands the knob on its top (z = 0), the open base up. + const top = byZ[0]; + const base = byZ[byZ.length - 1]; + const rBase = base.max; + const h = (z) => height - z; + + // The outside: at each height its largest radius (the flutes' crests). + const outer = byZ.filter((l) => l.max > 0.75 * rBase).map((l) => [l.max, h(l.z)]); + // Under it: the recess opens at the base's inner ring and ends at the + // ceiling, the one height whose largest radius is between the bore's and + // the outside's; the bore runs up from the ceiling and ends in a cone. + const recessOpen = base.min; + const ceiling = byZ.find((l) => l.max > 0.55 * rBase && l.max < 0.75 * rBase); + const bore = byZ.filter((l) => l.max < 0.45 * rBase); + const boreR = Math.max(...bore.map((l) => l.max)); + const coneStart = bore.find((l) => Math.abs(l.max - boreR) < 1e-3 && l !== ceiling); + const boreEnd = bore[0]; + + // What the knob is (hardware/case/README.md's knob for the BOM's 20 mm + // shaft, as measured from this STL when this was written): stop if + // the file has become something else rather than draw it wrong. + near(height, 20, 0.05, 'height'); + near(2 * rBase, 16.26, 0.1, 'base diameter'); + near(2 * top.max, 12.89, 0.1, 'top diameter'); + if (!ceiling || !coneStart || outer.length < 4) throw new Error('knob: the STL no longer has the recess, bore and wall this reads'); + + // Round, top first: centre of the top, its edge, down the outside to the + // base, in across the base to the recess, up the recess to its ceiling, in + // to the bore, up the bore and its cone, and in across the bore's end. + const outside = outer.slice().sort((a, b) => b[1] - a[1]); + const outline = [ + [0, height], + ...outside, + [recessOpen, 0], + [ceiling.max, h(ceiling.z)], + [boreR, h(ceiling.z)], + [boreR, h(coneStart.z)], + [boreEnd.max, h(boreEnd.z)], + [0, h(boreEnd.z)], + ]; + return roundCorner(outline, 1, TOP_ROUND); +} + +/** Replaces outline point i with a circular arc of radius rho tangent to both of its segments. */ +function roundCorner(outline, i, rho, steps = 5) { + const [p, a, b] = [outline[i], outline[i - 1], outline[i + 1]]; + const unit = (q, o) => { + const d = Math.hypot(q[0] - o[0], q[1] - o[1]); + return [(q[0] - o[0]) / d, (q[1] - o[1]) / d]; + }; + const u = unit(a, p); + const w = unit(b, p); + const turn = Math.acos(u[0] * w[0] + u[1] * w[1]); + const t = rho / Math.tan(turn / 2); + const s = [p[0] + u[0] * t, p[1] + u[1] * t]; + const e = [p[0] + w[0] * t, p[1] + w[1] * t]; + const bis = unit([u[0] + w[0], u[1] + w[1]], [0, 0]); + const dc = rho / Math.sin(turn / 2); + const c = [p[0] + bis[0] * dc, p[1] + bis[1] * dc]; + const a0 = Math.atan2(s[1] - c[1], s[0] - c[0]); + let a1 = Math.atan2(e[1] - c[1], e[0] - c[0]); + while (a1 - a0 > Math.PI) a1 -= 2 * Math.PI; + while (a1 - a0 < -Math.PI) a1 += 2 * Math.PI; + const arc = []; + for (let k = 0; k <= steps; k++) { + const ang = a0 + ((a1 - a0) * k) / steps; + arc.push([c[0] + rho * Math.cos(ang), c[1] + rho * Math.sin(ang)]); + } + return [...outline.slice(0, i), ...arc, ...outline.slice(i + 1)]; +} + +/** + * The knob as a mesh in `doc`, in model units (10 mm), material `material`: + * the profile turned SIDES times about +z, smooth across every bend under + * CREASE_DEG and sharp at the rest. + */ +export function knobMesh(doc, stlFile, material) { + const outline = knobProfile(stlFile); + const segs = []; + for (let i = 0; i + 1 < outline.length; i++) { + const [a, b] = [outline[i], outline[i + 1]]; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + segs.push({ a, b, n: [-(b[1] - a[1]) / len, (b[0] - a[0]) / len] }); + } + // The normal at each end of each segment: its own, or shared with its + // neighbour where the two meet at less than the crease angle. + const cosCrease = Math.cos((CREASE_DEG * Math.PI) / 180); + const blend = (n, m) => { + if (!m || n[0] * m[0] + n[1] * m[1] < cosCrease) return n; + const d = Math.hypot(n[0] + m[0], n[1] + m[1]); + return [(n[0] + m[0]) / d, (n[1] + m[1]) / d]; + }; + segs.forEach((s, i) => { + s.na = blend(s.n, segs[i - 1]?.n); + s.nb = blend(s.n, segs[i + 1]?.n); + }); + + const pos = []; + const nor = []; + const idx = []; + const ring = (p, n) => { + const start = pos.length / 3; + for (let k = 0; k < SIDES; k++) { + const phi = (2 * Math.PI * k) / SIDES; + const [c, s] = [Math.cos(phi), Math.sin(phi)]; + pos.push(p[0] * c * MM, p[0] * s * MM, p[1] * MM); + nor.push(n[0] * c, n[0] * s, n[1]); + } + return start; + }; + for (const s of segs) { + const ra = ring(s.a, s.na); + const rb = ring(s.b, s.nb); + for (let k = 0; k < SIDES; k++) { + const k1 = (k + 1) % SIDES; + // Facing out of the solid (the normal's side), counter-clockwise. + if (s.a[0] > 0) idx.push(ra + k, rb + k, ra + k1); + if (s.b[0] > 0) idx.push(ra + k1, rb + k, rb + k1); + } + } + const buffer = doc.getRoot().listBuffers()[0]; + const prim = doc + .createPrimitive() + .setAttribute('POSITION', doc.createAccessor().setType('VEC3').setArray(new Float32Array(pos)).setBuffer(buffer)) + .setAttribute('NORMAL', doc.createAccessor().setType('VEC3').setArray(new Float32Array(nor)).setBuffer(buffer)) + .setIndices(doc.createAccessor().setType('SCALAR').setArray(new Uint32Array(idx)).setBuffer(buffer)) + .setMaterial(material); + return doc.createMesh('knob').addPrimitive(prim); +} diff --git a/tools/case-models/lib/looks.mjs b/tools/case-models/lib/looks.mjs new file mode 100644 index 000000000..aaa016ee3 --- /dev/null +++ b/tools/case-models/lib/looks.mjs @@ -0,0 +1,56 @@ +// What every case model's materials look like, by name. The scripts that make +// a shell only name a material; its colour and finish are set here, so the +// three cases sit side by side under one light. +// +// 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_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 +// acrylic build is; the page can swap in MDF (caseModels.ts) +// sheet_edge the same sheet's cut edges +// acrylic_face laser-cut parts that are acrylic in every build (the box +// acrylic_edge over the board), faces and cut edges +// +// Any other name is left as the file has it (the board's, the DevKit's, the +// LED panel's). + +const srgbToLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const hex = (h, alpha = 1) => { + const n = parseInt(h.replace('#', ''), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => srgbToLinear(v / 255)).concat(alpha); +}; + +// Clear acrylic: glossy and nearly all see-through, so what is behind it — +// the page, the board — is what shows, as the carpet does through the sheet +// in the remix's photo. Its cut edges are where a sheet shows itself: darker +// and cool green, as light run along the sheet comes out of them. +const ACRYLIC_FACE = { color: hex('#e6efed', 0.12), roughness: 0.08, blend: true }; +const ACRYLIC_EDGE = { color: hex('#8fb5ad', 0.7), roughness: 0.25, blend: true }; + +export const LOOKS = { + // A neutral white. White PLA photographs anywhere from cool to cream with + // the light it is under (the build photos run from b* −6 to +11), so the + // 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 }, + pla_black: { color: hex('#151515'), roughness: 0.55 }, + sheet_face: ACRYLIC_FACE, + sheet_edge: ACRYLIC_EDGE, + acrylic_face: ACRYLIC_FACE, + acrylic_edge: ACRYLIC_EDGE, +}; + +/** Sets the look of every material in the document whose name is in LOOKS. */ +export function applyLooks(doc) { + for (const m of doc.getRoot().listMaterials()) { + const look = LOOKS[m.getName()]; + if (!look) continue; + m.setBaseColorFactor(look.color) + .setMetallicFactor(0) + .setRoughnessFactor(look.roughness) + .setAlphaMode(look.blend ? 'BLEND' : 'OPAQUE') + .setDoubleSided(Boolean(look.blend)); + } +} diff --git a/tools/case-models/package-lock.json b/tools/case-models/package-lock.json new file mode 100644 index 000000000..0ab1df185 --- /dev/null +++ b/tools/case-models/package-lock.json @@ -0,0 +1,747 @@ +{ + "name": "patternflow-case-models", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "patternflow-case-models", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@gltf-transform/core": "4.5.1", + "@gltf-transform/extensions": "4.5.1", + "@gltf-transform/functions": "4.5.1", + "draco3dgltf": "1.5.7", + "meshoptimizer": "1.3.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@gltf-transform/core": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@gltf-transform/core/-/core-4.5.1.tgz", + "integrity": "sha512-OWgBYssKHKoXMIrMb2N20jXqWRnMP9HdtcTFQBEAJtz4P+OOxx5fozl5eAeK7nktIs5uDE/lu4NbkE5VCZaR9g==", + "license": "MIT", + "dependencies": { + "property-graph": "^4.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/donmccurdy" + } + }, + "node_modules/@gltf-transform/extensions": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@gltf-transform/extensions/-/extensions-4.5.1.tgz", + "integrity": "sha512-3jTe+GWRy4fj1TU790LPsO+rpO/4byUWPTpNkjy6M6h8RvrUivRf12Mptc0770K4ZczibpJDNHXZFkFsFMRDLA==", + "license": "MIT", + "dependencies": { + "@gltf-transform/core": "^4.5.1", + "ktx-parse": "^1.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/donmccurdy" + } + }, + "node_modules/@gltf-transform/functions": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@gltf-transform/functions/-/functions-4.5.1.tgz", + "integrity": "sha512-rfm4nQY2DJ+ip1e15xp7YfQwhRno3kPLcGw4EZiRk1dVwMV3cdFMcF08glYcO/+oLyYkUEs3xXMJYXa0cPgcTg==", + "license": "MIT", + "dependencies": { + "@gltf-transform/core": "^4.5.1", + "@gltf-transform/extensions": "^4.5.1", + "ktx-parse": "^1.1.0", + "ndarray": "^1.0.19", + "ndarray-lanczos": "^0.3.0", + "ndarray-pixels": "^5.2.0" + }, + "funding": { + "url": "https://github.com/sponsors/donmccurdy" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@types/ndarray": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/ndarray/-/ndarray-1.1.0.tgz", + "integrity": "sha512-go+C6vHBUuO9jH6Tj6qEZ63JEGoMSxFdmd+6QAtthGD6xumBfIz3RbNDDs2aoRVLf9naH8lk5jnWy2UHHKgYQQ==", + "license": "MIT" + }, + "node_modules/cwise-compiler": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz", + "integrity": "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==", + "license": "MIT", + "dependencies": { + "uniq": "^1.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/draco3dgltf": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3dgltf/-/draco3dgltf-1.5.7.tgz", + "integrity": "sha512-LeqcpmoHIyYUi0z70/H3tMkGj8QhqVxq6FJGPjlzR24BNkQ6jyMheMvFKJBI0dzGZrEOUyQEmZ8axM1xRrbRiw==", + "license": "Apache-2.0" + }, + "node_modules/iota-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", + "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==", + "license": "MIT" + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/ktx-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz", + "integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ==", + "license": "MIT" + }, + "node_modules/meshoptimizer": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.3.0.tgz", + "integrity": "sha512-2oZqMAcEEiCtXWUjIGtZ71oPx1B0ZxYGTqIKsXB1wzWFhleNsCFxQuUJzgCV706bRcwPWtMGf1odmQvAcqKkIw==", + "license": "MIT" + }, + "node_modules/ndarray": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.1.1.tgz", + "integrity": "sha512-AsE8w/M2MS+0pD3nP5qbRRtJFeJJJaJ+qXZaZr0XjVGXhy6LIj2TERTlkpdK2AI3WufoOP+pwPk3q2ilAJcZ9g==", + "license": "MIT", + "dependencies": { + "iota-array": "^1.0.0", + "is-buffer": "^1.0.2" + } + }, + "node_modules/ndarray-lanczos": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/ndarray-lanczos/-/ndarray-lanczos-0.3.0.tgz", + "integrity": "sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==", + "license": "MIT", + "dependencies": { + "@types/ndarray": "^1.0.11", + "ndarray": "^1.0.19" + } + }, + "node_modules/ndarray-ops": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ndarray-ops/-/ndarray-ops-1.2.2.tgz", + "integrity": "sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==", + "license": "MIT", + "dependencies": { + "cwise-compiler": "^1.0.0" + } + }, + "node_modules/ndarray-pixels": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ndarray-pixels/-/ndarray-pixels-5.2.0.tgz", + "integrity": "sha512-lTh4tFKziAatVTa9crIsidUyn+lqujVOQpzfdBWvdFu2wo9Uo6z261lVX7SgMyP89xGmj3TMTPbbxl9YDnV4SA==", + "license": "MIT", + "dependencies": { + "@types/ndarray": "^1.0.14", + "ndarray": "^1.0.19", + "ndarray-ops": "^1.2.2", + "sharp": "^0.35.0" + } + }, + "node_modules/property-graph": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/property-graph/-/property-graph-4.1.0.tgz", + "integrity": "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==", + "license": "MIT" + } + } +} diff --git a/tools/case-models/package.json b/tools/case-models/package.json new file mode 100644 index 000000000..f1623c358 --- /dev/null +++ b/tools/case-models/package.json @@ -0,0 +1,20 @@ +{ + "name": "patternflow-case-models", + "version": "1.0.0", + "private": true, + "description": "Builds the 3D models the /build page's preview shows, one per case, from files in this repository.", + "license": "MIT", + "type": "module", + "scripts": { + "build": "./build.sh", + "assemble": "node assemble.mjs", + "preview": "node preview.mjs" + }, + "dependencies": { + "@gltf-transform/core": "4.5.1", + "@gltf-transform/extensions": "4.5.1", + "@gltf-transform/functions": "4.5.1", + "draco3dgltf": "1.5.7", + "meshoptimizer": "1.3.0" + } +} diff --git a/tools/case-models/preview.mjs b/tools/case-models/preview.mjs new file mode 100644 index 000000000..5196c8922 --- /dev/null +++ b/tools/case-models/preview.mjs @@ -0,0 +1,131 @@ +// Renders a case model from four sides into one PNG, to look at what a script +// made without starting the site. +// +// node preview.mjs [--explode] +// +// Needs the web app's dependencies installed (it borrows three.js and the +// Draco decoder from web/node_modules) and Playwright with a Chromium +// (`npm i -g playwright`, or NODE_PATH pointing at one). Views: front, back, +// three-quarter front, and the right side. With --explode every top-level +// node is pushed away from the model's centre along z by its order, so +// stacked parts can be told apart. +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, '../..'); +const args = process.argv.slice(2); +const explode = args.includes('--explode'); +const [modelArg, outArg] = args.filter((a) => !a.startsWith('--')); +if (!modelArg || !outArg) { + console.error('usage: node preview.mjs [--explode]'); + process.exit(1); +} + +function loadPlaywright() { + const require = createRequire(import.meta.url); + try { + return require('playwright'); + } catch { + const globalRoot = execSync('npm root -g').toString().trim(); + return require(path.join(globalRoot, 'playwright')); + } +} + +const model = fs.readFileSync(path.resolve(modelArg)); +const THREE_DIR = path.join(REPO, 'web/node_modules/three'); + +const page = /* html */ ` + + +`; + +const server = http.createServer((req, res) => { + const url = decodeURIComponent(req.url.split('?')[0]); + if (url === '/' || url === '/index.html') { + res.writeHead(200, { 'content-type': 'text/html' }); + return res.end(page); + } + if (url === '/model.glb') { + res.writeHead(200, { 'content-type': 'model/gltf-binary' }); + return res.end(model); + } + if (url.startsWith('/three/')) { + const file = path.join(THREE_DIR, url.slice('/three/'.length)); + if (!file.startsWith(THREE_DIR) || !fs.existsSync(file)) { + res.writeHead(404); + return res.end(); + } + const type = file.endsWith('.js') ? 'text/javascript' : file.endsWith('.wasm') ? 'application/wasm' : 'application/octet-stream'; + res.writeHead(200, { 'content-type': type }); + return res.end(fs.readFileSync(file)); + } + res.writeHead(404); + res.end(); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const { port } = server.address(); + +const { chromium } = loadPlaywright(); +const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); +try { + const tab = await browser.newPage({ viewport: { width: 1280, height: 1280 }, deviceScaleFactor: 1 }); + tab.on('pageerror', (e) => console.error('page error:', e.message)); + await tab.goto(`http://127.0.0.1:${port}/`); + await tab.waitForFunction(() => document.title !== '', null, { timeout: 120000 }); + const title = await tab.title(); + if (title !== 'ready') throw new Error(title); + await tab.locator('canvas').screenshot({ path: path.resolve(outArg) }); + console.log(`wrote ${outArg}`); +} finally { + await browser.close(); + server.close(); +} diff --git a/tools/case-models/requirements.txt b/tools/case-models/requirements.txt new file mode 100644 index 000000000..d513528ab --- /dev/null +++ b/tools/case-models/requirements.txt @@ -0,0 +1,10 @@ +# The two remix scripts (besoiobiy.py, simonepda.py). Python 3.12 or newer: +# numpy, scipy and networkx at these versions need it. +manifold3d==3.5.4 +mapbox_earcut==2.1.0 +networkx==3.7 +numpy==2.5.3 +pymupdf==1.28.2 +scipy==1.18.1 +shapely==2.2.0 +trimesh==5.1.1 diff --git a/tools/case-models/simonepda.py b/tools/case-models/simonepda.py new file mode 100755 index 000000000..787f53a51 --- /dev/null +++ b/tools/case-models/simonepda.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python3 +"""SimonePDA's laser-cut case as a 3D shell, made from its drawing. + + python simonepda.py # writes build/simonepda-lasercut/ + python simonepda.py --pdf --out + +Then `node assemble.mjs simonepda-lasercut build/simonepda-lasercut/shell.glb +build/simonepda-lasercut/placement.json` puts the LED panel, the board, the +DevKit and the knobs in it (README.md). MIT, like the rest of tools/. + +The case has no 3D file. What there is, is Simone Majocchi's drawing, +hardware/case/remixes/simonepda-lasercut/lasercut_layout.pdf: six A3 pages at +1:1, every cut a stroked vector path. This script reads those paths with +PyMuPDF (1 pt = 25.4/72 mm), so every size below comes from the drawing, and +the sheet's 256 x 340 mm is checked to make sure the scale survived: + + page 3 the plate: its outline (rounded corners), the panel's 14 screw holes + (5 mm), the four knob holes (8 mm, on 30 x 30 mm), the four windows + over the panel's connectors (60 x 40 mm) and the three slots that + hold the box (2.7 mm wide) + page 2 the same plate with the panel's outline drawn on it (160 x 320 mm): + where the panel sits + page 4 the same plate with the box's lid drawn where the box sits; the lid + there is the very outline page 5 cuts, which the script checks + page 1 the four border strips, 16 mm wide: two 322 mm long, two 162 mm + page 5 the box (the lid and four walls, finger jointed, keys on three walls + for the plate's three slots, notches for the cables) and the feet + (an open cube of five 30 mm squares, finger jointed; three are made) + +Every part is its outline as drawn, holes and fingers included, extruded by +the sheet's thickness and stood where it goes. The model frame is the one +every case model shares (README.md): one unit is 10 mm, the device stands +facing +z with +y up, the LED panel on the left and the knobs at the top +right. Page 3 is drawn as the plate is seen from the front: turning it onto +its side the way the MDF photo shows puts its slots exactly where the photo +has them, so the drawing maps onto the frame without a mirror. + +What the drawing does not say, and what this script assumes instead: + + - The plate and the strips are 2.75 mm thick, the acrylic the README names. + The box and the feet are 2.7 mm: their finger joints are drawn 2.7 deep + (makercase's sheet), and at that thickness they close exactly as drawn. + - The plate sits where the official case does: centred on x = 0, its middle + at the height of the official LED panel's middle, and its front face at + the official case's front face (z 1.5635). The board then needs no depth + of its own to speak of, since the official case's 3 mm wall and this + 2.75 mm sheet both carry the encoders on their back face. + - The strips stand on edge on the front face around the panel, each flush + with one corner of the panel and running past the next one by the 2 mm + the drawing gives them (162 = 160 + 2, 322 = 320 + 2), turning the same + way round the panel, so each strip closes the end of the next. Which way + round is not drawn; here the top strip runs past the panel's top-left + corner, the left one past its bottom-left, and so on round. + - The box's walls go where the plate's slots say: the keyed short walls at + the top and the bottom, the keyed long wall on the right (its slot is on + the right), the long wall without a key on the left, towards the panel. + The drawing does not say which way each wall faces along its length; the + photos do: the ribbon cable leaves through the left wall's notch a little + below its middle (the notch is drawn 55 to 100 mm from one end; that end + is the top), and the power cable through the bottom wall's notch near its + right-hand end. + - The board is centred under the four knob holes. They are 30 x 30 mm apart + and the board's encoders 31 x 30.5, so it cannot sit on all four centres; + the 8 mm holes are oversized for that (the remix README). + - The feet are loose cubes the drawing does not place. The box is as deep + as a cube is tall (30 mm), so laid flat the device stands on the box at + the top right of its back and needs feet at the other three corners: one + cube in each, 4 mm in from the plate's edges, open side against the + plate, clear of the screw heads. + - Page 1's title box (the edition's name) and page 2's construction boxes + are not parts; page 6 is the panel maker's drawing. + +Materials, named for lib/looks.mjs: the plate, the strips and the feet are +"sheet_face" on their two big faces and "sheet_edge" on their cut edges (the +same file shows as acrylic or as MDF: the finishes in +web/src/components/3d/cases/simonepda-lasercut.ts); the box is +"acrylic_face" / "acrylic_edge" in every build. One node per part, the two +materials as its two primitives, flat shaded: each face keeps its own normal. +The output is deterministic: run it twice, get the same bytes. +""" + +from __future__ import annotations + +import argparse +import json +import math +import struct +from pathlib import Path + +import numpy as np +import pymupdf +import trimesh +from shapely.geometry import Polygon +from shapely.geometry.polygon import orient + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] +DEFAULT_PDF = REPO / "hardware/case/remixes/simonepda-lasercut/lasercut_layout.pdf" +DEFAULT_OUT = HERE / "build/simonepda-lasercut" + +PT = 25.4 / 72 # PDF points to millimetres; the drawing is 1:1. +MODEL = 0.1 # millimetres to model units (one unit is 10 mm) + +# The plate and the strips: acrylic with its film off (the remix README). The +# box and the feet take their thickness from their own finger joints instead. +SHEET = 2.75 + +# The official device in the model frame, in model units, from lib/frame.mjs: +# its LED panel's middle, the case's front face and the face 3 mm behind it +# that the encoders bear on, the panel's depth, and the knobs' encoder axes. +# This case's plate takes the official front face's place. +OFFICIAL_LED_Y = 16.2677 +OFFICIAL_FRONT_Z = 1.5635 +OFFICIAL_INSIDE_Z = 1.2628 +LED_DEPTH = 1.699 +OFFICIAL_ENCODER_CENTRE = ((6.5265 + 9.6265) / 2, (29.6952 + 26.6452) / 2) +KNOB_LIFT = 0.08 # the knobs' base, 0.8 mm off the front face, as officially + +# Where the feet go, as the drawing does not say: inset from the plate's edges. +FOOT_INSET = 4.0 + +# -------------------------------------------------------------------------- +# Reading the drawing + + +def _pt(p) -> tuple[float, float]: + return (p.x * PT, p.y * PT) + + +def _bezier(p0, p1, p2, p3, tol=0.8) -> list[tuple[float, float]]: + """A cubic Bézier as points, about every `tol` mm along it (first point left out).""" + hull = math.dist(p0, p1) + math.dist(p1, p2) + math.dist(p2, p3) + n = max(4, math.ceil(hull / tol)) + out = [] + for i in range(1, n + 1): + t = i / n + a, b, c, d = (1 - t) ** 3, 3 * (1 - t) ** 2 * t, 3 * (1 - t) * t**2, t**3 + out.append((a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1])) + return out + + +class Shape: + """One stroked path of the drawing: its outline in millimetres, page coordinates (y down).""" + + def __init__(self, drawing: dict): + self.color = tuple(round(c, 2) for c in (drawing.get("color") or (0, 0, 0))) + r = drawing["rect"] + self.x0, self.y0, self.x1, self.y1 = r.x0 * PT, r.y0 * PT, r.x1 * PT, r.y1 * PT + self.kinds = "".join(item[0] for item in drawing["items"]) + self.circle = self.kinds == "cccc" and abs(self.w - self.h) < 0.05 + pts: list[tuple[float, float]] = [] + for item in drawing["items"]: + if item[0] == "re": + q = item[1] + seg = [(q.x0 * PT, q.y0 * PT), (q.x1 * PT, q.y0 * PT), (q.x1 * PT, q.y1 * PT), (q.x0 * PT, q.y1 * PT)] + elif item[0] == "l": + seg = [_pt(item[1]), _pt(item[2])] + elif item[0] == "c": + seg = [_pt(item[1])] + _bezier(*(_pt(p) for p in item[1:5])) + else: + raise ValueError(f"unexpected path item {item[0]!r}") + for p in seg: + if not pts or math.dist(p, pts[-1]) > 1e-6: + pts.append(p) + if len(pts) > 1 and math.dist(pts[0], pts[-1]) < 1e-3: + pts.pop() + self.points = pts + + @property + def w(self) -> float: + return self.x1 - self.x0 + + @property + def h(self) -> float: + return self.y1 - self.y0 + + @property + def centre(self) -> tuple[float, float]: + return ((self.x0 + self.x1) / 2, (self.y0 + self.y1) / 2) + + @property + def diameter(self) -> float: + return (self.w + self.h) / 2 + + def size_is(self, w: float, h: float, tol=0.2) -> bool: + return abs(self.w - w) < tol and abs(self.h - h) < tol + + def ring(self) -> list[tuple[float, float]]: + """The outline as a closed ring; a circle as a regular polygon, about 1 mm a side.""" + if self.circle: + cx, cy = self.centre + rad = self.diameter / 2 + n = 4 * max(4, math.ceil(math.pi * self.diameter / 4)) + return [(cx + rad * math.cos(2 * math.pi * i / n), cy + rad * math.sin(2 * math.pi * i / n)) for i in range(n)] + return self.points + + def local(self) -> list[tuple[float, float]]: + """The outline with its bounding box's top-left corner at the origin, as a part is laid out.""" + return [(x - self.x0, y - self.y0) for x, y in self.points] + + +def read_pages(pdf: Path) -> list[list[Shape]]: + doc = pymupdf.open(pdf) + if len(doc) < 5: + raise SystemExit(f"{pdf}: expected the six-page layout, found {len(doc)} pages") + return [[Shape(d) for d in page.get_drawings()] for page in doc] + + +def one(shapes: list[Shape], what: str, test) -> Shape: + found = [s for s in shapes if test(s)] + if len(found) != 1: + raise SystemExit(f"expected one {what} in the drawing, found {len(found)}") + return found[0] + + +def recover(pages: list[list[Shape]]) -> dict: + """Every dimension the model needs, from the drawing.""" + p1, p2, p3, p4, p5 = pages[:5] + + # Page 3: the plate. Its outline is the biggest path; everything inside it is a hole. + outline = max(p3, key=lambda s: s.w * s.h) + if not outline.size_is(256, 340, tol=0.1): + raise SystemExit(f"the sheet reads {outline.w:.2f} x {outline.h:.2f} mm, not 256 x 340: the scale is off") + inner = [s for s in p3 if s is not outline] + screw = [s for s in inner if s.circle and abs(s.diameter - 5) < 0.2] + knob = [s for s in inner if s.circle and abs(s.diameter - 8) < 0.2] + windows = [s for s in inner if not s.circle and s.size_is(60, 40)] + slots = [s for s in inner if not s.circle and min(s.w, s.h) < 3] + if len(screw) + len(knob) + len(windows) + len(slots) != len(inner): + raise SystemExit("page 3 has a path this script does not know") + if len(knob) != 4 or len(slots) != 3 or len(windows) != 4: + raise SystemExit("page 3: expected four knob holes, four windows and three slots") + # The rounded corners: how far the first straight edge starts from the corner. + xs = sorted({round(x, 3) for x, y in outline.points if abs(y - outline.y0) < 1e-3}) + corner_r = xs[0] - outline.x0 + + # Page 2: the panel's outline on the plate (the only rectangle about 160 x 320). + panel = one(p2, "panel outline", lambda s: s.kinds == "re" and s.size_is(160, 320)) + + # Page 4: the box's lid, drawn in red where the box sits. + red = [s for s in p4 if s.color[0] > 0.5 and s.color[2] < 0.5] + lid_placed = one(red, "box outline on page 4", lambda s: s.size_is(70, 130)) + + # Page 1: the strips, 16 mm wide. + strips = [s for s in p1 if s.kinds == "re" and abs(min(s.w, s.h) - 16) < 0.2] + long_strips = [s for s in strips if max(s.w, s.h) > 300] + short_strips = [s for s in strips if max(s.w, s.h) < 200] + if len(long_strips) != 2 or len(short_strips) != 2: + raise SystemExit("page 1: expected two long and two short strips") + + # Page 5: the box and a foot, told apart by size and shape. + parts = [s for s in p5 if s.color[0] > 0.5] + lid = one(parts, "lid", lambda s: s.size_is(70, 130)) + if sorted(map(_rounded, lid.local())) != sorted(map(_rounded, lid_placed.local())): + raise SystemExit("the lid on page 5 is not the outline page 4 places") + long_walls = [s for s in parts if s.size_is(130, s.h) and s.h < 40] + short_walls = [s for s in parts if s.size_is(70, s.h) and s.h < 40] + squares = [s for s in parts if s.size_is(30, 30)] + if len(long_walls) != 2 or len(short_walls) != 2 or len(squares) != 5: + raise SystemExit("page 5: expected a lid, two long walls, two short walls and five squares") + # The joints' depth: how far the lid's edge steps in between its fingers. + joint = min(x for x, y in lid.local() if x > 0.1) + depth = min(s.h for s in long_walls) # the long wall with no key is the box's depth + keyed_long = one(long_walls, "keyed long wall", lambda s: s.h > depth + 1) + plain_long = one(long_walls, "long wall without a key", lambda s: s.h < depth + 1) + # Of the two short walls, the one with a cable notch has a corner well inside its outline. + def notched(s: Shape) -> bool: + return any(5 < x < s.w - 5 and 5 < y < s.h - 5 for x, y in s.local()) + + bottom_wall = one(short_walls, "short wall with a notch", notched) + top_wall = one(short_walls, "short wall without a notch", lambda s: not notched(s)) + # The base has fingers on all four sides, so no straight run of its outline + # is longer than a finger; a side's open edge is straight. The sides whose + # outline reaches both lower corners of their box carry the cube's corners, + # the other two sit between them. + def longest_run(s: Shape) -> float: + pts = s.local() + return max(math.dist(pts[i - 1], pts[i]) for i in range(len(pts))) + + def reaches(s: Shape, x: float, y: float) -> bool: + return any(abs(px - x) < 1e-3 and abs(py - y) < 1e-3 for px, py in s.local()) + + base = one(squares, "foot base", lambda s: longest_run(s) < s.w / 3) + corner_sides = [s for s in squares if s is not base and reaches(s, 0, s.h) and reaches(s, s.w, s.h)] + between_sides = [s for s in squares if s is not base and s not in corner_sides] + if len(corner_sides) != 2 or len(between_sides) != 2: + raise SystemExit("page 5: could not tell the foot's sides apart") + + return { + "outline": outline, "corner_r": corner_r, "screw": screw, "knob": knob, "windows": windows, "slots": slots, + "panel": panel, "lid_placed": lid_placed, "long_strips": long_strips, "short_strips": short_strips, + "lid": lid, "joint": joint, "depth": depth, "keyed_long": keyed_long, "plain_long": plain_long, + "top_wall": top_wall, "bottom_wall": bottom_wall, "base": base, "corner_sides": corner_sides, + "between_sides": between_sides, + } + + +def _rounded(p): + return (round(p[0], 2), round(p[1], 2)) + + +# -------------------------------------------------------------------------- +# Building the parts + + +class Frame: + """Page coordinates (mm, y down, the plate seen from the front) and depth behind the plate, to the model frame (mm).""" + + def __init__(self, plate: Shape): + self.xc, self.yc = plate.centre + self.y_mid = OFFICIAL_LED_Y / MODEL + self.front = OFFICIAL_FRONT_Z / MODEL + self.back = self.front - SHEET + + def at(self, x: float, y: float, depth: float = 0.0) -> np.ndarray: + """A page point `depth` mm behind the plate's back face (negative: in front of it).""" + return np.array([x - self.xc, self.y_mid - (y - self.yc), self.back - depth]) + + # Page directions as model directions. + RIGHT = np.array([1.0, 0, 0]) # page +x + DOWN = np.array([0, -1.0, 0]) # page +y + BACK = np.array([0, 0, -1.0]) # away from the viewer, behind the plate + FRONT = -BACK + + +def part(ring, thickness: float, origin, eu, ev, ew, holes=()) -> tuple[trimesh.Trimesh, np.ndarray]: + """A flat part: its outline (u, v), extruded `thickness` along w, placed by origin + u*eu + v*ev + w*ew (mm).""" + poly = orient(Polygon(ring, holes), sign=1.0) + if not poly.is_valid: + raise SystemExit("a part's outline is not a valid polygon") + mesh = trimesh.creation.extrude_polygon(poly, thickness, engine="earcut") + m = np.eye(4) + m[:3, 0], m[:3, 1], m[:3, 2], m[:3, 3] = eu, ev, ew, origin + mesh.apply_transform(m) + return mesh, np.asarray(ew, dtype=float) + + +def build(dims: dict) -> tuple[dict, dict]: + """Every part of the shell, by node name: [(mesh, its thickness axis)], plus the placement.""" + F = Frame(dims["outline"]) + R, D, B, Fr = Frame.RIGHT, Frame.DOWN, Frame.BACK, Frame.FRONT + nodes: dict[str, list] = {} + joint, depth = dims["joint"], dims["depth"] + + # The plate: as drawn, front face toward the viewer. + holes = [s.ring() for s in dims["screw"] + dims["knob"] + dims["windows"] + dims["slots"]] + nodes["plate"] = [part(dims["outline"].points, SHEET, F.at(0, 0, -SHEET), R, D, B, holes)] + + # The strips, on edge on the front face. Each starts flush with one corner + # of the panel and runs past the next, the top one to the left, the left + # one down, and so on round (anticlockwise, seen from the front). + p = dims["panel"] + long_len = max(dims["long_strips"][0].w, dims["long_strips"][0].h) + short_len = max(dims["short_strips"][0].w, dims["short_strips"][0].h) + strip_h = min(dims["long_strips"][0].w, dims["long_strips"][0].h) + on_face = -SHEET # depth of the plate's front face + strips = { + # name: (start corner on the page, along, thickness direction, length) + "strip_left": ((p.x0 - SHEET, p.y0), D, R, long_len), + "strip_top": ((p.x1, p.y0 - SHEET), -R, D, short_len), + "strip_right": ((p.x1, p.y1), -D, R, long_len), + "strip_bottom": ((p.x0, p.y1), R, D, short_len), + } + for name, ((x, y), along, thick, length) in strips.items(): + # u is the strip's width, standing up off the face; v runs its length. + ring = [(0, 0), (strip_h, 0), (strip_h, length), (0, length)] + nodes[name] = [part(ring, SHEET, F.at(x, y, on_face), Fr, along, thick)] + + # The box, behind the plate where page 4 draws its lid. Each wall's lid + # edge (the one with the most fingers, at the bottom of page 5) lies on + # the lid's outer face, `depth` behind the plate; its key, if it has one, + # is in the plate's slot. + lp = dims["lid_placed"] + bx0, by0, bx1, by1 = lp.x0, lp.y0, lp.x1, lp.y1 + + def wall(shape: Shape, start, along, inward): + top = depth - shape.h # depth of the drawing's top edge: negative with a key, inside the slot + return [part(shape.local(), joint, F.at(*start, top), along, B, inward)] + + nodes["box_wall_top"] = wall(dims["top_wall"], (bx0, by0), R, D) + nodes["box_wall_bottom"] = wall(dims["bottom_wall"], (bx1, by1), -R, -D) + nodes["box_wall_left"] = wall(dims["plain_long"], (bx0, by0), D, R) + nodes["box_wall_right"] = wall(dims["keyed_long"], (bx1, by0), D, -R) + nodes["box_lid"] = [part(lp.local(), joint, F.at(bx0, by0, depth - joint), R, D, B)] + + # The feet: open cubes in the plate's three corners the box does not hold. + side = dims["base"].w + ox0, oy0 = dims["outline"].x0 + FOOT_INSET, dims["outline"].y0 + FOOT_INSET + ox1, oy1 = dims["outline"].x1 - FOOT_INSET - side, dims["outline"].y1 - FOOT_INSET - side + corners = [(ox0, oy0), (ox0, oy1), (ox1, oy1)] # top left, bottom left, bottom right + a, b = dims["between_sides"], dims["corner_sides"] + for i, (fx, fy) in enumerate(corners, start=1): + nodes[f"foot_{i}"] = [ + part(dims["base"].local(), joint, F.at(fx, fy, side - joint), R, D, B), + # The two sides between the corners: on the cube's left and right. + part(a[0].local(), joint, F.at(fx, fy, 0), D, B, R), + part(a[1].local(), joint, F.at(fx + side, fy, 0), D, B, -R), + # The two that carry the corners: top and bottom. + part(b[0].local(), joint, F.at(fx, fy, 0), R, B, D), + part(b[1].local(), joint, F.at(fx, fy + side, 0), R, B, -D), + ] + + # Where assemble.mjs puts the rest, in model units. + kx = sum(s.centre[0] for s in dims["knob"]) / 4 + ky = sum(s.centre[1] for s in dims["knob"]) / 4 + knob_centre = F.at(kx, ky) * MODEL + led = F.at(*p.centre, -SHEET) * MODEL + placement = { + "board": [ + round(knob_centre[0] - OFFICIAL_ENCODER_CENTRE[0], 4), + round(knob_centre[1] - OFFICIAL_ENCODER_CENTRE[1], 4), + round(F.back * MODEL - OFFICIAL_INSIDE_Z, 4), + ], + "led": [round(led[0], 4), round(led[1], 4), round(led[2] + LED_DEPTH, 4)], + "knobs": "official", + "knobBaseZ": round(F.front * MODEL + KNOB_LIFT, 4), + } + return nodes, placement + + +# -------------------------------------------------------------------------- +# Writing the shell + + +def primitives(pieces, face_material: str, edge_material: str) -> list[tuple[str, np.ndarray, np.ndarray, np.ndarray]]: + """A part's triangles as two flat-shaded primitives: its sheet faces and its cut edges.""" + groups: dict[str, list] = {face_material: [], edge_material: []} + for mesh, axis in pieces: + normals = mesh.face_normals + is_face = np.abs(normals @ axis) > 0.999 + tris = mesh.vertices[mesh.faces] * MODEL + for mat, mask in ((face_material, is_face), (edge_material, ~is_face)): + groups[mat].append((tris[mask], normals[mask])) + out = [] + for mat, chunks in groups.items(): + tris = np.concatenate([t for t, _ in chunks]) + norms = np.concatenate([n for _, n in chunks]) + corner_pos = tris.reshape(-1, 3) + corner_nrm = np.repeat(norms, 3, axis=0) + # One vertex per position and normal: shared within a flat face, split between faces. + key = np.round(np.hstack([corner_pos, corner_nrm]), 6) + _, first, inverse = np.unique(key, axis=0, return_index=True, return_inverse=True) + pos = corner_pos[first].astype(np.float32) + nrm = corner_nrm[first].astype(np.float32) + idx = inverse.reshape(-1).astype(np.uint32) + out.append((mat, pos, nrm, idx)) + return out + + +def write_glb(path: Path, nodes: dict[str, list[tuple[str, np.ndarray, np.ndarray, np.ndarray]]]) -> None: + """A plain glTF 2.0 binary: one node per part, one mesh each, a primitive per material.""" + blob = bytearray() + views, accessors, meshes, gl_nodes = [], [], [], [] + materials: list[str] = [] + + def add_view(data: bytes, target: int) -> int: + while len(blob) % 4: + blob.append(0) + views.append({"buffer": 0, "byteOffset": len(blob), "byteLength": len(data), "target": target}) + blob.extend(data) + return len(views) - 1 + + def add_accessor(array: np.ndarray, kind: str, component: int, target: int, bounds=False) -> int: + acc = {"bufferView": add_view(array.tobytes(), target), "componentType": component, "count": len(array), "type": kind} + if bounds: + acc["min"] = [float(v) for v in array.min(axis=0)] + acc["max"] = [float(v) for v in array.max(axis=0)] + accessors.append(acc) + return len(accessors) - 1 + + for name, prims in nodes.items(): + gl_prims = [] + for mat, pos, nrm, idx in prims: + if not len(idx): + continue + if mat not in materials: + materials.append(mat) + small = len(pos) < 65536 + gl_prims.append({ + "attributes": { + "POSITION": add_accessor(pos, "VEC3", 5126, 34962, bounds=True), + "NORMAL": add_accessor(nrm, "VEC3", 5126, 34962), + }, + "indices": add_accessor(idx.astype(np.uint16 if small else np.uint32), "SCALAR", 5123 if small else 5125, 34963), + "material": materials.index(mat), + "mode": 4, + }) + meshes.append({"name": name, "primitives": gl_prims}) + gl_nodes.append({"name": name, "mesh": len(meshes) - 1}) + + while len(blob) % 4: + blob.append(0) + gltf = { + "asset": {"version": "2.0", "generator": "patternflow tools/case-models/simonepda.py"}, + "scene": 0, + "scenes": [{"name": "Scene", "nodes": list(range(len(gl_nodes)))}], + "nodes": gl_nodes, + "meshes": meshes, + # The looks are set by name in lib/looks.mjs; these are placeholders. + "materials": [{"name": m, "pbrMetallicRoughness": {"baseColorFactor": [1, 1, 1, 1], "metallicFactor": 0, "roughnessFactor": 0.5}} for m in materials], + "accessors": accessors, + "bufferViews": views, + "buffers": [{"byteLength": len(blob)}], + } + text = json.dumps(gltf, separators=(",", ":")).encode() + text += b" " * (-len(text) % 4) + total = 12 + 8 + len(text) + 8 + len(blob) + with open(path, "wb") as f: + f.write(struct.pack("<4sII", b"glTF", 2, total)) + f.write(struct.pack(" None: + o, p, lp = dims["outline"], dims["panel"], dims["lid_placed"] + slots = sorted(dims["slots"], key=lambda s: (s.y0, s.x0)) + kx = sorted({round(s.centre[0], 2) for s in dims["knob"]}) + ky = sorted({round(s.centre[1], 2) for s in dims["knob"]}) + print(f"sheet {o.w:.2f} x {o.h:.2f} mm, corners r {dims['corner_r']:.2f}") + print(f"panel {p.w:.2f} x {p.h:.2f} mm, {p.x0 - o.x0:.2f} mm from the left edge, {p.y0 - o.y0:.2f} from the top") + print(f"screw holes {len(dims['screw'])} x d {np.mean([s.diameter for s in dims['screw']]):.2f} mm") + print(f"knob holes {len(dims['knob'])} x d {np.mean([s.diameter for s in dims['knob']]):.2f} mm on {kx[1] - kx[0]:.2f} x {ky[1] - ky[0]:.2f} mm") + print(f"windows {len(dims['windows'])} x {dims['windows'][0].w:.2f} x {dims['windows'][0].h:.2f} mm") + print("slots " + ", ".join(f"{min(s.w, s.h):.2f} x {max(s.w, s.h):.2f}" for s in slots) + " mm") + print("strips " + ", ".join(f"{min(s.w, s.h):.2f} x {max(s.w, s.h):.2f}" for s in dims["long_strips"] + dims["short_strips"]) + " mm") + print(f"box {lp.w:.2f} x {lp.h:.2f} x {dims['depth']:.2f} mm, joints {dims['joint']:.2f} deep, at ({lp.x0 - o.x0:.2f}, {lp.y0 - o.y0:.2f}) on the sheet") + print(f"box walls keyed {dims['keyed_long'].w:.2f} x {dims['keyed_long'].h:.2f}, plain {dims['plain_long'].w:.2f} x {dims['plain_long'].h:.2f}, " + f"short {dims['top_wall'].w:.2f} x {dims['top_wall'].h:.2f} and {dims['bottom_wall'].w:.2f} x {dims['bottom_wall'].h:.2f} (notched) mm") + print(f"feet 3 x open cube {dims['base'].w:.2f} mm (five squares)") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--pdf", type=Path, default=DEFAULT_PDF, help="the drawing (default: the remix folder's lasercut_layout.pdf)") + ap.add_argument("--out", type=Path, default=DEFAULT_OUT, help="where shell.glb and placement.json go (default: build/simonepda-lasercut)") + args = ap.parse_args() + + dims = recover(read_pages(args.pdf)) + report(dims) + parts, placement = build(dims) + + sheet = ("sheet_face", "sheet_edge") + acrylic = ("acrylic_face", "acrylic_edge") + nodes = {name: primitives(pieces, *(acrylic if name.startswith("box_") else sheet)) for name, pieces in parts.items()} + + args.out.mkdir(parents=True, exist_ok=True) + write_glb(args.out / "shell.glb", nodes) + (args.out / "placement.json").write_text(json.dumps(placement, indent=2) + "\n") + tris = sum(len(idx) // 3 for prims in nodes.values() for _, _, _, idx in prims) + print(f"wrote {args.out / 'shell.glb'} ({len(nodes)} parts, {tris} triangles) and placement.json: {json.dumps(placement)}") + + +if __name__ == "__main__": + main() diff --git a/tools/case-models/source/led_panel.glb b/tools/case-models/source/led_panel.glb new file mode 100644 index 000000000..b5cc6638e Binary files /dev/null and b/tools/case-models/source/led_panel.glb differ diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 0fc02e276..7ed0ee3bf 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -35,18 +35,19 @@ The `web/` app is the Patternflow site at [patternflow.work](https://patternflow ``` HomeView (src/components/HomeView.tsx) ├── ViewerPanel (components/3d/) ← sticky 3D panel -│ ├── HeroScene.tsx ← Canvas, GLB loader (public/3dforweb.glb), LED ShaderMaterial +│ ├── HeroScene.tsx ← Canvas, the picked case's GLB (public/cases//model.glb), LED ShaderMaterial +│ ├── caseModels.ts + cases/ ← one model per case on the Build panel's switch: URL, node names, exploded view, finishes. The GLBs are built by tools/case-models │ ├── LedMatrixTexture.ts ← renders JS patterns into a texture for the 3D LED mesh │ └── patterns/ ← GLSL pattern ports (common.ts = shared vertex shader + registry) └── RightPanel (components/sections/) ← tabbed content panel ├── Hero.tsx / Deck.tsx ← hero copy + panel open/close orchestration - ├── BuildPanel.tsx ← build paths, flasher entry + ├── BuildPanel.tsx ← build paths, the case switch, flasher entry ├── PatternPanel.tsx ← preset browser + code editor; every CTA leads to /pattern-lab ├── InsidePanel.tsx + InsideGlobe/ ← concept content + community builds globe └── Sponsor.tsx ``` -Cross-component landing state (active tab, virtual knob values, bloom toggle) lives in the Zustand store `src/store/useAppStore.ts`. +Cross-component landing state (active tab, virtual knob values, bloom toggle, the case picked on the Build panel and its finish) lives in the Zustand store `src/store/useAppStore.ts`. ## Pattern system diff --git a/web/README.md b/web/README.md index b47d73422..00c1caf23 100644 --- a/web/README.md +++ b/web/README.md @@ -33,7 +33,7 @@ None are required — the site runs fully without them. ## Where things live - `src/app/` — routes (App Router). `/pattern-lab` is the pattern workspace (noindex, not internal). The breadboard build guide is `src/app/build/breadboard/page.tsx` — React, not markdown. -- `src/components/` — 3D viewer, landing sections, journal renderer, the community UI. The build map's pins are `src/components/sections/InsideGlobe/builds.ts` plus photos in `public/builds//`. +- `src/components/` — 3D viewer, landing sections, journal renderer, the community UI. The build map's pins are `src/components/sections/InsideGlobe/builds.ts` plus photos in `public/builds//`. The Build panel's case cards are `src/components/sections/build-cases-data.ts` plus photos in `public/cases//` (CC BY-SA, resized from the remix's own folder, or for the official case from `docs/build-guide/`); the same folder holds the case's 3D model for the preview, `model.glb`, built by [`tools/case-models`](../tools/case-models/README.md). - `src/lib/presets/` — JS pattern library; **source of truth** for the firmware preset headers (`firmware/toolchain/check_presets.py` keeps the two in step). - `content/` — markdown/MDX site copy (`build.md`, `pattern.md`, `inside.md`) and the journal. The journal is the maintainer's own writing. - `public/flash/` — esp-web-tools manifest + firmware binaries for the browser flasher. `public/packs/` — the Basics pack. diff --git a/web/content/build.md b/web/content/build.md index f35cb2395..2904894c6 100644 --- a/web/content/build.md +++ b/web/content/build.md @@ -1,24 +1,6 @@ --- title: Build your own. subtitle: Around US$100–200 and an hour of hands-on work. -meta: - - label: Available now - value: "3D print + PCB · breadboard" - - label: Preparing - value: "Laser cut" - - label: Firmware - value: "Browser flash or Arduino IDE" -cta: - primary: - label: "Open the assembly map ->" - href: "https://github.com/engmung/Patternflow/blob/main/docs/assembly/README.md" - secondary: - label: "Current full guide" - href: "https://github.com/engmung/Patternflow/blob/main/BUILD_GUIDE.md" --- -Patternflow is becoming a build system you can choose your way through: pick an enclosure path, pick an electronics path, then flash firmware. - -The current supported combination is the original polished build: PLA 3D printed enclosure, hand-soldered custom PCB, and browser-flashed release firmware. - -The breadboard electronics path is now available — wire the same parts with jumpers and a breadboard power rail, no custom PCB or soldering iron. Laser-cut enclosure files are still being prepared, so you can start with the tools you have, then upgrade when you want a more finished object. +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. diff --git a/web/public/3dforweb.glb b/web/public/3dforweb.glb deleted file mode 100644 index 0e098894b..000000000 Binary files a/web/public/3dforweb.glb and /dev/null differ diff --git a/web/public/cases/besoiobiy-printed/built.jpg b/web/public/cases/besoiobiy-printed/built.jpg new file mode 100644 index 000000000..8ac5948a0 Binary files /dev/null and b/web/public/cases/besoiobiy-printed/built.jpg differ diff --git a/web/public/cases/besoiobiy-printed/exploded_back.jpg b/web/public/cases/besoiobiy-printed/exploded_back.jpg new file mode 100644 index 000000000..ad98d9194 Binary files /dev/null and b/web/public/cases/besoiobiy-printed/exploded_back.jpg differ diff --git a/web/public/cases/besoiobiy-printed/exploded_front.jpg b/web/public/cases/besoiobiy-printed/exploded_front.jpg new file mode 100644 index 000000000..2fd12668a Binary files /dev/null and b/web/public/cases/besoiobiy-printed/exploded_front.jpg differ diff --git a/web/public/cases/besoiobiy-printed/model.glb b/web/public/cases/besoiobiy-printed/model.glb new file mode 100644 index 000000000..1e3e7fe14 Binary files /dev/null and b/web/public/cases/besoiobiy-printed/model.glb differ diff --git a/web/public/cases/besoiobiy-printed/parts.jpg b/web/public/cases/besoiobiy-printed/parts.jpg new file mode 100644 index 000000000..fc9b38f69 Binary files /dev/null and b/web/public/cases/besoiobiy-printed/parts.jpg differ diff --git a/web/public/cases/official/close_back.jpg b/web/public/cases/official/close_back.jpg new file mode 100644 index 000000000..7116abdcf Binary files /dev/null and b/web/public/cases/official/close_back.jpg differ diff --git a/web/public/cases/official/model.glb b/web/public/cases/official/model.glb new file mode 100644 index 000000000..22fe7fd11 Binary files /dev/null and b/web/public/cases/official/model.glb differ diff --git a/web/public/cases/official/printed_parts.jpg b/web/public/cases/official/printed_parts.jpg new file mode 100644 index 000000000..6e3dd1f6d Binary files /dev/null and b/web/public/cases/official/printed_parts.jpg differ diff --git a/web/public/cases/official/printing.jpg b/web/public/cases/official/printing.jpg new file mode 100644 index 000000000..ffdbd33b4 Binary files /dev/null and b/web/public/cases/official/printing.jpg differ diff --git a/web/public/cases/simonepda-lasercut/acrylic_front.jpg b/web/public/cases/simonepda-lasercut/acrylic_front.jpg new file mode 100644 index 000000000..848367114 Binary files /dev/null and b/web/public/cases/simonepda-lasercut/acrylic_front.jpg differ diff --git a/web/public/cases/simonepda-lasercut/board_in_box.jpg b/web/public/cases/simonepda-lasercut/board_in_box.jpg new file mode 100644 index 000000000..9b6da2672 Binary files /dev/null and b/web/public/cases/simonepda-lasercut/board_in_box.jpg differ diff --git a/web/public/cases/simonepda-lasercut/mdf_back.jpg b/web/public/cases/simonepda-lasercut/mdf_back.jpg new file mode 100644 index 000000000..8e4e65a1c Binary files /dev/null and b/web/public/cases/simonepda-lasercut/mdf_back.jpg differ diff --git a/web/public/cases/simonepda-lasercut/mdf_front.jpg b/web/public/cases/simonepda-lasercut/mdf_front.jpg new file mode 100644 index 000000000..95c4b1797 Binary files /dev/null and b/web/public/cases/simonepda-lasercut/mdf_front.jpg differ diff --git a/web/public/cases/simonepda-lasercut/model.glb b/web/public/cases/simonepda-lasercut/model.glb new file mode 100644 index 000000000..871d9c7c4 Binary files /dev/null and b/web/public/cases/simonepda-lasercut/model.glb differ diff --git a/web/public/llms.txt b/web/public/llms.txt index 7536b4f6c..dc12883d2 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 (steel, acrylic, and laser-cut variants in progress). +- **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. - **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). @@ -26,7 +26,7 @@ It is not a single luminous object but an open system. The hardware files, firmw - [Play](https://patternflow.work/guide/play): The first hour with a finished board — flashing the ESP32, the four knobs, installing patterns, the device console. - [Make](https://patternflow.work/guide/make): A hands-on tutorial for the community and the Pattern Lab, from a prompt to a pattern on the device. - [Audio](https://patternflow.work/guide/audio): The Audio edition — sound from a browser tab (the extension and its mapping editor), the on-board PDM microphone, and the board as a network MIDI device with a DAW (Ableton Live as the example). -- [Build your own](https://patternflow.work/build): Build the device from scratch — firmware, PCB, 3D-printed enclosure, browser flasher, and the full build guide. +- [Build your own](https://patternflow.work/build): Build the device from scratch — firmware, PCB, the case (the official printed one or a community remix: printed, or laser-cut; `?case=` picks one, and the 3D view shows the device in it), browser flasher, and the full build guide. - [Inside](https://patternflow.work/inside): How Patternflow works — the electronics, encoders, and pattern architecture. - [Journal](https://patternflow.work/journal): Long-form notes and the story behind each step (English and Korean). diff --git a/web/src/app/build/breadboard/page.tsx b/web/src/app/build/breadboard/page.tsx index 487d78da3..bdb2b9b42 100644 --- a/web/src/app/build/breadboard/page.tsx +++ b/web/src/app/build/breadboard/page.tsx @@ -137,12 +137,12 @@ export default function BreadboardBuildPage() { ⚡ Flash the firmware first

- Flashing is fiddly once everything is wired into a tangle of jumpers. Connect the bare ESP32-S3 to your computer via USB-C, visit{' '} - patternflow.work, and click “Flash Patternflow” in the Patterns section (Chrome / Edge desktop). Confirm it boots — then start the build. + Flashing is fiddly once everything is wired into a tangle of jumpers. Connect the bare ESP32-S3 to your computer by its left USB-C port (the native USB one, or the Wi-Fi step never appears), open the{' '} + Pattern tab and click “Flash Patternflow” under Got the hardware? (Chrome / Edge desktop). Confirm it boots — then start the build.

Prefer Arduino IDE? See{' '} - + official guide Section 8.2 {' '} for board settings and upload steps. @@ -157,9 +157,9 @@ export default function BreadboardBuildPage() { lineHeight: 1.55, color: 'var(--pf-ink-muted)' }}> - 🖨 3D-printed enclosure: Print the case before you start wiring — it takes ~11 hours. STL files and print settings are in the{' '} + 🖨 3D-printed enclosure: Print the case before you start wiring — it takes about 10 hours. STL files and print settings are in the{' '} - official Build Guide — Sections 2 & 3 + official Build Guide — Section 4 . A cardboard box with four holes for the encoder shafts works too. diff --git a/web/src/app/build/page.tsx b/web/src/app/build/page.tsx index cc5b76fff..145bd2060 100644 --- a/web/src/app/build/page.tsx +++ b/web/src/app/build/page.tsx @@ -4,12 +4,12 @@ import HomeView from "@/components/HomeView"; export const metadata: Metadata = { title: "Build Your Own — Patternflow", description: - "Build the open-source Patternflow LED synthesizer from scratch: ESP32-S3 firmware, custom PCB, 3D-printed enclosure, and a browser flasher. All files and the full build guide.", + "Build the open-source Patternflow LED synthesizer from scratch: the hand-soldered v3.9 board, a printed or laser-cut case, and firmware flashed from the browser. Every file and the full build guide.", alternates: { canonical: "/build" }, openGraph: { title: "Build Your Own — Patternflow", description: - "Build the open-source Patternflow LED synthesizer from scratch — firmware, PCB, enclosure, and browser flasher.", + "Build the open-source Patternflow LED synthesizer from scratch — firmware, PCB, a printed or laser-cut case, and the browser flasher.", url: "https://patternflow.work/build", }, }; diff --git a/web/src/components/3d/CaseFinishSwitch.module.css b/web/src/components/3d/CaseFinishSwitch.module.css new file mode 100644 index 000000000..3698cd469 --- /dev/null +++ b/web/src/components/3d/CaseFinishSwitch.module.css @@ -0,0 +1,66 @@ +/* The finish switch under the device in the product preview. The Build + panel's vocabulary (CasePicker.module.css) at the size of the viewer's own + kicker: hairlines, no radius, ink as the only fill, mono capitals. */ + +.switch { + position: absolute; + left: 50%; + bottom: 56px; + z-index: 10; + display: flex; + transform: translateX(-50%); + border: 1px solid var(--pf-rule); + background: rgba(244, 239, 230, 0.82); +} + +.option { + appearance: none; + min-width: 84px; + padding: 8px 14px; + border: 0; + border-right: 1px solid var(--pf-rule); + background: transparent; + color: #6B655A; + font-family: var(--mono); + font-size: var(--pf-fs-mono); + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + line-height: 1.2; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.option:last-child { + border-right: 0; +} + +.option:hover { + background: var(--pf-cream-2); + color: #1a1814; +} + +.option[aria-pressed='true'] { + background: #1a1814; + color: var(--pf-cream); + cursor: default; +} + +.option:focus-visible { + outline: 2px solid var(--pf-led); + outline-offset: -2px; +} + +/* On a phone the viewer is a strip across the top of the screen; the switch + sits on its bottom edge, clear of the device. */ +@media (max-width: 900px) { + .switch { + bottom: 12px; + } + + .option { + min-width: 0; + padding: 7px 12px; + font-size: 11px; + } +} diff --git a/web/src/components/3d/CaseFinishSwitch.tsx b/web/src/components/3d/CaseFinishSwitch.tsx new file mode 100644 index 000000000..079e025a0 --- /dev/null +++ b/web/src/components/3d/CaseFinishSwitch.tsx @@ -0,0 +1,36 @@ +'use client'; + +import type { CaseFinish } from './caseModels'; +import styles from './CaseFinishSwitch.module.css'; + +interface CaseFinishSwitchProps { + /** Names the group for a screen reader: whose material this is. */ + label: string; + finishes: CaseFinish[]; + value: string; + onChange: (finish: string) => void; +} + +/** + * The materials a case can be made in, under the device in the product + * preview — SimonePDA's case is cut from clear acrylic or from MDF, and the + * preview shows either. A row of toggle buttons rather than tabs: it changes + * the picture, it opens nothing. + */ +export default function CaseFinishSwitch({ label, finishes, value, onChange }: CaseFinishSwitchProps) { + return ( +

+ {finishes.map((finish) => ( + + ))} +
+ ); +} diff --git a/web/src/components/3d/HeroScene.module.css b/web/src/components/3d/HeroScene.module.css new file mode 100644 index 000000000..e2f02ff8a --- /dev/null +++ b/web/src/components/3d/HeroScene.module.css @@ -0,0 +1,80 @@ +/* The product preview's one line of status, under the device where the + finish switch goes: a picked case's model on its way, or why it did not + come. In the viewer's kicker type, quieter. It fades in after a beat, so a + model that is already in the cache swaps without a word. */ + +.note { + position: absolute; + left: 0; + right: 0; + bottom: 64px; + z-index: 10; + text-align: center; + color: #6B655A; + font-family: var(--mono); + font-size: var(--pf-fs-mono); + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.note[data-on='1'] { + opacity: 0.85; + transition-delay: 0.25s; +} + +/* The arrows either side of the device on the Build tab, stepping through the + cases: the Inside globe's arrows (GlobeViewer.module.css), at the middle of + the frame, where the device is. */ + +.arrow { + position: absolute; + top: 50%; + z-index: 10; + display: grid; + place-items: center; + padding: 0; + border: 0; + background: transparent; + color: var(--pf-led); + cursor: pointer; + transform: translateY(-50%); + transition: transform 0.18s ease; +} + +.arrow svg { + width: 84px; + height: 84px; + fill: none; + stroke: currentColor; + stroke-width: 2.1; + stroke-linecap: round; + stroke-linejoin: round; +} + +.arrow:hover { + transform: translateY(-50%) scale(1.08); +} + +.arrowPrev { + left: 12px; +} + +.arrowNext { + right: 12px; +} + +@media (max-width: 900px) { + .note { + bottom: 18px; + font-size: 11px; + } + + .arrow svg { + width: 44px; + height: 44px; + } +} diff --git a/web/src/components/3d/HeroScene.tsx b/web/src/components/3d/HeroScene.tsx index f9f90c4d6..e71b56bcf 100644 --- a/web/src/components/3d/HeroScene.tsx +++ b/web/src/components/3d/HeroScene.tsx @@ -6,36 +6,66 @@ and never feeds back into React rendering. */ import { Canvas, useFrame, ThreeEvent } from '@react-three/fiber'; -import { useRef, useMemo, useEffect, useState } from 'react'; +import { + Component, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, + type RefObject, +} from 'react'; import * as THREE from 'three'; -import { useGLTF, ContactShadows, Environment, OrbitControls } from '@react-three/drei'; -import { EffectComposer, Bloom } from '@react-three/postprocessing'; -import { patternVert } from './patterns/common'; +import { useGLTF, ContactShadows, Environment, Lightformer, OrbitControls } from '@react-three/drei'; +import { EffectComposer, Bloom, ToneMapping } from '@react-three/postprocessing'; +import { ToneMappingMode } from 'postprocessing'; +import { ledPanel, ledVert } from './patterns/common'; import patterns from './patterns'; import { useAppStore } from '@/store/useAppStore'; import { LedMatrixTexture } from './LedMatrixTexture'; import { LOGICAL_KNOB_TO_WEB_KNOB, knobUnitsPerTurn, webKnobRange } from '@/lib/pattern/controls'; +import { BUILD_CASES, stepCase, writeCaseToUrl, type CaseId } from '@/components/sections/build-cases-data'; +import { captureEvent } from '@/lib/posthogEvents'; +import { CASE_MODELS, DRACO_DECODER, type CaseFinish } from './caseModels'; +import { preloadCaseModel } from './preloadCaseModel'; +import { + FRAME_CENTRE, + applyFinish, + disposeCase, + knobUnder, + prepareCase, + type KnobRig, + type PreparedCase, +} from './heroCase'; +import { poseFor, shows } from './buildPose'; +import CaseFinishSwitch from './CaseFinishSwitch'; +import { PageAlpha, StraightAlpha } from './canvasAlpha'; +import { NeutralToeBack } from './neutralToe'; +import styles from './HeroScene.module.css'; + +// The product preview on the Build and Pattern tabs: the device in the case +// the Build panel's switch is on (caseModels.ts), its LED panel running the +// pattern, its knobs turning the pattern's controls, and the Build panel's +// four steps acted out on it. Each case is its own model; the official one +// loads with the page, a remix's when it is picked, and until it has arrived +// the case on screen stays. const customFragmentShader = ` uniform sampler2D uTex; varying vec2 vUv; +${ledPanel} void main() { vec2 rotatedUV = vec2(vUv.y, 1.0 - vUv.x); vec2 gridUV = rotatedUV * vec2(128.0, 64.0); - vec2 localUV = fract(gridUV); - + // Sample discrete pixels to enforce pixelation vec2 pxUV = (floor(gridUV) + 0.5) / vec2(128.0, 64.0); vec4 texColor = texture2D(uTex, pxUV); - - float dist2 = length(localUV - 0.5); - float circle = smoothstep(0.45, 0.35, dist2); - - float fw = fwidth(vUv.x) * 128.0; - float lodBlend = smoothstep(0.0, 0.29, fw); - float finalAlpha = mix(circle, 1.0, lodBlend); - + vec3 col = texColor.rgb; float luma = dot(col, vec3(0.299, 0.587, 0.114)); @@ -44,47 +74,137 @@ void main() { } else { col *= 0.8; } - - float unlit = 0.02; - col = mix(vec3(unlit), col, step(0.01, length(col))); - gl_FragColor = vec4(col * finalAlpha, 1.0); + gl_FragColor = vec4(ledPanel(col), 1.0); } `; -useGLTF.preload('/3dforweb.glb'); +// Only the official case comes with the page; a remix's model is fetched +// when it is picked, or when its tab is hovered on the way (preloadCaseModel). +useGLTF.preload(CASE_MODELS.official.url, DRACO_DECODER); + +type KnobId = 'c1' | 'c2' | 'c3' | 'c4'; -// The GLB knob meshes are named after the PCB encoder nets, and K1/K2 are +// The knob meshes are named after the PCB encoder nets, and K1/K2 are // cross-routed on the official board (see firmware config.h): the mesh at the // physical K1 position is named "c2" and vice versa. Remap mesh name → knob id // so the on-screen knobs behave like the physical ones. -const MESH_TO_KNOB: Record = { +const MESH_TO_KNOB: Record = { c1: 'c2', c2: 'c1', c3: 'c3', c4: 'c4', }; -// Removed hardcoded ACTIVE_PATTERN +/** Where the preview looks: OrbitControls' target, and where the device is centred. */ +const TARGET = new THREE.Vector3(0, 1.7, 0); +const Y_AXIS = new THREE.Vector3(0, 1, 0); + +/** + * 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). + */ +const BOARD_SPAN = 3.1; + +/** A per-frame easing factor (as at 60 fps) made independent of the frame rate. */ +const ease = (perFrame: number, dt: number) => 1 - Math.pow(1 - perFrame, Math.min(dt, 0.5) * 60); + +/** + * A model that would not load or draw is taken off, and whoever is told + * shows the official case instead. Uncaught, the error would go up through + * the canvas and take the page with it. + */ +class ModelGuard extends Component<{ onError: () => void; children: ReactNode }, { failed: boolean }> { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + componentDidCatch() { + this.props.onError(); + } + render() { + return this.state.failed ? null : this.props.children; + } +} + +/** + * The cases whose models have been drawn on this page. The preview is taken + * down while the Inside tab shows the globe; coming back, a case already in + * drei's cache is drawn straight away rather than the official one first. + */ +const arrived = new Set(); + +/** Waits, off screen, for a picked case's model, and says when it is in drei's cache. */ +function CaseLoader({ id, onReady }: { id: CaseId; onReady: (id: CaseId) => void }) { + useGLTF(CASE_MODELS[id].url, DRACO_DECODER); + useEffect(() => onReady(id), [id, onReady]); + return null; +} + +interface DeviceHandlers { + onPointerDown: (e: ThreeEvent) => void; + onPointerMove: (e: ThreeEvent) => void; + onPointerOut: (e: ThreeEvent) => void; +} + +/** + * One case's device: its own copy of the model, placed to stand where every + * case stands, handed to the preview to animate. + */ +function CaseDevice({ + id, + finish, + deviceRef, + handlers, +}: { + id: CaseId; + finish: CaseFinish | undefined; + deviceRef: RefObject; + handlers: DeviceHandlers; +}) { + const model = CASE_MODELS[id]; + const { scene } = useGLTF(model.url, DRACO_DECODER); + const prepared = useMemo(() => prepareCase(scene, model), [scene, model]); + + useLayoutEffect(() => { + deviceRef.current = prepared; + arrived.add(id); + return () => { + if (deviceRef.current === prepared) deviceRef.current = null; + }; + }, [deviceRef, prepared, id]); + useEffect(() => () => disposeCase(prepared), [prepared]); + useEffect(() => applyFinish(prepared, finish), [prepared, finish]); -function Model() { + return ( + + {/* dispose={null}: the geometry is drei's cache's; disposeCase lets go of the rest. */} + + + ); +} + +/** + * The device as the preview animates it, whichever case it is in: the Build + * steps, the knobs, the pattern on the panel. It stays mounted while cases + * swap underneath it, so a swap never restarts the sway or the pattern. + */ +function ProductPreview({ + caseId, + finish, + onFailed, +}: { + caseId: CaseId; + finish: CaseFinish | undefined; + onFailed: (id: CaseId) => void; +}) { const groupRef = useRef(null); - const { scene } = useGLTF('/3dforweb.glb', 'https://www.gstatic.com/draco/versioned/decoders/1.5.7/'); - const knobValues = useAppStore((state) => state.knobValues); - const buildStep = useAppStore((state) => state.buildStep); - const explode = useAppStore((state) => state.explode); - - const partsRef = useRef<{ - top: THREE.Mesh[]; - mid: THREE.Mesh[]; - bot: THREE.Mesh[]; - pcb: THREE.Mesh[]; - led: THREE.Mesh[]; - knobs: THREE.Mesh[]; - others: THREE.Mesh[]; - }>({ - top: [], mid: [], bot: [], pcb: [], led: [], knobs: [], others: [] - }); + const deviceRef = useRef(null); + // Where the view is centred, in the group's own frame; eased like the rest. + const focus = useRef(FRAME_CENTRE.clone()); + const lift = useRef(0); const activePatternId = useAppStore((state) => state.activePatternId); const customJsCode = useAppStore((state) => state.customJsCode); @@ -92,7 +212,7 @@ function Model() { const defaults = useMemo(() => pattern.defaults || {}, [pattern]); const ledMatrix = useMemo(() => new LedMatrixTexture(), []); - + useEffect(() => { if (activePatternId === 'custom') { ledMatrix.loadCode(customJsCode); @@ -109,428 +229,250 @@ function Model() { uParam4: { value: defaults.uParam4 ?? 0.0 }, uAspect: { value: 2.0 }, }, - vertexShader: patternVert, + vertexShader: ledVert, fragmentShader: pattern.fragmentShader, }), [pattern, defaults]); + useEffect(() => () => ledMat.dispose(), [ledMat]); const customMat = useMemo(() => new THREE.ShaderMaterial({ uniforms: { uTex: { value: ledMatrix.texture }, }, - vertexShader: patternVert, + vertexShader: ledVert, fragmentShader: customFragmentShader, }), [ledMatrix.texture]); const blackMat = useMemo(() => new THREE.MeshStandardMaterial({ color: 0x050505, roughness: 0.8 }), []); - useEffect(() => { - const isPoweredOff = buildStep === 1 || buildStep === 2 || buildStep === 3; - const targetMat = isPoweredOff ? blackMat : (activePatternId === 'custom' ? customMat : ledMat); - partsRef.current.led.forEach(m => { - m.material = targetMat; - }); - }, [activePatternId, ledMat, customMat, blackMat, buildStep]); - - useEffect(() => { - ledMat.uniforms.uParam1.value = knobValues[LOGICAL_KNOB_TO_WEB_KNOB[0]]; // Hue - ledMat.uniforms.uSpeed.value = knobValues[LOGICAL_KNOB_TO_WEB_KNOB[1]]; // Speed - ledMat.uniforms.uParam3.value = knobValues[LOGICAL_KNOB_TO_WEB_KNOB[2]]; // Mode - ledMat.uniforms.uParam4.value = knobValues[LOGICAL_KNOB_TO_WEB_KNOB[3]]; // Freq/Offset - }, [knobValues, ledMat]); - // --- Knob Interaction Logic --- - const activeKnobRef = useRef(null); - const lastMouseAngle = useRef(0); - const knobCenterScreen = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const drag = useRef<{ rig: KnobRig; knob: KnobId; cx: number; cy: number; lastAngle: number } | null>(null); useEffect(() => { const handlePointerMove = (e: PointerEvent) => { - if (!activeKnobRef.current) return; - - const dx = e.clientX - knobCenterScreen.current.x; - const dy = e.clientY - knobCenterScreen.current.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - // 중심점에 너무 가깝게 클릭/드래그하면 각도가 급격하게 튀는 현상 방지 - if (distance < 10) return; + const d = drag.current; + if (!d) return; + + const dx = e.clientX - d.cx; + const dy = e.clientY - d.cy; + // Too near the centre the angle jumps about; wait for the pointer to move out. + if (dx * dx + dy * dy < 100) return; const currentAngle = Math.atan2(dy, dx); - let deltaAngle = currentAngle - lastMouseAngle.current; - + let deltaAngle = currentAngle - d.lastAngle; // Normalize deltaAngle (-PI ~ PI) while (deltaAngle < -Math.PI) deltaAngle += 2 * Math.PI; while (deltaAngle > Math.PI) deltaAngle -= 2 * Math.PI; - - lastMouseAngle.current = currentAngle; - - const knobName = MESH_TO_KNOB[activeKnobRef.current.name]; - if (!knobName) return; - - // 노브 시각적 회전 (시계방향 마우스 회전 시 3D 모델도 시계방향 회전) - activeKnobRef.current.rotation.y -= deltaAngle; - + d.lastAngle = currentAngle; + + // The knob turns about its own axis, its local +z: a clockwise drag on + // screen (the angle growing, y pointing down) turns it clockwise as + // seen from the front. + d.rig.node.rotation.z -= deltaAngle; + + // Sensitivity: the same rule as the physical encoder, the whole range + // in a set number of turns (knobUnitsPerTurn). + const knobName = d.knob; const currentVal = useAppStore.getState().knobValues[knobName]; - let deltaVal = 0; - - // 회전 민감도 (1바퀴(2*PI) 돌릴 때 변하는 값). 실물 엔코더와 같은 규칙: - // 전체 범위를 TURNS_PER_FULL_RANGE 바퀴에 걸쳐 훑는다. - deltaVal = deltaAngle * (knobUnitsPerTurn(webKnobRange(knobName)) / (2 * Math.PI)); - + const deltaVal = deltaAngle * (knobUnitsPerTurn(webKnobRange(knobName)) / (2 * Math.PI)); + let newVal = currentVal + deltaVal; if (knobName === 'c1') newVal = (newVal % 1.0 + 1.0) % 1.0; // Hue if (knobName === 'c2') newVal = THREE.MathUtils.clamp(newVal, 0.1, 10.0); // Speed if (knobName === 'c3') newVal = (newVal % 1.0 + 1.0) % 1.0; // Freq/Offset if (knobName === 'c4') newVal = THREE.MathUtils.clamp(newVal, 0.0, 4.9); // Mode - + useAppStore.getState().setKnobValue(knobName, newVal); }; const handlePointerUp = () => { - if (activeKnobRef.current) { - activeKnobRef.current = null; - useAppStore.getState().setIsDraggingKnob(false); - useAppStore.getState().setActiveKnobId(null); - } + if (!drag.current) return; + drag.current = null; + useAppStore.getState().setIsDraggingKnob(false); + useAppStore.getState().setActiveKnobId(null); + document.body.style.cursor = ''; }; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); - + window.addEventListener('pointercancel', handlePointerUp); + return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + // Taken down mid-turn (the Inside tab swaps the preview for the globe): + // let go, or the orbit would stay locked as if a knob were still held. + handlePointerUp(); + document.body.style.cursor = ''; }; }, []); - const onPointerDown = (e: ThreeEvent) => { - // Check if we clicked the knob or its child elements - let knobMesh = e.object as THREE.Mesh; - while (knobMesh.parent && !knobMesh.name.match(/^c[1-4]$/)) { - knobMesh = knobMesh.parent as THREE.Mesh; - } + /** + * The knob under the pointer, if the nearest thing there is a knob (or its + * ring, or the disc over it a finger can find): never a knob through the + * case, and never one the current step has put away. + */ + const knobAt = useCallback((e: ThreeEvent) => { + const device = deviceRef.current; + if (!device || e.intersections[0]?.object !== e.object) return null; + const rig = knobUnder(device, e.object); + const part = rig && device.parts.find((p) => p.node === rig.node); + return rig && part && part.presence > 0.5 ? rig : null; + }, []); - if (knobMesh.name.match(/^c[1-4]$/)) { + const handlers = useMemo(() => ({ + onPointerDown: (e) => { + const rig = knobAt(e); + const knob = rig && MESH_TO_KNOB[rig.node.name]; + if (!rig || !knob) return; e.stopPropagation(); // 드래그 중 화면 회전 방지 - activeKnobRef.current = knobMesh; - - // 3D 공간의 노브 중심점을 2D 화면(Viewport) 좌표로 정확히 변환 - const worldPos = new THREE.Vector3(); - knobMesh.getWorldPosition(worldPos); - worldPos.project(e.camera); - - // Canvas의 실제 위치와 크기를 가져와서 계산해야 함 (window.innerWidth 사용 시 레이아웃 때문에 오차 발생) + + // The drag turns about the centre of the knob's top face as it lies on + // screen (the node's origin is at the knob's base). Measured against + // the canvas's own box: the window's would be off by the page layout. + const top = rig.node.localToWorld(new THREE.Vector3(0, 0, rig.height)); + top.project(e.camera); const rect = (e.nativeEvent.target as HTMLElement).getBoundingClientRect(); - const screenX = (worldPos.x * 0.5 + 0.5) * rect.width + rect.left; - const screenY = (worldPos.y * -0.5 + 0.5) * rect.height + rect.top; - - knobCenterScreen.current = { x: screenX, y: screenY }; - - // 최초 클릭 지점의 각도 저장 - lastMouseAngle.current = Math.atan2(e.nativeEvent.clientY - screenY, e.nativeEvent.clientX - screenX); + const cx = (top.x * 0.5 + 0.5) * rect.width + rect.left; + const cy = (top.y * -0.5 + 0.5) * rect.height + rect.top; + drag.current = { + rig, + knob, + cx, + cy, + lastAngle: Math.atan2(e.nativeEvent.clientY - cy, e.nativeEvent.clientX - cx), + }; useAppStore.getState().setIsDraggingKnob(true); - useAppStore.getState().setActiveKnobId(knobMesh.name as 'c1'|'c2'|'c3'|'c4'); - + useAppStore.getState().setActiveKnobId(rig.node.name as KnobId); // 커서를 드래그용으로 변경 (원형 회전임을 암시하기 위해 grabbing 사용) document.body.style.cursor = 'grabbing'; - - const handlePointerUpDom = () => { - document.body.style.cursor = 'auto'; - window.removeEventListener('pointerup', handlePointerUpDom); - }; - window.addEventListener('pointerup', handlePointerUpDom); - } - }; - - useEffect(() => { - // Reset arrays - partsRef.current = { top: [], mid: [], bot: [], pcb: [], led: [], knobs: [], others: [] }; - - scene.traverse((child) => { - if ((child as THREE.Mesh).isMesh) { - const m = child as THREE.Mesh; - if (m.userData.originalX === undefined) { - m.userData.originalX = m.position.x; - } - if (m.userData.originalY === undefined) { - m.userData.originalY = m.position.y; - } - if (m.userData.originalZ === undefined) { - m.userData.originalZ = m.position.z; - } - if (m.userData.originalScale === undefined) { - m.userData.originalScale = m.scale.clone(); - } - - if (m.name === 'l') { - m.material = activePatternId === 'custom' ? customMat : ledMat; - partsRef.current.led.push(m); - } else if (m.name.startsWith('t')) { - partsRef.current.top.push(m); - } else if (m.name === 'm') { - partsRef.current.mid.push(m); - } else if (m.name.startsWith('b')) { - partsRef.current.bot.push(m); - } else if (m.name === 'p' || m.name.includes('PCB')) { - partsRef.current.pcb.push(m); - } else if (m.name.match(/^c[1-4]$/)) { - // 개별 색상 변경을 위해 메테리얼 복제 및 원래 색상 저장 (falsy 버그 방지를 위해 === undefined 체크) - if (m.userData.originalColor === undefined) { - m.material = (m.material as THREE.Material).clone(); - m.userData.originalColor = (m.material as THREE.MeshStandardMaterial).color.getHex(); - } - partsRef.current.knobs.push(m); - - // 행성 고리와 공전하는 점 그룹 추가 - if (!m.children.find(c => c.name === 'knob-ring-group')) { - m.geometry.computeBoundingBox(); - const bbox = m.geometry.boundingBox; - if (bbox) { - const radius = (bbox.max.x - bbox.min.x) / 2; - const height = bbox.max.y - bbox.min.y; - const ringRadius = radius * 1.5; // 노브보다 조금 더 큰 고리 반경 - - const ringGroup = new THREE.Group(); - ringGroup.name = 'knob-ring-group'; - // 노브 높이의 중간 살짝 아래쯤에 고리 배치 - ringGroup.position.set(0, bbox.min.y + height * 0.4, 0); - - const effectMat = new THREE.MeshStandardMaterial({ - color: 0xff3333, emissive: 0xff0000, - emissiveIntensity: 2.0, - transparent: true, opacity: 0.9 - }); - - // 두께를 키운 토러스(고리) - const torusGeom = new THREE.TorusGeometry(ringRadius, radius * 0.08, 12, 48); - const torus = new THREE.Mesh(torusGeom, effectMat); - torus.rotation.x = Math.PI / 2; // XZ 평면으로 눕히기 - - // 크기를 키운 고리 위에 얹혀 있는 구슬(점) - const dotGeom = new THREE.SphereGeometry(radius * 0.4, 16, 16); - const dot = new THREE.Mesh(dotGeom, effectMat); - dot.position.set(ringRadius, 0, 0); - - ringGroup.add(torus); - ringGroup.add(dot); - ringGroup.visible = false; // 평소에는 숨김 - - m.add(ringGroup); - } - } - } else if (!m.parent || m.parent.name !== 'knob-ring-group') { - partsRef.current.others.push(m); - } - m.castShadow = true; - m.receiveShadow = true; - } - }); - }, [scene, ledMat]); + }, + // The hand while over a knob. On every move, not on over/out: the + // handlers sit on the device's root, so r3f reports one over and one out + // for the device as a whole, and gliding from the case onto a knob would + // never show it. Only the nearest hit decides; the others are behind it. + onPointerMove: (e) => { + if (drag.current || e.object !== e.intersections[0]?.object) return; + document.body.style.cursor = knobAt(e) ? 'grab' : ''; + }, + onPointerOut: () => { + if (!drag.current) document.body.style.cursor = ''; + }, + }), [knobAt]); - const prevKnobValues = useRef(knobValues); + const prevKnobValues = useRef(useAppStore.getState().knobValues); + // Scratch objects for the frame loop. + const tmp = useMemo(() => ({ box: new THREE.Box3(), part: new THREE.Box3(), v: new THREE.Vector3(), aim: new THREE.Vector3() }), []); useFrame((state, delta) => { - if (!groupRef.current) return; const t = state.clock.getElapsedTime(); - + const { knobValues, buildStep, explode, activeKnobId } = useAppStore.getState(); + + // The pattern on the panel. if (activePatternId === 'custom') { ledMatrix.render(delta, t, knobValues, prevKnobValues.current); prevKnobValues.current = { ...knobValues }; } else { ledMat.uniforms.uTime.value = t; + ledMat.uniforms.uParam1.value = knobValues[LOGICAL_KNOB_TO_WEB_KNOB[0]]; // Hue + ledMat.uniforms.uSpeed.value = knobValues[LOGICAL_KNOB_TO_WEB_KNOB[1]]; // Speed + ledMat.uniforms.uParam3.value = knobValues[LOGICAL_KNOB_TO_WEB_KNOB[2]]; // Mode + ledMat.uniforms.uParam4.value = knobValues[LOGICAL_KNOB_TO_WEB_KNOB[3]]; // Freq/Offset } - // 현재 선택된 노브만 고리 이펙트 표시 및 회색 변환 - const currentActiveKnob = useAppStore.getState().activeKnobId; - partsRef.current.knobs.forEach(m => { - const isSelected = (currentActiveKnob === m.name); - - // 고리 표시 토글 - const ringGroup = m.children.find(c => c.name === 'knob-ring-group'); - if (ringGroup) { - ringGroup.visible = isSelected; - } - - // 색상 토글 - const mat = m.material as THREE.MeshStandardMaterial; - if (isSelected) { - mat.color.setHex(0x666666); // 선택 시 회색 - } else if (m.userData.originalColor !== undefined) { - mat.color.setHex(m.userData.originalColor); // 아닐 시 원래 색상 복구 - } - }); - - let targetRotationY = 0; - let targetGroupY = 0; - let targetGroupX = 0; - let targetScale = 0.1; - - if (buildStep === 0) { - targetRotationY = Math.sin(t * 0.15) * 0.45; - targetGroupY = Math.sin(t * 0.3) * 0.03; - } else { - targetRotationY = -0.5; - - if (buildStep === 1) { - targetScale = 0.085; // Slight zoom out to show cases - targetGroupX = -0.3; // Slight left - } else if (buildStep === 2) { - targetScale = 0.230; - targetGroupX = 1.60; - targetGroupY = -4.45; - targetRotationY = 2.64; - } else if (buildStep === 3) { - // Pull back as the parts separate, rather than snapping between two - // framings — the reader is dragging this, so it has to track the drag. - const t = useAppStore.getState().explode; - targetScale = THREE.MathUtils.lerp(0.11, 0.075, t); - targetGroupX = -0.3; - targetRotationY = -0.5; - } - } - - groupRef.current.rotation.y = THREE.MathUtils.lerp(groupRef.current.rotation.y, targetRotationY, 0.05); - groupRef.current.position.y = THREE.MathUtils.lerp(groupRef.current.position.y, targetGroupY, 0.05); - groupRef.current.position.x = THREE.MathUtils.lerp(groupRef.current.position.x, targetGroupX, 0.05); - - const currentScale = groupRef.current.scale.x; - const nextScale = THREE.MathUtils.lerp(currentScale, targetScale, 0.05); - groupRef.current.scale.set(nextScale, nextScale, nextScale); - - let offsetTopZ = 0, offsetMidZ = 0, offsetLedZ = 0, offsetPcbZ = 0, offsetBotZ = 0; - - if (buildStep === 1) { // 1. Print - offsetTopZ = 8; offsetBotZ = -8; - } else if (buildStep === 2) { // 2. Solder - offsetLedZ = 6; offsetPcbZ = 0; - } else if (buildStep === 3) { // 3. Assemble - // LED matrix frontmost, then Top cover, Mid case, PCB, Bot case (furthest - // back). Every offset is the full-separation value times `explode`, so - // t=0 lands exactly on the assembled positions. - offsetLedZ = 24 * explode; - offsetTopZ = 16 * explode; - offsetMidZ = 8 * explode; - offsetPcbZ = 0; - offsetBotZ = -12 * explode; + const group = groupRef.current; + const device = deviceRef.current; + if (!group || !device) return; + + const pose = poseFor(buildStep, explode, t); + // A model that has just arrived starts where the step has everything, + // not assembled and easing out from there: a swap is a cut, not a move. + const fresh = !device.root.userData.settled; + device.root.userData.settled = true; + const kPart = fresh ? 1 : ease(0.08, delta); + const kView = ease(0.05, delta); + + // The panel: the pattern while there is power, black while it is being built. + const ledTarget = !pose.lit ? blackMat : activePatternId === 'custom' ? customMat : ledMat; + for (const mesh of device.led) { + if (mesh.material !== ledTarget) mesh.material = ledTarget; } - const lerpSpeed = 0.08; - - // Z-axis separation for true exploded view (depth) - partsRef.current.top.forEach(m => { - let targetZ = m.userData.originalZ + offsetTopZ; - let targetY = m.userData.originalY; - const targetX = m.userData.originalX; - - if (buildStep === 3) { - if (m.name === 't_rb') { - // Top cover: move UP - targetY += 25 * explode; - targetZ += 2 * explode; - } else if (m.name === 't_b') { - // Back top case: move BACK - targetZ -= 20 * explode; // push far back - } else if (m.name !== 't') { - targetZ += 4 * explode; - } - // General top group slight Y upward shift - if (m.name !== 't_rb') targetY += 2 * explode; - } + // Each part eases to where this step has it: apart by its explode vector + // times the step's spread, and present or shrunk away into the middle of + // its own box. The aim is the middle of what will be on screen once the + // move is done, so the framing heads for it from the first frame. + tmp.box.makeEmpty(); + for (const p of device.parts) { + const shown = shows(pose, p); + tmp.v.copy(p.apart).multiplyScalar(pose.spread); + if (shown) tmp.box.union(tmp.part.copy(p.box).translate(tmp.v)); + p.offset.lerp(tmp.v, kPart); + p.presence = THREE.MathUtils.lerp(p.presence, shown ? 1 : 0, kPart); + if (Math.abs(p.presence - (shown ? 1 : 0)) < 0.002) p.presence = shown ? 1 : 0; + const s = Math.max(p.presence, 0.001); + p.node.scale.copy(p.homeScale).multiplyScalar(s); + // Scaled about the middle of its box, wherever the offset has taken it. + p.node.position.copy(p.home).sub(p.centre).multiplyScalar(s).add(p.centre).add(p.offset); + p.node.visible = p.presence > 0.01; + } - m.position.z = THREE.MathUtils.lerp(m.position.z, targetZ, lerpSpeed); - m.position.y = THREE.MathUtils.lerp(m.position.y, targetY, lerpSpeed); - m.position.x = THREE.MathUtils.lerp(m.position.x, targetX, lerpSpeed); - }); - - partsRef.current.knobs.forEach(m => { - const knobLift = buildStep === 3 ? explode : 0; - m.position.z = THREE.MathUtils.lerp(m.position.z, m.userData.originalZ + offsetTopZ + 8 * knobLift, lerpSpeed); - m.position.y = THREE.MathUtils.lerp(m.position.y, m.userData.originalY + 2 * knobLift, lerpSpeed); - }); - - partsRef.current.mid.forEach(m => m.position.z = THREE.MathUtils.lerp(m.position.z, m.userData.originalZ + offsetMidZ, lerpSpeed)); - partsRef.current.led.forEach(m => m.position.z = THREE.MathUtils.lerp(m.position.z, m.userData.originalZ + offsetLedZ, lerpSpeed)); - partsRef.current.pcb.forEach(m => m.position.z = THREE.MathUtils.lerp(m.position.z, m.userData.originalZ + offsetPcbZ, lerpSpeed)); - partsRef.current.others.forEach(m => m.position.z = THREE.MathUtils.lerp(m.position.z, m.userData.originalZ + offsetPcbZ, lerpSpeed)); - - partsRef.current.bot.forEach(m => { - let targetZ = m.userData.originalZ + offsetBotZ; - let targetY = m.userData.originalY; - let targetX = m.userData.originalX; - - if (buildStep === 3) { - if (m.name === 'b_f') { - // Front cover: move to the RIGHT - targetX += 20 * explode; - targetZ += 2 * explode; - } else if (m.name === 'b_b') { - // Back bottom case: move BACK - targetZ -= 20 * explode; // push far back - } else { - targetZ += (m.name !== 'b' ? -4 : 0) * explode; - targetY -= 2 * explode; - } + // The knob in hand wears its ring and goes grey. + for (const k of device.knobs) { + const held = activeKnobId === k.node.name; + k.ring.visible = held; + for (const tint of k.tints) { + if (held) tint.material.color.setHex(0x666666); // 선택 시 회색 + else tint.material.color.copy(tint.own); } + } - m.position.z = THREE.MathUtils.lerp(m.position.z, targetZ, lerpSpeed); - m.position.y = THREE.MathUtils.lerp(m.position.y, targetY, lerpSpeed); - m.position.x = THREE.MathUtils.lerp(m.position.x, targetX, lerpSpeed); - }); - - const showCase = buildStep === 0 || buildStep === 1 || buildStep === 3 || buildStep === 4; - const showPcb = buildStep === 0 || buildStep === 2 || buildStep === 3 || buildStep === 4; - const showLed = buildStep === 0 || buildStep === 3 || buildStep === 4; - - const applyScaleLerp = (m: THREE.Mesh, show: boolean) => { - const targetScale = show ? m.userData.originalScale as THREE.Vector3 : new THREE.Vector3(0.001, 0.001, 0.001); - m.scale.lerp(targetScale, lerpSpeed); - m.visible = m.scale.x > 0.01 || show; - }; + // Framing. The aim, in the group's frame: the board on Solder, otherwise + // the middle of what is shown. Every case is centred alike and drawn at + // one scale (prepareCase), so a swap lands where the last case stood. + if (pose.board) tmp.box.copy(device.board); + if (tmp.box.isEmpty()) tmp.box.copy(device.board); + tmp.box.getCenter(tmp.aim).add(device.fitPosition); + let scale = pose.scale; + if (pose.board) { + const size = device.board.getSize(tmp.v); + scale = BOARD_SPAN / Math.hypot(size.x, size.y); + } - partsRef.current.top.forEach(m => applyScaleLerp(m, showCase)); - partsRef.current.mid.forEach(m => applyScaleLerp(m, showCase)); - partsRef.current.bot.forEach(m => applyScaleLerp(m, showCase)); - partsRef.current.knobs.forEach(m => applyScaleLerp(m, showCase)); - partsRef.current.pcb.forEach(m => applyScaleLerp(m, showPcb)); - - // LED Matrix Visibility and Scale restore - partsRef.current.led.forEach(m => { - m.visible = showLed; - if (m.userData.originalScale) { - m.scale.copy(m.userData.originalScale); - } - }); - - partsRef.current.others.forEach(m => applyScaleLerp(m, showPcb)); - - // Glow Effect for Step 4 - const glowTarget = buildStep === 4 ? 2.0 : 0.0; - partsRef.current.others.forEach(m => { - if (m.name.toLowerCase().includes('esp') || m.name.toLowerCase().includes('chip')) { - const mat = m.material as THREE.MeshStandardMaterial; - if (mat.emissive) { - if (m.userData.originalMaterial === undefined) { - m.userData.originalMaterial = mat; - m.material = mat.clone(); - (m.material as THREE.MeshStandardMaterial).emissive.setHex(0xFFD466); - } - const currentMat = m.material as THREE.MeshStandardMaterial; - currentMat.emissiveIntensity = THREE.MathUtils.lerp(currentMat.emissiveIntensity || 0, glowTarget, 0.05); - } - } - }); + group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, pose.turn, kView); + const nextScale = THREE.MathUtils.lerp(group.scale.x, scale, kView); + group.scale.setScalar(nextScale); + focus.current.lerp(tmp.aim, kView); + lift.current = THREE.MathUtils.lerp(lift.current, pose.lift, kView); + // Turn and scale about the aim, which stays on the orbit target: the + // device turns in place and the view zooms into what it is about. + tmp.v.copy(focus.current).multiplyScalar(nextScale).applyAxisAngle(Y_AXIS, group.rotation.y); + group.position.copy(TARGET).sub(tmp.v); + group.position.y += lift.current; }); return ( - - + + onFailed(caseId)}> + + + + ); } +const caseName = (id: CaseId) => BUILD_CASES.find((c) => c.id === id)?.tab ?? id; + export default function HeroScene() { const isDraggingKnob = useAppStore((state) => state.isDraggingKnob); const activeKnobId = useAppStore((state) => state.activeKnobId); + const buildCase = useAppStore((state) => state.buildCase); + const setBuildCase = useAppStore((state) => state.setBuildCase); + const homeTab = useAppStore((state) => state.homeTab); + const caseFinish = useAppStore((state) => state.caseFinish); + const setCaseFinish = useAppStore((state) => state.setCaseFinish); const [hasInteracted, setHasInteracted] = useState(false); // Latch on first knob interaction — adjusting state during render (guarded) // avoids an extra effect-driven render pass. @@ -538,6 +480,54 @@ export default function HeroScene() { setHasInteracted(true); } + // The case on screen, and the one wanted: the picked case once its model is + // in, the official one for a case whose model would not load. While the + // wanted one loads, the shown one stays. + const [shown, setShown] = useState(() => (arrived.has(buildCase) ? buildCase : 'official')); + const [failed, setFailed] = useState([]); + // Picking a case whose model failed tries it again: its failure was let go + // of in drei's cache (onFailed), so this is a fresh request. Adjusted during + // render, guarded, like the hint's latch above. + const [picked, setPicked] = useState(buildCase); + if (picked !== buildCase) { + setPicked(buildCase); + if (failed.includes(buildCase)) setFailed(failed.filter((id) => id !== buildCase)); + } + const wanted: CaseId = failed.includes(buildCase) ? 'official' : buildCase; + const loading = wanted !== shown; + const onReady = useCallback((id: CaseId) => setShown(id), []); + const onFailed = useCallback((id: CaseId) => { + // suspend-react keeps a failed load as a thrown error; without this, the + // case could not load again until the page was reloaded. + useGLTF.clear(CASE_MODELS[id].url); + setFailed((list) => (list.includes(id) ? list : [...list, id])); + // Back to the official case, and if that is what failed, onto it all the + // same: its slot then draws nothing, rather than a remix staying on screen + // under the Official tab. + setShown((current) => (current === id || id === 'official' ? 'official' : current)); + }, []); + + const finishes = CASE_MODELS[shown].finishes ?? []; + const finishId = caseFinish[shown] ?? finishes[0]?.id; + const finish = finishes.find((f) => f.id === finishId); + + // Under the device: the picked case on its way, or why it is not there. + const note = loading + ? `Loading ${caseName(wanted)}…` + : failed.includes(buildCase) + ? `${caseName(buildCase)}’s model did not load` + : null; + + // On the Build tab, arrows either side of the device step through the + // cases, as the Inside globe's step through the builds. It is the Build + // panel's switch from the other side: the card, the address and the model + // follow it the same way. + const pickCase = (id: CaseId) => { + setBuildCase(id); + if (window.location.pathname === '/build') writeCaseToUrl(id); + captureEvent('build_case_selected', { case_id: id, interaction: 'click', surface: 'product_preview' }); + }; + return (
@@ -562,6 +552,38 @@ export default function HeroScene() { Rotate the knobs to explore
+
+ {note} +
+ + {homeTab === 'build' && BUILD_CASES.length > 1 && [-1, 1].map((step) => { + const target = stepCase(buildCase, step); + return ( + + ); + })} + + {!loading && finishes.length > 1 && finishId && ( + setCaseFinish(shown, id)} + /> + )} + - - + {/* The room the reflections and the soft light come from: light + panels made here, as on the guide's stage, not an environment map + fetched at run time. Each faces the middle (drei aims a + Lightformer at the origin). Neutral white, with the left panel a + touch cool: the light has no cast of its own. */} + + + + + + + + + + {loading && ( + onFailed(wanted)}> + + + + + )} - - {/* 빛 번짐(Glow/Bloom) 효과 */} + + {/* The frame, in order: the tone curve, on each pixel's own colour + (canvasAlpha.tsx), and the glow. The curve is Neutral's shoulder: + its toe is given back first (neutralToe.tsx), so below the knee + the frame is as lit — the white case the white it is drawn, a + pure LED colour pure (patterns/common.ts holds it at the knee), + the dark parts as dark as they are. What is brighter than that, an + LED's white core or a highlight, is rolled off with its channels + together, rather than one channel clipping before the others and + leaving the rest yellow. The glow then takes what the LED shader + runs past 2.0. */} - + + + + diff --git a/web/src/components/3d/buildPose.test.ts b/web/src/components/3d/buildPose.test.ts new file mode 100644 index 000000000..478e249ab --- /dev/null +++ b/web/src/components/3d/buildPose.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { poseFor, shows } from './buildPose'; +import type { PartRole } from './heroCase'; + +// What the preview shows at each Build step: the case's loose pieces (a +// case's feet, CaseModel.loose) come out with the parts to make and on +// Assemble, and stay off the device standing and lit. + +const piece = (role: PartRole, loose = false) => ({ role, loose }); +const STEPS = { idle: 0, print: 1, solder: 2, assemble: 3, power: 4 }; + +describe('poseFor and shows', () => { + it('has the loose pieces out while the case is made and put together', () => { + for (const step of [STEPS.print, STEPS.assemble]) { + expect(shows(poseFor(step, 1, 0), piece('shell', true)), `step ${step}`).toBe(true); + } + }); + + it('keeps them off the device once it is powered, and off the board', () => { + for (const step of [STEPS.idle, STEPS.solder, STEPS.power]) { + expect(shows(poseFor(step, 1, 0), piece('shell', true)), `step ${step}`).toBe(false); + } + }); + + it('leaves every other part to its step', () => { + for (const step of Object.values(STEPS)) { + const pose = poseFor(step, 1, 0); + for (const role of ['shell', 'knob', 'led', 'pcb', 'devkit'] as const) { + expect(shows(pose, piece(role)), `step ${step}, ${role}`).toBe(pose.show[role]); + } + } + expect(shows(poseFor(STEPS.solder, 1, 0), piece('shell'))).toBe(false); + expect(shows(poseFor(STEPS.power, 1, 0), piece('led'))).toBe(true); + }); + + it('lights the panel only once the device has power', () => { + expect(Object.values(STEPS).filter((step) => poseFor(step, 1, 0).lit)).toEqual([STEPS.idle, STEPS.power]); + }); +}); diff --git a/web/src/components/3d/buildPose.ts b/web/src/components/3d/buildPose.ts new file mode 100644 index 000000000..89a6dc4de --- /dev/null +++ b/web/src/components/3d/buildPose.ts @@ -0,0 +1,83 @@ +import * as THREE from 'three'; +import type { CasePart, PartRole } from './heroCase'; + +// What the product preview shows of the device at each of the Build panel's +// four steps, and idle: which parts, how far apart, from where, lit or not. +// HeroScene eases the device toward the pose for the step the reader is on. + +/** + * Solder's turn: to the back, at an angle, so the DevKit on its sockets faces + * the reader and the encoders' shafts stand out past the board's edge, and + * both sides of what is soldered read in one view. + */ +const BOARD_TURN = Math.PI - 0.8; + +/** What each Build step shows. */ +export interface Pose { + /** The device's turn about the vertical, radians, and its scale. */ + turn: number; + scale: number; + /** A small drift up and down, world units (the idle sway). */ + lift: number; + /** How far the parts stand apart, as a fraction of each one's explode vector. */ + spread: number; + show: Record; + /** The case's loose pieces (CaseModel.loose) are out too: the parts are being made or put together. */ + loose: boolean; + /** Frame the board instead of the whole device. */ + board: boolean; + /** The LED panel is lit (the device has power). */ + lit: boolean; +} + +const ALL: Record = { shell: true, knob: true, led: true, pcb: true, devkit: true }; +const CASE_ONLY: Record = { shell: true, knob: true, led: false, pcb: false, devkit: false }; +const BOARD_ONLY: Record = { shell: false, knob: false, led: false, pcb: true, devkit: true }; + +/** The pose for a Build step (0 is none: idle), the Assemble slider's spread and the clock. */ +export function poseFor(step: number, explode: number, t: number): Pose { + switch (step) { + // 1. Print / cut the case: its parts and the knobs, drawn a little apart + // so it reads as parts to make, nothing inside yet. + case 1: + return { turn: -0.5, scale: 0.085, lift: 0, spread: 0.4, show: CASE_ONLY, loose: true, board: false, lit: false }; + // 2. Solder: the board and the DevKit alone, close. (Its scale comes from + // the board's size in the model, HeroScene's BOARD_SPAN, so it is left 0 here.) + case 2: + return { turn: BOARD_TURN, scale: 0, lift: 0, spread: 0, show: BOARD_ONLY, loose: false, board: true, lit: false }; + // 3. Assemble: everything, as far apart as the reader has dragged it. + // The view pulls back as the parts separate, rather than snapping between + // two framings — the reader is dragging this, so it has to track the drag. + case 3: + return { + turn: -0.5, + scale: THREE.MathUtils.lerp(0.11, 0.075, explode), + lift: 0, + spread: explode, + show: ALL, + loose: true, + board: false, + lit: false, + }; + // 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 }; + // Idle: together, lit, swaying slowly. + default: + return { + turn: Math.sin(t * 0.15) * 0.45, + scale: 0.1, + lift: Math.sin(t * 0.3) * 0.03, + spread: 0, + show: ALL, + loose: false, + board: false, + lit: true, + }; + } +} + +/** 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/canvasAlpha.tsx b/web/src/components/3d/canvasAlpha.tsx new file mode 100644 index 000000000..7fba90472 --- /dev/null +++ b/web/src/components/3d/canvasAlpha.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Effect } from 'postprocessing'; +import { useEffect, useMemo } from 'react'; + +// The preview's canvas is see-through: the page is behind the device, and +// whatever only partly covers a pixel — the clear acrylic of a laser-cut +// case, a soft shadow, the antialiased edge of anything — lets it through. +// +// What reaches the composer is premultiplied colour (three blends that way), +// and its last pass sRGB-encodes the colour as it stands. For a pixel that is +// half covered that is far too bright — the encoding is a curve, and a +// premultiplied value sits low on it — so the page composites a clear sheet +// as a frosted white one, however clear the sheet is drawn. These two put the +// tone curve (and neutralToe.tsx before it) between them, in order: +// +// StraightAlpha each pixel's own colour, for the tone curve to work on +// PageAlpha premultiplied again, so that once the last pass has +// encoded it, it is what the page should composite: the +// encoded colour times its coverage +// +// A pixel with next to no coverage is left alone in both: there is no colour +// of its own to speak of, only light added over the page (the bloom's glow, +// which comes after them). + +const EPS = '0.002'; + +const straightFrag = /* glsl */ ` +void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { + float a = inputColor.a; + outputColor = a > ${EPS} && a < 1.0 ? vec4(inputColor.rgb / a, a) : inputColor; +} +`; + +const pageFrag = /* glsl */ ` +vec3 pageEncode(vec3 c) { + c = clamp(c, 0.0, 1.0); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, vec3(lessThanEqual(c, vec3(0.0031308)))); +} +vec3 pageDecode(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, vec3(lessThanEqual(c, vec3(0.04045)))); +} +void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { + float a = inputColor.a; + outputColor = a > ${EPS} && a < 1.0 ? vec4(pageDecode(pageEncode(inputColor.rgb) * a), a) : inputColor; +} +`; + +class StraightAlphaEffect extends Effect { + constructor() { + super('StraightAlphaEffect', straightFrag); + } +} + +class PageAlphaEffect extends Effect { + constructor() { + super('PageAlphaEffect', pageFrag); + } +} + +/** Before the tone curve: each pixel's own colour, not its colour times its coverage. */ +export function StraightAlpha() { + const effect = useMemo(() => new StraightAlphaEffect(), []); + useEffect(() => () => effect.dispose(), [effect]); + return ; +} + +/** After the tone curve: premultiplied again, as the page composites an sRGB canvas. */ +export function PageAlpha() { + const effect = useMemo(() => new PageAlphaEffect(), []); + useEffect(() => () => effect.dispose(), [effect]); + return ; +} diff --git a/web/src/components/3d/caseModels.test.ts b/web/src/components/3d/caseModels.test.ts new file mode 100644 index 000000000..a934fbc15 --- /dev/null +++ b/web/src/components/3d/caseModels.test.ts @@ -0,0 +1,151 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { BUILD_CASES } from '@/components/sections/build-cases-data'; +import { CASE_MODELS, DEVKIT_NODE, KNOB_NODES, LED_NODE, PCB_NODE } from './caseModels'; + +// The case models against the files in public/: every case on the Build +// panel's switch has one, each has the parts the preview finds by name +// (caseModels.ts), what an entry names — the parts its exploded view moves, +// the materials its finishes recolour — is in the file, and the model the +// page loads first is no heavier than the one it replaced. Reads the GLB's +// JSON chunk only; the meshes are Draco-compressed and stay that way. + +const PUBLIC = path.resolve(__dirname, '../../../public'); + +/** The landing page's first model, public/3dforweb.glb, which the page used to load first. */ +const OLD_MODEL_BYTES = 1_154_992; +/** The official model loads with the page: well under the old one. */ +const FIRST_LOAD_BUDGET = 450_000; +/** A remix's model loads when it is picked. */ +const REMIX_BUDGET = 600_000; + +interface GltfNode { + name?: string; + mesh?: number; + rotation?: number[]; + children?: number[]; +} + +interface Gltf { + scene?: number; + scenes: { nodes: number[] }[]; + nodes: GltfNode[]; + meshes: { primitives: { attributes: Record; material?: number }[] }[]; + materials?: { name?: string; pbrMetallicRoughness?: { baseColorFactor?: number[]; roughnessFactor?: number } }[]; + extensionsRequired?: string[]; +} + +function readGlb(file: string): Gltf { + const buf = fs.readFileSync(file); + expect(buf.readUInt32LE(0), `${file} is not a GLB`).toBe(0x46546c67); + const length = buf.readUInt32LE(12); + expect(buf.readUInt32LE(16), `${file}: first chunk is not JSON`).toBe(0x4e4f534a); + return JSON.parse(buf.subarray(20, 20 + length).toString('utf8')) as Gltf; +} + +const models = BUILD_CASES.map((item) => ({ id: item.id, model: CASE_MODELS[item.id] })); + +/** CIELAB (D65) of a linear-light RGB colour, as glTF stores a base colour. */ +function lab([r, g, b]: number[]) { + const x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047; + const y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b; + const z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883; + const f = (t: number) => (t > (6 / 29) ** 3 ? Math.cbrt(t) : t / (3 * (6 / 29) ** 2) + 4 / 29); + return { L: 116 * f(y) - 16, a: 500 * (f(x) - f(y)), b: 200 * (f(y) - f(z)) }; +} + +describe('case models', () => { + it('has one for every case on the switch, and no other', () => { + expect(Object.keys(CASE_MODELS).sort()).toEqual(BUILD_CASES.map((item) => item.id).sort()); + for (const { id, model } of models) expect(model.id).toBe(id); + }); + + describe.each(models)('$id', ({ id, model }) => { + const file = path.join(PUBLIC, model.url); + + it('is a file in public/, inside the case’s own folder', () => { + expect(model.url).toBe(`/cases/${id}/model.glb`); + expect(fs.existsSync(file), `${model.url} is missing`).toBe(true); + }); + + it('has the parts the preview finds by name', () => { + const gltf = readGlb(file); + const top = gltf.scenes[gltf.scene ?? 0].nodes.map((i) => gltf.nodes[i]); + const byName = new Map(top.map((node) => [node.name, node])); + for (const name of [LED_NODE, ...KNOB_NODES, PCB_NODE, DEVKIT_NODE]) { + expect(byName.has(name), `${id}: no top-level node "${name}"`).toBe(true); + } + // The pattern shaders draw with the panel's UVs. + const led = byName.get(LED_NODE)!; + for (const prim of gltf.meshes[led.mesh!].primitives) { + expect(Object.keys(prim.attributes)).toContain('TEXCOORD_0'); + } + // A knob turns about its own +z: no rotation on the node. + for (const name of KNOB_NODES) { + const knob = byName.get(name)!; + expect(knob.mesh, `${id}: knob ${name} has no mesh`).toBeDefined(); + expect(knob.rotation ?? [0, 0, 0, 1], `${id}: knob ${name} is turned`).toEqual([0, 0, 0, 1]); + } + // The case itself: at least one part besides the device's own. + const shared = new Set([LED_NODE, ...KNOB_NODES, PCB_NODE, DEVKIT_NODE]); + expect(top.filter((node) => !shared.has(node.name ?? '')).length).toBeGreaterThan(0); + }); + + it('moves only parts that are in the file', () => { + const gltf = readGlb(file); + const top = new Set(gltf.scenes[gltf.scene ?? 0].nodes.map((i) => gltf.nodes[i].name)); + for (const name of Object.keys(model.explode)) { + expect(top.has(name), `${id}: explode names "${name}", which the file has no top-level node for`).toBe(true); + } + }); + + it('sets aside only parts of the case that are in the file', () => { + const gltf = readGlb(file); + const top = new Set(gltf.scenes[gltf.scene ?? 0].nodes.map((i) => gltf.nodes[i].name)); + const device = new Set([LED_NODE, ...KNOB_NODES, PCB_NODE, DEVKIT_NODE]); + for (const name of model.loose ?? []) { + expect(top.has(name), `${id}: loose names "${name}", which the file has no top-level node for`).toBe(true); + expect(device.has(name), `${id}: "${name}" is the device, not a loose piece of its case`).toBe(false); + } + }); + + it('recolours only materials that are in the file', () => { + const materials = new Set((readGlb(file).materials ?? []).map((m) => m.name)); + for (const finish of model.finishes ?? []) { + for (const name of Object.keys(finish.looks ?? {})) { + expect(materials.has(name), `${id}: finish ${finish.id} recolours "${name}", not in the file`).toBe(true); + } + } + }); + + it('prints its white parts in a white without a cast', () => { + // White PLA reads cool or cream with the light it is photographed + // under; the model draws the filament neutral, so the preview's light + // decides, and a warm white read as beige (PR #3's detail review). + const white = (readGlb(file).materials ?? []).find((m) => m.name === 'pla_white'); + if (!white) return; + // #f4f4f2 is b* +0.96, a* −0.35; the warm #eceae4 it replaced, +3.14. + const c = lab(white.pbrMetallicRoughness!.baseColorFactor!); + expect(Math.abs(c.b), `${id}: pla_white b* ${c.b.toFixed(2)}`).toBeLessThan(1.5); + expect(Math.abs(c.a), `${id}: pla_white a* ${c.a.toFixed(2)}`).toBeLessThan(1.5); + expect(c.L).toBeGreaterThan(94); + }); + + it('uses only the Draco compression the page already decodes', () => { + for (const ext of readGlb(file).extensionsRequired ?? []) { + expect(['KHR_draco_mesh_compression']).toContain(ext); + } + }); + + it('stays within its download budget', () => { + const bytes = fs.statSync(file).size; + if (id === 'official') { + expect(bytes).toBeLessThanOrEqual(FIRST_LOAD_BUDGET); + expect(bytes).toBeLessThan(OLD_MODEL_BYTES); + } else { + expect(bytes).toBeLessThanOrEqual(REMIX_BUDGET); + } + }); + }); +}); diff --git a/web/src/components/3d/caseModels.ts b/web/src/components/3d/caseModels.ts new file mode 100644 index 000000000..16906d855 --- /dev/null +++ b/web/src/components/3d/caseModels.ts @@ -0,0 +1,81 @@ +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'; + +// 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 +// files in this repository (its README says how), in the frame the guide's +// models share: one unit is 10 mm, the device stands facing +z with the knobs +// at the top right, the LED panel on the left. +// +// Every model has the same Patternflow in it, so the preview finds its parts +// by name, whichever case it is: +// l the LED panel; the pattern shaders draw on its front face +// c1..c4 the knobs, origin on the encoder axis at the knob's base, axis +// +z. Named after the encoder nets as case-v39.glb names them: +// seen from the front c1 top-left, c2 top-right, c3 bottom-left, +// c4 bottom-right (K2, K1, K4, K3 on the device) +// pcb_v39 the v3.9 board; its children keep pcb-v39.glb's names +// devkit the ESP32-S3 DevKit on its sockets ("module" is the ESP32) +// Every other top-level node is a part of the case itself. + +export type Vec3 = [number, number, number]; + +/** A material's look, as tools/case-models/lib/looks.mjs writes them. */ +export interface CaseLook { + /** sRGB hex. */ + color: string; + roughness: number; + /** Below 1, the material is drawn see-through. */ + opacity?: number; +} + +/** One way a case can be made, when it can be made in more than one material. */ +export interface CaseFinish { + id: string; + /** On the switch in the preview. */ + label: string; + /** + * Material name → look, laid over the file's own. The first finish is the + * file as built and needs none. + */ + looks?: Record; +} + +export interface CaseModel { + id: CaseId; + /** The GLB, under public/. */ + url: string; + /** + * Where each top-level node goes when the device is drawn apart (Build step + * 3 at full separation), model units from where it sits assembled. Nodes + * not listed stay put. The case's own parts, the LED panel, the knobs, the + * board and the DevKit alike. + */ + explode: Record; + /** When the case comes in more than one material: the first is the file's. */ + finishes?: CaseFinish[]; + /** + * Top-level nodes that come with the case but are not on the device as it + * stands, like feet for laying it flat: drawn with the parts to make (Build + * step 1) and on Assemble (step 3), and not on the device once it is + * powered. + */ + loose?: string[]; +} + +export const CASE_MODELS: Record = { + official: OFFICIAL_MODEL, + 'besoiobiy-printed': BESOIOBIY_MODEL, + 'simonepda-lasercut': SIMONEPDA_MODEL, +}; + +/** Where the Draco decoder comes from: the same copy the guide's models use. */ +export const DRACO_DECODER = 'https://www.gstatic.com/draco/versioned/decoders/1.5.7/'; + +/** The parts every model has, by name (see above). */ +export const LED_NODE = 'l'; +export const KNOB_NODES = ['c1', 'c2', 'c3', 'c4'] as const; +export const PCB_NODE = 'pcb_v39'; +export const DEVKIT_NODE = 'devkit'; diff --git a/web/src/components/3d/cases/besoiobiy-printed.ts b/web/src/components/3d/cases/besoiobiy-printed.ts new file mode 100644 index 000000000..8f7778ae0 --- /dev/null +++ b/web/src/components/3d/cases/besoiobiy-printed.ts @@ -0,0 +1,36 @@ +import type { CaseModel } from '../caseModels'; + +// Besoiobiy's printed case: tools/case-models/besoiobiy.py, from the parts in +// hardware/case/remixes/besoiobiy-printed/source/patternbox.stl put together. +// A frame round the LED panel and a box beside it for the board, each printed +// as a top and a bottom half and glued, closed at the back by four flat +// covers. The knobs are Besoiobiy's own caps. +export const BESOIOBIY_MODEL: CaseModel = { + id: 'besoiobiy-printed', + url: '/cases/besoiobiy-printed/model.glb', + // Drawn apart the way it goes together. The four halves open a little at + // their glue joints, top from bottom and box from frame, so the joints and + // their keys show; whatever is held in the box (the knobs, the board, the + // DevKit, its covers) moves with the box's half. The panel goes into the + // frame from the front and the knobs onto the shafts from the front, so + // both come off that way. Behind the box the board comes out, the DevKit + // comes off its sockets, and the covers come off last, all four together + // as the back of the case, each clear of what is in front of it. + explode: { + frame_top: [0, 1, 0], + frame_bottom: [0, -1, 0], + box_top: [1, 1, 0], + box_bottom: [1, -1, 0], + l: [0, 0, 6.4], + c1: [1, 1, 4.2], + c2: [1, 1, 4.2], + c3: [1, 1, 4.2], + c4: [1, 1, 4.2], + pcb_v39: [1, 1, -6.8], + devkit: [1, 1, -11.2], + cover_frame_top: [0, 1, -15.5], + cover_frame_bottom: [0, -1, -15.5], + cover_box_top: [1, 1, -15.5], + cover_box_bottom: [1, -1, -15.5], + }, +}; diff --git a/web/src/components/3d/cases/official.ts b/web/src/components/3d/cases/official.ts new file mode 100644 index 000000000..5ea9a6785 --- /dev/null +++ b/web/src/components/3d/cases/official.ts @@ -0,0 +1,27 @@ +import type { CaseModel } from '../caseModels'; + +// The official printed case, v3.9: tools/case-models/assemble.mjs official, +// from the guide's case-v39.glb (exported from +// hardware/case/source/patternflow_case.blend). +export const OFFICIAL_MODEL: CaseModel = { + id: 'official', + url: '/cases/official/model.glb', + // The guide's exploded device (components/guide/stage/explodeParts.ts): + // each part along the way it really goes on or comes off, told from the + // body, which stays. The knobs and the panel come off the front, the lid + // slides out sideways over the power-bank tray, and behind the body the + // board, the DevKit and the back plate with its cover come away in that + // order. + explode: { + c1: [0, 0, 4.2], + c2: [0, 0, 4.2], + c3: [0, 0, 4.2], + c4: [0, 0, 4.2], + l: [0, 0, 6.4], + top_lid: [7.2, 0, 2.2], + pcb_v39: [0, 0, -6.8], + devkit: [0, 0, -11.2], + back_plate: [0, 0, -15.5], + back_slider: [5.4, 0, -15.5], + }, +}; diff --git a/web/src/components/3d/cases/simonepda-lasercut.ts b/web/src/components/3d/cases/simonepda-lasercut.ts new file mode 100644 index 000000000..a0d41e12b --- /dev/null +++ b/web/src/components/3d/cases/simonepda-lasercut.ts @@ -0,0 +1,61 @@ +import type { CaseModel } from '../caseModels'; + +// Simone Majocchi's laser-cut case: tools/case-models/simonepda.py, from the +// drawing in hardware/case/remixes/simonepda-lasercut/lasercut_layout.pdf. +// One sheet carries everything: the LED panel on its front inside four strips +// standing on edge, the board behind it under a finger-jointed acrylic box, +// and three open cubes on its back as feet. +export const SIMONEPDA_MODEL: CaseModel = { + id: 'simonepda-lasercut', + url: '/cases/simonepda-lasercut/model.glb', + // Drawn apart around the sheet, which stays put: the knobs and the panel + // come off the front (the knobs as far as on the other two cases; further, + // they cross in front of the panel in the three-quarter view), the strips + // open out round where the panel was, and behind the sheet the board, the + // DevKit, the box's walls, its lid and the feet come away in that order, + // each clear of the one in front of it. The walls also step outward so + // their finger joints show. + explode: { + c1: [0, 0, 4.2], + c2: [0, 0, 4.2], + c3: [0, 0, 4.2], + c4: [0, 0, 4.2], + l: [0, 0, 6], + strip_left: [-2.5, 0, 3], + strip_right: [2.5, 0, 3], + strip_top: [0, 2.5, 3], + strip_bottom: [0, -2.5, 3], + pcb_v39: [0, 0, -2.5], + devkit: [0, 0, -5], + box_wall_left: [-1.2, 0, -9], + box_wall_right: [1.2, 0, -9], + box_wall_top: [0, 1.2, -9], + box_wall_bottom: [0, -1.2, -9], + box_lid: [0, 0, -11], + foot_1: [0, 0, -12], + foot_2: [0, 0, -12], + foot_3: [0, 0, -12], + }, + // The three open cubes are feet for laying the case flat on a table, and + // neither build in the remix's photos has them on. In the model they sit on + // the sheet's back at three corners, two poking out past the panel's and + // one beside the box, and the clear sheet shows all three from the front. + // They are cut with the rest and shown with it, on Assemble too, but not on + // the device standing and lit. + loose: ['foot_1', 'foot_2', 'foot_3'], + // The file is the clear acrylic build. The MDF one is the same drawing cut + // from 2.8 mm board: a light tan face, and edges the laser burns dark brown + // (the colours of the remix's photos). The box over the board is acrylic in + // both, so only the sheet's own materials change. + finishes: [ + { id: 'acrylic', label: 'Acrylic' }, + { + id: 'mdf', + label: 'MDF', + looks: { + sheet_face: { color: '#dcc19c', roughness: 0.9 }, + sheet_edge: { color: '#3d2b1d', roughness: 0.95 }, + }, + }, + ], +}; diff --git a/web/src/components/3d/heroCase.test.ts b/web/src/components/3d/heroCase.test.ts new file mode 100644 index 000000000..b161d5904 --- /dev/null +++ b/web/src/components/3d/heroCase.test.ts @@ -0,0 +1,200 @@ +import * as THREE from 'three'; +import { describe, expect, it } from 'vitest'; +import type { CaseModel } from './caseModels'; +import { applyFinish, disposeCase, FRAME_CENTRE, knobUnder, prepareCase } from './heroCase'; + +// The preview's copy of a case model, on a model made here rather than read +// from a GLB (jsdom has no WebGL, and none is needed: this is the scene graph +// and its materials). The shape is the node contract in caseModels.ts: an LED +// panel, four knobs sharing one material as every file has them, the board, +// the DevKit, and the case's own parts. + +const std = (name: string, color = '#ffffff') => { + const m = new THREE.MeshStandardMaterial({ color, roughness: 0.5 }); + m.name = name; + return m; +}; + +function cachedScene() { + const scene = new THREE.Group(); + const add = (name: string, geometry: THREE.BufferGeometry, material: THREE.Material, at: [number, number, number]) => { + const mesh = new THREE.Mesh(geometry, material); + mesh.name = name; + mesh.position.set(...at); + scene.add(mesh); + return mesh; + }; + add('plate', new THREE.BoxGeometry(25, 33, 0.3), std('sheet_face', '#eef4f2'), [0, 16, 1.4]); + add('l', new THREE.BoxGeometry(16, 32, 1.7), std('Black', '#000000'), [-4, 16, 2.5]); + const knob = std('pla_black', '#151515'); + const cylinder = new THREE.CylinderGeometry(0.8, 0.8, 2, 16).rotateX(Math.PI / 2).translate(0, 0, 1); + add('c1', cylinder, knob, [6.5, 29.7, 1.6]); + add('c2', cylinder, knob, [9.6, 29.7, 1.6]); + add('c3', cylinder, knob, [6.5, 26.6, 1.6]); + add('c4', cylinder, knob, [9.6, 26.6, 1.6]); + const pcb = new THREE.Group(); + pcb.name = 'pcb_v39'; + pcb.add(new THREE.Mesh(new THREE.BoxGeometry(6, 11, 0.16), std('pcb_green', '#08530f'))); + pcb.position.set(8, 25, 0.6); + scene.add(pcb); + const kit = new THREE.Group(); + kit.name = 'devkit'; + const esp = new THREE.Mesh(new THREE.BoxGeometry(2.6, 5.5, 0.3), std('metal.module')); + esp.name = 'module'; + kit.add(esp); + kit.position.set(8, 22.5, -0.8); + scene.add(kit); + return scene; +} + +const MODEL: CaseModel = { + id: 'simonepda-lasercut', + url: '/cases/simonepda-lasercut/model.glb', + explode: { c1: [0, 0, 4.2], l: [0, 0, 6] }, + finishes: [ + { id: 'acrylic', label: 'Acrylic' }, + { id: 'mdf', label: 'MDF', looks: { sheet_face: { color: '#dcc19c', roughness: 0.9 } } }, + ], +}; + +const meshNamed = (root: THREE.Object3D, name: string) => root.getObjectByName(name) as THREE.Mesh; +const materialOf = (root: THREE.Object3D, name: string) => meshNamed(root, name).material as THREE.MeshStandardMaterial; + +describe('prepareCase', () => { + it('works on a copy: the cached scene keeps its own materials and places', () => { + const scene = cachedScene(); + const before = new Map(); + scene.traverse((o) => { + if ((o as THREE.Mesh).isMesh) before.set(o.name || o.parent!.name, (o as THREE.Mesh).material as THREE.Material); + }); + const prepared = prepareCase(scene, MODEL); + expect(prepared.root).not.toBe(scene); + scene.traverse((o) => { + if ((o as THREE.Mesh).isMesh) expect((o as THREE.Mesh).material).toBe(before.get(o.name || o.parent!.name)); + }); + // Nothing the preview changes is shared with the cache. + for (const name of ['plate', 'l', 'c1', 'module']) { + expect(materialOf(prepared.root, name)).not.toBe(materialOf(scene, name)); + } + expect(meshNamed(scene, 'c1').children).toHaveLength(0); + }); + + it('sorts the parts by the node contract', () => { + const prepared = prepareCase(cachedScene(), MODEL); + const roles = Object.fromEntries(prepared.parts.map((p) => [p.node.name, p.role])); + expect(roles).toEqual({ + plate: 'shell', + l: 'led', + c1: 'knob', + c2: 'knob', + c3: 'knob', + c4: 'knob', + pcb_v39: 'pcb', + devkit: 'devkit', + }); + expect(prepared.parts.find((p) => p.node.name === 'c1')!.apart.toArray()).toEqual([0, 0, 4.2]); + expect(prepared.parts.find((p) => p.node.name === 'plate')!.apart.toArray()).toEqual([0, 0, 0]); + expect(prepared.led.map((m) => m.name)).toEqual(['l']); + }); + + it('gives each knob a material of its own, so only the held one greys', () => { + const prepared = prepareCase(cachedScene(), MODEL); + expect(prepared.knobs.map((k) => k.node.name)).toEqual(['c1', 'c2', 'c3', 'c4']); + const materials = prepared.knobs.map((k) => materialOf(prepared.root, k.node.name)); + expect(new Set(materials).size).toBe(4); + // What the preview tints is the knob's own material, and only that. + prepared.knobs.forEach((k, i) => expect(k.tints.map((t) => t.material)).toEqual([materials[i]])); + prepared.knobs[0].tints[0].material.color.setHex(0x666666); + expect(materials[1].color.getHex()).toBe(0x151515); + }); + + it('finds the knob from its ring or its touch disc', () => { + const prepared = prepareCase(cachedScene(), MODEL); + const c3 = prepared.knobs[2]; + const disc = c3.node.getObjectByName('knob_hit')!; + expect(knobUnder(prepared, disc)).toBe(c3); + expect(knobUnder(prepared, c3.ring.children[0])).toBe(c3); + expect(knobUnder(prepared, meshNamed(prepared.root, 'plate'))).toBeNull(); + }); + + it('centres every case on the preview’s frame', () => { + const prepared = prepareCase(cachedScene(), MODEL); + const box = new THREE.Box3(); + 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); + }); + + it('draws every case at one scale: a bigger case does not shrink the panel in it', () => { + const small = prepareCase(cachedScene(), MODEL); + const scene = cachedScene(); + const plate = scene.getObjectByName('plate') as THREE.Mesh; + plate.geometry = new THREE.BoxGeometry(27, 35, 0.3); + const big = prepareCase(scene, MODEL); + const panel = (c: typeof small) => c.parts.find((p) => p.role === 'led')!.box.getSize(new THREE.Vector3()); + expect(panel(big).toArray()).toEqual(panel(small).toArray()); + [16, 32, 1.7].forEach((size, i) => expect(panel(big).getComponent(i)).toBeCloseTo(size, 5)); + // Placing it is a move and nothing else: the copy is not scaled, and + // there is no scale for the preview to put on it (it used to get one, + // fitScale, that drew a bigger case smaller). + expect(big.root.scale.toArray()).toEqual([1, 1, 1]); + expect(Object.keys(big)).not.toContain('fitScale'); + }); + + it('marks the case’s loose pieces, and only those', () => { + const scene = cachedScene(); + const foot = new THREE.Mesh(new THREE.BoxGeometry(3, 3, 3), std('sheet_face')); + foot.name = 'foot_1'; + foot.position.set(10, 2, -2); + scene.add(foot); + const prepared = prepareCase(scene, { ...MODEL, loose: ['foot_1'] }); + const loose = prepared.parts.filter((p) => p.loose).map((p) => p.node.name); + expect(loose).toEqual(['foot_1']); + expect(prepared.parts.find((p) => p.node.name === 'foot_1')!.role).toBe('shell'); + }); +}); + +describe('applyFinish', () => { + it('lays a finish over the file’s look by material name, and takes it off again', () => { + const prepared = prepareCase(cachedScene(), MODEL); + const plate = materialOf(prepared.root, 'plate'); + const own = plate.color.getHex(); + applyFinish(prepared, MODEL.finishes![1]); + expect(`#${plate.color.getHexString()}`).toBe('#dcc19c'); + expect(plate.roughness).toBe(0.9); + // Only the material it names. + expect(materialOf(prepared.root, 'c1').color.getHex()).toBe(0x151515); + applyFinish(prepared, MODEL.finishes![0]); + expect(plate.color.getHex()).toBe(own); + expect(plate.roughness).toBe(0.5); + }); + + it('draws a see-through look blended, without writing depth or casting a shadow', () => { + const prepared = prepareCase(cachedScene(), MODEL); + applyFinish(prepared, { id: 'clear', label: 'Clear', looks: { sheet_face: { color: '#ffffff', roughness: 0.1, opacity: 0.3 } } }); + const plate = materialOf(prepared.root, 'plate'); + expect(plate.transparent).toBe(true); + expect(plate.depthWrite).toBe(false); + expect(meshNamed(prepared.root, 'plate').castShadow).toBe(false); + applyFinish(prepared, undefined); + expect(plate.transparent).toBe(false); + expect(plate.depthWrite).toBe(true); + expect(meshNamed(prepared.root, 'plate').castShadow).toBe(true); + }); +}); + +describe('disposeCase', () => { + it('lets go of what the copy made, and not of the cached geometry', () => { + const scene = cachedScene(); + const prepared = prepareCase(scene, MODEL); + const knobMaterial = materialOf(prepared.root, 'c2'); + const cached = meshNamed(scene, 'plate').geometry; + let materialGone = false; + let geometryGone = false; + knobMaterial.addEventListener('dispose', () => (materialGone = true)); + cached.addEventListener('dispose', () => (geometryGone = true)); + disposeCase(prepared); + expect(materialGone).toBe(true); + expect(geometryGone).toBe(false); + }); +}); diff --git a/web/src/components/3d/heroCase.ts b/web/src/components/3d/heroCase.ts new file mode 100644 index 000000000..d6e8a9a62 --- /dev/null +++ b/web/src/components/3d/heroCase.ts @@ -0,0 +1,339 @@ +import * as THREE from 'three'; +import { + DEVKIT_NODE, + KNOB_NODES, + LED_NODE, + PCB_NODE, + type CaseFinish, + type CaseModel, +} from './caseModels'; + +// The product preview's copy of a case model (caseModels.ts), made ready to +// be drawn: its parts sorted by what they are, measured, and placed so every +// case stands where the official one does, at the same scale. HeroScene animates what this +// returns; this file only builds and tears it down. +// +// drei keeps one parsed scene per GLB and hands the same one to every +// component that asks — the guide's stage among them — so nothing here +// touches it: the preview works on a deep clone, with its own materials. +// Geometry stays shared; nothing here changes a vertex. + +/** What a top-level node of a case model is, by its name (caseModels.ts). */ +export type PartRole = 'shell' | 'knob' | 'led' | 'pcb' | 'devkit'; + +export interface CasePart { + node: THREE.Object3D; + role: PartRole; + /** Where it sits assembled, and its own scale, as the file has them. */ + home: THREE.Vector3; + homeScale: THREE.Vector3; + /** + * A piece that comes with the case but is not on the device as it stands + * (CaseModel.loose): drawn with the parts to make and on Assemble only. + */ + loose: boolean; + /** Its box, assembled, in the model's frame. */ + box: THREE.Box3; + /** The middle of that box: a part that leaves the picture shrinks into it. */ + centre: THREE.Vector3; + /** Where it goes when the device is drawn fully apart (CaseModel.explode), from home. */ + apart: THREE.Vector3; + /** As drawn this frame: how far from home, and how present (0 gone, 1 there). */ + offset: THREE.Vector3; + presence: number; +} + +export interface KnobRig { + node: THREE.Object3D; + /** The red ring and its dot, shown while this knob is held. */ + ring: THREE.Object3D; + /** The knob's materials and their own colours, to grey it while held. */ + tints: { material: THREE.MeshStandardMaterial; own: THREE.Color }[]; + /** From the knob's base (its origin) to its top face, along its axis (+z). */ + height: number; +} + +/** A material's look as the file has it, so a finish can be laid over it and taken off again. */ +interface OwnLook { + material: THREE.MeshStandardMaterial; + color: THREE.Color; + roughness: number; + opacity: number; + transparent: boolean; + depthWrite: boolean; +} + +export interface PreparedCase { + model: CaseModel; + /** The clone, in the model's frame (one unit is 10 mm). */ + root: THREE.Group; + parts: CasePart[]; + knobs: KnobRig[]; + /** The LED panel's meshes: the pattern is drawn on them. */ + led: THREE.Mesh[]; + /** Centres the model on FRAME_CENTRE: where the model's frame sits in the preview's. */ + fitPosition: THREE.Vector3; + /** The board with the DevKit on it, assembled, in the model's frame. */ + board: THREE.Box3; + looks: OwnLook[]; + /** What this copy made for itself, to let go of when it is unmounted. */ + owned: { dispose(): void }[]; +} + +/** + * Where every case's device is centred, in model units: the orbit target, + * (0, 1.7, 0), at the preview's resting scale of 0.1. Every case's box is + * centred here, so a case swaps in exactly where the last one stood. + */ +export const FRAME_CENTRE = new THREE.Vector3(0, 17, 0); + +/** The knob's ring: its radius against the knob's, and how far up the knob it sits. */ +const RING_RADIUS = 1.5; +const RING_HEIGHT = 0.4; +const RING_COLOR = 0xff3333; + +/** + * What a finger or pointer can take hold of: a disc over each knob's top, a + * little under half the 31 mm between knob axes (model units). A knob drawn + * on a phone is a few pixels across, too small to find by touch. + */ +const KNOB_HIT_RADIUS = 1.4; + +function roleOf(name: string): PartRole { + if (name === LED_NODE) return 'led'; + if ((KNOB_NODES as readonly string[]).includes(name)) return 'knob'; + if (name === PCB_NODE) return 'pcb'; + if (name === DEVKIT_NODE) return 'devkit'; + return 'shell'; +} + +const meshesOf = (node: THREE.Object3D) => { + const out: THREE.Mesh[] = []; + node.traverse((o) => { + if ((o as THREE.Mesh).isMesh) out.push(o as THREE.Mesh); + }); + return out; +}; + +const materialsOf = (mesh: THREE.Mesh) => (Array.isArray(mesh.material) ? mesh.material : [mesh.material]); + +/** A node's meshes' box in the node's own frame: the knob's, about its axis. */ +function localBox(node: THREE.Object3D) { + node.updateWorldMatrix(true, true); + const toLocal = node.matrixWorld.clone().invert(); + const rel = new THREE.Matrix4(); + const box = new THREE.Box3(); + for (const mesh of meshesOf(node)) { + if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox(); + box.union(mesh.geometry.boundingBox!.clone().applyMatrix4(rel.multiplyMatrices(toLocal, mesh.matrixWorld))); + } + return box; +} + +/** + * Makes the preview's copy of a loaded case model. `scene` is drei's cached + * scene and is only read. + */ +export function prepareCase(scene: THREE.Object3D, model: CaseModel): PreparedCase { + const root = scene.clone(true) as THREE.Group; + root.position.set(0, 0, 0); + root.quaternion.identity(); + root.scale.set(1, 1, 1); + root.updateMatrixWorld(true); + const owned: { dispose(): void }[] = []; + + // Every material is this copy's own: the knobs grey while held, a finish + // recolours the case, the LED panel's is swapped for the pattern's — none + // of which may reach the cached scene, or another case, or the guide. + const copies = new Map(); + root.traverse((o) => { + const mesh = o as THREE.Mesh; + if (!mesh.isMesh) return; + const copy = (m: THREE.Material) => { + let c = copies.get(m); + if (!c) { + c = m.clone(); + copies.set(m, c); + owned.push(c); + } + return c; + }; + mesh.material = Array.isArray(mesh.material) ? mesh.material.map(copy) : copy(mesh.material); + mesh.castShadow = true; + mesh.receiveShadow = true; + }); + + const looks: OwnLook[] = []; + for (const m of copies.values()) { + const std = m as THREE.MeshStandardMaterial; + if (!std.isMeshStandardMaterial) continue; + looks.push({ + material: std, + color: std.color.clone(), + roughness: std.roughness, + opacity: std.opacity, + transparent: std.transparent, + depthWrite: std.depthWrite, + }); + } + + const parts: CasePart[] = root.children.map((node) => { + const box = new THREE.Box3().setFromObject(node); + const apart = model.explode[node.name]; + return { + node, + role: roleOf(node.name), + loose: model.loose?.includes(node.name) ?? false, + home: node.position.clone(), + homeScale: node.scale.clone(), + box, + centre: box.getCenter(new THREE.Vector3()), + apart: apart ? new THREE.Vector3(...apart) : new THREE.Vector3(), + offset: new THREE.Vector3(), + presence: 1, + }; + }); + + // 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). + const device = new THREE.Box3(); + parts.forEach((p) => device.union(p.box)); + const fitPosition = FRAME_CENTRE.clone().sub(device.getCenter(new THREE.Vector3())); + + const board = new THREE.Box3(); + parts.filter((p) => p.role === 'pcb' || p.role === 'devkit').forEach((p) => board.union(p.box)); + // A model without the board (it should not happen: assemble.mjs adds it to + // every case) frames the device instead. + if (board.isEmpty()) board.copy(device); + + const led = parts.filter((p) => p.role === 'led').flatMap((p) => meshesOf(p.node)); + + // The ring and its dot, the same for all four knobs; and the hit discs. + const ringMat = new THREE.MeshStandardMaterial({ + color: RING_COLOR, + emissive: 0xff0000, + emissiveIntensity: 2.0, + transparent: true, + opacity: 0.9, + }); + const hitMat = new THREE.MeshBasicMaterial({ visible: false }); + const hitGeo = new THREE.CircleGeometry(KNOB_HIT_RADIUS, 32); + owned.push(ringMat, hitMat, hitGeo); + + const knobs: KnobRig[] = KNOB_NODES.flatMap((name) => { + const node = root.children.find((c) => c.name === name); + if (!node) return []; + const box = localBox(node); + const radius = (box.max.x - box.min.x) / 2; + const height = box.max.z - box.min.z; + + // A material of its own: the file's four knobs share one, and only the + // knob being held greys. Its look is kept with the rest, so a finish + // that names it still reaches it. + for (const mesh of meshesOf(node)) { + const own = (m: THREE.Material) => { + const c = m.clone(); + owned.push(c); + const std = c as THREE.MeshStandardMaterial; + if (std.isMeshStandardMaterial) { + looks.push({ + material: std, + color: std.color.clone(), + roughness: std.roughness, + opacity: std.opacity, + transparent: std.transparent, + depthWrite: std.depthWrite, + }); + } + return c; + }; + mesh.material = Array.isArray(mesh.material) ? mesh.material.map(own) : own(mesh.material); + } + + // A ring round the knob, a little below half its height, with a bead + // riding it: it turns with the knob, so the plain black cylinder shows + // that it is turning. The knob's axis is its local +z, so the ring lies + // in its xy plane as the torus is made. + const ring = new THREE.Group(); + ring.name = 'knob-ring-group'; + ring.position.set(0, 0, box.min.z + height * RING_HEIGHT); + const torusGeo = new THREE.TorusGeometry(radius * RING_RADIUS, radius * 0.08, 12, 48); + const dotGeo = new THREE.SphereGeometry(radius * 0.4, 16, 16); + owned.push(torusGeo, dotGeo); + const torus = new THREE.Mesh(torusGeo, ringMat); + const dot = new THREE.Mesh(dotGeo, ringMat); + dot.position.set(radius * RING_RADIUS, 0, 0); + ring.add(torus, dot); + ring.visible = false; + node.add(ring); + + const hit = new THREE.Mesh(hitGeo, hitMat); + hit.name = 'knob_hit'; + hit.position.z = box.max.z + 0.02; + node.add(hit); + + const tints = meshesOf(node) + .filter((m) => m !== hit && m !== torus && m !== dot) + .flatMap(materialsOf) + .filter((m): m is THREE.MeshStandardMaterial => (m as THREE.MeshStandardMaterial).isMeshStandardMaterial) + .map((material) => ({ material, own: material.color.clone() })); + return [{ node, ring, tints, height: box.max.z }]; + }); + + return { model, root, parts, knobs, led, fitPosition, board, looks, owned }; +} + +/** The knob a pointer is on, if it is on one: the knob itself, its ring or its hit disc. */ +export function knobUnder(prepared: PreparedCase, object: THREE.Object3D | null): KnobRig | null { + for (let o = object; o; o = o.parent) { + const rig = prepared.knobs.find((k) => k.node === o); + if (rig) return rig; + } + return null; +} + +/** + * Lays a finish over the file's own look (CaseFinish.looks, by material + * name), or takes it off again with no finish or the first. A look below full + * opacity is drawn see-through: blended, not writing depth, so what is behind + * it — the board under a clear acrylic box — still shows; and a see-through + * part casts no shadow, since a clear sheet barely does. + */ +export function applyFinish(prepared: PreparedCase, finish: CaseFinish | undefined) { + for (const own of prepared.looks) { + const m = own.material; + const look = finish?.looks?.[m.name]; + const wasTransparent = m.transparent; + if (look) { + m.color.set(look.color); + m.roughness = look.roughness; + const opacity = look.opacity ?? 1; + m.opacity = opacity; + m.transparent = opacity < 1; + m.depthWrite = opacity >= 1; + } else { + m.color.copy(own.color); + m.roughness = own.roughness; + m.opacity = own.opacity; + m.transparent = own.transparent; + m.depthWrite = own.depthWrite; + } + if (m.transparent !== wasTransparent) m.needsUpdate = true; + } + for (const p of prepared.parts) { + if (p.role !== 'shell') continue; + for (const mesh of meshesOf(p.node)) { + mesh.castShadow = !materialsOf(mesh).some((m) => m.transparent); + } + } + // The knobs' own colour is whatever the finish left them. + for (const k of prepared.knobs) k.tints.forEach((t) => t.own.copy(t.material.color)); +} + +/** Lets go of what prepareCase made; the cached scene's geometry stays. */ +export function disposeCase(prepared: PreparedCase) { + prepared.owned.forEach((o) => o.dispose()); +} diff --git a/web/src/components/3d/neutralToe.tsx b/web/src/components/3d/neutralToe.tsx new file mode 100644 index 000000000..03765cc30 --- /dev/null +++ b/web/src/components/3d/neutralToe.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { Effect } from 'postprocessing'; +import { useEffect, useMemo } from 'react'; + +// three's Neutral tone curve (Khronos PBR Neutral, NeutralToneMapping) does +// two things. Below its knee it takes an offset off every channel: 0.04, or, +// where a colour's smallest channel x is under 0.08, x − 6.25x², its toe — +// meant to take off the 4% a glossy surface reflects, so a base colour shows +// as authored. From the knee up it rolls the peak off toward white, the +// channels together. The preview wants only the second: here the toe crushed +// the dark things to black and saturated them — the knobs to 1/255, the +// panel's dim LEDs, MDF's burnt edges to orange. +// +// NeutralToeBack, just before the curve, adds back exactly what the toe will +// take, so below the knee the frame is as it was lit, and the curve only +// rolls off what is brighter. On each pixel's own colour (StraightAlpha). + +const toeBackFrag = /* glsl */ ` +void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { + vec3 c = max(inputColor.rgb, 0.0); + float x = min(c.r, min(c.g, c.b)); + // The smallest channel that the toe takes back down to x. + float lifted = x < 0.04 ? 0.4 * sqrt(x) : x + 0.04; + outputColor = vec4(c + (lifted - x), inputColor.a); +} +`; + +class NeutralToeBackEffect extends Effect { + constructor() { + super('NeutralToeBackEffect', toeBackFrag); + } +} + +/** Before the Neutral tone curve: what its toe takes off, added back. */ +export function NeutralToeBack() { + const effect = useMemo(() => new NeutralToeBackEffect(), []); + useEffect(() => () => effect.dispose(), [effect]); + return ; +} diff --git a/web/src/components/3d/patterns/common.ts b/web/src/components/3d/patterns/common.ts index 323262057..1d4f97e7a 100644 --- a/web/src/components/3d/patterns/common.ts +++ b/web/src/components/3d/patterns/common.ts @@ -6,6 +6,104 @@ void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`; +// The LED panel's mesh in the case models ("l") is the whole panel: its face, +// and its moulded rim, sides and back. ledVert hands ledPanel what it needs to +// tell them apart and to light the frame. +export const ledVert = ` +varying vec2 vUv; +varying vec3 vObjN; +varying vec3 vViewN; +varying vec3 vViewPos; +void main() { + vUv = uv; + vObjN = normal; + vec4 mv = modelViewMatrix * vec4(position, 1.0); + vViewPos = mv.xyz; + vViewN = normalMatrix * normal; + gl_Position = projectionMatrix * mv; +}`; + +/** + * What a pattern's colour for one LED looks like on the panel: GLSL defining + * vec3 ledPanel(vec3 col), for a pattern's fragment shader to finish with + * (it declares vUv itself; this adds ledVert's other varyings). + * + * As a lit panel looks, exposed for its lit LEDs: a grid of round emitters on + * a black mask, an LED that is off a dark package in it (an unpowered panel, + * photographed for itself, shows its packages light grey; the preview draws + * it unpowered with HeroScene's plain black material instead). Each + * emitter's edge is smoothed over a screen pixel; once an LED is smaller than + * about three pixels the dots would beat against the pixel grid, so each LED + * becomes its whole cell instead, which is how the preview always drew it. + * + * The colours reach the bloom and the tone curve (HeroScene) as light. An + * LED whose cell is past the bloom's threshold is a light: a white, or a + * bright yellow or cyan that the editor's path runs hot. It keeps all of its + * energy, glows as it did, and drawn as a dot carries its whole cell's + * light, so the bloom off a field of dots is what it was off the solid + * cells. Any other colour is held at the tone curve's knee, so it keeps its + * hue, as pure as before there was a curve. + * + * Off the face — the rim, the sides and the back — it is the frame's black + * plastic under the preview's key light, not the pattern smeared over it. + */ +export const ledPanel = ` +varying vec3 vObjN; +varying vec3 vViewN; +varying vec3 vViewPos; + +const vec2 LED_GRID = vec2(128.0, 64.0); +// The emitter's radius in its cell. +const float LED_R = 0.4; +// The bloom's threshold (HeroScene), by luminance: an LED whose cell is past +// it is a light. +const float LED_BLOOM = 2.0; +// Where any other colour tops out: at the tone curve's knee. Neutral +// compresses from 0.76 up and greys a colour only as far as it goes past +// that (0.8 red comes out 0.794 red, 0.0007 green). +const float LED_HUE_MAX = 0.8; +// A light drawn as a dot carries its whole cell's light: the bloom takes in +// what is over its threshold whole, so a field of dots glows as the solid +// cells did. +const float LED_HOT_DOT = 1.0 / (3.14159265 * LED_R * LED_R); + +vec3 ledPanel(vec3 col) { + vec3 n = normalize(vViewN); + vec3 v = normalize(-vViewPos); + // HeroScene's key light: above right, in front. + vec3 key = normalize((viewMatrix * vec4(0.306, 0.519, 0.799, 0.0)).xyz); + float diff = max(dot(n, key), 0.0); + + if (vObjN.y > -0.5) { + float spec = pow(max(dot(n, normalize(key + v)), 0.0), 28.0); + return vec3(0.010) + vec3(0.040) * diff + vec3(0.05) * spec; + } + + // A light keeps what it has; any other colour has the colour over its + // white held at the knee. + float hot = smoothstep(LED_BLOOM - 0.05, LED_BLOOM + 0.05, dot(col, vec3(0.2126, 0.7152, 0.0722))); + float white = min(col.r, min(col.g, col.b)); + vec3 hue = col - white; + hue *= min(1.0, LED_HUE_MAX / max(max(hue.r, max(hue.g, hue.b)), 1e-5)); + vec3 led = mix(hue + white, col, hot); + + vec2 g = vec2(vUv.y, 1.0 - vUv.x) * LED_GRID; + vec2 gw = fwidth(g); + // LEDs per screen pixel, along the worse of the two directions. + float fw = max(gw.x, gw.y); + float d = length(fract(g) - 0.5); + float edge = max(fw * 0.7, 0.06); + float emitter = 1.0 - smoothstep(LED_R - edge, LED_R + edge, d); + // Dots while an LED is about three pixels or more; its whole cell below that. + float dots = 1.0 - smoothstep(0.28, 0.42, fw); + float shape = mix(1.0, emitter * mix(1.0, LED_HOT_DOT, hot), dots); + + // Unlit: the black mask, and each LED's dark package in it. + vec3 body = vec3(0.004) + vec3(0.012) * mix(1.0, emitter, dots) + vec3(0.004) * diff; + return body + led * shape; +} +`; + // Pattern type definition export interface PatternDef { name: string; diff --git a/web/src/components/3d/patterns/patternFlowOriginal.ts b/web/src/components/3d/patterns/patternFlowOriginal.ts index e8e772fd5..6631dcfff 100644 --- a/web/src/components/3d/patterns/patternFlowOriginal.ts +++ b/web/src/components/3d/patterns/patternFlowOriginal.ts @@ -1,4 +1,4 @@ -import { PatternDef } from './common'; +import { ledPanel, PatternDef } from './common'; const fragmentShader = ` uniform float uTime; @@ -9,6 +9,7 @@ uniform float uParam3; // Mode (0.0 ~ 4.9) uniform float uParam4; // Freq (0.0 ~ 1.0) varying vec2 vUv; +${ledPanel} // HSV to RGB conversion vec3 hsv2rgb(vec3 c) { @@ -21,7 +22,6 @@ void main() { vec2 rotatedUV = vec2(vUv.y, 1.0 - vUv.x); vec2 gridUV = rotatedUV * vec2(128.0, 64.0); vec2 px = floor(gridUV); - vec2 localUV = fract(gridUV); float x = px.x; float y = 63.0 - px.y; @@ -88,17 +88,7 @@ void main() { } } - float dist2 = length(localUV - 0.5); - float circle = smoothstep(0.45, 0.35, dist2); - - float fw = fwidth(vUv.x) * 128.0; - float lodBlend = smoothstep(0.0, 0.29, fw); - float finalAlpha = mix(circle, 1.0, lodBlend); - - float unlit = 0.02; - col = mix(vec3(unlit), col, step(0.01, length(col))); - - gl_FragColor = vec4(col * finalAlpha, 1.0); + gl_FragColor = vec4(ledPanel(col), 1.0); } `; diff --git a/web/src/components/3d/patterns/patternWaveSaw.ts b/web/src/components/3d/patterns/patternWaveSaw.ts index e4d80b062..78527e4df 100644 --- a/web/src/components/3d/patterns/patternWaveSaw.ts +++ b/web/src/components/3d/patterns/patternWaveSaw.ts @@ -1,4 +1,4 @@ -import { PatternDef } from './common'; +import { ledPanel, PatternDef } from './common'; const fragmentShader = ` precision highp float; @@ -9,6 +9,7 @@ uniform float uParam3; // Dist (0..1 -> 0.0..4.0) uniform float uParam4; // dScale (0..1 -> 0.3..5.0) uniform float uAspect; varying vec2 vUv; +${ledPanel} // Perlin Noise helper functions float hash(vec2 p) { @@ -43,7 +44,6 @@ void main() { vec2 rotatedUV = vec2(vUv.y, 1.0 - vUv.x); vec2 gridUV = rotatedUV * vec2(128.0, 64.0); vec2 px = floor(gridUV); - vec2 localUV = fract(gridUV); float x = px.x; float y = 63.0 - px.y; @@ -90,16 +90,7 @@ void main() { col = vec3(0.0, 0.0, 1.0); } - // LED Circle Mask logic from original pattern for consistency - float distCircle = length(localUV - 0.5); - float circle = smoothstep(0.45, 0.35, distCircle); - - // LOD logic: fade gaps when far away - float fw = fwidth(vUv.x) * 128.0; - float lodBlend = smoothstep(0.0, 0.29, fw); - float finalAlpha = mix(circle, 1.0, lodBlend); - - gl_FragColor = vec4(col * finalAlpha, 1.0); + gl_FragColor = vec4(ledPanel(col), 1.0); } `; diff --git a/web/src/components/3d/preloadCaseModel.ts b/web/src/components/3d/preloadCaseModel.ts new file mode 100644 index 000000000..06f298b9c --- /dev/null +++ b/web/src/components/3d/preloadCaseModel.ts @@ -0,0 +1,13 @@ +import { useGLTF } from '@react-three/drei'; +import type { CaseId } from '@/components/sections/build-cases-data'; +import { CASE_MODELS, DRACO_DECODER } from './caseModels'; + +/** + * Starts loading a case's model before it is picked — on a hover or a focus + * of its tab — so the switch has it in drei's cache by the time the preview + * asks. Only the official model loads with the page; a remix's loads on the + * way to being chosen. + */ +export function preloadCaseModel(id: CaseId) { + useGLTF.preload(CASE_MODELS[id].url, DRACO_DECODER); +} diff --git a/web/src/components/guide/stage/Device.tsx b/web/src/components/guide/stage/Device.tsx index 902259cbe..3ef84e75c 100644 --- a/web/src/components/guide/stage/Device.tsx +++ b/web/src/components/guide/stage/Device.tsx @@ -50,13 +50,13 @@ import { forgetPillSize, NO_POINTER, placeTag } from "./tags"; import { VIEWS } from "./views"; // The Patternflow in the guide, put together from four files (parts.ts): the -// v3.9 enclosure and knobs from the case's Blender source, the landing page +// v3.9 enclosure and knobs from the case's Blender source, the official case // model's LED panel (nothing else of that model is used), the v3.9 board // exported from KiCad, and the ESP32 DevKit on its sockets. Its LED mesh // shows the simulated board's frame, its knobs turn, press and hold like the // real encoders, and the back slider comes off for the DevKit to come out. -// The landing model's GLB is shared with the landing page's HeroScene through -// drei's cache, so this works on clones and never touches a cached scene. +// The landing page's preview loads the same official case model, so this +// works on clones and never touches drei's cached scene. // A ring drawn on a plane: `uFill` of the circle as a bright arc (a hold on // its way to a long-press) over a faint full ring (the knob in focus). @@ -165,12 +165,13 @@ export default function Device() { const scene = useMemo(() => { const root = new THREE.Group(); root.name = "patternflow"; - // Of the landing page's model only the LED panel stays: its case, knobs - // and board are older than v3.9. + // Of the official case model only the LED panel is used: its case, board + // and DevKit are the guide's own files (parts.ts), which it carries + // simplified for the landing page's preview. const ledSrc = ledGltf.scene.getObjectByName("l"); if (ledSrc) { const led = ledSrc.clone(true); - // Start from the model's own pose even if the landing page left it mid-animation. + // Start from the file's own pose even if a page animating the cached scene left it mid-move. if (led.userData.originalX !== undefined) led.position.set(led.userData.originalX, led.userData.originalY, led.userData.originalZ); if (led.userData.originalScale) led.scale.copy(led.userData.originalScale); root.add(led); diff --git a/web/src/components/guide/stage/build/BuildStage.tsx b/web/src/components/guide/stage/build/BuildStage.tsx index 3e5b1d39a..fe26d3469 100644 --- a/web/src/components/guide/stage/build/BuildStage.tsx +++ b/web/src/components/guide/stage/build/BuildStage.tsx @@ -653,8 +653,8 @@ export default function BuildStage() { holder.jaws.push(jaw); } - // The LED panel, for everything but lighting up: the landing model's LED - // mesh in a plain dark finish, and its back. + // The LED panel, for everything but lighting up: the official case model's + // LED mesh in a plain dark finish, and its back. const panel = { group: add(new THREE.Group()), slab: null as THREE.Mesh | null, mats: [] as THREE.MeshStandardMaterial[] }; const slabMat = std("#0d0d0e", 0.62, 0, { transparent: true }); // The moulded frame is a glossier black than the LED face; the driver diff --git a/web/src/components/guide/stage/build/layout.ts b/web/src/components/guide/stage/build/layout.ts index 00bc175f5..6779dc52b 100644 --- a/web/src/components/guide/stage/build/layout.ts +++ b/web/src/components/guide/stage/build/layout.ts @@ -104,7 +104,7 @@ export const PANEL_BENCH: Pose = (() => { const m = new THREE.Matrix4().makeBasis(v(0, 0, 1), v(-1, 0, 0), v(0, -1, 0)); return { p: v(-24, MAT_TOP + 0.85, 28), q: new THREE.Quaternion().setFromRotationMatrix(m) }; })(); -/** The panel's place in the case: the landing model's LED mesh "l", whose front face is at z 1.4977 and back at −0.2023. */ +/** The panel's place in the case: the official case model's LED mesh "l", whose front face is at z 1.4977 and back at −0.2023. */ export const PANEL_HOME = { p: v(-4.1908, 16.2677, 1.4977), back: -0.2023 }; /** The panel's centre in the model frame when assembled. */ export const PANEL_CENTRE = v(-4.1908, 16.2677, 0.6477); diff --git a/web/src/components/guide/stage/build/props.ts b/web/src/components/guide/stage/build/props.ts index 97d383f8a..86e8aead3 100644 --- a/web/src/components/guide/stage/build/props.ts +++ b/web/src/components/guide/stage/build/props.ts @@ -72,7 +72,7 @@ export function nutGeometry() { // one — and the 4-pin power header below the band (layout.ts has the // numbers). Everything is in the panel's own frame: its // centre at the origin, LED face toward +z, top +y, as it sits in the case. -// The landing model's LED mesh ("l") already has the rim (18 mm wide, its +// The case models' LED mesh ("l") already has the rim (18 mm wide, its // back at z −0.85) and the recess (its floor, the driver board, at z +0.225); // what is built here stands in that recess and never comes proud of the rim — // the rim is what the case's ledge and tabs bear on. diff --git a/web/src/components/guide/stage/geometry.ts b/web/src/components/guide/stage/geometry.ts index 0694b4f0e..63b8c6b58 100644 --- a/web/src/components/guide/stage/geometry.ts +++ b/web/src/components/guide/stage/geometry.ts @@ -1,21 +1,23 @@ import * as THREE from "three"; -// Where things are on the model. The frame is public/3dforweb.glb's (the -// landing page's model, whose LED mesh the guide still uses); the v3.9 case, -// case-v39.glb, is exported into the same frame (parts.ts). One unit is -// 10 mm: the 320 × 160 mm LED panel is 32 × 16 units. +// Where things are on the model. Every model of the device shares one frame: +// the v3.9 case, case-v39.glb (parts.ts), and the landing page's case models +// (public/cases//model.glb, built by tools/case-models), of which the +// guide uses the official one's LED mesh. One unit is 10 mm: the +// 320 × 160 mm LED panel is 32 × 16 units. // // The device stands facing +z with the panel on the left and the knob column // on the right, knobs at the top — the portrait the firmware's screens are // drawn for. The stage scales the model by MODEL_SCALE and lifts it by // MODEL_OFFSET so its middle sits at the origin. -export const MODEL_URL = "/3dforweb.glb"; +/** The official case model: the guide takes its LED panel, "l", and nothing else. The landing page's preview loads the same file. */ +export const MODEL_URL = "/cases/official/model.glb"; export const DRACO_URL = "https://www.gstatic.com/draco/versioned/decoders/1.5.7/"; export const MODEL_SCALE = 0.1; export const MODEL_OFFSET = new THREE.Vector3(0, -1.65, 0); -// case-v39.glb's knob nodes keep the landing model's names, after the PCB +// case-v39.glb's knob nodes, like the case models', are named after the PCB // encoder nets. Seen from the front: c1 top-left, c2 top-right, c3 // bottom-left, c4 bottom-right; the device numbers them K1 top-right, K2 // top-left, K3 bottom-right, K4 bottom-left (docs/media/device-card.png, diff --git a/web/src/components/guide/stage/look/ledPanel.ts b/web/src/components/guide/stage/look/ledPanel.ts index 2c89242bd..89c30dc5f 100644 --- a/web/src/components/guide/stage/look/ledPanel.ts +++ b/web/src/components/guide/stage/look/ledPanel.ts @@ -20,7 +20,7 @@ import { PANEL_H, PANEL_W } from "@/lib/guide/panelScreens"; // a fifth of full power, as it is on the real panel after the driver's gamma, // and the room light, the bloom and the tone curve all work on light. // -// The mesh ("l" of the landing model) is the whole panel, not only its face: +// The mesh ("l" of the case models) is the whole panel, not only its face: // its rim, sides and back are the moulded black frame, and they are drawn as // that — dark plastic under the key light — instead of with the pattern // smeared over them, which showed when the device comes apart. diff --git a/web/src/components/guide/stage/parts.ts b/web/src/components/guide/stage/parts.ts index f46991c3c..d511ed25e 100644 --- a/web/src/components/guide/stage/parts.ts +++ b/web/src/components/guide/stage/parts.ts @@ -1,16 +1,16 @@ import * as THREE from "three"; -// The guide's device is four files put together in the model frame of -// 3dforweb.glb (see geometry.ts: the device stands facing +z, knobs top -// right, 10 mm per unit): +// The guide's device is four files put together in the model frame (see +// geometry.ts: the device stands facing +z, knobs top right, 10 mm per unit): // - case-v39.glb the v3.9 enclosure (body, back_slider, back_plate, // top_lid) and knobs c1..c4, exported from // hardware/case/source/patternflow_case.blend (collection // 0904_v3.9, its assembled copy) by the pure translation // (−300.2723, +58.4313, −1.9078) that lands the blend's LED -// panel exactly on 3dforweb.glb's -// - 3dforweb.glb the landing page's model, of which only that LED mesh, -// "l", is used +// panel exactly on the LED mesh below +// - model.glb public/cases/official/, the official case model the +// landing page's preview shows (tools/case-models), of +// which only the LED mesh, "l", is used // - pcb-v39.glb the v3.9 board, exported from hardware/pcb/kicad with // kicad-cli, placed so its four encoders sit under the knobs // - devkit.glb the ESP32-S3 DevKit, in a canonical frame: origin at the diff --git a/web/src/components/sections/BuildPanel.test.tsx b/web/src/components/sections/BuildPanel.test.tsx new file mode 100644 index 000000000..ef3247f5d --- /dev/null +++ b/web/src/components/sections/BuildPanel.test.tsx @@ -0,0 +1,368 @@ +import type { AnchorHTMLAttributes, ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +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 +// 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 +// writes the store and the card follows whatever the store holds. + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...rest + }: AnchorHTMLAttributes & { href: string; children: ReactNode }) => ( + + {children} + + ), +})); + +const capture = vi.fn(); +vi.mock('@/lib/posthogEvents', () => ({ + captureEvent: (...args: unknown[]) => capture(...args), +})); + +// The real one hands a GLB to drei's loader; jsdom has no WebGL, and the +// panel only needs to have asked. +const preload = vi.fn(); +vi.mock('@/components/3d/preloadCaseModel', () => ({ + preloadCaseModel: (...args: unknown[]) => preload(...args), +})); + +const content: SectionContent = { + title: 'Build your own.', + subtitle: 'Around US$100–200 and an hour of hands-on work.', + content: 'Every case here holds the same v3 board.', +}; + +const GH = 'https://github.com/engmung/Patternflow'; +const MAKERWORLD = + 'https://makerworld.com/en/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015'; + +beforeEach(() => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // The store outlives a render the way it outlives a panel on the page, so + // each test starts from a fresh page's. + useAppStore.setState({ buildStep: 0, explode: 1, buildCase: 'official', caseFinish: {} }); + window.history.replaceState(null, '', '/build'); + capture.mockClear(); + preload.mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const card = () => within(screen.getByRole('tabpanel')); +const readme = () => card().queryByRole('link', { name: /Read its README on GitHub/ }); +const tab = (name: RegExp) => screen.getByRole('tab', { name }); +const stepOne = () => screen.getAllByRole('button').find((el) => el.textContent?.startsWith('01')); +// The case row under Start here: step 01's title, then where it goes. +const startRow = (name: RegExp) => + within(screen.getByText('Start here').parentElement as HTMLElement).getByRole('link', { name }); + +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(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(); + expect(card().getByRole('heading', { name: 'The official printed case' })).toBeInTheDocument(); + expect(card().getByRole('link', { name: /One-click print profiles/ })).toHaveAttribute( + 'href', + expect.stringContaining('makerworld.com/en/models/3072492'), + ); + expect(card().queryByText(/The 3D model on this page is the official case/)).not.toBeInTheDocument(); + // The official case's guide is the build guide, not a README of its own. + expect(readme()).not.toBeInTheDocument(); + expect(card().getByText(/printing in section 4, assembly in section 6/)).toBeInTheDocument(); + expect(useAppStore.getState().buildCase).toBe('official'); + // Start here keeps the MakerWorld shortcut while the official case is on. + expect(startRow(/^Print the case\s*MakerWorld/)).toHaveAttribute('href', MAKERWORLD); + // The official case has its own check: the adjustable mount is not universal. + expect(card().getByText(/is not universal/)).toBeInTheDocument(); + expect(card().getByRole('link', { name: /How to check/ })).toHaveAttribute( + 'href', + `${GH}/blob/main/hardware/case/README.md#for_other_panels--using-a-different-led-panel`, + ); + expect(stepOne()).toHaveTextContent('Print the body in white PLA and the knobs in black'); + }); + + it("shows Besoiobiy's author, material, fit, photos and files at once", () => { + render(); + fireEvent.click(tab(/Besoiobiy/)); + expect(tab(/Besoiobiy/)).toHaveAttribute('aria-selected', 'true'); + expect(tab(/Official/)).toHaveAttribute('tabindex', '-1'); + const panel = card(); + expect(panel.getByRole('heading', { name: 'Besoiobiy’s printed case' })).toBeInTheDocument(); + expect(panel.getByText('Community remix')).toBeInTheDocument(); + expect(panel.getByRole('link', { name: 'Besoiobiy' })).toHaveAttribute('href', 'https://discord.gg/Vr9QtsxeTk'); + expect(panel.getByText(/Bambu Lab P1S/)).toBeInTheDocument(); + expect(panel.getByText(/M3 sockets on Besoiobiy’s hole pattern, ending 14\.35 mm behind the LED face/)).toBeInTheDocument(); + expect(panel.getByRole('link', { name: /How to check/ })).toHaveAttribute( + 'href', + `${GH}/blob/main/hardware/case/remixes/besoiobiy-printed/README.md#check-your-panel-first`, + ); + expect(panel.getByText(/takes M4 screws on a different pattern/)).toBeInTheDocument(); + // The preview now shows the picked case, so the card no longer says it + // is the official one; it says what the model of this one is made from. + expect(panel.queryByText(/The 3D model on this page is the official case/)).not.toBeInTheDocument(); + expect(panel.getByText('The 3D view puts it together from its STL files.')).toBeInTheDocument(); + expect(useAppStore.getState().buildCase).toBe('besoiobiy-printed'); + // The remix's guide is its README, and the card leads with it. + expect(readme()).toHaveAttribute( + 'href', + `${GH}/blob/main/hardware/case/remixes/besoiobiy-printed/README.md`, + ); + expect(panel.getByText(/It replaces sections 4 and 6 of the build guide/)).toBeInTheDocument(); + expect(panel.getAllByRole('img')).toHaveLength(2); + expect(panel.getByRole('link', { name: /README and every file/ })).toHaveAttribute( + 'href', + `${GH}/tree/main/hardware/case/remixes/besoiobiy-printed`, + ); + expect(panel.getByRole('link', { name: /print_layout\.3mf/ })).toHaveAttribute( + 'href', + `${GH}/blob/main/hardware/case/remixes/besoiobiy-printed/print_layout.3mf`, + ); + for (const link of panel.getAllByRole('link')) { + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noreferrer'); + } + // Step 01 and the Start here row follow the switch. + expect(stepOne()).toHaveTextContent('Print eight parts and four knob caps'); + expect(startRow(/^Print the case\s*besoiobiy-printed\//)).toHaveAttribute( + 'href', + `${GH}/tree/main/hardware/case/remixes/besoiobiy-printed`, + ); + expect(screen.queryByRole('link', { name: /MakerWorld/ })).not.toBeInTheDocument(); + expect(capture).toHaveBeenCalledWith('build_case_selected', { + case_id: 'besoiobiy-printed', + interaction: 'click', + surface: 'build_panel', + }); + fireEvent.click(readme() as HTMLElement); + expect(capture).toHaveBeenCalledWith('build_case_link_opened', { + case_id: 'besoiobiy-printed', + target: 'README.md', + surface: 'build_panel', + }); + }); + + it('moves along the tabs with the arrow keys, Home and End', () => { + render(); + const official = tab(/Official/); + official.focus(); + 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); + expect(tab(/SimonePDA/)).toHaveAttribute('aria-selected', 'true'); + expect(tab(/SimonePDA/)).toHaveFocus(); + expect(screen.getByRole('tabpanel')).toHaveAccessibleName(/SimonePDA/); + expect(card().getByRole('heading', { name: 'Simone Majocchi’s laser-cut case' })).toBeInTheDocument(); + expect(card().getByRole('link', { name: 'Simone Majocchi' })).toHaveAttribute('href', 'https://github.com/SimonePDA'); + expect(card().getByText(/2\.8 mm MDF, laser cut/)).toBeInTheDocument(); + expect(card().getByRole('link', { name: /lasercut_layout\.pdf/ })).toHaveAttribute( + 'href', + `${GH}/blob/main/hardware/case/remixes/simonepda-lasercut/lasercut_layout.pdf`, + ); + expect(readme()).toHaveAttribute('href', `${GH}/blob/main/hardware/case/remixes/simonepda-lasercut/README.md`); + expect(card().getByText(/built to the drawing’s sizes/)).toBeInTheDocument(); + expect(useAppStore.getState().buildCase).toBe('simonepda-lasercut'); + expect(stepOne()).toHaveTextContent('Cut the case'); + expect(startRow(/^Cut the case\s*simonepda-lasercut\//)).toHaveAttribute( + 'href', + `${GH}/tree/main/hardware/case/remixes/simonepda-lasercut`, + ); + fireEvent.keyDown(tab(/SimonePDA/), { 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(/Official/)).toHaveAttribute('aria-selected', 'true'); + expect(tab(/Official/)).toHaveFocus(); + // Home on the first tab picks nothing new, so it sends nothing: like a + // click on the selected tab. It still keeps the page from scrolling. + const selections = capture.mock.calls.filter(([name]) => name === 'build_case_selected').length; + expect(fireEvent.keyDown(tab(/Official/), { key: 'Home' })).toBe(false); + expect(tab(/Official/)).toHaveFocus(); + expect(capture.mock.calls.filter(([name]) => name === 'build_case_selected')).toHaveLength(selections); + // Any other key is left alone. + expect(fireEvent.keyDown(tab(/Official/), { key: 'Tab' })).toBe(true); + expect(capture).toHaveBeenCalledWith('build_case_selected', expect.objectContaining({ interaction: 'key' })); + }); + + it('writes ?case= while /build is on screen, and drops it for the official case', () => { + // Replaced, never pushed: Back would change the URL and not the card. + const push = vi.spyOn(window.history, 'pushState'); + render(); + fireEvent.click(tab(/SimonePDA/)); + expect(window.location.pathname + window.location.search).toBe('/build?case=simonepda-lasercut'); + fireEvent.click(tab(/Besoiobiy/)); + expect(window.location.pathname + window.location.search).toBe('/build?case=besoiobiy-printed'); + fireEvent.click(tab(/Official/)); + expect(window.location.pathname + window.location.search).toBe('/build'); + expect(push).not.toHaveBeenCalled(); + }); + + it('leaves the URL alone when the panel is mounted behind another tab', () => { + window.history.replaceState(null, '', '/pattern'); + render(); + fireEvent.click(tab(/Besoiobiy/)); + expect(window.location.pathname + window.location.search).toBe('/pattern'); + // The store still takes it: the preview on that tab shows the case too. + expect(useAppStore.getState().buildCase).toBe('besoiobiy-printed'); + }); + + it('opens on the case a /build?case= link names, and ignores one it does not know', () => { + window.history.replaceState(null, '', '/build?case=besoiobiy-printed'); + const { unmount } = render(); + expect(tab(/Besoiobiy/)).toHaveAttribute('aria-selected', 'true'); + expect(useAppStore.getState().buildCase).toBe('besoiobiy-printed'); + unmount(); + // A link is a fresh page, and a fresh page's store is on the official + // case: the case now outlives the panel, so reset it as a load would. + useAppStore.setState({ buildCase: 'official' }); + window.history.replaceState(null, '', '/build?case=steel'); + render(); + expect(tab(/Official/)).toHaveAttribute('aria-selected', 'true'); + expect(useAppStore.getState().buildCase).toBe('official'); + }); + + it('follows the store, so a case set anywhere else shows on the card', () => { + render(); + act(() => useAppStore.getState().setBuildCase('simonepda-lasercut')); + expect(tab(/SimonePDA/)).toHaveAttribute('aria-selected', 'true'); + expect(card().getByRole('heading', { name: 'Simone Majocchi’s laser-cut case' })).toBeInTheDocument(); + expect(stepOne()).toHaveTextContent('Cut the case'); + }); + + it('takes the case from the URL on Back and Forward, and keeps it under a URL with none', () => { + render(); + act(() => { + window.history.replaceState(null, '', '/build?case=simonepda-lasercut'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + expect(useAppStore.getState().buildCase).toBe('simonepda-lasercut'); + expect(tab(/SimonePDA/)).toHaveAttribute('aria-selected', 'true'); + // /pattern, /inside and / carry no case either; leaving for one of them + // must not put the preview back on the official case. + act(() => { + window.history.replaceState(null, '', '/pattern'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + expect(useAppStore.getState().buildCase).toBe('simonepda-lasercut'); + }); + + it('puts the kept case back in the URL when the Build tab comes on screen', () => { + // A remix picked earlier, then the reader went to Pattern: the panel was + // remounted behind it, and the Build tab button pushes a bare /build. + useAppStore.setState({ buildCase: 'besoiobiy-printed' }); + window.history.replaceState(null, '', '/pattern'); + const { rerender } = render(); + expect(window.location.pathname + window.location.search).toBe('/pattern'); + window.history.pushState(null, '', '/build'); + rerender(); + expect(window.location.pathname + window.location.search).toBe('/build?case=besoiobiy-printed'); + expect(tab(/Besoiobiy/)).toHaveAttribute('aria-selected', 'true'); + }); + + it('lets a case the URL names win when the Build tab comes back on screen', () => { + // Back to an earlier /build?case=: the tab can come on before the panel's + // own popstate listener has read the URL, and must not overwrite it. + useAppStore.setState({ buildCase: 'besoiobiy-printed' }); + window.history.replaceState(null, '', '/pattern'); + const { rerender } = render(); + window.history.replaceState(null, '', '/build?case=simonepda-lasercut'); + rerender(); + expect(window.location.pathname + window.location.search).toBe('/build?case=simonepda-lasercut'); + expect(useAppStore.getState().buildCase).toBe('simonepda-lasercut'); + expect(tab(/SimonePDA/)).toHaveAttribute('aria-selected', 'true'); + }); + + it("starts loading a case's model when a pointer or focus reaches its tab", () => { + render(); + fireEvent.pointerEnter(tab(/Besoiobiy/)); + expect(preload).toHaveBeenLastCalledWith('besoiobiy-printed'); + fireEvent.focus(tab(/SimonePDA/)); + expect(preload).toHaveBeenLastCalledWith('simonepda-lasercut'); + // The case on screen is loaded already. + preload.mockClear(); + fireEvent.pointerEnter(tab(/Official/)); + fireEvent.focus(tab(/Official/)); + expect(preload).not.toHaveBeenCalled(); + // Prefetching is not picking. + expect(useAppStore.getState().buildCase).toBe('official'); + expect(capture).not.toHaveBeenCalledWith('build_case_selected', expect.anything()); + }); + + it('opens every photo of the case in the viewer', () => { + render(); + fireEvent.click(tab(/SimonePDA/)); + fireEvent.click(card().getByRole('button', { name: 'All 4 photos' })); + const dialog = screen.getByRole('dialog', { name: 'Build photos' }); + expect(within(dialog).getAllByRole('button', { name: /MDF|acrylic|board/ }).length).toBeGreaterThanOrEqual(4); + fireEvent.click(within(dialog).getByRole('button', { name: 'Close gallery' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('a card photo opens the viewer on that photo', () => { + render(); + fireEvent.click(tab(/SimonePDA/)); + fireEvent.click(card().getByRole('button', { name: /cut in MDF and turned on its side/ })); + const dialog = screen.getByRole('dialog', { name: 'Build photos' }); + expect(within(dialog).getByRole('button', { name: /cut in MDF and turned on its side/ })).toHaveAttribute( + 'aria-current', + 'true', + ); + }); + + it('closes the viewer when the case changes under it, even past the new case’s last photo', () => { + // Focus can still reach the tabs behind the viewer. Photo 4 of a remix + // has no counterpart in the official case's three. + window.history.replaceState(null, '', '/build?case=besoiobiy-printed'); + render(); + fireEvent.click(card().getByRole('button', { name: 'All 4 photos' })); + fireEvent.keyDown(document, { key: 'ArrowLeft' }); + const dialog = screen.getByRole('dialog', { name: 'Build photos' }); + expect(within(dialog).getByRole('button', { name: /thirteen STL files/ })).toHaveAttribute('aria-current', 'true'); + fireEvent.keyDown(tab(/Besoiobiy/), { key: 'Home' }); + expect(tab(/Official/)).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); + +describe('the rest of the panel', () => { + it('keeps the hover preview and points at the current routes', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /Print the case/ })); + expect(useAppStore.getState().buildStep).toBe(1); + expect(screen.getByText('~10 hr')).toBeInTheDocument(); + expect(screen.queryByText(/~10–12 hr/)).not.toBeInTheDocument(); + expect(screen.getByText(/ESP32-S3 ~\$13/)).toBeInTheDocument(); + expect(screen.getByText(/Hand-solder the v3\.9 board/)).toBeInTheDocument(); + expect(screen.getByText(/power up through the screw terminal/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Before you buy the panel/ })).toHaveAttribute( + 'href', + `${GH}/blob/main/docs/panel-compatibility.md`, + ); + expect(screen.getByRole('link', { name: 'Breadboard guide' })).toHaveAttribute('href', '/build/breadboard'); + expect(screen.getByRole('link', { name: 'v2 board guide' })).toHaveAttribute('href', `${GH}/blob/main/BUILD_GUIDE_v2.md`); + expect(screen.queryByText(/laser-cut, or an older/)).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/sections/BuildPanel.tsx b/web/src/components/sections/BuildPanel.tsx index fa4471a04..5a86b2a49 100644 --- a/web/src/components/sections/BuildPanel.tsx +++ b/web/src/components/sections/BuildPanel.tsx @@ -3,6 +3,9 @@ import Link from 'next/link'; import { useAppStore, SectionType } from '@/store/useAppStore'; import { SectionContent } from '@/lib/content'; import { captureEvent } from '@/lib/posthogEvents'; +import { preloadCaseModel } from '@/components/3d/preloadCaseModel'; +import CasePicker from './CasePicker'; +import { BUILD_CASES, findCase, writeCaseToUrl, type CaseId } from './build-cases-data'; import styles from './BuildPanel.module.css'; interface BuildPanelProps { @@ -13,7 +16,8 @@ interface BuildPanelProps { // The reality check every would-be builder wants before committing. Ordered // cost → your time → machine time → waiting, so the two numbers the reader is // actually deciding on come first. Numbers follow the main path (custom PCB + -// 3D print, see BUILD_GUIDE.md BOM). +// the official printed case, see BUILD_GUIDE.md: the BOM in §1 and the time +// at the top). The case switch below changes step 01, not these. const FACTS = [ { // A range, not the floor. The breakdown below adds up to about $100, and @@ -27,7 +31,7 @@ const FACTS = [ // Printing, not filament: most people order the case rather than owning a // printer, and $30 is what that costs. detail: - 'At best: 3D printing ~$30 · panel ~$20 · ESP32-S3 ~$15 · PCB & rest ~$35. Shipping, minimum order quantities and a reprint or two take it up from there.', + 'At best: 3D printing ~$30 · panel ~$20 · ESP32-S3 ~$13 · PCB & rest ~$35. Shipping, minimum order quantities and a reprint or two take it up from there.', }, { value: '~1 hr', @@ -35,9 +39,11 @@ const FACTS = [ detail: '30 min soldering, 30 min assembly. Big through-hole joints — a first time is fine.', }, { - value: '~10–12 hr', + // BUILD_GUIDE.md and hardware/case/README.md both say ~10 h for the + // official case on a 256 mm bed, at the 0.2 mm layers they set. + value: '~10 hr', name: 'Printing', - detail: 'Printer time, not yours. Nearer 12 at finer layer heights.', + detail: 'Printer time, not yours, for the official case on a 256 mm bed.', }, { value: '~2 wk', @@ -46,16 +52,18 @@ const FACTS = [ }, ]; +// Step 01 is replaced by the chosen case's own (build-cases-data.ts); the +// one written here is the official case's. const STEPS = [ { id: 1, title: 'Print the case', - desc: '3D print the current PLA enclosure.', + desc: 'Print the body in white PLA and the knobs in black.', }, { id: 2, title: 'Solder the PCB', - desc: 'Hand-solder the custom Patternflow PCB.', + desc: 'Hand-solder the v3.9 board. Every part is through-hole.', }, { id: 3, @@ -63,12 +71,20 @@ const STEPS = [ desc: 'Encoders, matrix, power wiring, and case fit.', }, { + // BUILD_GUIDE.md §8.1: flash the module off the board, then seat it; power + // only ever comes in through J4, the screw terminal (§2). id: 4, title: 'Flash and power on', - desc: 'Browser flash the release firmware, then insert the ESP32-S3.', + desc: 'Flash the ESP32-S3 from the browser, seat it, then power up through the screw terminal.', }, ]; +// /build?case= opens the panel on that case, so a remix can be linked to +// directly. The official case is the bare /build. +function caseFromUrl(): CaseId | null { + return findCase(new URLSearchParams(window.location.search).get('case'))?.id ?? null; +} + export default function BuildPanel({ content, isActive }: BuildPanelProps) { const setActiveSection = useAppStore((state) => state.setActiveSection); const buildStep = useAppStore((state) => state.buildStep); @@ -79,6 +95,57 @@ export default function BuildPanel({ content, isActive }: BuildPanelProps) { const [isMobile, setIsMobile] = useState(false); const [lockedStep, setLockedStep] = useState(null); const [activeTouchStep, setActiveTouchStep] = useState(null); + // The case is the store's, not this panel's: the product preview draws the + // device in it, on the Pattern tab as well as here, and it has to outlive + // this panel, which is remounted every time the Build tab is left. + const buildCase = useAppStore((state) => state.buildCase); + const setBuildCase = useAppStore((state) => state.setBuildCase); + const selectedCase = findCase(buildCase) ?? BUILD_CASES[0]; + const steps = STEPS.map((step) => (step.id === 1 ? { ...step, ...selectedCase.step } : step)); + const wasActive = useRef(isActive); + + const handleCaseSelect = (nextId: CaseId, interaction: 'click' | 'key') => { + setBuildCase(nextId); + // Only while the panel is the one on screen — it stays mounted, hidden, + // on the other tabs, and must not write to their URLs. + if (isActive && window.location.pathname === '/build') writeCaseToUrl(nextId); + captureEvent('build_case_selected', { + case_id: nextId, + interaction, + surface: 'build_panel', + }); + }; + + // A ?case= link sets the store once the client can read the URL, and again + // on Back and Forward. In an effect, after hydration: the page is static, so + // the server's HTML is always the store's default, and the first client + // render has to match it. A URL that names no case leaves the store alone — + // it is the URL of every other tab too, and the case a reader picked stays + // the one they are looking at. + useEffect(() => { + const readUrl = () => { + const id = caseFromUrl(); + if (id) setBuildCase(id); + }; + readUrl(); + window.addEventListener('popstate', readUrl); + return () => window.removeEventListener('popstate', readUrl); + }, [setBuildCase]); + + // The tab button pushes a bare /build, but the store may hold a remix picked + // before the reader left: put it back in the address bar when the panel + // comes on screen, so the link still says what the card and the preview + // show. A URL that does name a case — Back to an earlier /build?case= — is + // the reader's, and wins; RightPanel's popstate can bring the panel on + // screen before the listener above has read it. + useEffect(() => { + const cameOn = isActive && !wasActive.current; + wasActive.current = isActive; + if (!cameOn || window.location.pathname !== '/build') return; + const named = caseFromUrl(); + if (named) setBuildCase(named); + else writeCaseToUrl(buildCase); + }, [isActive, buildCase, setBuildCase]); useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth <= 900); @@ -126,7 +193,7 @@ export default function BuildPanel({ content, isActive }: BuildPanelProps) { }; const handleStepClick = (stepId: number) => { - const step = STEPS.find((item) => item.id === stepId); + const step = steps.find((item) => item.id === stepId); if (isMobile) { if (activeTouchStep === stepId) { @@ -180,13 +247,29 @@ export default function BuildPanel({ content, isActive }: BuildPanelProps) { ))} + {/* Which case comes before the steps: it decides step 01, whether you + need a printer or a laser cutter at all, and which case the 3D + preview shows. */} + captureEvent('build_case_link_opened', { + case_id: id, + target, + surface: 'build_panel', + })} + lead={content.content} + /> +
{isMobile ? 'Four steps — tap to preview' : 'Four steps — hover to preview on the device'}
- {STEPS.map((step) => { + {steps.map((step) => { const isActive = isMobile ? activeTouchStep === step.id : buildStep === step.id; const stepIndex = String(step.id).padStart(2, '0'); @@ -247,9 +330,10 @@ export default function BuildPanel({ content, isActive }: BuildPanelProps) { {/* ONE prominent route — the build guide on the site, where every step is shown on the 3D model — with the written guide and the two - ordering shortcuts under it. Every other combination (breadboard, - laser cut, older boards) lives in the assembly map, which replaced - the old build matrix here. */} + ordering shortcuts under it. The case row follows the switch above: + MakerWorld for the official case, the remix's folder otherwise. The + electronics routes (breadboard, older boards) live in the assembly + map, which replaced the old build matrix here. */}

- Breadboard, laser-cut, or an older v2 board? Every route is in the assembly map. + No custom PCB, or an older v2 board? Every route is in the assembly map.

diff --git a/web/src/components/sections/CasePicker.module.css b/web/src/components/sections/CasePicker.module.css new file mode 100644 index 000000000..323ca5f99 --- /dev/null +++ b/web/src/components/sections/CasePicker.module.css @@ -0,0 +1,421 @@ +/* The case switch and its card. Same vocabulary as the rest of the Build + panel: hairlines, no radius, weight 500 at most, ink as the only fill. The + container queries resolve against .buildPanel (BuildPanel.module.css), so + they track the panel's own width, not the window's. */ + +.picker { + display: grid; +} + +/* No one-word last line ("… TO MAKE / IT") on a phone. */ +.picker > :global(.pf-kicker) { + text-wrap: balance; +} + +/* .panel-body p is a global rule at (0,1,1) — 20px, a 28px bottom margin — + so every paragraph here is matched with two classes to stay ahead of it. */ +.picker .lead { + margin-bottom: 18px; +} + +.picker .lead p { + 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. */ +.tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--pf-rule); +} + +.tab { + appearance: none; + display: grid; + gap: 4px; + /* Start, not centre: on a phone "Printed remix" wraps and "Printed" does + not, and centred names would then sit at different heights. */ + align-content: start; + min-height: 60px; + padding: 10px 14px; + border: 0; + border-right: 1px solid var(--pf-rule); + background: transparent; + color: var(--pf-ink-muted); + font: inherit; + text-align: left; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.tab:last-child { + border-right: 0; +} + +.tab:hover { + background: var(--pf-cream-2); + color: var(--pf-ink); +} + +.tab[aria-selected='true'] { + background: var(--pf-ink); + color: var(--pf-cream); + cursor: default; +} + +.tab:focus-visible { + outline: 2px solid var(--pf-led); + outline-offset: -2px; +} + +.tabName { + font-size: var(--pf-fs-row-d); + font-weight: 500; + line-height: 1.25; +} + +.tabMethod { + font-family: var(--pf-mono); + font-size: 10.5px; + letter-spacing: var(--pf-track-mono); + line-height: 1.35; + text-transform: uppercase; +} + +/* Faded only on the ink cell, where cream still reads; on white the muted ink + is already as light as small text can go. */ +.tab[aria-selected='true'] .tabMethod { + opacity: 0.8; +} + +/* ── The card ── */ +.card { + display: grid; + border: 1px solid var(--pf-rule); + border-top: 0; +} + +.photos { + display: flex; + gap: 2px; + background: var(--pf-cream); +} + +/* flex-grow is the photo's own ratio, set inline; with a zero basis every + photo in the row comes out the same height. */ +.photo { + appearance: none; + position: relative; + flex-basis: 0; + flex-shrink: 1; + min-width: 0; + padding: 0; + border: 0; + overflow: hidden; + background: var(--pf-cream-2); + cursor: zoom-in; +} + +.photo img { + object-fit: cover; + transition: opacity 120ms ease; +} + +.photo:hover img { + opacity: 0.88; +} + +.photo:focus-visible { + outline: 2px solid var(--pf-led); + outline-offset: -2px; +} + +.photoMeta { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + gap: 6px 14px; + padding: 10px 22px 0; + color: var(--pf-ink-muted); + font-family: var(--pf-mono); + font-size: 11px; + letter-spacing: 0.04em; + line-height: 1.5; +} + +.photoMeta button { + appearance: none; + padding: 0; + border: 0; + border-bottom: 1px solid currentColor; + background: transparent; + color: var(--pf-ink); + font: inherit; + letter-spacing: var(--pf-track-mono); + text-transform: uppercase; + cursor: pointer; +} + +.photoMeta button:hover { + color: var(--pf-ink-muted); +} + +.body { + display: grid; + gap: 16px; + padding: 18px 22px 22px; +} + +.head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 6px 14px; +} + +/* .panel-body h3 is global too: 24px with a 48px top margin. */ +.card .name { + margin: 0; + color: var(--pf-ink); + font-size: clamp(19px, 2vw, 22px); + font-weight: 500; + letter-spacing: -0.01em; + line-height: 1.2; + text-wrap: balance; +} + +.kind { + flex: none; + color: var(--pf-ink-muted); + font-family: var(--pf-mono); + font-size: 11px; + letter-spacing: var(--pf-track-mono); + text-transform: uppercase; +} + +.card .summary { + max-width: none; + margin: 0; + color: var(--pf-ink-muted); + font-size: var(--pf-fs-body); + line-height: 1.6; +} + +/* Label | value rows, the label in the panel's mono. */ +.facts { + display: grid; + margin: 0; + border-top: 1px solid var(--pf-rule-soft); +} + +.facts > div { + display: grid; + grid-template-columns: 96px 1fr; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--pf-rule-soft); +} + +.facts dt { + padding-top: 3px; + color: var(--pf-ink-muted); + font-family: var(--pf-mono); + font-size: 10.5px; + letter-spacing: var(--pf-track-mono); + line-height: 1.5; + text-transform: uppercase; +} + +.facts dd { + margin: 0; + min-width: 0; + color: var(--pf-ink); + font-size: var(--pf-fs-row-d); + line-height: 1.5; +} + +.facts a { + color: var(--pf-ink); + text-decoration: underline; + text-underline-offset: 3px; +} + +.facts .note { + color: var(--pf-ink-muted); +} + +/* A remix's README, the guide to building it: the one box in the card with an + ink border all round, and the whole of it is the link. Still a hairline — + the guide card under Start here stays the panel's only solid. */ +.readme { + position: relative; + display: grid; + gap: 4px; + padding: 12px 14px; + border: 1px solid var(--pf-ink); + transition: background 120ms ease; +} + +.readme:hover { + background: var(--pf-cream-2); +} + +.readme a { + justify-self: start; + color: var(--pf-ink); + font-size: var(--pf-fs-row-d); + font-weight: 500; + line-height: 1.4; + text-decoration: underline; + text-underline-offset: 3px; +} + +.readme a::after { + content: ''; + position: absolute; + inset: 0; +} + +.readme a:focus-visible { + outline: 0; +} + +.readme:has(a:focus-visible) { + outline: 2px solid var(--pf-led); + outline-offset: -2px; +} + +.card .readme p { + max-width: none; + margin: 0; + color: var(--pf-ink-muted); + font-size: var(--pf-fs-row-d); + line-height: 1.55; +} + +/* An ink rule down the side rather than the LED orange: --pf-led is kept + for things that are live. */ +.card .caution { + display: grid; + gap: 4px; + max-width: none; + margin: 0; + padding: 12px 14px; + border-left: 2px solid var(--pf-ink); + background: var(--pf-cream); + color: var(--pf-ink); + font-size: var(--pf-fs-row-d); + line-height: 1.55; +} + +.caution a { + color: var(--pf-ink); + font-family: var(--pf-mono); + font-size: 11px; + letter-spacing: 0.06em; + text-decoration: underline; + text-underline-offset: 3px; + white-space: nowrap; +} + +.caution strong { + font-family: var(--pf-mono); + font-size: 10.5px; + font-weight: 500; + letter-spacing: var(--pf-track-mono); + text-transform: uppercase; +} + +.card .guide { + max-width: none; + margin: 0; + color: var(--pf-ink-muted); + font-size: var(--pf-fs-row-d); + line-height: 1.55; +} + +/* The files, as the hairline rows the Start here links use. */ +.links { + display: grid; + border-top: 1px solid var(--pf-rule); +} + +.links a { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 14px; + padding: 13px 22px; + border-bottom: 1px solid var(--pf-rule); + color: var(--pf-ink); + text-decoration: none; +} + +.links a:last-child { + border-bottom: 0; +} + +/* The global a:hover underline would run under both halves of the row. */ +.links a:hover, +.links a:focus-visible { + outline: 0; + background: var(--pf-cream-2); + text-decoration: none; +} + +.links strong { + font-size: var(--pf-fs-row-d); + font-weight: 500; +} + +.links span { + flex: none; + max-width: 55%; + overflow-wrap: anywhere; + color: var(--pf-ink-muted); + font-family: var(--pf-mono); + font-size: 11px; + letter-spacing: 0.06em; + text-align: right; +} + +/* A phone: the label goes over its value, and the padding comes in. */ +@container (max-width: 420px) { + .tab { + padding: 10px 10px; + } + + .photoMeta { + padding: 10px 14px 0; + } + + .body { + padding: 16px 14px 18px; + } + + .facts > div { + grid-template-columns: 1fr; + gap: 2px; + } + + .facts dt { + padding-top: 0; + } + + /* Side by side, a long file name wrapped mid-word and left the arrow on a + line of its own; stacked, each half keeps its line. */ + .links a { + flex-direction: column; + align-items: flex-start; + gap: 3px; + padding: 12px 14px; + } + + .links span { + max-width: none; + text-align: left; + } +} diff --git a/web/src/components/sections/CasePicker.tsx b/web/src/components/sections/CasePicker.tsx new file mode 100644 index 000000000..e52fd8419 --- /dev/null +++ b/web/src/components/sections/CasePicker.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { useRef, useState } from 'react'; +import type { KeyboardEvent } from 'react'; +import Image from 'next/image'; +import ReactMarkdown from 'react-markdown'; +import PhotoLightbox from './InsideGlobe/PhotoLightbox'; +import type { BuildCase, CaseId } from './build-cases-data'; +import styles from './CasePicker.module.css'; + +interface CasePickerProps { + cases: BuildCase[]; + selected: BuildCase; + onSelect: (id: CaseId, interaction: 'click' | 'key') => void; + onLinkOpen?: (caseId: CaseId, target: string) => void; + /** A tab is about to be picked (pointer over it, or focus on it): time to + start loading that case's model. */ + onPrefetch?: (id: CaseId) => void; + /** The body of content/build.md, shown above the switch. */ + lead?: string; +} + +// Two photos on the card; the viewer has all of them. Two is what fits a phone +// at a height you can still read, and it keeps one of each where a case has +// 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 +// way they do in any other tab set. +export default function CasePicker({ + cases, + selected, + onSelect, + onLinkOpen, + onPrefetch, + lead, +}: CasePickerProps) { + // The viewer belongs to the case it was opened on. A bare index would + // outlive a switch (focus can still reach the tabs behind the viewer) and + // point past the end of a case with fewer photos. + const [viewer, setViewer] = useState<{ caseId: CaseId; index: number } | null>(null); + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); + const index = Math.max(0, cases.findIndex((item) => item.id === selected.id)); + + const handleKeyDown = (event: KeyboardEvent) => { + const last = cases.length - 1; + let next: number | null = null; + if (event.key === 'ArrowRight') next = index === last ? 0 : index + 1; + else if (event.key === 'ArrowLeft') next = index === 0 ? last : index - 1; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = last; + if (next === null) return; + event.preventDefault(); + // Home on the first tab or End on the last picks nothing new: no select, + // as a click on the selected tab sends none. + if (next !== index) onSelect(cases[next].id, 'key'); + tabRefs.current[next]?.focus(); + }; + + const photos = selected.photos.slice(0, STRIP); + const footnote = selected.readme ? selected.modelNote : selected.guide; + const panelId = 'build-case-panel'; + + return ( +
+ The case — three ways to make it + {lead && lead.trim().length > 0 && ( +
+ {lead} +
+ )} + +
+ {cases.map((item, tabIndex) => { + const isSelected = item.id === selected.id; + return ( + + ); + })} +
+ +
+ {/* Each photo grows by its own width-to-height ratio from a zero + basis, so a portrait and a landscape shot end up the same height + and fill the row together, whatever the card's width. */} +
+ {photos.map((photo, photoIndex) => ( + + ))} +
+
+ {selected.credit} + {selected.photos.length > photos.length && ( + + )} +
+ +
+
+

{selected.name}

+ {selected.kind === 'official' ? 'Official' : 'Community remix'} +
+

{selected.summary}

+ + {/* A remix is built from its README, so that comes first, ahead of + the facts below — which are copied from it, not a second guide. */} + {selected.readme && ( + + )} + +
+
+
By
+
+ {selected.author.href ? ( + + {selected.author.name} + + ) : ( + selected.author.name + )} + {selected.author.note && , {selected.author.note}} +
+
+
+
Material
+
{selected.material}
+
+
+
Fits
+
{selected.fits}
+
+
+
Make
+
{selected.make}
+
+
+
Also needs
+
{selected.parts}
+
+
+
Verified
+
{selected.verified}
+
+
+
License
+
{selected.license}
+
+
+ +

+ Check your panel first + + {selected.caution}{' '} + onLinkOpen?.(selected.id, 'caution')} + > + How to check ↗ + + +

+ + {/* The official case's place in the build guide; for a remix that + is said by its README link above, and this line says instead what + the 3D view of it is made from. */} + {footnote &&

{footnote}

} +
+ + +
+ + {viewer !== null && viewer.caseId === selected.id && ( + ({ src, alt }))} + index={viewer.index} + onIndexChange={(index) => setViewer({ caseId: selected.id, index })} + onClose={() => setViewer(null)} + /> + )} +
+ ); +} diff --git a/web/src/components/sections/RightPanel.tsx b/web/src/components/sections/RightPanel.tsx index 51a0d8bd3..3a0743e7a 100644 --- a/web/src/components/sections/RightPanel.tsx +++ b/web/src/components/sections/RightPanel.tsx @@ -157,19 +157,23 @@ export default function RightPanel({ initialTab = 'hero', buildContent, patternC
-
+ {/* The panels not on screen are only faded and folded away, so they + are inert too: out of the tab order and the accessibility tree. + Otherwise a keyboard could reach the Build panel's case switch + from the Pattern tab and change the case under the preview. */} +
-
+
-
+
-
+
diff --git a/web/src/components/sections/build-cases-data.test.ts b/web/src/components/sections/build-cases-data.test.ts new file mode 100644 index 000000000..205f84d07 --- /dev/null +++ b/web/src/components/sections/build-cases-data.test.ts @@ -0,0 +1,176 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import matter from 'gray-matter'; +import { describe, expect, it } from 'vitest'; +import { BUILD_CASES, DEFAULT_CASE, findCase, stepCase } from './build-cases-data'; + +// The case cards against the repo: every GitHub link points at a path that +// exists on this tree (a blob at a file, a tree at a folder) and every #anchor +// at a heading in that file, every photo is in public/ at the size the card +// says, each remix card agrees with its own README's header, nothing presents +// USB-C as power, and content/build.md no longer says the laser-cut case is +// still being prepared. Paths resolve from this file, not the working +// directory, so the suite runs from the repo root as well as from web/. + +const REPO_ROOT = path.resolve(__dirname, '../../../..'); +const PUBLIC = path.join(REPO_ROOT, 'web/public'); +const GITHUB = /^https:\/\/github\.com\/engmung\/Patternflow\/(blob|tree)\/main\/(.+)$/; + +// Width and height from a baseline or progressive JPEG's SOF marker. +function jpegSize(file: string): { width: number; height: number } { + const buf = fs.readFileSync(file); + expect(buf[0] === 0xff && buf[1] === 0xd8, `${file} is not a JPEG`).toBe(true); + let at = 2; + while (at < buf.length) { + const marker = buf[at + 1]; + const length = buf.readUInt16BE(at + 2); + if (marker >= 0xc0 && marker <= 0xc3) { + return { height: buf.readUInt16BE(at + 5), width: buf.readUInt16BE(at + 7) }; + } + at += 2 + length; + } + throw new Error(`${file}: no SOF marker`); +} + +// GitHub's heading anchors: lower case, punctuation dropped (backticks, +// slashes, dashes of other kinds, brackets), each space a hyphen. +function anchors(markdown: string): Set { + return new Set( + markdown + .split('\n') + .filter((line) => /^#{1,6} /.test(line)) + .map((line) => + line + .replace(/^#{1,6} /, '') + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}\p{M} _-]/gu, '') + .replace(/ /g, '-'), + ), + ); +} + +function readmeHeader(folder: string): Record { + const text = fs.readFileSync(path.join(REPO_ROOT, folder, 'README.md'), 'utf8'); + const fields: Record = {}; + for (const line of text.split('\n').slice(0, 6)) { + const match = line.match(/^(\w[\w ]*): (.*?)\s*$/); + if (match) fields[match[1]] = match[2]; + } + return fields; +} + +describe('the case cards', () => { + it('are the official case and the two remixes, official first and the default', () => { + expect(BUILD_CASES.map((item) => item.id)).toEqual([ + 'official', + 'besoiobiy-printed', + 'simonepda-lasercut', + ]); + expect(DEFAULT_CASE).toBe('official'); + expect(BUILD_CASES[0].kind).toBe('official'); + // The official case's guide is the build guide itself. + expect(BUILD_CASES[0].readme).toBeUndefined(); + expect(findCase('nope')).toBeUndefined(); + expect(findCase(null)).toBeUndefined(); + }); + + it.each(BUILD_CASES)('$id links only to paths and headings that exist on this tree', (item) => { + const hrefs = [ + ...item.links.map((link) => link.href), + item.author.href, + item.cautionHref, + item.readme, + ].filter(Boolean) as string[]; + expect(item.links.length).toBeGreaterThanOrEqual(3); + // The caution's "How to check" lands on the README section that says how. + expect(item.cautionHref).toMatch(GITHUB); + expect(item.cautionHref).toContain('#'); + for (const href of hrefs) { + const match = href.match(GITHUB); + if (!match) { + expect(href, 'a link off GitHub is https').toMatch(/^https:\/\//); + continue; + } + const [, kind, target] = match; + const [repoPath, anchor] = target.split('#'); + const full = path.join(REPO_ROOT, repoPath); + expect(fs.existsSync(full), `${href} → ${repoPath} is missing`).toBe(true); + expect(fs.statSync(full).isDirectory(), `${href}: blob/ wants a file, tree/ a folder`).toBe(kind === 'tree'); + if (anchor) { + expect(anchors(fs.readFileSync(full, 'utf8')), `${href}: no heading for #${anchor}`).toContain(anchor); + } + } + }); + + it.each(BUILD_CASES)('$id photos are in public/ at the size the card gives', (item) => { + expect(item.photos.length).toBeGreaterThanOrEqual(2); + for (const photo of item.photos) { + expect(photo.src).toMatch(new RegExp(`^/cases/${item.id}/[a-z0-9_]+\\.jpg$`)); + expect(photo.alt.length).toBeGreaterThan(20); + const size = jpegSize(path.join(PUBLIC, photo.src)); + expect(size, photo.src).toEqual({ width: photo.width, height: photo.height }); + // Copies for the web, not the originals: nothing past 1400 px. + expect(Math.max(size.width, size.height)).toBeLessThanOrEqual(1400); + } + }); + + it.each(BUILD_CASES.filter((item) => item.kind === 'remix'))( + '$id agrees with its README header', + (item) => { + const folder = `hardware/case/remixes/${item.id}`; + const header = readmeHeader(folder); + // The folder name is the id, so /build?case= reads like the path. + expect(item.links[0].href).toBe(`https://github.com/engmung/Patternflow/tree/main/${folder}`); + // The remix's guide is its README: the card leads with a link to it, + // and says what the preview's model of it is made from. + expect(item.readme).toBe(`https://github.com/engmung/Patternflow/blob/main/${folder}/README.md`); + expect(item.modelNote).toMatch(/^The 3D view /); + expect(header.Author).toContain(item.author.name); + expect(header.License).toBe('CC-BY-SA-4.0'); + expect(item.license).toMatch(/^CC BY-SA 4\.0/); + // The verified date on the card is the one in the README. + 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. + 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/); + }, + ); + + it('sends the laser-cut reader to all three pages of the drawing, not one sheet', () => { + const simone = findCase('simonepda-lasercut'); + expect(simone?.make).toMatch(/page 1/); + expect(simone?.make).toMatch(/page 3/); + expect(simone?.make).toMatch(/page 5/); + expect(simone?.step.desc).not.toMatch(/one sheet/i); + }); + + it('never offers USB-C as power', () => { + // BUILD_GUIDE §2: J4, the screw terminal, is the only power input. + expect(JSON.stringify(BUILD_CASES)).not.toMatch(/USB/i); + }); + + it('steps through every case in the switch order, wrapping at both ends (the preview arrows)', () => { + const ids = BUILD_CASES.map((item) => item.id); + ids.forEach((id, i) => { + expect(stepCase(id, 1)).toBe(ids[(i + 1) % ids.length]); + expect(stepCase(id, -1)).toBe(ids[(i - 1 + ids.length) % ids.length]); + }); + }); +}); + +describe('content/build.md', () => { + it('no longer lists the laser-cut case as in preparation', () => { + // gray-matter, as lib/content.ts reads it — but from this file's path, + // where the loader goes by process.cwd(). + const raw = fs.readFileSync(path.join(REPO_ROOT, 'web/content/build.md'), 'utf8'); + const { data, content } = matter(raw); + expect(data.title).toBe('Build your own.'); + expect(data.subtitle).toMatch(/US\$100–200/); + expect(raw).not.toMatch(/Preparing|being prepared|in preparation/i); + expect(content).toMatch(/laser-cut from acrylic or MDF/); + }); +}); diff --git a/web/src/components/sections/build-cases-data.ts b/web/src/components/sections/build-cases-data.ts new file mode 100644 index 000000000..c62bddc26 --- /dev/null +++ b/web/src/components/sections/build-cases-data.ts @@ -0,0 +1,316 @@ +// The three cases the Build panel lets a reader switch between: the official +// printed case and the two 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, +// each remix from the six header fields and the prose of its own README. A +// remix's guide is its README, not this card: the card says what the case is +// and leads with a link to it. When a README changes, change the card with it; +// build-cases-data.test.ts checks that every GitHub link here still points at +// a path that exists, and that every photo is in public/. +// +// A new remix is one more entry: copy its photos (resized, ~1200 px) into +// public/cases//, and use the folder name as the id so +// /build?case= reads like the path it links to. + +const REPO = 'https://github.com/engmung/Patternflow'; + +/** A file on main: a README, a 3MF, a PDF. */ +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 interface CasePhoto { + src: string; + alt: string; + /** Pixel size of the file in public/, so the strip can size each photo to its own ratio. */ + width: number; + height: number; +} + +export interface CaseLink { + /** What the reader gets, on the left of the row. */ + label: string; + /** Where it goes, short, on the right: a file name, a folder, a site. */ + target: string; + href: string; +} + +export interface BuildCase { + id: CaseId; + /** The two lines on the switch: who, then how. */ + tab: string; + method: string; + kind: 'official' | 'remix'; + name: string; + summary: string; + author: { name: string; note?: string; href?: string }; + material: string; + fits: string; + make: string; + /** What it needs beyond the BOM. */ + parts: string; + verified: string; + license: string; + /** What to check on your own panel before printing or cutting, and the + README section that says how. */ + caution: string; + cautionHref: string; + /** How this case relates to the written build guide. */ + guide: string; + /** A remix's README on GitHub. It is the guide to building that case, so + the card leads with it, ahead of the facts. */ + readme?: string; + /** What the preview's 3D model of a remix is made from, in a line: the + models are drawn from the remix's files by tools/case-models, and a + reader comparing them with the photos should know how closely. */ + modelNote?: string; + /** The first two show on the card; all of them open in the viewer. */ + photos: CasePhoto[]; + credit: string; + /** The first one is also this case's row under Start here: MakerWorld for + the official case, the folder (its README) for a remix. */ + links: CaseLink[]; + /** Step 01 of the four steps, in this case's words. */ + step: { title: string; desc: string }; +} + +const OFFICIAL = 'hardware/case'; +const BESOIOBIY = 'hardware/case/remixes/besoiobiy-printed'; +const SIMONE = 'hardware/case/remixes/simonepda-lasercut'; + +export const BUILD_CASES: BuildCase[] = [ + { + id: 'official', + tab: 'Official', + method: 'Printed', + kind: 'official', + name: 'The official printed case', + summary: + 'The case the build guide walks through and the 3D model on this page shows: a frame for the panel and a bay for the board and the power bank, with a snap-fit back panel and two wall-mount holes. On a 256 mm bed it prints in halves you glue right after printing; on a 330 mm bed it is one piece.', + author: { name: 'Patternflow', note: 'the project’s own design' }, + material: 'White PLA for the body, black PLA for the knobs, as a separate print.', + fits: 'v3.9 and v3.0 boards, and the panel linked in the BOM. For another panel there is an adjustable version, in for_other_panels/.', + make: 'About 10 hours on a 256 mm bed (Bambu P1S, X1C or A1 class), then the knobs. One piece, no gluing, on a ~330 mm bed.', + parts: '6–12 M4 screws, about 10 mm, for the panel; CA glue for the halves.', + verified: 'Print and assembly verified: the assembly video is built from these files.', + license: 'CC BY-SA 4.0', + caution: + 'The adjustable version in for_other_panels/ is not universal: if your panel’s holes sit far from the BOM panel’s, it may still not fit. Check its mounting part against your panel before the full print.', + cautionHref: `${blob(`${OFFICIAL}/README.md`)}#for_other_panels--using-a-different-led-panel`, + guide: + 'The build guide covers it end to end: printing in section 4, assembly in section 6.', + photos: [ + { + src: '/cases/official/printing.jpg', + alt: 'The case printing on a Bambu P1S: the white frame halves standing on the bed', + width: 787, + height: 1400, + }, + { + src: '/cases/official/printed_parts.jpg', + alt: 'Everything from the 256 mm print laid out before gluing: the two frame halves, the back-panel halves and the covers, all in white', + width: 1400, + height: 788, + }, + { + src: '/cases/official/close_back.jpg', + alt: 'The back panel sliding shut over the v3 board inside the white case', + width: 1400, + height: 788, + }, + ], + credit: 'Photos from the build guide.', + links: [ + { + label: 'One-click print profiles', + target: 'MakerWorld', + href: 'https://makerworld.com/en/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015', + }, + { label: 'Every file, by bed size', target: 'hardware/case/', href: tree(OFFICIAL) }, + { + label: 'Bambu Studio project, four plates', + target: 'patternflow_v3.3mf', + href: blob(`${OFFICIAL}/bed_256mm/patternflow_v3.3mf`), + }, + { + label: 'For a panel other than the BOM’s', + target: 'for_other_panels/', + href: tree(`${OFFICIAL}/bed_256mm/for_other_panels`), + }, + ], + step: { + title: 'Print the case', + desc: 'Print the body in white PLA and the knobs in black. About 10 hours of printer time.', + }, + }, + { + id: 'besoiobiy-printed', + tab: 'Besoiobiy', + method: 'Printed remix', + kind: 'remix', + name: 'Besoiobiy’s printed case', + summary: + 'Two long pieces glued side by side: a frame around the panel, and a box for the board and the power bank with PATTERNFLOW lettered down its side. Four covers close the back with countersunk screws, and twelve M3 bolts go through the frame’s covers and tabs into the panel’s own sockets, clamping the three together.', + author: { + name: 'Besoiobiy', + note: 'on the Patternflow Discord', + href: 'https://discord.gg/Vr9QtsxeTk', + }, + material: + 'PLA, set up for a Bambu Lab P1S with a 0.4 mm nozzle. White case, black knob caps.', + fits: 'v3.9 and v3.0 boards, and a 320 × 160 mm panel with M3 sockets on Besoiobiy’s hole pattern, ending 14.35 mm behind the LED face.', + make: 'Eight parts and four knob caps, each fitting a 256 mm bed on its own. Glue the halves, then set the brass inserts. The print settings are in the 3MF.', + parts: '8 M3 heat-set inserts (M3 × 8 × 5), 8 M3 × 10 and 12 M3 × 25 countersunk screws, glue.', + verified: 'Built by its author, and working for a week when they shared it (2026-10-08).', + license: 'CC BY-SA 4.0', + caution: + 'As drawn, this case takes an M3 panel on Besoiobiy’s hole pattern, whose sockets end 14.35 mm behind the LED face. An M3 × 25 bolt reaches about 8.5 mm into each socket: check yours are that deep and closed at the bottom, or use a shorter bolt. The panel linked in the BOM takes M4 screws on a different pattern, so for that one the frame’s tabs and covers have to be redrawn from the Blender source.', + cautionHref: `${blob(`${BESOIOBIY}/README.md`)}#check-your-panel-first`, + guide: + 'It replaces sections 4 and 6 of the build guide, and changes the order of 7 to 9: the panel is wired before its covers go on.', + readme: blob(`${BESOIOBIY}/README.md`), + modelNote: 'The 3D view puts it together from its STL files.', + photos: [ + { + src: '/cases/besoiobiy-printed/built.jpg', + alt: 'Besoiobiy’s build held upright: the lit LED panel on the left, the white box on the right with four black knobs at the top and PATTERNFLOW lettered down its side', + width: 1050, + height: 1400, + }, + { + src: '/cases/besoiobiy-printed/exploded_front.jpg', + alt: 'Render of the case pulled apart: the two box halves with their lettering and the knob caps on the left, the two frame halves with the panel tabs on the right, the four covers behind', + width: 1400, + height: 900, + }, + { + src: '/cases/besoiobiy-printed/exploded_back.jpg', + alt: 'Render of the case pulled apart, seen from behind: the two frame covers close the whole back of the panel, the two box covers close the box', + width: 1400, + height: 900, + }, + { + src: '/cases/besoiobiy-printed/parts.jpg', + alt: 'The thirteen STL files rendered as they lie for printing: two box halves, two frame halves, four covers, the knob cap, and the four box-half variants', + width: 1400, + height: 1096, + }, + ], + credit: 'Photo by Besoiobiy; renders from the STL files.', + links: [ + { label: 'README and every file', target: 'besoiobiy-printed/', href: tree(BESOIOBIY) }, + { + label: 'Print layout, Bambu Studio', + target: 'print_layout.3mf', + href: blob(`${BESOIOBIY}/print_layout.3mf`), + }, + { label: 'One STL per part', target: 'stl/', href: tree(`${BESOIOBIY}/stl`) }, + { + label: 'Blender source', + target: 'patternbox.blend', + href: blob(`${BESOIOBIY}/source/patternbox.blend`), + }, + ], + step: { + title: 'Print the case', + desc: 'Print eight parts and four knob caps, glue the halves, then set the brass inserts.', + }, + }, + { + id: 'simonepda-lasercut', + tab: 'SimonePDA', + method: 'Laser-cut remix', + kind: 'remix', + name: 'Simone Majocchi’s laser-cut case', + summary: + 'The body is cut from sheet: one flat plate carries everything. The panel screws on from behind into its own inserts, four strips guard the LEDs at its edge, and the board sits beside it under a small finger-jointed box, its four encoder shafts coming through to the front. Three open cubes are feet for laying it flat.', + author: { + name: 'Simone Majocchi', + note: 'SimonePDA', + href: 'https://github.com/SimonePDA', + }, + material: '3 mm acrylic (about 2.75 mm with the film off) or 2.8 mm MDF, laser cut.', + fits: 'v3.9 and v3.0 boards (built on v3.0; v3.9 has the same outline), and a 320 × 160 mm panel.', + make: 'Three pages of the drawing to cut: the plate (page 3), 256 × 340 mm, which has to fit your cutter’s bed whole; the four border strips (page 1); and the box and the three feet (page 5). The knobs are not in the drawing: print the official ones, or fit any that suit your shafts.', + parts: 'Panel screws of 10 or 12 mm at most, M4 or M3 to suit your panel; tape or glue for the box.', + verified: 'Cut three times by its author, in acrylic and in MDF (2026-10-05).', + license: 'CC BY-SA 4.0, except page 6 of the PDF, a panel maker’s drawing', + caution: + 'Its holes have to sit where the drawing has them: compare it with page 2 before cutting. Keep the panel screws to 10 or 12 mm: a longer one can pass through the panel’s inserts into its circuit board.', + cautionHref: `${blob(`${SIMONE}/README.md`)}#notes-do-not-skip`, + guide: + 'It replaces sections 4 and 6 of the build guide. The soldering, the wiring and the firmware are the same.', + readme: blob(`${SIMONE}/README.md`), + modelNote: 'The 3D view is a plain model of its parts, built to the drawing’s sizes.', + photos: [ + { + src: '/cases/simonepda-lasercut/acrylic_front.jpg', + alt: 'The laser-cut case in clear acrylic, upright: the LED panel on the left, the board and its four knobs to the right', + width: 925, + height: 1200, + }, + { + src: '/cases/simonepda-lasercut/mdf_front.jpg', + alt: 'The same case cut in MDF and turned on its side: the LED panel above, four knobs below it on the right', + width: 1200, + height: 905, + }, + { + src: '/cases/simonepda-lasercut/mdf_back.jpg', + alt: 'The MDF case from behind: screws through the sheet into the panel, four windows over the panel’s connectors, the board under an acrylic box', + width: 1200, + height: 929, + }, + { + src: '/cases/simonepda-lasercut/board_in_box.jpg', + alt: 'The v3.0 board under its finger-jointed acrylic box, wired at the two screw terminals', + width: 1200, + height: 537, + }, + ], + credit: 'Photos by Simone Majocchi.', + links: [ + { label: 'README and every file', target: 'simonepda-lasercut/', href: tree(SIMONE) }, + { + label: 'The drawing, six A3 pages at 1:1', + target: 'lasercut_layout.pdf', + href: blob(`${SIMONE}/lasercut_layout.pdf`), + }, + { + label: 'CorelDRAW source, with its guides', + target: 'lasercut_layout.cdr', + href: blob(`${SIMONE}/lasercut_layout.cdr`), + }, + { label: 'Knobs to print', target: 'knobs/', href: tree(`${OFFICIAL}/knobs`) }, + ], + step: { + title: 'Cut the case', + desc: 'Laser-cut the plate, the border strips, the box and the feet, and print the knobs.', + }, + }, +]; + +export const DEFAULT_CASE: CaseId = 'official'; + +export function findCase(id: string | null | undefined): BuildCase | undefined { + return BUILD_CASES.find((item) => item.id === id); +} + +/** The case `step` places along the switch from `id`, wrapping round at either end: the preview's arrows. */ +export function stepCase(id: CaseId, step: number): CaseId { + const count = BUILD_CASES.length; + const at = Math.max(0, BUILD_CASES.findIndex((item) => item.id === id)); + return BUILD_CASES[(((at + step) % count) + count) % count].id; +} + +// replaceState, not push: switching cases is not a page to go back to. The +// official case is the bare /build. +export function writeCaseToUrl(id: CaseId) { + const query = id === DEFAULT_CASE ? '' : `?case=${id}`; + const next = `/build${query}${window.location.hash}`; + const now = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (now !== next) window.history.replaceState(null, '', next); +} diff --git a/web/src/providers/PostHogProvider.tsx b/web/src/providers/PostHogProvider.tsx index 9fc724f10..1c4bcec48 100644 --- a/web/src/providers/PostHogProvider.tsx +++ b/web/src/providers/PostHogProvider.tsx @@ -36,7 +36,12 @@ function PostHogPageView({ enabled }: { enabled: boolean }) { const pathname = usePathname() const searchParams = useSearchParams() const previousUrl = useRef('') - const search = searchParams.toString() + // The Build panel's case switch rewrites ?case= in place (replaceState): + // picking a case is an event of its own (build_case_selected), not a page. + // A /build?case= link still counts once, as the page it lands on. + const params = new URLSearchParams(searchParams.toString()) + if (pathname === '/build') params.delete('case') + const search = params.toString() useEffect(() => { if (!enabled || typeof window === 'undefined') return diff --git a/web/src/store/useAppStore.ts b/web/src/store/useAppStore.ts index 021989a9d..f6337960b 100644 --- a/web/src/store/useAppStore.ts +++ b/web/src/store/useAppStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import type { CaseId } from '@/components/sections/build-cases-data'; export type SectionType = 'hero' | 'case' | 'pcb' | 'assembly' | 'firmware' | 'inside'; @@ -42,6 +43,16 @@ interface AppState { // separation to the reader instead of animating it at them. explode: number; setExplode: (explode: number) => void; + // Which case the Build panel's switch is on. The product preview shows the + // device in it (components/3d/caseModels.ts), on every tab that shows the + // product, so the case a reader picked stays the one they look at. + buildCase: CaseId; + setBuildCase: (id: CaseId) => void; + // For a case that comes in more than one finish (caseModels.ts: finishes), + // which one the preview shows. Kept per case, so going back to it keeps the + // reader's pick; a case without an entry shows its first finish. + caseFinish: Partial>; + setCaseFinish: (id: CaseId, finish: string) => void; } export const useAppStore = create((set) => ({ @@ -111,4 +122,9 @@ export function draw(display, params, globalTime) { setBuildStep: (step) => set({ buildStep: step }), explode: 1, setExplode: (val) => set({ explode: val }), + buildCase: 'official', + setBuildCase: (id) => set({ buildCase: id }), + caseFinish: {}, + setCaseFinish: (id, finish) => + set((state) => ({ caseFinish: { ...state.caseFinish, [id]: finish } })), }));