diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e5f36f4..0fc4fc92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,6 +84,23 @@ jobs: # Bump the suffix when changing the IDF version to invalidate. key: esp-idf-v6.1-dev-${{ runner.os }}-v1 + # The release-channel tag burned into the binary as MM_RELEASE (shown by + # SystemModule alongside the semver). Same resolution as the release + # job's "Resolve effective tag" step — kept here because this build job + # runs before that one. workflow_dispatch tag wins; main push → latest; + # a vX.Y.Z tag push → that tag. + - name: Resolve release tag + id: tag + env: + INPUT_TAG: ${{ inputs.tag }} + REF_NAME: ${{ github.ref_name }} + IS_MAIN: ${{ github.ref == 'refs/heads/main' }} + run: | + set -euo pipefail + if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" + elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT" + else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi + - name: Build firmware uses: espressif/esp-idf-ci-action@v1 with: @@ -99,8 +116,8 @@ jobs: path: 'esp32' # We run our own builder (not the action's default `idf.py build`) # so the sdkconfig fragments and EXCLUDE_COMPONENTS go through the - # same code path as local builds. - command: python ../scripts/build/build_esp32.py --firmware ${{ matrix.firmware }} + # same code path as local builds. --release burns the channel tag in. + command: python ../scripts/build/build_esp32.py --firmware ${{ matrix.firmware }} --release "${{ steps.tag.outputs.tag }}" - name: Stage release artifacts run: | diff --git a/CLAUDE.md b/CLAUDE.md index c13d927a..31b14fdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,8 +9,11 @@ See `docs/architecture.md` for system design. This file contains only rules and ## Principles - **Common patterns first.** This repo is meant to be a recognisable example of good practice across code, docs, tests, and UI — not a Frankenstein of bespoke conventions only the authors understand. Hold every decision against it, especially in core architecture and documentation. Before introducing a pattern, name a widely-used project / framework / canonical resource that uses it; if you can't, treat it as bespoke and justify the divergence in a one-line comment at the introduction site. A new contributor with general C++/web experience should recognise the pattern within 30 seconds. Bespoke choices are allowed — header-only light modules, the MoonModule lifecycle, present-tense docs — but each carries its reason at the place it's introduced. -- **Minimalism means simplicity.** Flat, simple, predictable code. Not clever abstractions. Not elegant templates. If a contributor can't understand it in 30 seconds, it's too complex. Every addition must pay for itself. Prefer removing code over adding it. -- **Data over objects.** Design around data flow, not class hierarchies. +- **Minimalism means simplicity.** Flat, simple, predictable code. Not clever abstractions. Not elegant templates. If a contributor can't understand it in 30 seconds, it's too complex. +- **Core grows slower than the domain.** Adding a domain module is expected growth: a new unit of domain capability adds lines because it adds a feature, and that's fine. The **core** (domain-neutral infrastructure — `src/core/`, the platform layer, the docs that describe them) is held to a higher bar: while the system is still being built the core does grow, but each core change should buy proportionally more than a domain addition — new infrastructure that many modules use, not a one-off. A core change is suspect until that leverage is shown. Watch the ratio — core is meant to be the lean base under a wider domain, not the other way around. +- **Default to subtraction.** The reflex on most changes — a bug fix, a review finding, a refactor — should be *can this remove or replace code, or land net-neutral?*, not *what do I add?* If a change only ever grows the line count and the doc count, that's the smell this rule exists to catch. Prefer removing code over adding it; a deletion that preserves behaviour is the best kind of change. +- **No duplication, in code or docs.** Same logic in two places belongs in one shared function; same fact in two docs belongs in one place the other links to. A comment or doc paragraph that restates what the code already says is duplication too — delete it. (Reuse a recognisable shape rather than inventing one — see *Common patterns first* above.) +- **Data over objects in the hot path.** Where speed and memory matter most, design around plain contiguous data, not an object graph: a flat buffer of elements that one stage writes and the next stage reads, following the producer/consumer data flow in [docs/architecture.md](docs/architecture.md). This is a deliberate performance choice — a contiguous buffer is cache-friendly and lets a stage do integer math straight on the array, whereas per-element objects with virtual accessors are cache-hostile and allocation-heavy, exactly what the hot-path rules forbid. The one deliberate class hierarchy is the module tree (one `MoonModule` base, shallow subclasses, a single virtual-dispatch boundary), because uniform polymorphism is what lets the UI render any module generically with zero per-module UI code. Don't add inheritance elsewhere, and don't wrap hot-path buffer data in objects. - **Concrete first, abstract later.** Build one working feature end-to-end before extracting patterns into shared abstractions. Don't build the framework before the domain logic works. - **Domain-neutral core.** Separate core infrastructure from the light domain as much as practical. When mixing is necessary, use domain-neutral naming so the code stays open to future separation. - **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. Exceptions: `docs/plan.md` and `docs/history/`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 483ae459..4c6e054c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,7 +47,7 @@ add_custom_target(build_info_gen DEPENDS ${CMAKE_SOURCE_DIR}/src/core/build_info add_custom_command( OUTPUT ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h COMMAND ${CMAKE_COMMAND} -DUI_DIR=${CMAKE_SOURCE_DIR}/src/ui -DOUT=${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h -P ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake - DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js + DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js ${CMAKE_SOURCE_DIR}/src/ui/preview3d.js ${CMAKE_SOURCE_DIR}/src/ui/moonlight-logo.png ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake COMMENT "Embedding UI files" ) add_custom_target(ui_embed DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h) diff --git a/docs/architecture.md b/docs/architecture.md index 97a774a3..4c50e98e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,18 +121,20 @@ The model is **producers vs consumers**: producers generate data, consumers proc ## Data exchange between modules -When one module produces data another module reads on the hot path — the Layer pixel buffer that drivers send, the `PreviewFrame` the HTTP module broadcasts, the `Correction` table physical drivers apply — the pattern is the same throughout the codebase: +When one module produces data another module reads on the hot path, the pattern is the same throughout the codebase. Two shapes, both core-defined and domain-neutral: + +**Shared-struct (pull).** The reader holds a pointer to the producer's data and reads it when it needs it. - The **producer owns a small POD struct** as a member, overwritten in place each tick. No allocation per frame. -- A **plain-data header** declares the struct (`Buffer`, `Correction`, `PreviewFrame`). Both producer and consumer include it; neither needs to know the other's class. +- A **plain-data header** declares the struct. Both producer and consumer include it; neither needs to know the other's class. - The producer exposes the struct via a `const`-returning getter (or a `setX(const Foo*)` setter on the consumer). - The **consumer holds a `const Foo*`** received once at wiring time in `main.cpp`, and reads it on the hot path each frame. No registry, no subscription, no event bus. The consumer reads the latest value when it needs it; if the producer wrote nothing this tick, the consumer sees the previous value (acceptable for the kinds of data this exchanges — frame buffers, periodic captures). Producer and consumer can run on different cores: publisher-write / readers-read with a one-frame staleness tolerance that doesn't need a lock. -Concrete examples: `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white); `PreviewDriver` writes a `PreviewFrame` that `HttpServerModule` reads for the WebSocket broadcast; `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path. +**Push through a domain-neutral sink.** When the producer should hand bytes to a generic core service rather than expose a struct, the core defines a narrow interface and the producer pushes to it. The producer owns the data and its wire format; the core sink (the interface's implementer) knows only "take these bytes and do my generic job" — it has zero knowledge of what the bytes mean or which domain produced them. `BinaryBroadcaster` (`HttpServerModule` implements it: "broadcast these bytes to all WebSocket clients") is the example — the producer side lives in the light domain (see [§ The pipeline](#the-pipeline)). -The shape extends without ceremony to any future producer/consumer pair (a sensor module owning a state struct, an effect reading it through a `const Foo*` set at wiring time). It is deliberately not pub/sub: there's one producer per data kind and the consumer explicitly wants that specific data — the registry overhead and listener-lifecycle complexity of pub/sub buy nothing. +Both shapes extend without ceremony to any future producer/consumer pair (a sensor module owning a state struct, an effect reading it through a `const Foo*` set at wiring time; or a module pushing bytes to a core sink). Neither is pub/sub: there's one producer per data kind and the consumer explicitly wants that specific data — the registry overhead and listener-lifecycle complexity of pub/sub buy nothing. ## Event triggering between modules @@ -212,6 +214,11 @@ Modules in the light pipeline can be added, replaced, or removed dynamically at └── PreviewDriver (raw buffer, no Correction) ─→ WebSocket ``` +**Data flow.** The pipeline instantiates both core data-exchange shapes (see [§ Data exchange between modules](#data-exchange-between-modules)): + +- *Shared-struct (pull):* `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white), and `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path — each consumer holds a `const`-pointer set once at wiring time and reads it per frame. +- *Push to a core sink:* `PreviewDriver` owns the preview wire format (a one-time coordinate table + per-frame RGB point list) and pushes the bytes to a `BinaryBroadcaster` (the core HTTP server). The server broadcasts them over WebSocket without knowing they're a preview — the format and the light types stay entirely in the driver. See [PreviewDriver](moonmodules/light/drivers/PreviewDriver.md). + **Naming convention.** Capital `Layouts`, `Layers`, `Drivers` are class names (always capitalised when referring to the class). Lowercase "layouts", "layers", "drivers" is the English plural — used freely when context makes it clear. Singular "layout", "layer", "driver" is an individual instance. ## 3D from the start @@ -412,7 +419,7 @@ The UI is **MoonModule-driven**. It contains no hard-coded knowledge of specific - Controls are auto-rendered by type (slider, toggle, colour picker, text input, dropdown). - Modules can be switched (change which effect a layer uses) and linked (assign a layout to a layer). -Adding a new MoonModule with controls needs **zero changes** to the UI files. +Adding a new MoonModule with controls needs **zero changes** to the UI files. This extends to the tree-mutation affordances: which modules accept children (and of what role) comes from each type's `acceptsChildRoles()`, and whether a module can be deleted/replaced comes from its `userEditable()` — both declared on the C++ side and reported in `/api/types` + `/api/state`. The UI hardcodes no list of "which types are containers" or "which roles are editable"; a new container type or a fixed child is a one-line C++ override. The light domain plugs into the UI at three points: a fixed top-level tree (Layouts / Layers / Drivers pinned in `main.cpp`, root reorder disabled while child reorder works via drag-and-drop), a binary WebSocket preview channel ([PreviewDriver](moonmodules/light/drivers/PreviewDriver.md) — type byte `0x02`, 13-byte header `dw/dh/dd/ow/oh/od`, RGB triples), and emoji-key assignments for the chip filter (full table in [core/ui.md](moonmodules/core/ui.md)). Full UI spec: [docs/moonmodules/core/ui.md](moonmodules/core/ui.md). diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 1c5a15ad..3a985fcc 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -50,7 +50,7 @@ When a `switch (type)` outside the type's home file is legitimate: the caller ha ## File shape: header-only vs `.h` + `.cpp` - **Light-domain modules and the `MoonModule` base: header-only.** Every effect, modifier, driver, layout, the light-domain containers (`Layouts`, `Layers`, `Drivers`, `Layer`), and the `MoonModule` base class live in a single `.h` with implementation inline. The benefit is concrete: a contributor copies `RainbowEffect.h`, edits, saves as `MyEffect.h`, registers one line in `main.cpp` — no "where does the `.cpp` go, what does CMake need" friction. The chain `RainbowEffect.h → EffectBase.h → MoonModule.h` is uniform; readers don't pivot to a different file shape at the base. When a light-domain file outgrows one concern, extract a helper into its own header (`BlendMap`, `MappingLUT`) rather than splitting to `.h` + `.cpp`. Header-only is a feature of the light domain. -- **Core service modules: `.h` + `.cpp`.** Core modules that bridge to the platform layer or implement substantial infrastructure (`HttpServerModule`, `FilesystemModule`, `NetworkModule`, `Scheduler`, `SystemModule`, `Control`) ship as a `.h` (interface) plus a `.cpp` (implementation). Three reasons that compound: (a) implementation changes recompile only the `.cpp`, not every TU that includes the header — incremental builds are 2–5× faster on the kind of edits that happen in development; (b) readers want the interface separately from the body; (c) symbol bloat and link-time stay bounded. Small core utilities that are *almost entirely declarations or inline accessors* — `types.h`, `color.h`, `version.h`, `PreviewFrame.h`, `JsonUtil.h`, `JsonSink.h`, `Sha1.h`, `Base64.h` — stay header-only. Templates (e.g. `ModuleFactory::registerType`) also must stay in the header because of C++ instantiation rules; a module that's mostly template can therefore stay header-only. +- **Core service modules: `.h` + `.cpp`.** Core modules that bridge to the platform layer or implement substantial infrastructure (`HttpServerModule`, `FilesystemModule`, `NetworkModule`, `Scheduler`, `SystemModule`, `Control`) ship as a `.h` (interface) plus a `.cpp` (implementation). Three reasons that compound: (a) implementation changes recompile only the `.cpp`, not every TU that includes the header — incremental builds are 2–5× faster on the kind of edits that happen in development; (b) readers want the interface separately from the body; (c) symbol bloat and link-time stay bounded. Small core utilities that are *almost entirely declarations or inline accessors* — `types.h`, `color.h`, `version.h`, `BinaryBroadcaster.h`, `JsonUtil.h`, `JsonSink.h`, `Sha1.h`, `Base64.h` — stay header-only. Templates (e.g. `ModuleFactory::registerType`) also must stay in the header because of C++ instantiation rules; a module that's mostly template can therefore stay header-only. - **Exceptions need a one-line comment at the top of the file naming the reason.** Without a stated reason the file is expected to follow the default for its category. When in doubt: light → header-only, core → `.h` + `.cpp`. ## Override-and-chain convention diff --git a/docs/history/decisions.md b/docs/history/decisions.md index df4d82a1..fabe0bd0 100644 --- a/docs/history/decisions.md +++ b/docs/history/decisions.md @@ -649,3 +649,33 @@ Three non-obvious failures showed up while adding native-USB S3 support, all wit 2. **CORS preflight is silent on the client side.** Every cross-origin POST from a browser with `Content-Type: application/json` (everything the web installer does) triggers an OPTIONS preflight. If the device's HTTP server returns 405 to OPTIONS, the browser **silently drops the subsequent POST** — no error surfaces in client code, no network tab line, no console message in the install log. The symptom is "the API write didn't happen" with no diagnostic to follow. Burned an entire session diagnosing what looked like a board-injection-fan-out bug; the root cause was an unhandled HTTP verb. **Fix:** always implement OPTIONS in any device-side HTTP server that's reachable cross-origin. Return 204 with `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers: Content-Type`. Verify with `curl -X OPTIONS -H "Origin: ..." -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type" http://device/api/control` — must return 204 with the headers, not 405. 3. **Cached "last applied" state goes stale when the underlying stack restarts.** The `txPowerSetting` cap used an `appliedTxPowerSetting_` field to skip redundant `esp_wifi_set_max_tx_power` calls. When ESP-IDF stops the WiFi stack (AP→STA cascade, STA reconnect, AP shutdown), it resets the radio's TX-power state — but our cached "applied" value stayed equal to the desired value, so `syncTxPower()`'s equality check short-circuited and the cap never re-landed on the restarted radio. The user saw "the cap doesn't actually work after the first power-cycle." Classic cache-invariant bug: the cache was correct vs the *driver state we set*, but stale vs the *underlying hardware state* that an external event reset. **Fix:** every callsite that calls `wifiStaStop()` / `wifiApStop()` also invalidates the cache (`appliedTxPowerSetting_ = -1`). Generalisable: any "skip if already applied" optimisation against a hardware peripheral needs an invalidation hook tied to every event that resets the peripheral, not just the obvious explicit ones. + +## Core/light type boundary: light_types.h split + preview decouple + +`src/core/types.h` had grown into a junk-drawer of light-domain types (`nrOfLightsType`, `CoordCallback`, `defaultGridSize`, `HEAP_RESERVE`, `lengthType`) alongside `Dim`. Split it so each symbol lives with its owner. Done in two passes: + +**Pass 1 — the symbols with no core consumer:** +- **`nrOfLightsType`, `CoordCallback`, `defaultGridSize`** → `src/light/light_types.h`. +- **`HEAP_RESERVE`** → `platform.h` (it's a platform memory constraint guarding stack/HTTP/WiFi headroom — not a light type and not Layer's, even though Layer was its only caller; ownership follows concept, not call count). + +**Pass 2 — `lengthType`, by removing its core consumers rather than declaring them load-bearing:** +The apparent blocker was "core uses `lengthType`." On inspection the uses were incidental: `Control.h` only *mentioned* it in a comment; `HttpServerModule`'s `put16` only took it because it serialised `PreviewFrame`. The real tie was `PreviewFrame` itself — a light-produced struct sitting in `src/core/` that core's `HttpServerModule` read to build the preview WS frame. We severed it properly: +- Introduced `BinaryBroadcaster` (core interface, ~6 lines): "send these bytes to all WS clients." `HttpServerModule` implements it via `broadcastBinary` — the old `broadcastPreviewFrame` body minus all preview specifics. +- `PreviewFrame.h` → `src/light/`. `PreviewDriver` now owns the 13-byte header packing and **pushes** the bytes to the broadcaster; the HTTP server no longer reads `PreviewFrame` or knows the format. Push replaced the old `PreviewFrame::ready` poll (flag deleted). +- `lengthType` → `light/light_types.h`, zero core users left. + +**Result:** core has zero light dependency in the preview path; light owns the preview end to end; the binary-frame *transport* stays in core (reusable by any future binary feed — the leverage that justifies the new interface). + +**Pass 3 — `Dim`, and the deletion of `core/types.h`.** `Dim` (the effect/modifier dimensionality enum) was the last light symbol in core. It looked load-bearing: `ModuleFactory::registerType` probes a type for `dimensions()` to capture the UI's 📏/🟦/🧊 chip, and the probe named the enum — `requires { { t.dimensions() } -> std::same_as; }`. But the very next line did `static_cast(probe.dimensions())` — the factory only ever wanted a byte. The `Dim` name in the constraint was incidental, not essential. Loosened the probe to `requires { static_cast(t.dimensions()); }` — detects the method and reduces it to a byte without naming the type. `Dim` then moved to `light/light_types.h`, and with it gone **`core/types.h` was empty and was deleted.** Verified the probe still captures correctly: `/api/types` reports dim 3 for NoiseEffect, 2 for CheckerboardEffect, 0 for GridLayout/ArtNetSend — unchanged. (Safe because only EffectBase/ModifierBase declare `dimensions()`; nothing else matches the loose constraint.) + +**The rule that held:** don't accept "core uses X" at face value — check whether the use is *essential* or *incidental*. Every tie here turned out incidental and severable: a comment (`Control.h`), a serializer following a misplaced struct's field type (`PreviewFrame`/`put16`), and a SFINAE constraint that named a type it immediately discarded (`Dim`). The fixes: move the real owner and give core a domain-neutral seam — `BinaryBroadcaster` for the preview bytes, a return-type-agnostic probe for `dimensions()`. End state: **no `core/types.h`; core names zero light types.** + +## Time-gated effects must still paint every frame (GameOfLifeEffect) + +`GameOfLifeEffect` advances one generation per `bpm`-derived "beat", so most frames don't step the simulation. The first version skipped the render on non-step frames as an optimisation — and the display went black between beats, "a flash now and then". The cause: `Layer::loop()` calls `buffer_.clear()` **before every effect frame**, so an effect that doesn't write produces a black frame, not a held one. The render hot path has no frame-to-frame persistence; the buffer is the effect's to fill, every time. + +**The rule:** separate *when the simulation advances* from *when the effect paints*. Time-gate the state update (the `dt*bpm` accumulator, same shape as `CheckerboardEffect`), but **always repaint the current state**. The sim runs at `bpm`; the paint runs at frame rate. This applies to any future effect whose internal clock is slower than the tick (a slow automaton, a beat-synced pattern). A `unit_GameOfLifeEffect` case ("renders every frame between generations") pins it. + +**Two adjacent traps the same effect hit:** +- **First-frame `dt`.** `lastElapsed_` starts at 0, so the first `now - lastElapsed_` is the whole device uptime — a huge `dt` that pins the step accumulator above the beat threshold *permanently* (max rate forever, `bpm` ignored). Bootstrap `lastElapsed_` on the first call and take `dt = 0` that frame. +- **Width of the change delta.** The stagnation check narrowed `alive - lastAlive_` to `uint16_t`; at the 512×512 max grid on a PSRAM board (`nrOfLightsType == uint32_t`, 262144 cells) that truncates and triggers false re-seeds. Counters derived from cell counts must be `nrOfLightsType`, not a fixed width. diff --git a/docs/moonmodules/core/HttpServerModule.md b/docs/moonmodules/core/HttpServerModule.md index 9d39b103..1a22b15c 100644 --- a/docs/moonmodules/core/HttpServerModule.md +++ b/docs/moonmodules/core/HttpServerModule.md @@ -48,14 +48,14 @@ All JSON responses stream through a `JsonSink` — no fixed-buffer ceiling, so a `GET /ws` with `Upgrade: websocket` → RFC 6455 handshake (SHA-1 + base64). Up to 4 concurrent clients. - **Server → client text frames:** full state JSON, pushed by `loop1s()`. -- **Server → client binary frames:** a domain-defined preview channel. Today only the light domain emits frames — leading byte `0x02`, 13-byte header (`dw/dh/dd/ow/oh/od`, little-endian uint16), RGB triples. Pushed by `loop20ms()` when `PreviewFrame::ready` is set by [PreviewDriver](../light/drivers/PreviewDriver.md). See [architecture.md § Web UI](../../architecture.md#web-ui). +- **Server → client binary frames:** `broadcastBinary(chunks)` sends one binary WS message (FIN+binary opcode) to every connected client — it prepends the WS frame header and writes; the payload bytes are the caller's. Domain-neutral: the server doesn't know what the bytes mean. Today the only caller is the light domain's [PreviewDriver](../light/drivers/PreviewDriver.md), whose frame format (leading byte `0x02`, 13-byte header, RGB triples) lives in the driver, not here. - **Client → server:** none. Mutations go through the REST API. -The preview broadcast uses a single non-blocking scatter-gather write (`TcpConnection::writeChunks` — one `writev`/`sendmsg`) so the render task never blocks on a slow browser. `Complete` and `WouldBlock` both keep the connection open; `Partial` or socket error drops the connection and the browser auto-reconnects. PreviewDriver downsamples the frame to ≤1849 voxels so the whole WebSocket message fits lwIP's TCP send buffer (`CONFIG_LWIP_TCP_SND_BUF_DEFAULT` = 11520 B on ESP32). +`broadcastBinary` uses a single non-blocking scatter-gather write (`TcpConnection::writeChunks` — one `writev`/`sendmsg`) so the render task never blocks on a slow browser. `Complete` and `WouldBlock` both keep the connection open; `Partial` or socket error drops the connection and the browser auto-reconnects. ## Cross-domain wiring -HttpServerModule is core infrastructure with no light-domain dependencies. It walks the module tree via the generic `MoonModule::childCount()` / `child()` interface. For the 3D preview it reads from a `PreviewFrame` (`src/core/PreviewFrame.h`) — a plain struct with a `const uint8_t*` pointer + dimensions, no light types. The light-domain `PreviewDriver` writes to `PreviewFrame`; HttpServerModule reads it. The wiring happens in `main.cpp`, which is the only file that knows both domains. +HttpServerModule is core infrastructure with **no** light-domain dependencies — no `PreviewFrame`, no light types, no light includes. It exposes the `BinaryBroadcaster` interface (`broadcastBinary`); the light-domain `PreviewDriver` holds a `BinaryBroadcaster*` and pushes each downsampled frame's bytes to it. `main.cpp` wires `PreviewDriver`'s broadcaster to the HttpServerModule instance — the only file that knows both. The preview's voxel budget (≤1849, fitting lwIP's TCP send buffer) and wire format are PreviewDriver's concern, documented there. ## Prior art diff --git a/docs/moonmodules/core/MoonModule.md b/docs/moonmodules/core/MoonModule.md index f291b29c..ff427b26 100644 --- a/docs/moonmodules/core/MoonModule.md +++ b/docs/moonmodules/core/MoonModule.md @@ -64,6 +64,8 @@ Every MoonModule has a dynamic children array. `addChild()`, `removeChild()`, `r Children are distinguished by `role()` (Effect, Modifier, Driver, Layout, Generic). Containers that need role-specific iteration (e.g. Layer::loop() only calls loop() on Effects, not Modifiers) filter children by role at the call site. +Two virtuals govern UI tree-mutation, keeping that policy on the device rather than hardcoded in the web UI (see [architecture.md § Web UI](../../architecture.md#web-ui)): `acceptsChildRoles()` — comma-separated roles this module accepts as user-added children (`""` default; a container like Layer returns `"effect,modifier"`), surfaced per-type in `/api/types`, drives the UI's `+ add child` affordance and picker filter. `userEditable()` — whether the user may delete/replace this module (`true` default; a load-bearing child like PreviewDriver returns `false`), surfaced per-instance in `/api/state` (emitted only when false). The `+ add child` policy lives on the parent; the deletable/replaceable policy lives on the child. + Parents own their children's lifecycle. Only top-level modules are registered with the Scheduler — parents propagate `setup()`, `onBuildControls()`, `onBuildState()`, `loop()`, `loop20ms()`, `loop1s()`, and `teardown()` to their children. This means children don't need separate Scheduler registration. ### Lifecycle-aware add/remove diff --git a/docs/moonmodules/core/SystemModule.md b/docs/moonmodules/core/SystemModule.md index 398ea222..704140e4 100644 --- a/docs/moonmodules/core/SystemModule.md +++ b/docs/moonmodules/core/SystemModule.md @@ -18,7 +18,7 @@ System-level diagnostics and device identity. Always loaded, always visible in t - `deviceName` (text, default `MM-XXXX` where XXXX = last 4 hex of MAC) — device name. Used as hostname for mDNS, AP SSID, and UI display. Persisted after item 11. **Static (set at boot):** -- `version` (read-only) — projectMM version from library.json +- `version` (read-only) — semver from library.json (`MM_VERSION`), plus the release channel in parentheses when the build was published under one: `1.0.0-rc2 (latest)`, `1.0.0 (v1.0.0)`. The channel (`MM_RELEASE`) is burned in by `release.yml` via `build_esp32.py --release `; a local / dev build has no channel and shows the bare semver. Semver answers *what code*; the channel answers *which release this device was flashed from* — a moving `latest` build and a tagged release can share a semver but differ in channel. Desktop builds show the bare semver today (the desktop packager doesn't set the channel). - `build` (read-only) — build date/time - `firmware` (read-only) — build-time firmware variant key from `src/core/build_info.h` (`MM_FIRMWARE_NAME`): `esp32`, `esp32-eth`, `esp32-eth-wifi`, `esp32s3-n16r8` for the shipped firmware variants; `desktop-macos-arm64` / `desktop-windows-x64` for packaged desktop binaries; `desktop-dev` for unpackaged local desktop builds. Identifies which release asset matches the device — the same key appears in the firmware filenames published by `release.yml`. "Firmware" is the compiled binary; the physical board the firmware runs on lives on the [BoardModule](BoardModule.md) child (code-wired in `main.cpp`, mirrors how Improv sits under Network). - `chip` (read-only) — chip model (ESP32, ESP32-S3, etc.) diff --git a/docs/moonmodules/core/ui.md b/docs/moonmodules/core/ui.md index 1131933a..d39cffef 100644 --- a/docs/moonmodules/core/ui.md +++ b/docs/moonmodules/core/ui.md @@ -97,10 +97,9 @@ Card structure: - **Help link (?)** at the far right of the title row opens the module's spec page on GitHub in a new tab. The path comes from `docPath` in `/api/types` (engine-provided, relative to `docs/moonmodules/`); the link is omitted when the type declares no doc path. - **Stats line** shows timing and memory: `🕒 ` then `🧠 [ + ]`. Timing is fps or µs/ms per the global toggle (µs under 1 ms, ms above); it is omitted entirely when the module has no measured loop time. Memory is the module's C++ object size (`classSize`); the `+ ` part (`dynamicBytes`, heap) is shown only when the module allocated heap. Sizes are compact-formatted (`B` / `KB`). Clicking the line cycles the timing figure fps ↔ ms; the memory figure is unaffected. Mode persists in `localStorage['mm_timing_mode']` and the toggle applies to all cards globally. - **Reorder is drag-and-drop** (HTML5 DnD) on the whole card — works on desktop and mobile. A drag starting on an interactive control (slider, button, toggle, select, text input, help link) is canceled in `dragstart` so the control's own gesture is used instead; drags from any other part of the card start a reorder. A drag is only accepted when source and target share the same `.card-children` container — i.e. they are true siblings under one parent. -- **Replace (✎)** swaps this module for another type at the same position. Opens the [type picker](#type-picker) filtered to the module's own role (same-role swap only). The replacement starts with its own default control values — a clean swap, no value carry-over. Siblings, order, and the selected root are preserved. -- **Delete (×)** removes the child via a press-twice confirm: the first click arms the button (it turns red and shows `✓`), a second click deletes. It disarms after 3s or when the pointer leaves — no browser `confirm()` popup. +- **Replace (✎) / Delete (×) / drag-reorder** apply to *user-managed children* — any module whose role is one a container accepts (`acceptsChildRoles`) and that hasn't opted out via `userEditable: false`. Replace swaps for another type at the same position (type picker filtered to the module's own role; replacement starts at its own defaults; siblings/order/root preserved). Delete is a press-twice confirm (first click arms — red `✓` — second deletes; disarms after 3s or on pointer-leave; no browser `confirm()`). A child with `userEditable: false` (e.g. PreviewDriver, which feeds the live preview) shows none of these — it's fixed-shape like a code-wired child. - **Drag handle (☰)** is a visual cue; the whole card is the draggable element. -- **`+ add child`** in the card footer opens the [type picker](#type-picker) filtered to legal child roles for this parent. The button hides while the picker is open (the picker takes its place) and reappears when it closes. +- **`+ add child`** renders in a parent's footer when its type declares `acceptsChildRoles` (non-empty). Opens the [type picker](#type-picker) filtered to those roles. The button hides while the picker is open and reappears when it closes. ## Control types @@ -130,7 +129,7 @@ Auto-rendered by `controls[].type`. Adding a new MoonModule with these control t The same picker serves two purposes: **add** (triggered by `+ add child`) and **replace** (triggered by the ✎ button on a card). Renders inline inside the card (not a modal). -- **Role filter**: in add mode, filters to roles legal for the parent (the container declares which child roles it accepts). In replace mode, filters to the target module's own role. The role→child mapping is derived in the UI. +- **Role filter**: in add mode, filters to the parent's `acceptsChildRoles` (the device declares it per-type in `/api/types` — the UI hardcodes no container→role mapping). In replace mode, filters to the target module's own role. - **Emoji tag chips**: a row of toggle chips above the list, one per distinct emoji across the role-filtered types. Each type's emoji set has three sources, in this order: a **role chip** (derived in the UI from `role`), a **dimensional chip** (derived in the UI from `dim` when the type declares one — 1/2/3 means 1D/2D/3D), and the curated **`tags`** string from `/api/types` (the module's `tags()` — a flash string literal). The UI treats `tags` as opaque: it splits the string into grapheme clusters and renders each as a chip. The domain that owns this UI assigns each emoji's meaning — see the domain's own architecture page for the assignments (e.g. [architecture.md § Web UI](../../architecture.md#web-ui) for the role / dim / origin / creator / audio / moving-head assignments used by the light domain shipped today). Toggling chips narrows the list with **AND** logic: a type shows only if it carries every active chip. Each list row shows the type's emoji before its name. - **Search box** with substring match on type name. Search and chips combine (both must match). - **Keyboard nav**: type to filter, ↓ to enter list, ↑↓ to move, Enter to confirm, Esc to cancel. @@ -159,14 +158,18 @@ Child reorder *within* a parent (a child within a container) is supported via HT GET /api/state full module tree state — initial load + post-mutation refresh each module entry includes name, type, role, enabled, loopTimeUs, classSize, dynamicBytes, controls[] + plus userEditable:false ONLY when the module opts out of + UI delete/replace (omitted = editable, the common case) streamed to the socket (no Content-Length, Connection: close) so a tree of any size serializes without a fixed-buffer limit -GET /api/types {types:[{name, displayName, role, docPath, tags, dim, defaults}]} — for the type picker +GET /api/types {types:[{name, displayName, role, docPath, tags, dim, acceptsChildRoles, defaults}]} — for the type picker name is the stable factory key (e.g. "SomeTypeRole"); use this for create/replace API calls displayName is the role-suffix-stripped label (e.g. "SomeType") — what the cards and picker rows show docPath is the spec page relative to docs/moonmodules/ ("" if none) tags is a curated emoji string for the picker's chip filter ("" if none) dim is the dimensionality (1/2/3) when the type declares one; 0 otherwise + acceptsChildRoles is a comma-separated list of child roles this type + accepts ("" = none) — drives the "+ add child" affordance + picker filter defaults map is captured from a fresh probe instance per type GET /api/system fps, tickTimeUs, freeHeap, freeInternal, maxBlock, uptime POST /api/control {module, control, value} — set a control value diff --git a/docs/moonmodules/light/drivers/PreviewDriver.md b/docs/moonmodules/light/drivers/PreviewDriver.md index e9d33528..2fd69e1c 100644 --- a/docs/moonmodules/light/drivers/PreviewDriver.md +++ b/docs/moonmodules/light/drivers/PreviewDriver.md @@ -2,49 +2,50 @@ ![PreviewDriver controls](../../../assets/screenshots/PreviewDriver.png) -Streams light data to the web UI via WebSocket for real-time 3D visualization. +Streams a true-shape 3D preview to the web UI over WebSocket. The preview is a **point list** — only the real lights, at their real positions — not a dense grid. So a sphere, ring, or arbitrary fixture map shows in its true shape, and the per-frame data is just the lights that exist (much less than a padded bounding box). ## Controls -- `fps` (uint8_t, default 12, range 1-60) — preview frame rate (independent of render loop) -- `detail` (uint8_t, default 2, range 1-3) — downsample detail. Sets the voxel budget for the strided copy: 1 = coarse (256 voxels), 2 = medium (1024), 3 = fine (1849). Higher = a denser point cloud and a larger WebSocket payload, capped so even `detail = 3` (~5.5 KB) fits lwIP's TCP send buffer. -- `decompress` (bool, default false) — UI render hint. When on, the browser reconstructs the downsampled frame back to the original physical grid resolution by block-replicating each received voxel across its original cells, so the preview shows the same voxel count as the real layout. Purely client-side — the wire payload is the downsampled frame either way. +- `fps` (uint8_t, default 24, range 1-60) — preview stream rate (independent of the render loop) ## Protocol -Binary WebSocket frames: `[0x02][dw16][dh16][dd16][ow16][oh16][od16][R G B ...]` +PreviewDriver owns both wire formats end to end and pushes the bytes to a `BinaryBroadcaster` (the core [HttpServerModule](../../core/HttpServerModule.md) implements it via `broadcastBinary`). The HTTP server only writes the bytes to its WebSocket clients — it has no knowledge of the preview, the light domain, or the formats below. `main.cpp` wires the driver's broadcaster to the HTTP server instance. This mirrors MoonLight's model: positions sent once at mapping time, channels per frame. -- 13-byte header: opcode, then the downsampled `dw/dh/dd` and the original-grid `ow/oh/od`, each a little-endian uint16 -- RGB data: 3 bytes per voxel of the downsampled grid, row-major order -- The UI renders this as a 3D point cloud using WebGL with orbit camera controls. With `decompress` off it draws the `dw×dh×dd` cloud directly; with `decompress` on it block-replicates to the `ow×oh×od` grid. +Two binary message types (first byte selects): -## Downsampling +- **`0x03` coordinate table** — sent on every LUT rebuild (layout add/replace/remove, resize, modifier change) and re-broadcast ~once per second so a newly-connected client catches up. Layout: -The preview frame is sent over WebSocket in a single non-blocking write, so it must fit lwIP's TCP send buffer (a backpressured browser must never stall the render task — see [HttpServerModule](../../core/HttpServerModule.md)). PreviewDriver therefore copies every Nth voxel (per axis) into a small owned buffer. The voxel budget is set by the `detail` control (256 / 1024 / 1849 for levels 1 / 2 / 3). The stride N is adaptive — the smallest stride whose downsampled voxel count fits the budget, recomputed each frame so it tracks runtime grid resizes. The frame's `width`/`height`/`depth` describe the downsampled grid; the browser derives positions from those, so no UI change is needed — it simply renders a coarser or finer point cloud. The owned buffer is sized once to the largest budget (`detail = 3`, ~5.5 KB) in `onBuildState()` — PreviewDriver's only allocation. + `[0x03][count:u16][bx:u8][by:u8][bz:u8][stride:u16][ (x:u8, y:u8, z:u8) × count ]` -The strided copy is fully 3D and channel-agnostic: it copies the first 3 (RGB) channels of each sampled light regardless of `channelsPerLight` (RGBW, RGBCCT, multi-channel DMX all work). The light index is derived from the bounding-box `(x, y, z)` but bounded by the real light count, so a **sparse layout** (wheel, sphere, arbitrary 3D shape — fewer lights than its bounding box) cannot read past the buffer; out-of-range cells render as black. Showing such non-grid layouts in their true shape needs the planned one-time coordinate message (below) — until then they preview as their dense bounding box. + `count` = points actually sent; `bx/by/bz` = bounding-box extent (the browser centres the cloud on it); positions are **1 byte per axis** (a layout's bounding box is ≤255/axis in practice; clamped on build). `stride` is the index-downsample factor (see Large layouts). -## Non-grid layouts +- **`0x02` per-frame channels** — RGB by driver-light index, in the same order as the coordinate table: -For grid layouts, the browser derives 3D positions from the index (`x = i % w`). For non-grid layouts (wheel, ring, sphere, arbitrary point clouds), positions can't be derived — the browser needs actual coordinates. + `[0x02][count:u16][stride:u16][ (r, g, b) × count ]` -Solution: **one-time coordinate message**. When the layout changes, send a separate WebSocket message with the coordinate table per light. The browser caches it. Binary pixel frames continue to stream only RGB data — coordinates sent once, then only pixel data streams. + The browser colours coordinate-table entry `i` with RGB triple `i`. It holds `0x02` frames until a `0x03` table has arrived. -Frame types: -- `0x02` — pixel data (every frame) -- `0x03` — coordinate table (on layout change only): `[0x03][count_lo][count_hi][x16 y16 z16 ...]` +## Sparse layouts & where the data comes from + +The driver reads the **sparse driver buffer** — the `Layer`'s `MappingLUT` extracts the real lights from the dense render grid into a buffer of exactly `Layouts::totalLightCount()` entries (a radius-4 sphere → 210, not its 9×9×9 = 729 box). That same buffer is what ArtNet sends. PreviewDriver reads it flat by light index and builds the coordinate table from `Layouts::forEachCoord` (same driver order), so RGB index `i` and coordinate `i` always refer to the same light. See [Layer](../Layer.md) / [MappingLUT](../MappingLUT.md) for the box→driver mapping. + +## Large layouts (index downsample) + +A preview message is one non-blocking `writev`; it must fit lwIP's TCP send buffer (`CONFIG_LWIP_TCP_SND_BUF_DEFAULT` = 11520 B), or the connection is dropped. Sparse layouts (sphere ≈ 634 B) send every light exactly (`stride` = 1). A large dense grid (128² = 16384 lights × 3 ≈ 48 KB) is **index-downsampled**: `stride` = smallest factor whose sent-point count (≤ 1800, ≈ 5.4 KB — well under half the send buffer, since the render task shares it and a payload near the ceiling would partial-write and drop the connection) fits the cap. Both `0x03` and `0x02` carry `stride`, and the browser plots every `stride`-th light **at its real position** — far better than the old dense-box block-replicate (which this replaces; there is no `decompress` / `detail` control anymore). + +Positions are 1 byte per axis. A layout whose bounding box exceeds 255 on any axis (e.g. a 512-wide grid) is **scaled** so the largest box edge maps to 255, preserving aspect ratio (the `0x03` header carries the scaled box extents, which the browser normalises against). Boxes ≤255/axis — every sparse layout and any grid up to 255 — are sent at exact integer positions (scale factor 1). So large grids preview at their true proportions, not flattened onto the 255 plane. ## Tests -- [Unit tests: PreviewDriver](../../../tests/unit-tests.md#previewdriver) — `detail` strides, original-dimension reporting, send-buffer budget, channel-agnostic copy. -- [Scenario: scenario_PreviewDriver_detail](../../../tests/scenario-tests.md#scenario_previewdriver_detail) — toggles `detail`/`decompress` on a live device, asserts no render-FPS regression. -- [Scenario: scenario_Layer_base_pipeline](../../../tests/scenario-tests.md#scenario_layer_base_pipeline) — full pipeline including preview driver. +- [Unit tests: PreviewDriver](../../../tests/unit-tests.md#previewdriver) — coordinate table = real-light count (sphere → 210, not 729), per-frame RGB count matches the table, large layout strides down, small layout exact. +- [Scenario: scenario_Layer_base_pipeline](../../../tests/scenario-tests.md#scenario_layer_base_pipeline) — full pipeline including the preview driver. ## Prior art -### MoonLight — PhysicalLayer + WebSocket +### MoonLight — PhysicalLayer + WebSocket ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h)) -Light preview sent via WebSocket binary frames directly from PhysicalLayer's display buffer. 3D WebGL renderer in frontend. No intermediate copy. +The model this implements: virtual(logical grid) → physical(sparse lights) via a mapping table; light **positions sent once** at mapping time (`monitorPass`, `packCoord3DInto3Bytes` = 1 byte/axis, `isPositions` header state), **channels streamed per frame**. 3D WebGL renderer in the frontend. ### projectMM v1 — PreviewModule ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/drivers/PreviewModule.h)) diff --git a/docs/moonmodules/light/effects/GameOfLifeEffect.md b/docs/moonmodules/light/effects/GameOfLifeEffect.md new file mode 100644 index 00000000..e15d1672 --- /dev/null +++ b/docs/moonmodules/light/effects/GameOfLifeEffect.md @@ -0,0 +1,63 @@ +# Game of Life Effect + +Conway's Game of Life (B3/S23) on the XY plane. A D2 effect: it simulates the +z=0 plane and `Layer::extrude` fills z on 3D layers. + +## Controls + +- `seed` — PRNG seed for the first initial state. Later re-seeds continue the + same PRNG stream, so each revival is a fresh soup rather than a replay. +- `wraparound` — when on, the grid edges wrap (a torus); when off, off-grid + neighbours count as dead. +- `hue` — base hue of living cells; a per-cell spatial offset is added so the + colony shows a gradient rather than a flat colour. +- `bpm` — generation rate. Roughly `bpm / 8` generations per second (bpm 8 ≈ + 1/s for watching gliders move, 255 ≈ as fast as the frame rate allows). The + step is time-gated, so the speed is independent of the device's tick rate. + +## Rendering + +Two `width × height` byte grids (cur/nxt) hold one cell each. The step reads +cur, applies B3/S23 (birth on exactly 3 live neighbours, survival on 2 or 3), +writes nxt, then swaps. Living cells render as +`hsvToRgb(hue + x*3 + y*5, 200, 255)`; dead cells are black. + +**Keeping it lively.** A random Conway soup always decays toward sparse +still-lifes plus a few blinkers — visually frozen, even though a plain +"nothing changed" check never fires (the blinkers keep flipping). So the grid +re-seeds when it goes **extinct**, **thins below ~3% density**, or **stops +growing for ~32 generations** (the live count barely moving). That keeps fresh +gliders and chaos coming. MoonLight does the richer version (pentomino +injection + CRC cycle detection); this is the minimal equivalent. + +## Memory + +`2 × width × height` bytes, allocated in `onBuildState` (PSRAM-first via +`platform::alloc`, like `FireEffect`'s heat grid) and reallocated when the +layer's dimensions change. At 128×128 that is 32 KB. Freed in `teardown` and the +destructor. Reported via `setDynamicBytes` so the per-effect heap figure is +honest. + +## Extension seams + +The simulation step and the colouring are decoupled: the rule lives in one +predicate (B3/S23 — birth on 3 neighbours, survival on 2 or 3) and the colour +in one render line (`hsvToRgb`). A different birth/survival mask drops into the +predicate without touching the rest; a palette lookup drops into the render +line in place of `hsvToRgb`. Nothing else is coupled to either. + +## Tests + +[Unit tests: GameOfLifeEffect](../../../tests/unit-tests.md#gameoflifeeffect) — B3/S23 rule (blinker oscillates, block is a still-life, lone cell dies), wraparound, grid (re)allocation and free, 0×0 survival, bpm pacing, and the renders-every-frame regression. + +## Prior art + +- **MoonLight `E_MoonModules.h` GameOfLife** — the feature-rich origin + (rulesets, palette colouring, blur, mutation, pentomino seeding, CRC stasis + detection). We take the proven algorithm and re-seed idea, not the structure. + ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h), + Ewoud Wijma 2022 / Brandon Butler 2024). +- **projectMM v1 — GameOfLifeEffect** + ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/GameOfLifeEffect.h)) — + used PSRAM grids and exposed `setCell` / `getCell` / `liveCount` test helpers + for deterministic rule testing without rendering; mirrored here. diff --git a/docs/moonmodules/light/layouts/GridLayout.md b/docs/moonmodules/light/layouts/GridLayout.md index b4c33a9d..fa3942f0 100644 --- a/docs/moonmodules/light/layouts/GridLayout.md +++ b/docs/moonmodules/light/layouts/GridLayout.md @@ -6,9 +6,8 @@ Arranges lights in a 3D grid (row-major: x varies fastest, then y, then z). Full ## Controls -- `width` (lengthType, default 16 no-PSRAM / 128 PSRAM, range 1-1024) -- `height` (lengthType, default 16 no-PSRAM / 128 PSRAM, range 1-1024) -- `depth` (lengthType, default 1, range 1-32) +- `width`, `height` (default `defaultGridSize` = 16, range 1–512) +- `depth` (default 1, range 1–512) ## Coordinate Iterator diff --git a/docs/moonmodules/light/layouts/SphereLayout.md b/docs/moonmodules/light/layouts/SphereLayout.md new file mode 100644 index 00000000..ff19a61c --- /dev/null +++ b/docs/moonmodules/light/layouts/SphereLayout.md @@ -0,0 +1,34 @@ +# Sphere Layout + +Arranges lights on the **surface of a hollow sphere** — a one-light-thick shell, no interior lights. Lattice layout: every light sits at an integer `(x, y, z)` inside a `(2·radius+1)³` bounding box centred at `(radius, radius, radius)`. + +## Controls + +- `radius` (default 4, range 1–64) — surface radius in light-units. A lattice point is on the shell when its distance from the centre rounds to `radius` (it falls in the half-open band `[radius−0.5, radius+0.5)`). `radius = 1` is the smallest hollow sphere — 18 lights: the 6 axis-neighbours (d²=1) plus the 12 edge-neighbours (d²=2) of the centre, all of which round to distance 1. + +## Coordinate Iterator + +Yields `(physicalIndex, x, y, z)` for each shell light, scanning the bounding box in z-then-y-then-x order. The physical index is sequential over emitted shell points only (gaps in the lattice aren't indexed). `lightCount()` and the iterator share one shell predicate, so the count always matches the emitted points — no off-by-one between allocation and fill. + +## Light count + +Derived from `radius`, not set directly: it's the number of lattice points landing in the shell band, counted with the same predicate the iterator uses. Grows roughly with the sphere's surface area (`~4π·radius²`) — e.g. `radius = 4` yields a few dozen lights. There is no minimum-light control; pick the `radius` that gives the surface density you need. + +## Mapping + +A sphere is **not** a 1:1 unshuffled layout — the shell points are sparse within the bounding box, so the physical indices don't map linearly to box coordinates. It supplies explicit coordinates per light via `forEachCoord`, the same contract every non-grid layout uses; the Layer/Drivers wiring treats it identically to any other `LayoutBase`. + +## Edge cases + +- `radius = 0`: prevented by min = 1 on the control. +- Distances are compared in squared integer space (no `sqrt`, no float per light), so the shell is exact and deterministic across platforms. + +## Tests + +[Unit tests: SphereLayout](../../../tests/unit-tests.md#spherelayout) — shell-only (no interior/centre point), symmetry, count matches the iterator, radius-1 base case. Add / replace / remove / multiple layouts are covered in [Layouts](../../../tests/unit-tests.md#layouts) and the layout-mutation scenario. + +## Prior art + +### MoonLight / projectMM v1–v2 — layout nodes ([MoonLight L_MoonLight.h](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h)) + +Prior projects expose layouts that call an `addLight(x, y, z)` per position; SphereLayout follows the same "a layout enumerates its light coordinates" shape, computing the shell analytically rather than from a stored list. diff --git a/docs/moonmodules_draft/light/effects/GameOfLifeEffect.md b/docs/moonmodules_draft/light/effects/GameOfLifeEffect.md deleted file mode 100644 index e48a795f..00000000 --- a/docs/moonmodules_draft/light/effects/GameOfLifeEffect.md +++ /dev/null @@ -1,29 +0,0 @@ -# Game of Life Effect - -Conway's Game of Life on the XY plane. - -## Controls - -- `seed` (slider, default 42, range 0-255) — PRNG seed for initial state -- `wraparound` (toggle, default false) — whether edges wrap -- `hue` (slider, default 160, range 0-255) — base colour hue (spatial offset added per cell) - -## Rendering - -Two uint8_t grids (cur/nxt) allocated in setup (in PSRAM when available). Standard GoL rules (B3/S23). On extinction (alive==0) or stasis (changed==0), the grid re-seeds automatically. Living cells are colored with `hsvToRgb(hue + x*3 + y*5, 200, 255)`. - -## Memory - -Allocates 2 × width × height bytes for the cell grids. At 128x128 = 32KB. Freed in teardown. - -## Edge cases - -- Grid reallocation needed when layout dimensions change (control `onUpdate`). -- `srand(seed)` is not thread-safe. Consider a local PRNG state. - -## Prior art - -### projectMM v1 — GameOfLifeEffect ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/GameOfLifeEffect.h)) - -v1 GameOfLifeEffect (commit 54b50bc). Used `pal::psram_malloc` for grids. Had test helpers: `setPattern()`, `getCell()`, `liveCount()`, `stepGeneration()` for deterministic testing without rendering. -Full implementation with test helpers (setPattern, getCell, liveCount, stepGeneration). diff --git a/docs/plan.md b/docs/plan.md index 91433a83..b1829210 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -159,6 +159,12 @@ When picked up: - Memory-aware allocator at `onBuildState` time decides how many Layers fit and degrades gracefully. - Persistence already encodes Layers children positionally — adding siblings just works on the file-format side. +### Per-layout coordinate offset for independent placement (backlog) + +`Layouts` stitches multiple child layouts into one physical light space, but only their *indices* are stitched (offset sequentially in `forEachCoord`) — their *coordinates* are not translated. Two layouts therefore overlap in the same coordinate box: two 64×64 grids both occupy x,y ∈ 0..63, so the Layer's dense bounding-box buffer is 64×64 (4096 voxels) even though the container reports 8192 lights, and the second layout's lights land on the first's positions. `scenario_Layouts_mutation` documents this (its steps assert pipeline liveness, not buffer-size arithmetic). + +When picked up: add `offsetX/Y/Z` (lengthType) controls to `LayoutBase`; `Layouts::forEachCoord` translates each child's emitted coords by its offset so layouts occupy disjoint regions of the physical extent (a 64-wide grid at offsetX=64 sits beside another at offsetX=0 → a 128×64 combined extent). `Layer::onBuildState` already derives physical dims from the max emitted coordinate, so it would pick up the wider extent automatically. Until then, "multiple layouts" means "multiple layouts sharing a coordinate box", which is only useful when they genuinely overlap (e.g. a sphere inscribed in a grid). + ### Improv as a child of NetworkModule (deferred — needs scheduler work first) Architecturally the right shape; attempted in plan-21, reverted. Blocker: `Scheduler::tick()` only walks top-level modules for `loop20ms`/`loop1s` — children silently miss those callbacks. See [decisions.md](history/decisions.md) "Trying to add a child module to NetworkModule". @@ -241,14 +247,15 @@ What to build (~4 h): Only **NoiseEffect** and **PlasmaEffect** have z-aware math. The other 10 effects are honest D2 — `Layer::extrude` duplicates the z=0 plane, so every z-slice is identical on 3D layers. Candidates for genuine D3 promotion: Metaballs/GlowParticles (add z to blob coordinates), Plasma palette/Spiral (add z-driven phase term), Fire (z-drift heat grid), Ripples/LavaLamp/Checkerboard/Particles (add z to each element). Prioritise after seeing real 3D installations; each promoted effect also needs its `dynamicBytes` budget for the full 3D buffer. -### Preview coordinate message — true-shape 3D preview (backlog) +### Full-density interpolated preview for large layouts (backlog) + +The preview index-downsamples a large layout to fit the WS send budget (e.g. 128×128 = 16384 lights → ~1639 sent at stride 10), so the UI shows a sparse sample, not every light. To show **all** lights at their real positions with **interpolated** colours for the unsent ones: -The 3D preview derives `(x, y, z)` from a dense grid index — correct only for grid layouts. For rings, spheres, or arbitrary point clouds the preview shows a wrong dense bounding box. +- Decouple the `0x03` coordinate-table density from the per-frame `0x02` stride. Positions are static and sent once, so the table can carry **all** light coordinates (16384 × 3 = ~48 KB one-time — acceptable off the per-frame path, possibly chunked) while the per-frame RGB stays strided to protect ArtNet/the link. +- The browser holds the full position set and, per frame, interpolates each unsent light's colour from its nearest sent neighbours (the sent indices are known from the stride). True positions, guessed colours — better than the removed dense-box block-replicate because positions are exact. +- Open questions: 48 KB one-time table vs `MAX_WRITE_CHUNKS` / send-buffer (needs chunked send or a raised cap, with the same partial-write care as `writeChunks`' drain); interpolation cost on a 16384-point cloud each frame in JS; whether nearest-neighbour or weighted is worth it. -Design (already noted in [PreviewDriver.md](moonmodules/light/drivers/PreviewDriver.md)): -- Engine sends a one-time coordinate table per layout change and per new WS client: `[type][count16][x16 y16 z16]×count` (~6 bytes/light). Data source: `Layouts::forEachCoord`. -- Browser positions preview points from the table instead of the grid formula; per-frame frames stream RGB-only indexed by light. -- PreviewDriver downsample switches to index-based striding (simpler, correct for any shape). +Not simple — own planning pass. Until then the preview is a faithful strided *sample* (correct shape/colour/motion, not per-pixel). A cheap interim (point-size scaled by stride to fatten samples into their cells) was tried and reverted as not what's wanted — it filled the volume but didn't add real points. --- diff --git a/docs/tests/scenario-tests.md b/docs/tests/scenario-tests.md index f65b7fe0..edce93d8 100644 --- a/docs/tests/scenario-tests.md +++ b/docs/tests/scenario-tests.md @@ -29,11 +29,13 @@ Scenario tests are the integration tier in the [test strategy](../testing.md): e | `esp32` | — / 1,337 | — / 129KB | — / 48KB | | `esp32-eth` | ≥ 1,429 / 1,845-1,848 | ≥ 166KB / 178KB | ≥ 88KB / 96KB-100KB | | `esp32-eth-wifi` | ≥ 1,429 / 1,821 | ≥ 146KB / 139KB | ≥ 49KB / 52KB | +| `esp32s3-n16r8` | — / 1,672 | — / 8360KB | — / 160KB | | `pc-macos` | ≥ 200,000 / 200,000-1,000,000 | unlimited / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `esp32s3-n16r8`: observed 2026-06-04 - `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 #### `size-32x32` (set_control) 📏 @@ -53,12 +55,14 @@ Scenario tests are the integration tier in the [test strategy](../testing.md): e | `esp32` | — / 147 | — / 121KB | — / 48KB | | `esp32-eth` | ≥ 303 / 379-381 | ≥ 161KB / 172KB | ≥ 78KB / 92KB | | `esp32-eth-wifi` | ≥ 400 / 390 | ≥ 142KB / 132KB | ≥ 49KB / 50KB | -| `pc-macos` | ≥ 100,000 / 142,857-166,667 | unlimited / unlimited | — / unlimited | +| `esp32s3-n16r8` | — / 288 | — / 8349KB | — / 140KB | +| `pc-macos` | ≥ 100,000 / 111,111-166,667 | unlimited / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `esp32s3-n16r8`: observed 2026-06-04 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 #### `size-64x64` (set_control) 📏 @@ -77,12 +81,14 @@ Scenario tests are the integration tier in the [test strategy](../testing.md): e | `esp32` | — / 17.5 | — / 97KB | — / 48KB | | `esp32-eth` | ≥ 55.6 / 74.5-74.7 | ≥ 137KB / 147KB | ≥ 54KB / 62KB | | `esp32-eth-wifi` | ≥ 76.9 / 85.7 | ≥ 117KB / 108KB | ≥ 44KB / 48KB | -| `pc-macos` | ≥ 33,333 / 33,333-43,478 | unlimited / unlimited | — / unlimited | +| `esp32s3-n16r8` | — / 25.9 | — / 8310KB | — / 152KB | +| `pc-macos` | ≥ 33,333 / 30,303-43,478 | unlimited / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `esp32s3-n16r8`: observed 2026-06-04 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 #### `size-128x128` (set_control) 📏 @@ -101,12 +107,14 @@ Scenario tests are the integration tier in the [test strategy](../testing.md): e | `esp32` | — / 4.4 | — / 83KB | — / 52KB | | `esp32-eth` | ≥ 9.1 / 10.5-10.6 | ≥ 122KB / 132KB | ≥ 47KB / 48KB | | `esp32-eth-wifi` | ≥ 10.0 / 54.5 | ≥ 103KB / 129KB | ≥ 44KB / 52KB | -| `pc-macos` | ≥ 8,333 / 7,874-10,204 | unlimited / unlimited | — / unlimited | +| `esp32s3-n16r8` | — / 6.1 | — / 8163KB | — / 164KB | +| `pc-macos` | ≥ 8,333 / 4,975-10,204 | unlimited / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `esp32s3-n16r8`: observed 2026-06-04 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 ### scenario_GridLayout_resize @@ -146,12 +154,12 @@ Shrink to 128x64. Measured: FPS must stay within 20% of the baseline (proves the | `esp32` | — / 11.1 | — / 63KB | — / 17KB | | `esp32-eth` | — / 26.4-26.5 | — / 114KB | — / 48KB | | `esp32-eth-wifi` | ≥ 22.2 / 31.8 | ≥ 83KB / 75KB | — / 24KB | -| `pc-macos` | ≥ 16,667 / 5,208-21,277 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 16,667 / 5,208-21,739 | unlimited / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 #### `grow-to-128x128` (set_control) 📏 @@ -164,7 +172,7 @@ Grow back to 128x128. Measured: confirms the heap can return to the heavy baseli | `esp32` | — / 4.0 | — / 83KB | — / 52KB | | `esp32-eth` | — / 10.4 | — / 132KB | — / 48KB | | `esp32-eth-wifi` | ≥ 10.0 / 12.2 | ≥ 103KB / 93KB | — / 52KB | -| `pc-macos` | ≥ 8,333 / 4,237-10,101 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 8,333 / 3,257-10,204 | unlimited / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 @@ -181,11 +189,11 @@ Grow back to 128x128. Measured: confirms the heap can return to the heavy baseli #### `add-artnet` (add_module) 📏 -Add ArtNetSendDriver and run the bounded FPS measurement (must stay at >=80% of the rated FPS for grid size 16x16). +Add ArtNetSendDriver and run the bounded FPS measurement (expected to stay at >=80% of the rated FPS for the 128x128 grid this scenario builds; min_pct needs a live baseline, so it gates only on hardware and is skipped with a WARN in the desktop runner). **Setup** (preceding non-measured steps): - `add-layout-group` (add_module) — Create the top-level Layouts container. -- `add-grid` (add_module) — Add a GridLayout child to Layouts (default 16x16x1). +- `add-grid` (add_module) — Add a 128x128 GridLayout child to Layouts. Set explicitly (the module default is 16x16x1) so the tick is above the host's microsecond clock resolution — a 16x16 grid renders in <1us on desktop, flooring tick to 0. - `add-layer` (add_module) — Add a top-level Layer wired to the Layouts container, RGB (3 channels per light). - `add-rainbow` (add_module) — Add RainbowEffect as the Layer's only effect. - `add-driver-group` (add_module) — Add a top-level Drivers container wired to the Layer's output buffer. @@ -198,9 +206,9 @@ Add ArtNetSendDriver and run the bounded FPS measurement (must stay at >=80% of | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | ≥ 20,000 / 7,576-28,571 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 20,000 / 7,576-— | unlimited / unlimited | — / unlimited | -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 ### scenario_Layer_buildup @@ -210,7 +218,7 @@ Add ArtNetSendDriver and run the bounded FPS measurement (must stay at >=80% of #### `measure-minimum` (measure) 📏 -Baseline: 16x16 grid + Rainbow only. No Drivers yet (Layer renders into its own buffer). +Baseline: 16x16 grid + Rainbow only. No Drivers yet (Layer renders into its own buffer). No fps floor asserted — a 16x16 grid renders in <1us on desktop, flooring the integer-us tick (and thus FPS) to 0; the per-target tick contract is the meaningful check here (heap deltas are asserted on the later buildup steps that add Drivers/LUT). **Setup** (preceding non-measured steps): - `add-layout-group` (add_module) — Top-level Layouts container — no children yet, no lights, no buffer. @@ -218,36 +226,32 @@ Baseline: 16x16 grid + Rainbow only. No Drivers yet (Layer renders into its own - `add-layer` (add_module) — Layer wired to Layouts (RGB, 3 channels per light). - `add-rainbow` (add_module) — RainbowEffect as the only effect. Renderable from this point on. -**Bounds**: -- FPS ≥ 1 (absolute) - **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | ≥ 20,000 / 8,197-28,571 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 20,000 / 8,197-— | unlimited / unlimited | — / unlimited | -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 #### `measure-full-16x16` (measure) 📏 -Full pipeline at 16x16. Heap delta vs previous measure-minimum step should stay within +8KB on ESP32 (Drivers + ArtNet overhead, no LUT yet). +Full pipeline at 16x16. Heap delta vs previous measure-minimum step should stay within +8KB on ESP32 (Drivers + ArtNet overhead, no LUT yet). No fps floor — 16x16 ticks below the host's microsecond resolution on desktop; heap delta is the check here. **Setup** (preceding non-measured steps): - `add-drivers` (add_module) — Drivers container wired to the Layer. - `add-artnet` (add_module) — ArtNetSendDriver under Drivers. Full pipeline now end-to-end. **Bounds**: -- FPS ≥ 1 (absolute) - heap growth ≤ 8192B vs previous measure step **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | ≥ 20,000 / 5,464-28,571 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 20,000 / 5,464-— | unlimited / unlimited | — / unlimited | -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 #### `measure-with-lut-16x16` (measure) 📏 @@ -263,9 +267,9 @@ Mirror is on: Layer has a LUT, Drivers has an output buffer. min_fps_led_product | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | ≥ 16,667 / 7,407-22,727 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 16,667 / 6,667-— | unlimited / unlimited | — / unlimited | -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 #### `measure-full-128x128` (measure) 📏 @@ -311,9 +315,86 @@ Add ArtNetSendDriver and run the bounded FPS measurement on the no-LUT path. | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | ≥ 20,000 / 12,500-28,571 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 20,000 / 12,500-— | unlimited / unlimited | — / unlimited | -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 + +## Layouts + +### scenario_Layouts_mutation + +`test/scenarios/light/scenario_Layouts_mutation.json` — Tree mutation on the Layouts container while the pipeline runs: add a second layout (multiple layouts under one Layouts), replace a layout with a different type, and remove a layout. The check is that each mutation leaves the pipeline RENDERING — Layer + Drivers re-wire via buildState and the buffer stays non-null and non-zero. Mirrors the HTTP add/replace/delete handlers; exercises the runner's add_module / replace_module / remove_module ops. NOTE: the Layer renders a dense bounding-box buffer sized by the layouts' coordinate EXTENT, not the summed light count — layouts that overlap in coordinate space share voxels (two 64x64 grids both occupy x,y in 0..63). Independent placement awaits per-layout coordinate offsets (see docs/plan.md), so these steps assert liveness, not buffer-size arithmetic. Grids are 64x64 so the tick stays above the host's microsecond clock at every step. + +**Mode**: `mutate` · **Also touches**: GridLayout, SphereLayout, Layer, RainbowEffect, Drivers, ArtNetSendDriver + +#### `measure-one-layout` (measure) 📏 + +Baseline: a single 64x64 grid layout drives the pipeline. + +**Bounds**: +- FPS ≥ 1 (absolute) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | — / 29,412-125,000 | — / unlimited | — / unlimited | + +- `pc-macos`: observed 2026-06-05 + +#### `measure-two-layouts` (measure) 📏 + +Pipeline still renders with two layouts wired (buffer non-null, fps measurable). + +**Setup** (preceding non-measured steps): +- `add-second-layout` (add_module) — Add a SECOND layout (a 64x64 grid) under Layouts — two layouts now live under one container. buildState re-runs; the pipeline must still render. (Both grids share the 0..63 coordinate box, so the Layer buffer stays 64x64 — see the scenario NOTE.) + +**Bounds**: +- FPS ≥ 1 (absolute) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | — / 33,333-111,111 | — / unlimited | — / unlimited | + +- `pc-macos`: observed 2026-06-05 + +#### `measure-after-replace` (measure) 📏 + +Pipeline still renders after replacing a grid with a sphere (different layout type, same slot) — buffer re-wires without crashing. + +**Setup** (preceding non-measured steps): +- `replace-second-layout` (replace_module) — Replace the second grid with a SphereLayout (different type, same slot). The first grid is untouched; the pipeline re-wires to the new layout's light count. + +**Bounds**: +- FPS ≥ 1 (absolute) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | — / 11,494-100,000 | — / unlimited | — / unlimited | + +- `pc-macos`: observed 2026-06-05 + +#### `measure-after-remove` (measure) 📏 + +Pipeline renders with the single remaining grid, same as the baseline. + +**Setup** (preceding non-measured steps): +- `remove-second-layout` (remove_module) — Remove the sphere — back to a single grid layout. Layer/Drivers shrink their buffers via buildState. + +**Bounds**: +- FPS ≥ 1 (absolute) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | — / 16,949-125,000 | — / unlimited | — / unlimited | + +- `pc-macos`: observed 2026-06-05 ## MirrorModifier @@ -342,9 +423,9 @@ Add ArtNetSendDriver and run the bounded FPS measurement on the LUT path. | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | ≥ 8,333 / 3,322-9,901 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 8,333 / 3,322-1,000,000 | unlimited / unlimited | — / unlimited | -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 ### scenario_MirrorModifier_pipeline @@ -358,7 +439,7 @@ Add ArtNetSendDriver and run the bounded FPS measurement (mirror + LUT path must **Setup** (preceding non-measured steps): - `add-layout-group` (add_module) — Create the top-level Layouts container. -- `add-grid` (add_module) — Add a GridLayout child to Layouts. +- `add-grid` (add_module) — Add a 128x128 GridLayout child to Layouts. Set explicitly (the module default is 16x16x1) so the tick is measurable above the host's microsecond clock. - `add-layer` (add_module) — Add a Layer wired to Layouts (RGB). - `add-noise` (add_module) — Add NoiseEffect as the Layer's effect. - `add-mirror` (add_module) — Add MirrorModifier so logical pixels reflect across X and Y in the physical grid. @@ -371,9 +452,9 @@ Add ArtNetSendDriver and run the bounded FPS measurement (mirror + LUT path must | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | ≥ 8,333 / 4,695-10,000 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 8,333 / 4,065-1,000,000 | unlimited / unlimited | — / unlimited | -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 ## MoonModule @@ -397,12 +478,12 @@ Set NoiseEffect.scale=4 and measure baseline FPS (mirror on). Effect controls do | `esp32` | — / 3.9 | — / 88KB | — / 48KB | | `esp32-eth` | — / 10.5-10.6 | — / 133KB | — / 48KB-50KB | | `esp32-eth-wifi` | ≥ 10.0 / 12.2 | ≥ 103KB / 94KB | — / 48KB | -| `pc-macos` | ≥ 8,333 / 4,808-9,901 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 8,333 / 4,785-10,309 | unlimited / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 #### `disable-mirrorX` (set_control) 📏 @@ -415,12 +496,12 @@ Disable mirrorX. Modifier control triggers a pipeline rebuild — measures the r | `esp32` | — / 4.8 | — / 88KB | — / 48KB | | `esp32-eth` | — / 10.4 | — / 132KB | — / 48KB-50KB | | `esp32-eth-wifi` | ≥ 10.0 / 12.0 | ≥ 103KB / 94KB | — / 48KB | -| `pc-macos` | ≥ 5,000 / 4,184-5,525 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 5,000 / 3,650-5,525 | unlimited / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 #### `disable-mirrorY` (set_control) 📏 @@ -433,12 +514,12 @@ Disable mirrorY. Mirror is now fully off — should land on the no-LUT path. | `esp32` | — / 4.4 | — / 88KB | — / 48KB | | `esp32-eth` | — / 8.9-9.0 | — / 132KB | — / 48KB-50KB | | `esp32-eth-wifi` | ≥ 10.0 / 11.1 | ≥ 103KB / 94KB | — / 48KB | -| `pc-macos` | ≥ 2,500 / 2,481-2,890 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 2,500 / 1,916-2,890 | unlimited / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 #### `re-enable-mirrorY` (set_control) 📏 @@ -457,7 +538,7 @@ Re-enable mirrorY and measure — the heavy LUT path must recover (FPS within 50 | `esp32` | — / 4.4 | — / 88KB | — / 48KB | | `esp32-eth` | — / 10.5-10.6 | — / 132KB | — / 48KB-50KB | | `esp32-eth-wifi` | ≥ 10.0 / 12.1 | ≥ 103KB / 94KB | — / 48KB | -| `pc-macos` | ≥ 8,333 / 9,009-10,417 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 8,333 / 5,348-10,417 | unlimited / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 @@ -522,116 +603,3 @@ mDNS on again — measured with a bound: FPS must stay within 20% of the baselin - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 - -## PreviewDriver - -### scenario_PreviewDriver_detail - -`test/scenarios/light/scenario_PreviewDriver_detail.json` — Toggle the Preview driver's detail and decompress controls and measure the render-FPS impact. detail 2/3 have a known, accepted downsample cost on the render task; decompress is purely client-side and cannot affect the render tick (see performance.md). All steps assert a relative bound (min_pct) only — a single ESP32 scenario step swings too much for an absolute FPS floor to be meaningful (the absolute throughput floor is enforced in collect_kpi.py --commit, which uses a settled reading). detail 3 gets a looser bound because its downsample cost is real and accepted. - -**Mode**: `mutate` - -#### `detail-1-coarse` (set_control) 📏 - -detail=1 (coarsest, 16x16 downsample on a 128 grid). Cheapest preview render. - -**Bounds**: -- FPS ≥ 80% of baseline - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.0 | — / 83KB | — / 48KB | -| `esp32-eth` | — / 10.5-10.6 | — / 132KB | — / 50KB-52KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.2 | ≥ 103KB / 93KB | — / 48KB | -| `pc-macos` | ≥ 3,333 / 2,033-3,322 | unlimited / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-02 -- `esp32-eth`: observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 - -#### `detail-2-medium` (set_control) 📏 - -detail=2 (medium, 32x32 downsample). Known accepted cost — still hits 80% of baseline. - -**Bounds**: -- FPS ≥ 80% of baseline - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.9 | — / 83KB | — / 48KB | -| `esp32-eth` | — / 10.4-10.5 | — / 132KB | — / 50KB-52KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.0 | ≥ 103KB / 93KB | — / 48KB | -| `pc-macos` | ≥ 3,333 / 2,079-3,333 | unlimited / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-02 -- `esp32-eth`: observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 - -#### `detail-3-fine` (set_control) 📏 - -detail=3 (finest, 43x43 downsample). Looser bound (70%) because the downsample cost is real and accepted. - -**Bounds**: -- FPS ≥ 70% of baseline - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.5 | — / 83KB | — / 48KB | -| `esp32-eth` | — / 9.5-9.6 | — / 132KB | — / 50KB-52KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.6 | ≥ 103KB / 93KB | — / 48KB | -| `pc-macos` | ≥ 3,125 / 2,762-3,378 | unlimited / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-02 -- `esp32-eth`: observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 - -#### `decompress-on` (set_control) 📏 - -decompress=true. Client-side hint — does not affect the render tick. - -**Bounds**: -- FPS ≥ 80% of baseline - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.6 | — / 83KB | — / 48KB | -| `esp32-eth` | — / 10.5-10.6 | — / 132KB | — / 50KB-52KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.2 | ≥ 103KB / 93KB | — / 48KB | -| `pc-macos` | ≥ 3,333 / 2,703-3,401 | unlimited / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-02 -- `esp32-eth`: observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 - -#### `decompress-off` (set_control) 📏 - -decompress=false. Same as above — pure client-side, no render impact expected. - -**Bounds**: -- FPS ≥ 80% of baseline - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `esp32` | — / 4.8 | — / 83KB | — / 48KB | -| `esp32-eth` | — / 10.3 | — / 132KB | — / 50KB-52KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.0 | ≥ 103KB / 93KB | — / 48KB | -| `pc-macos` | ≥ 3,333 / 2,591-3,356 | unlimited / unlimited | — / unlimited | - -- `esp32`: observed 2026-06-02 -- `esp32-eth`: observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 diff --git a/docs/tests/unit-tests.md b/docs/tests/unit-tests.md index 6cd2ffb5..c5d8dd5b 100644 --- a/docs/tests/unit-tests.md +++ b/docs/tests/unit-tests.md @@ -29,6 +29,19 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - One logical light routed to multiple physical positions copies the colour to each (mirror-style mappings work). - Two logical lights writing into the same physical light add and clamp at 255 (no overflow). +## BoardModule + +`test/unit/core/unit_BoardModule.cpp` + +- After onBuildControls, BoardModule exposes exactly one `board` control, bound as Text to a 32-byte buffer. +- Default state is the empty string — MoonDeck pushes a value on first reach. +- respectsEnabled() returns false so the `board` value stays visible even when the module is disabled — identity-class data shouldn't vanish. +- setBoard happy path: valid value lands in the buffer + marks dirty so FilesystemModule's debounced save picks it up. Mirrors the shape of NetworkModule::setWifiCredentials' unit-test pattern. +- Empty string is rejected — no buffer write, no dirty flag. +- 32+ char value is rejected (buffer is 32 bytes including NUL, so 31 max). +- Non-printable bytes are rejected. Catches accidental binary smuggling (would also break the persistence JSON encoder). +- nullptr is rejected (defensive — a bogus caller shouldn't crash the device). + ## Buffer `test/unit/core/unit_Buffer.cpp` @@ -107,6 +120,21 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - With sparking at max, the buffer contains non-zero pixels within 50 frames (sparks emerge and propagate). - Disabling the effect releases its heat buffer back (dynamicBytes drops to 0). +## GameOfLifeEffect + +`test/unit/light/unit_GameOfLifeEffect.cpp` + +- Two cell grids of width × height bytes each. +- Disabling releases both grids (dynamicBytes drops to 0) via the parent lifecycle. +- A blinker (horizontal 3-in-a-row) oscillates with period 2 under B3/S23: it becomes a vertical 3-in-a-row, then back. Pins both birth (B3) and survival (S23) on a known pattern. +- A 2×2 block is a still-life: every live cell has 3 neighbours (S3), no dead cell has exactly 3 (no B3), so stepOnce leaves it unchanged. +- A lone cell dies (underpopulation: 0 neighbours, not S2/S3) → extinction. +- Wraparound: a blinker on the right edge stays a valid 3-cell pattern because neighbours wrap, rather than losing cells to a hard edge. +- Reallocation on dimension change: grids resize, byte count tracks new w×h. +- Must not crash on a zero-size grid (no allocation, loop is a no-op). +- bpm time-gates the generation rate: a low bpm advances fewer generations per unit time than a high bpm over the same elapsed window. Drives time via the desktop millis() test seam (Layer reads platform::millis in loop()). +- Regression: the Layer clears the buffer before every effect frame, so the grid must be re-painted on EVERY frame, not just on the (rarer) beats where a generation advances. A bpm gate that skipped the paint left non-step frames black — visible as "a flash now and then" at low bpm. Drive several frames at a slow bpm (most are non-step) and require the buffer stays lit on all of them. + ## GridLayout `test/unit/light/unit_GridLayout.cpp` @@ -161,6 +189,12 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Spiral animates across 100ms (rotation visible). - Replace path: swap one effect for another mid-flight (same shape as HttpServerModule::handleReplaceModule) and confirm the new effect animates. Replacing one effect with another mid-tick (HttpServerModule's swap path) leaves the new effect animating, not frozen. +`test/unit/light/unit_Layer_sparse_mapping.cpp` + +- Dense grid: every box cell is a light, so no LUT — the identity/memcpy fast path is preserved exactly (the grid short-circuit). +- Sparse sphere: a LUT is built; its destinations are driver indices in [0, lightCount), and the render buffer stays the dense bounding box. +- Sphere + Mirror: the modifier's box-coordinate destinations are translated into driver-index space; no destination escapes [0, lightCount). + `test/unit/light/unit_Layer_zero_grid.cpp` *Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, CheckerboardEffect, SpiralEffect, MetaballsEffect, PlasmaPaletteEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect, FireEffect, ParticlesEffect.* @@ -194,6 +228,13 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Disabled layouts contribute nothing; enabled siblings shift down to close the gap (no index holes). - Disabling the Layouts container itself zeroes totalLightCount and yields no coordinates. +`test/unit/light/unit_Layouts_mutation.cpp` + +- Add a single layout: the container reports its light count and iterates it. +- Add more than one layout (mixed types): counts sum, indices stitch end-to-end. +- Replace a layout with a different type at the same slot: the other layouts and their order are preserved; only the replaced slot's contribution changes. +- Remove a layout: it leaves the tree, the remaining layouts shift to close the gap, and the total drops by exactly the removed layout's light count. + `test/unit/light/unit_Layouts_toggle_cycle.cpp` *Also touches: Layer, Drivers.* @@ -333,12 +374,11 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run `test/unit/light/unit_PreviewDriver.cpp` -- The three `detail` levels (1/2/3) downsample a 128-axis grid into 16/32/43 axes — distinct strides so the levels are visibly different. -- Even when downsampled, the frame carries the original grid dimensions so the UI's `decompress` hint can block-replicate back. -- detail=3 (largest payload) stays under lwIP's ~5.7 KB TCP send buffer so writeChunks completes in one whole pass. -- A small grid (≤ budget) is copied 1:1 with no downsampling — preview matches the original exactly. -- On RGBW (4-channel) sources the preview keeps only the first 3 channels — the wire frame is always 3 bytes per voxel. -- Default controls: fps=24 (preview stream rate), detail=3 (finest), decompress=true. +- A sphere sends its SHELL lights (210), not the dense 9x9x9 box (729). +- Per-frame 0x02 RGB count matches the coordinate-table count. +- A small grid sends every light at its grid position (stride 1, exact). +- A large layout is index-downsampled (stride > 1) so the payload fits the send-buffer cap — but at REAL positions, not a padded box. +- Default fps is the rate-limited preview stream rate. ## RainbowEffect @@ -359,12 +399,23 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - firstByName(name) returns the first match in DFS order, or nullptr if no module carries that name. - If the disambiguating suffix would overflow the 16-byte name buffer, ensureUniqueName refuses to truncate and keeps the colliding name (sharp edge, documented). +## SphereLayout + +`test/unit/light/unit_SphereLayout.cpp` + +- lightCount() must equal the number of points forEachCoord emits — they share one shell predicate, so allocation and fill can never disagree. +- The sphere is HOLLOW: the centre lattice point (r,r,r) is never emitted, and neither is any interior point (distance < radius-0.5 from centre). +- radius = 1 is the smallest hollow sphere: the 6 axis neighbours (d^2=1) plus the 12 edge points (d^2=2) of the centre — 18 lights, no centre. +- The shell is symmetric about the centre: for every emitted point its mirror through the centre is also emitted (a sphere has no preferred direction). +- Physical indices are sequential 0..N-1 over the emitted shell points (no gaps from the unindexed lattice voids), so the buffer maps 1:1 to emitted lights. +- Default radius is a sensible small sphere (not 0, not huge). + ## SystemModule `test/unit/core/unit_SystemModule.cpp` - On the desktop platform (MAC DE:AD:BE:EF:CA:FE), the auto-generated device name is "MM-CAFE" (last two MAC bytes). -- After setup, SystemModule exposes exactly 12 controls on desktop, including a deviceName Text control bound to the MAC-derived name. +- deviceName is bound as a Text control to the MAC-derived default ("MM-CAFE" on the desktop platform). - The `firmware` control is always present and non-empty (either a real firmware key from build_info.h or the fallback "unknown"). - The `bootReason` control is populated from platform::resetReason; on desktop it reports "OK". diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 30f3ee76..97e07a33 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -51,6 +51,13 @@ if(MM_FIRMWARE_NAME) # and fails as if "esp32" were an identifier. target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_FIRMWARE_NAME=${MM_FIRMWARE_NAME}) endif() +if(MM_RELEASE) + # Same quoting note as MM_FIRMWARE_NAME above — the value arrives with its + # quotes already baked in. Release-channel tag (latest / vX.Y.Z); only the + # release workflow sets it, so local builds fall through to build_info.h's + # #ifndef "" default. + target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_RELEASE=${MM_RELEASE}) +endif() if(MM_ETH_ONLY) target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_NO_WIFI) endif() @@ -63,7 +70,7 @@ set(UI_DIR ${COMPONENT_DIR}/../../src/ui) add_custom_command( OUTPUT ${UI_DIR}/ui_embedded.h COMMAND ${CMAKE_COMMAND} -DUI_DIR=${UI_DIR} -DOUT=${UI_DIR}/ui_embedded.h -P ${UI_DIR}/embed_ui.cmake - DEPENDS ${UI_DIR}/index.html ${UI_DIR}/app.js ${UI_DIR}/style.css ${UI_DIR}/install-picker.js + DEPENDS ${UI_DIR}/index.html ${UI_DIR}/app.js ${UI_DIR}/style.css ${UI_DIR}/install-picker.js ${UI_DIR}/preview3d.js ${UI_DIR}/moonlight-logo.png ${UI_DIR}/embed_ui.cmake COMMENT "Embedding UI files" ) add_custom_target(ui_embed DEPENDS ${UI_DIR}/ui_embedded.h) diff --git a/esp32/main/main.cpp b/esp32/main/main.cpp index 4c1072a7..bbd92771 100644 --- a/esp32/main/main.cpp +++ b/esp32/main/main.cpp @@ -4,9 +4,7 @@ #include -#include "core/types.h" - -extern void mm_main(volatile bool& keepRunning, mm::lengthType gridW, mm::lengthType gridH, uint16_t httpPort); +extern void mm_main(volatile bool& keepRunning, uint16_t httpPort); static volatile bool running = true; @@ -25,5 +23,5 @@ extern "C" void app_main() { // Network init moved to platform layer (NetworkModule calls ethInit/wifiStaInit/wifiApInit) - mm_main(running, mm::defaultGridSize, mm::defaultGridSize, 80); + mm_main(running, 80); } diff --git a/scripts/build/build_esp32.py b/scripts/build/build_esp32.py index 9c9950c8..10a61572 100644 --- a/scripts/build/build_esp32.py +++ b/scripts/build/build_esp32.py @@ -165,8 +165,13 @@ def idf_cmd(idf_path: Path) -> list[str]: return [str(idf_path / "tools" / "idf.py")] -def firmware_cmake_args(firmware: str) -> list[str]: - """Extra -D cache args for the requested firmware.""" +def firmware_cmake_args(firmware: str, release: str = "") -> list[str]: + """Extra -D cache args for the requested firmware. + + `release` is the release-channel tag (e.g. "latest", "v1.0.0") to burn + into the binary as MM_RELEASE. Empty for local builds — SystemModule + then shows the bare semver with no channel suffix. + """ spec = FIRMWARES[firmware] fragments = ";".join(spec["fragments"]) args = [f"-DSDKCONFIG_DEFAULTS={fragments}"] @@ -174,6 +179,11 @@ def firmware_cmake_args(firmware: str) -> list[str]: # the OTA path can pick the matching release asset (every release ships # one .bin per firmware key — see release.yml). args.append(f'-DMM_FIRMWARE_NAME="{firmware}"') + # Burn the release-channel tag too, when the build pipeline supplies one. + # Same -D mechanism; empty default left to build_info.h's #ifndef so a + # local build needs no flag. + if release: + args.append(f'-DMM_RELEASE="{release}"') if spec["eth_only"]: # Drop the WiFi components from the link, and tell our code to compile # out the WiFi paths (MM_ETH_ONLY → esp32/main/CMakeLists.txt). @@ -232,6 +242,10 @@ def main(): help="Firmware variant. One of: " + ", ".join(sorted(FIRMWARES))) parser.add_argument("--profile", choices=["default", "eth-only"], help="Deprecated alias for --firmware. Use --firmware instead.") + parser.add_argument("--release", default="", + help="Release-channel tag to burn into the binary as " + "MM_RELEASE (e.g. 'latest', 'v1.0.0'). Set by the " + "release workflow; omit for local builds.") args = parser.parse_args() firmware = resolve_firmware(args) @@ -276,7 +290,7 @@ def main(): # builds the per-build-dir sdkconfig already has the chip pinned, so # set-target is skipped — switching to another firmware uses a different # build_dir entirely, so its sdkconfig is untouched. - extra = firmware_cmake_args(firmware) + extra = firmware_cmake_args(firmware, args.release) if not build_dir.exists(): print(f"Setting target to {chip} (firmware: {firmware}, build dir: " f"{build_dir.relative_to(ROOT)})...") diff --git a/scripts/build/generate_build_info.py b/scripts/build/generate_build_info.py index 4355417e..452fe5cf 100644 --- a/scripts/build/generate_build_info.py +++ b/scripts/build/generate_build_info.py @@ -13,6 +13,10 @@ "Firmware" is the compiled-binary variant; the physical board is a separate concept the device cannot self-identify. See docs/architecture.md § Firmware vs board. + MM_RELEASE — release-channel tag (`latest`, `v1.0.0`), set by the + release workflow as a -D flag. #ifndef "" fallback for + local/dev builds (no channel). MM_VERSION = what code; + MM_RELEASE = which channel. The generator rewrites the whole file from this template each time library.json changes; the #ifndef defaults below are part of the template, @@ -54,11 +58,23 @@ #define MM_FIRMWARE_NAME "unknown" #endif +// MM_RELEASE — the release-channel tag this binary was published under +// (`latest`, `v1.0.0`, `v1.0.0-rc2`). Set by the release workflow as a -D +// flag (same mechanism as MM_FIRMWARE_NAME). MM_VERSION is the semver from +// library.json — what code this is; MM_RELEASE is which channel it shipped +// on — a moving `latest` build and a tagged release can share a semver but +// differ in channel. Empty default: a local / dev build has no channel, and +// SystemModule shows just the bare semver in that case. +#ifndef MM_RELEASE +#define MM_RELEASE "" +#endif + namespace mm {{ constexpr const char* kVersion = MM_VERSION; constexpr const char* kBuildDate = MM_BUILD_DATE; constexpr const char* kFirmwareName = MM_FIRMWARE_NAME; +constexpr const char* kRelease = MM_RELEASE; }} // namespace mm ''' diff --git a/src/core/BinaryBroadcaster.h b/src/core/BinaryBroadcaster.h new file mode 100644 index 00000000..3ddc6263 --- /dev/null +++ b/src/core/BinaryBroadcaster.h @@ -0,0 +1,22 @@ +#pragma once + +#include "platform/platform.h" // platform::WriteChunk + +namespace mm { + +// A sink that broadcasts a binary WebSocket message to all connected clients. +// HttpServerModule implements it; producers (e.g. PreviewDriver) hold a pointer +// to this interface rather than to the concrete server, so a light-domain +// producer depends only on "something I can send bytes to" — not on the HTTP +// server's full surface. Domain-neutral: the bytes' meaning is the caller's. +struct BinaryBroadcaster { + // Send one binary WS frame whose payload is the given scatter-gather chunks + // (the implementation prepends the WS frame header). Backpressured clients + // skip the frame; corrupt / dead sockets are dropped. + virtual void broadcastBinary(const platform::WriteChunk* payload, int chunkCount) = 0; + +protected: + ~BinaryBroadcaster() = default; // not owned through this interface +}; + +} // namespace mm diff --git a/src/core/Control.h b/src/core/Control.h index 36b06f40..bfc01145 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -44,9 +44,10 @@ inline void formatDottedQuad(char out[16], const uint8_t ip[4]) { enum class ControlType : uint8_t { Uint8, Uint16, - Int16, // signed 16-bit. Used by `lengthType` coordinate controls (Layer - // start/end), where negative values are legal — e.g. a Layer - // dragged out of the visible area by a future modifier. + Int16, // signed 16-bit. For coordinate-style controls where negative + // values are legal — e.g. a Layer's start/end dragged out of the + // visible area by a future modifier. (The light domain's grid + // coordinate type is int16 for this reason.) Bool, Text, Password, // secret text — /api/state serializes it XOR-obfuscated + diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 7a9bf281..2ec39623 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -7,7 +7,6 @@ #include "core/HttpServerModule.h" #include "core/Scheduler.h" -#include "core/PreviewFrame.h" #include "core/ModuleFactory.h" #include "core/JsonUtil.h" #include "core/JsonSink.h" @@ -49,11 +48,9 @@ void HttpServerModule::loop20ms() { return; // don't broadcast in same tick as accept (WebSocket needs time to process 101) } - // Broadcast preview frame if ready - if (previewFrame_ && previewFrame_->ready) { - broadcastPreviewFrame(); - previewFrame_->ready = false; - } + // Binary frames (e.g. the 3D preview) are no longer polled here — their + // producer (PreviewDriver) pushes them via broadcastBinary() from its own + // loop. HttpServer owns only the transport, not the content. } void HttpServerModule::loop1s() { @@ -129,6 +126,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { if (std::strcmp(path, "/") == 0) serveFile(conn, "index.html", "text/html"); else if (std::strcmp(path, "/app.js") == 0) serveFile(conn, "app.js", "application/javascript"); else if (std::strcmp(path, "/install-picker.js") == 0) serveFile(conn, "install-picker.js", "application/javascript"); + else if (std::strcmp(path, "/preview3d.js") == 0) serveFile(conn, "preview3d.js", "application/javascript"); else if (std::strcmp(path, "/style.css") == 0) serveFile(conn, "style.css", "text/css"); else if (std::strcmp(path, "/moonlight-logo.png") == 0) serveFile(conn, "moonlight-logo.png", "image/png"); else if (std::strcmp(path, "/api/state") == 0) serveState(conn); @@ -289,13 +287,18 @@ void HttpServerModule::serveFile(platform::TcpConnection& conn, const char* file return; } - // Fall back to embedded data (ESP32 or when disk files not found) + // Fall back to embedded data (ESP32 or when disk files not found). The text + // assets are embedded gzipped (see embed_ui.cmake) and served with + // Content-Encoding: gzip — the browser inflates them. gzipped is false only + // for already-compressed binaries (the PNG), which are embedded raw. const uint8_t* data = nullptr; size_t dataLen = 0; - if (std::strcmp(filename, "index.html") == 0) { data = ui::indexHtml; dataLen = ui::indexHtmlLen; } - else if (std::strcmp(filename, "app.js") == 0) { data = ui::appJs; dataLen = ui::appJsLen; } - else if (std::strcmp(filename, "install-picker.js") == 0) { data = ui::installPickerJs; dataLen = ui::installPickerJsLen; } - else if (std::strcmp(filename, "style.css") == 0) { data = ui::styleCss; dataLen = ui::styleCssLen; } + bool gzipped = false; + if (std::strcmp(filename, "index.html") == 0) { data = ui::indexHtml; dataLen = ui::indexHtmlLen; gzipped = true; } + else if (std::strcmp(filename, "app.js") == 0) { data = ui::appJs; dataLen = ui::appJsLen; gzipped = true; } + else if (std::strcmp(filename, "install-picker.js") == 0) { data = ui::installPickerJs; dataLen = ui::installPickerJsLen; gzipped = true; } + else if (std::strcmp(filename, "preview3d.js") == 0) { data = ui::preview3dJs; dataLen = ui::preview3dJsLen; gzipped = true; } + else if (std::strcmp(filename, "style.css") == 0) { data = ui::styleCss; dataLen = ui::styleCssLen; gzipped = true; } else if (std::strcmp(filename, "moonlight-logo.png") == 0) { data = ui::logoPng; dataLen = ui::logoPngLen; } if (!data) { @@ -308,10 +311,12 @@ void HttpServerModule::serveFile(platform::TcpConnection& conn, const char* file "HTTP/1.1 200 OK\r\n" "Content-Type: %s\r\n" "Content-Length: %zu\r\n" + "%s" "Connection: close\r\n" "Cache-Control: no-cache\r\n" "\r\n", - contentType, dataLen); + contentType, dataLen, + gzipped ? "Content-Encoding: gzip\r\n" : ""); conn.write(reinterpret_cast(header), headerLen); conn.write(data, dataLen); } @@ -364,6 +369,11 @@ void HttpServerModule::writeModuleJson(JsonSink& sink, MoonModule* mod) { static_cast(mod->classSize()), static_cast(mod->dynamicBytes())); writeStatus(sink, mod); + // userEditable: omit when true (the common case) to save bytes — the UI + // treats absent as editable, same convention as the control hidden/readonly + // flags. Emitted only for modules that opt out (e.g. PreviewDriver), so the + // UI hides their delete/replace affordance. + if (!mod->userEditable()) sink.append(",\"userEditable\":false"); sink.append(",\"controls\":["); writeControls(sink, mod); sink.append("]"); @@ -783,6 +793,7 @@ void HttpServerModule::serveTypes(platform::TcpConnection& conn) { const char* docPath = ModuleFactory::typeDocPath(i); const char* tags = ModuleFactory::typeTags(i); uint8_t dim = ModuleFactory::typeDim(i); + const char* childRoles = ModuleFactory::typeAcceptsChildRoles(i); // displayNameFor returns a pointer into a static buffer shared // across calls, so copy it to the stack before another factory // call (or the next loop iteration) overwrites it. @@ -790,10 +801,12 @@ void HttpServerModule::serveTypes(platform::TcpConnection& conn) { std::strncpy(displayName, ModuleFactory::displayNameFor(name, role), sizeof(displayName) - 1); displayName[sizeof(displayName) - 1] = 0; sink.appendf("%s{\"name\":\"%s\",\"displayName\":\"%s\",\"role\":\"%s\"," - "\"docPath\":\"%s\",\"tags\":\"%s\",\"dim\":%u,\"defaults\":{", + "\"docPath\":\"%s\",\"tags\":\"%s\",\"dim\":%u," + "\"acceptsChildRoles\":\"%s\",\"defaults\":{", first ? "" : ",", name, displayName, roleStr, docPath ? docPath : "", tags ? tags : "", - static_cast(dim)); + static_cast(dim), + childRoles ? childRoles : ""); writeTypeDefaults(sink, name); sink.append("}}"); first = false; @@ -1004,28 +1017,13 @@ bool HttpServerModule::sendWsTextFrame(platform::TcpConnection& conn, const char return conn.write(reinterpret_cast(data), len); } -void HttpServerModule::broadcastPreviewFrame() { - if (!previewFrame_ || !previewFrame_->data || previewFrame_->dataLen == 0) return; - - // Build 13-byte preview header on stack: - // [0x02][dw16][dh16][dd16][ow16][oh16][od16] - // dw/dh/dd = dimensions of the (downsampled) data in the payload; - // ow/oh/od = original physical grid dimensions, for optional UI upscale. - // All uint16 little-endian. - uint8_t previewHeader[13]; - previewHeader[0] = 0x02; - auto put16 = [&](int at, lengthType v) { - previewHeader[at] = static_cast(v & 0xFF); - previewHeader[at + 1] = static_cast(v >> 8); - }; - put16(1, previewFrame_->width); - put16(3, previewFrame_->height); - put16(5, previewFrame_->depth); - put16(7, previewFrame_->origWidth); - put16(9, previewFrame_->origHeight); - put16(11, previewFrame_->origDepth); - - size_t totalLen = 13 + previewFrame_->dataLen; +void HttpServerModule::broadcastBinary(const platform::WriteChunk* payload, int chunkCount) { + if (!payload || chunkCount <= 0) return; + + // Total payload length = sum of the caller's chunks. + size_t totalLen = 0; + for (int i = 0; i < chunkCount; i++) totalLen += payload[i].len; + if (totalLen == 0) return; // WebSocket frame header for a binary message of totalLen bytes. uint8_t wsHeader[4]; @@ -1043,21 +1041,21 @@ void HttpServerModule::broadcastPreviewFrame() { return; // frame too large for the 16-bit length form } - // One scatter-gather chunk list: WS header + preview header + payload. - // The payload chunk points at PreviewDriver's own downsample buffer — - // PreviewDriver::loop() writes the strided RGB copy there and sets - // previewFrame_->data to it. No copy here; the buffer is driver-owned - // (not a Drivers slice — the downsample step owns its own storage). - const platform::WriteChunk chunks[] = { - { wsHeader, static_cast(wsHeaderLen) }, - { previewHeader, sizeof(previewHeader) }, - { previewFrame_->data, previewFrame_->dataLen }, - }; - constexpr int chunkCount = sizeof(chunks) / sizeof(chunks[0]); + // Scatter-gather: our WS header, then the caller's payload chunks. The + // payload buffers are caller-owned (e.g. PreviewDriver's downsample buffer); + // no copy here. Stack array sized for WS header + a small fixed payload + // (the preview uses 2 chunks). MAX_PAYLOAD_CHUNKS caps it so this stays a + // stack array, not an allocation in the broadcast path. + static constexpr int MAX_PAYLOAD_CHUNKS = 4; + if (chunkCount > MAX_PAYLOAD_CHUNKS) return; // caller bug; don't allocate + platform::WriteChunk chunks[1 + MAX_PAYLOAD_CHUNKS]; + chunks[0] = { wsHeader, static_cast(wsHeaderLen) }; + for (int i = 0; i < chunkCount; i++) chunks[1 + i] = payload[i]; + const int totalChunks = 1 + chunkCount; for (auto& ws : wsClients_) { if (!ws.valid()) continue; - switch (ws.writeChunks(chunks, chunkCount)) { + switch (ws.writeChunks(chunks, totalChunks)) { case platform::WriteResult::Complete: case platform::WriteResult::WouldBlock: // WouldBlock: browser is backpressured — skip this frame, diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h index a20d7079..1404c486 100644 --- a/src/core/HttpServerModule.h +++ b/src/core/HttpServerModule.h @@ -1,6 +1,7 @@ #pragma once #include "core/MoonModule.h" +#include "core/BinaryBroadcaster.h" #include "platform/platform.h" #include @@ -10,7 +11,6 @@ namespace mm { // Forward declarations — bodies in HttpServerModule.cpp include the real headers. class JsonSink; class Scheduler; -struct PreviewFrame; // HttpServerModule serves the UI's REST API, the static UI assets, and the // WebSocket channel that pushes state JSON + binary preview frames. Implementation @@ -27,13 +27,17 @@ struct PreviewFrame; // - base64Encode() (WS handshake + Password obfuscation) → core/Base64.h // They live in `namespace mm` so the call sites in HttpServerModule.cpp are // unchanged. -class HttpServerModule : public MoonModule { +class HttpServerModule : public MoonModule, public BinaryBroadcaster { public: uint16_t port = 8080; void setScheduler(Scheduler* s) { scheduler_ = s; } void setUiPath(const char* path) { uiPath_ = path; } - void setPreviewFrame(PreviewFrame* f) { previewFrame_ = f; } + + // BinaryBroadcaster — send a binary WS frame to every connected client. + // Producers (PreviewDriver) build the payload chunks; this prepends the WS + // header. Domain-neutral: no knowledge of what the bytes carry. + void broadcastBinary(const platform::WriteChunk* payload, int chunkCount) override; // Keep running even when "disabled" via the UI — otherwise the user has no way // to re-enable themselves through the same UI. The `enabled` checkbox on this @@ -49,7 +53,6 @@ class HttpServerModule : public MoonModule { private: platform::TcpServer server_; Scheduler* scheduler_ = nullptr; - PreviewFrame* previewFrame_ = nullptr; const char* uiPath_ = "src/ui"; static constexpr int MAX_WS_CLIENTS = 4; @@ -120,7 +123,6 @@ class HttpServerModule : public MoonModule { void handleWebSocketUpgrade(platform::TcpConnection& conn, const char* req); void pushStateToWebSockets(); static bool sendWsTextFrame(platform::TcpConnection& conn, const char* data, int len); - void broadcastPreviewFrame(); }; } // namespace mm diff --git a/src/core/ModuleFactory.h b/src/core/ModuleFactory.h index 08aa6dd4..0a8d16a7 100644 --- a/src/core/ModuleFactory.h +++ b/src/core/ModuleFactory.h @@ -1,10 +1,8 @@ #pragma once #include "core/MoonModule.h" -#include "core/types.h" #include "platform/platform.h" -#include #include namespace mm { @@ -29,13 +27,18 @@ class ModuleFactory { // dim is captured via `if constexpr` only when T declares `dimensions()` // (EffectBase and ModifierBase do; everything else doesn't). Modules without // it get dim=0 ("not applicable") and the UI skips their dimensional chip. + // The probe detects the method and reduces its result to a byte — it does + // NOT name the return type, so the dimensionality enum (a light-domain + // concept, `Dim` in light_types.h) stays out of core. Any type exposing a + // `dimensions()` convertible to uint8_t is captured; only EffectBase / + // ModifierBase do, so the loose constraint matches exactly them. template static bool registerType(const char* typeName, const char* docPath = "") { if (!typeName) return false; if (!grow()) return false; T probe; uint8_t dim = 0; - if constexpr (requires(const T& t) { { t.dimensions() } -> std::same_as; }) { + if constexpr (requires(const T& t) { static_cast(t.dimensions()); }) { dim = static_cast(probe.dimensions()); } types_[count_++] = {typeName, @@ -43,7 +46,8 @@ class ModuleFactory { sizeof(T), probe.role(), docPath ? docPath : "", probe.tags() ? probe.tags() : "", - dim}; + dim, + probe.acceptsChildRoles() ? probe.acceptsChildRoles() : ""}; return true; } @@ -56,7 +60,10 @@ class ModuleFactory { const char* tags = "") { if (!typeName || !fn) return false; if (!grow()) return false; - types_[count_++] = {typeName, fn, classSize, role, docPath ? docPath : "", tags ? tags : "", 0}; + // Hand-built entries can't probe an instance for acceptsChildRoles, so + // they default to "" (accepts no children). No hand-built type is a + // container today; if one ever is, add a parameter here. + types_[count_++] = {typeName, fn, classSize, role, docPath ? docPath : "", tags ? tags : "", 0, ""}; return true; } @@ -130,6 +137,11 @@ class ModuleFactory { // does (EffectBase/ModifierBase today). The UI uses this to derive 📏/🟦/🧊 // alongside the role chip and origin emoji from tags(). static uint8_t typeDim(uint8_t i) { return (types_ && i < count_) ? types_[i].dim : 0; } + // Comma-separated child roles this type accepts ("" = none). The UI reads + // this per-type to know which modules show a "+ add child" affordance and + // what role the add-picker should offer — replacing the old hardcoded + // list of container type names in app.js. + static const char* typeAcceptsChildRoles(uint8_t i) { return (types_ && i < count_) ? types_[i].acceptsChildRoles : ""; } private: struct TypeEntry { @@ -140,6 +152,7 @@ class ModuleFactory { const char* docPath; const char* tags; uint8_t dim; // 0 = N/A; 1/2/3 for types whose probe.dimensions() returns a Dim + const char* acceptsChildRoles; // comma-separated roles this type accepts as children; "" = none }; static inline TypeEntry* types_ = nullptr; diff --git a/src/core/MoonModule.h b/src/core/MoonModule.h index 73686995..55aaf33d 100644 --- a/src/core/MoonModule.h +++ b/src/core/MoonModule.h @@ -199,6 +199,28 @@ class MoonModule { // return value is a flash string literal; no per-instance RAM cost. virtual const char* tags() const { return ""; } + // Comma-separated role names this module accepts as user-added children + // (e.g. "effect,modifier"). "" = accepts none — the default, covering + // leaf modules and fixed-shape containers. A container overrides this to + // tell the UI's "+ add child" picker what to offer. Comma-separated + // string (not a bitmask) so it serialises straight into /api/types and a + // multi-role parent (Layer → "effect,modifier") needs no enum-set type. + // This is what makes the UI domain-neutral: it reads the accepted roles + // from here instead of hardcoding which module types are containers. + // Like tags(), overrides MUST return static-lifetime storage (a string + // literal or static const char[]): ModuleFactory::registerType() probes + // the type once and stores this pointer in the static type registry, so a + // pointer to a temporary or member buffer would dangle. + virtual const char* acceptsChildRoles() const { return ""; } + + // Whether the user may delete or replace this module from the UI. Default + // true — most user-added modules are freely editable. A load-bearing or + // code-wired child overrides to false (e.g. PreviewDriver, whose deletion + // would kill the live 3D preview). The flag lives on the CHILD because the + // child knows whether it's safe to remove; the parent only decides what + // can be added (acceptsChildRoles). Surfaced per-instance in /api/state. + virtual bool userEditable() const { return true; } + // Generic children — grows on demand, only allocates during setup bool addChild(MoonModule* child) { if (!child) return false; diff --git a/src/core/PreviewFrame.h b/src/core/PreviewFrame.h deleted file mode 100644 index 03f32242..00000000 --- a/src/core/PreviewFrame.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include "core/types.h" -#include -#include - -namespace mm { - -// Zero-copy preview: points to the existing output buffer, no allocation. -// PreviewDriver updates the pointer each frame. HttpServerModule reads it. -// Single-threaded scheduler — no lock needed. -struct PreviewFrame { - const uint8_t* data = nullptr; // points to existing buffer (no ownership) - size_t dataLen = 0; - // Dimensions of the (downsampled) data actually in `data`. - lengthType width = 0; - lengthType height = 0; - lengthType depth = 0; - // Dimensions of the original physical grid before downsampling. Equal to - // width/height/depth when no downsampling occurred. Sent so the UI can - // optionally reconstruct (block-replicate) the preview at full resolution. - lengthType origWidth = 0; - lengthType origHeight = 0; - lengthType origDepth = 0; - uint8_t fps = 20; - bool ready = false; -}; - -} // namespace mm diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h index d3031015..25c3ed38 100644 --- a/src/core/SystemModule.h +++ b/src/core/SystemModule.h @@ -32,7 +32,15 @@ class SystemModule : public MoonModule { // on the next WebSocket push. std::snprintf(chipInfo_, sizeof(chipInfo_), "%s", platform::chipModel()); std::snprintf(sdkInfo_, sizeof(sdkInfo_), "%s", platform::sdkVersion()); - std::snprintf(versionStr_, sizeof(versionStr_), "%s", kVersion); + // version = semver (what code) + release channel (which channel) when + // the build pipeline burned one in: "1.0.0-rc2 (latest)". kRelease is + // "" on local / dev builds, where we show the bare semver. See + // build_info.h for the MM_VERSION vs MM_RELEASE split. + if (kRelease[0] != 0) { + std::snprintf(versionStr_, sizeof(versionStr_), "%s (%s)", kVersion, kRelease); + } else { + std::snprintf(versionStr_, sizeof(versionStr_), "%s", kVersion); + } std::snprintf(buildStr_, sizeof(buildStr_), "%s", kBuildDate); std::snprintf(firmwareStr_, sizeof(firmwareStr_), "%s", kFirmwareName); std::snprintf(bootReasonStr_, sizeof(bootReasonStr_), "%s", platform::resetReason()); @@ -163,7 +171,7 @@ class SystemModule : public MoonModule { // Static (set in setup) char chipInfo_[16] = {}; char sdkInfo_[24] = {}; - char versionStr_[16] = {}; + char versionStr_[32] = {}; // semver + " (channel)" — e.g. "1.0.0-rc2 (latest)" char buildStr_[24] = {}; // 24 fits the longest current key ("desktop-macos-arm64" = 19) with headroom. char firmwareStr_[24] = {}; diff --git a/src/core/types.h b/src/core/types.h deleted file mode 100644 index a7c457dc..00000000 --- a/src/core/types.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include -#include -#include -#include "platform_config.h" - -namespace mm { - -// Type sizes depend on PSRAM availability (set per-platform in platform_config.h). -// With PSRAM: larger types for big installations (10K+ lights, large coordinates). -// Without PSRAM: smaller nrOfLightsType to minimize LUT memory. -// lengthType stays int16_t everywhere — int8_t can't hold 128 and the savings -// only matter in MappingLUT (not yet implemented). -using nrOfLightsType = std::conditional_t; -using lengthType = int16_t; - -constexpr lengthType defaultGridSize = 128; - -// Minimum heap to preserve for stack, HTTP, WiFi, and overhead -constexpr size_t HEAP_RESERVE = 32768; - -// Callback for layout coordinate iteration -using CoordCallback = void(*)(void* ctx, nrOfLightsType idx, lengthType x, lengthType y, lengthType z); - -// Dimensional support. Effects use this to declare which axes they iterate so the -// Layer can extrude lower-dimensional output across unused axes (D1 row → fill y/z, -// D2 slice → fill z, D3 → no extrusion). Modifiers use it to advertise which axes -// they can transform. The UI derives the 📏/🟦/🧊 chip from it. Domain-neutral on -// purpose so EffectBase and ModifierBase can both refer to it without one including -// the other. -enum class Dim : uint8_t { - D1 = 1, - D2 = 2, - D3 = 3, -}; - -} // namespace mm diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index 69a97d12..7e45c317 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -44,6 +44,8 @@ class DriverBase : public MoonModule { class Drivers : public MoonModule { public: + const char* acceptsChildRoles() const override { return "driver"; } + uint8_t brightness = 255; uint8_t lightPreset = 0; // index into kLightPresetOptions; 0 = RGB diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h index ad542720..2e4be087 100644 --- a/src/light/drivers/PreviewDriver.h +++ b/src/light/drivers/PreviewDriver.h @@ -1,50 +1,61 @@ #pragma once #include "light/drivers/Drivers.h" -#include "core/PreviewFrame.h" +#include "light/light_types.h" // lengthType, nrOfLightsType +#include "core/BinaryBroadcaster.h" #include "platform/platform.h" namespace mm { +// Streams a true-shape 3D preview to the web UI over the binary WebSocket. +// +// The preview is a POINT LIST, not a dense grid: only the real lights are sent, +// at their real (x,y,z) positions. This is the proven MoonLight model (virtual +// grid → physical sparse lights; positions sent once at mapping time, channels +// per frame). Two message types — PreviewDriver owns both wire formats; the +// HTTP server is a domain-neutral BinaryBroadcaster that just writes the bytes: +// +// 0x03 coordinate table (one-time, on every LUT rebuild + periodic re-send so +// new clients catch it): +// [0x03][count:u16][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count] +// bx/by/bz = bounding-box extent (for client centring); positions are +// 1 byte/axis (a layout box ≤255/axis is the realistic case). +// +// 0x02 per-frame channels: [0x02][count:u16][stride:u16][(r,g,b) × count] +// RGB by driver index, every `stride`-th light. The browser positions +// triple i at coord-table entry i*stride. +// +// `count` here is the number of points actually sent = ceil(lightCount/stride). +// stride>1 only when lightCount*3 would exceed the send-buffer cap (a large +// dense grid); sparse layouts (sphere) send every light exactly (stride 1). class PreviewDriver : public DriverBase { public: - // 24 fps keeps the preview WebSocket broadcast well within the render-tick - // budget while staying visually smooth (the browser caches geometry between - // frames). User-tunable 1-60 via the "fps" control. - uint8_t fps = 24; - - // Preview detail: 1 = coarse, 2 = medium, 3 = fine. Higher = more voxels in - // the downsampled frame = a denser point cloud, at the cost of a larger (but - // still send-buffer-safe) WebSocket payload. Lets both coarse and fine - // previews be tested live without reflashing. - uint8_t detail = 3; + // The 3D preview the web UI renders streams from this driver. Deleting or + // replacing it from the UI would silently kill that preview, so it opts out + // of user-editing — it stays a fixed child of Drivers. + bool userEditable() const override { return false; } - // UI render hint: when true the browser reconstructs (block-replicates) the - // downsampled frame back to the original physical grid resolution, so the - // preview shows the same voxel count as the real layout. Purely client-side - // — the wire payload is the downsampled frame either way. The frame always - // carries the original dimensions; this flag just tells the UI to use them. - bool decompress = true; + // Preview stream rate (Hz), independent of render FPS. User-tunable 1-60. + uint8_t fps = 24; - void setPreviewFrame(PreviewFrame* f) { frame_ = f; } + // The sink each message is pushed to (HttpServerModule, as a + // BinaryBroadcaster). Wired in main.cpp. Light depends only on the + // interface, not the concrete HTTP server. + void setBroadcaster(BinaryBroadcaster* b) { broadcaster_ = b; } void onBuildControls() override { controls_.addUint8("fps", fps, 1, 60); - controls_.addUint8("detail", detail, 1, 3); - controls_.addBool("decompress", decompress); } void setSourceBuffer(Buffer* buf) override { sourceBuffer_ = buf; } + // A rebuild (layout add/replace/remove, resize, modifier change) ran — the + // light set / positions may have changed, so rebuild + broadcast the + // coordinate table. This is the MoonLight "positions once at mapping time". void onBuildState() override { - // Owned downsample buffer, sized once to the largest possible budget - // (detail = 3). The preview frame is sent over WebSocket in a single - // non-blocking write, so even the finest setting must fit lwIP's - // 11520 B TCP send buffer — MAX_PREVIEW_VOXELS keeps the payload safe. - downsampled_.allocate(MAX_PREVIEW_VOXELS, 3); - setDynamicBytes(downsampled_.bytes()); + buildAndSendCoordTable(); MoonModule::onBuildState(); } @@ -54,110 +65,160 @@ class PreviewDriver : public DriverBase { uint32_t interval = 1000 / fps; if (now - lastSendTime_ < interval) return; // rate-limit gate lastSendTime_ = now; - renderFrame(); + + // Rebuild + re-broadcast the coordinate table ~once per second (and + // immediately while it's still empty). The ~1 Hz cadence lets a client + // that connected after the last onBuildState catch the positions, and + // rebuilding (not just re-sending a cache) self-heals the case where the + // layout/source wasn't wired yet at onBuildState time — cheap on the + // cold path and idempotent on the client. + if (coordCount_ == 0 || now - lastCoordTime_ >= 1000) { + buildAndSendCoordTable(); + } + + sendFrame(); } - // Produce one downsampled preview frame, bypassing the loop()'s fps - // rate-limit. Public so tests can drive frame production deterministically - // without sleeping for the rate-limit interval. - void renderFrame() { - if (!sourceBuffer_ || !sourceBuffer_->data() || !frame_ || !layer_) return; - if (!downsampled_.data()) return; - - // Dimensions read from Layer each frame so the preview tracks runtime resizes. - lengthType w = layer_->physicalWidth(); - lengthType h = layer_->physicalHeight(); - lengthType d = layer_->physicalDepth(); - if (w <= 0 || h <= 0 || d <= 0) return; - - size_t budget = voxelBudget(); - - // Pick the smallest stride whose downsampled voxel count fits the budget. - uint8_t stride = 1; - lengthType dw = w, dh = h, dd = d; - while (static_cast(dw) * dh * dd > budget) { - stride++; - dw = (w + stride - 1) / stride; - dh = (h + stride - 1) / stride; - dd = (d + stride - 1) / stride; + // Build (or rebuild) the cached coordinate table from the layout's real + // lights and broadcast it. Public so tests can drive it deterministically. + void buildAndSendCoordTable() { + coordCount_ = 0; + if (!layer_ || !layer_->layouts()) return; + Layouts* layouts = layer_->layouts(); + nrOfLightsType n = layouts->totalLightCount(); + if (n == 0) return; + stride_ = computeStride(n); + coordCount_ = (n + stride_ - 1) / stride_; // points actually sent + + // Positions are 1 byte/axis. To support layouts whose bounding box + // exceeds 255 on an axis (a 512-wide grid, say), scale every axis by the + // same factor so the largest box edge maps to 255 — preserving aspect + // ratio. For boxes ≤255/axis the factor is 1 (exact integer positions). + // The header carries the SCALED box extents, so the browser's centring + // (divide by max axis) stays consistent with the packed coordinates. + lengthType maxEdge = layer_->physicalWidth(); + if (layer_->physicalHeight() > maxEdge) maxEdge = layer_->physicalHeight(); + if (layer_->physicalDepth() > maxEdge) maxEdge = layer_->physicalDepth(); + if (maxEdge < 1) maxEdge = 1; + posScale_ = (maxEdge > 255) ? maxEdge : 0; // 0 = no scaling (1:1) + bx_ = scaleAxis(layer_->physicalWidth()); + by_ = scaleAxis(layer_->physicalHeight()); + bz_ = scaleAxis(layer_->physicalDepth()); + + // Header: [0x03][count:u16][bx][by][bz][stride:u16] + uint8_t* h = coordHeader_; + h[0] = 0x03; + h[1] = static_cast(coordCount_ & 0xFF); + h[2] = static_cast(coordCount_ >> 8); + h[3] = bx_; h[4] = by_; h[5] = bz_; + h[6] = static_cast(stride_ & 0xFF); + h[7] = static_cast(stride_ >> 8); + + // Pack (x,y,z) for every stride-th light, in forEachCoord (driver) order. + if (!coords_.data() || coords_.count() < coordCount_) { + coords_.allocate(MAX_PREVIEW_POINTS, 3); // owned u8×3 position buffer } + if (!coords_.data()) { coordCount_ = 0; return; } + struct PackCtx { PreviewDriver* self; uint8_t* dst; nrOfLightsType stride; nrOfLightsType out; nrOfLightsType cap; }; + PackCtx pc{this, coords_.data(), stride_, 0, coordCount_}; + layouts->forEachCoord([](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) { + auto* p = static_cast(c); + if (idx % p->stride != 0) return; + if (p->out >= p->cap) return; + uint8_t* d = p->dst + static_cast(p->out) * 3; + d[0] = p->self->scaleAxis(x); d[1] = p->self->scaleAxis(y); d[2] = p->self->scaleAxis(z); + p->out++; + }, &pc); + + lastCoordTime_ = platform::millis(); + sendCoordTable(); + } - // Downsample: for each stride-sized cell, take the brightest voxel - // (max per channel across the cell). A simple strided pick misses all - // lit pixels whenever the effect pattern falls between stride steps, - // producing empty frames. Max-pooling guarantees a cell is non-black - // whenever any voxel inside it is lit. + // Produce + push one per-frame 0x02 RGB message. Public so tests can drive + // it without the loop() rate-limit. + void sendFrame() { + if (!broadcaster_ || !sourceBuffer_ || !sourceBuffer_->data() || coordCount_ == 0) return; const uint8_t* src = sourceBuffer_->data(); uint8_t cpl = sourceBuffer_->channelsPerLight(); - size_t lightCount = sourceBuffer_->count(); - uint8_t* dst = downsampled_.data(); - size_t di = 0; - for (lengthType cz = 0; cz < dd; cz++) { - for (lengthType cy = 0; cy < dh; cy++) { - for (lengthType cx = 0; cx < dw; cx++) { - // Pool over the stride×stride×stride cell - uint8_t maxR = 0, maxG = 0, maxB = 0; - for (uint8_t sz = 0; sz < stride; sz++) { - lengthType oz = cz * stride + sz; - if (oz >= d) continue; - for (uint8_t sy = 0; sy < stride; sy++) { - lengthType oy = cy * stride + sy; - if (oy >= h) continue; - for (uint8_t sx = 0; sx < stride; sx++) { - lengthType ox = cx * stride + sx; - if (ox >= w) continue; - size_t lightIdx = static_cast(oz) * h * w - + static_cast(oy) * w + ox; - if (lightIdx >= lightCount) continue; - size_t srcIdx = lightIdx * cpl; - if (src[srcIdx] > maxR) maxR = src[srcIdx]; - if (cpl >= 2 && src[srcIdx+1] > maxG) maxG = src[srcIdx+1]; - if (cpl >= 3 && src[srcIdx+2] > maxB) maxB = src[srcIdx+2]; - } - } - } - dst[di * 3 + 0] = maxR; - dst[di * 3 + 1] = maxG; - dst[di * 3 + 2] = maxB; - di++; - } - } + nrOfLightsType n = sourceBuffer_->count(); + + // RGB scratch sized to the sent-point count; pick every stride-th light. + if (!rgb_.data() || rgb_.count() < coordCount_) rgb_.allocate(MAX_PREVIEW_POINTS, 3); + if (!rgb_.data()) return; + uint8_t* dst = rgb_.data(); + nrOfLightsType out = 0; + for (nrOfLightsType i = 0; i < n && out < coordCount_; i += stride_) { + const uint8_t* s = src + static_cast(i) * cpl; + dst[out * 3 + 0] = s[0]; + dst[out * 3 + 1] = cpl >= 2 ? s[1] : 0; + dst[out * 3 + 2] = cpl >= 3 ? s[2] : 0; + out++; } - frame_->data = dst; - frame_->dataLen = di * 3; - frame_->width = dw; - frame_->height = dh; - frame_->depth = dd; - frame_->origWidth = w; - frame_->origHeight = h; - frame_->origDepth = d; - frame_->fps = fps; - frame_->ready = true; + uint8_t header[5]; + header[0] = 0x02; + header[1] = static_cast(out & 0xFF); + header[2] = static_cast(out >> 8); + header[3] = static_cast(stride_ & 0xFF); + header[4] = static_cast(stride_ >> 8); + + const platform::WriteChunk payload[] = { + { header, sizeof(header) }, + { dst, static_cast(out) * 3 }, + }; + broadcaster_->broadcastBinary(payload, 2); } private: - // Largest voxel budget (detail = 3): 1849 × 3 B = 5547 B payload + ~11 B - // headers ≈ 5558 B — fits the 11520 B lwIP TCP send buffer with comfortable - // headroom (see CONFIG_LWIP_TCP_SND_BUF_DEFAULT in sdkconfig.defaults), so the - // non-blocking preview write completes whole rather than partial-sending. - static constexpr size_t MAX_PREVIEW_VOXELS = 1849; - - // Voxel budget per detail level. The values are chosen so that on a 128-axis - // grid the adaptive stride lands on a distinct value per level: - // 1 → stride 8 (16×16), 2 → stride 4 (32×32), 3 → stride 3 (43×43). - size_t voxelBudget() const { - switch (detail) { - case 1: return 256; - case 3: return MAX_PREVIEW_VOXELS; // 1849 - default: return 1024; // detail == 2 - } + // Send-buffer cap: a preview message is one non-blocking writev. lwIP's TCP + // send buffer is 11520 B (CONFIG_LWIP_TCP_SND_BUF_DEFAULT), but it is NOT + // reliably all-free at send time (the render task shares it, frames go out at + // the render rate), so a payload near the ceiling partial-writes — and a + // partial write drops the WS connection (broadcastBinary closes on Partial), + // making the preview flicker/blank. Cap at 1800 points → 1800×3 = 5400 B + // payload + headers ≈ 5.4 KB, well under half the buffer — the same safe + // headroom the pre-point-list code used (1849 voxels). Larger layouts stride + // down to fit. + static constexpr nrOfLightsType MAX_PREVIEW_POINTS = 1800; + + // Smallest stride whose sent-point count fits the cap. stride 1 (exact) for + // anything ≤ MAX_PREVIEW_POINTS — every sparse layout and small grid. + static nrOfLightsType computeStride(nrOfLightsType n) { + nrOfLightsType s = 1; + while ((n + s - 1) / s > MAX_PREVIEW_POINTS) s++; + return s; + } + + // Map an axis coordinate into the 0..255 byte range. posScale_ == 0 means + // the box already fits (1:1, exact integer positions); otherwise scale by + // 255/posScale_ (posScale_ = the largest box edge), preserving aspect ratio + // so a >255 axis isn't silently flattened onto the 255 plane. + uint8_t scaleAxis(lengthType v) const { + if (v < 0) return 0; + int32_t s = posScale_ ? (static_cast(v) * 255 / posScale_) : v; + return s > 255 ? 255 : static_cast(s); + } + + void sendCoordTable() { + if (!broadcaster_ || coordCount_ == 0 || !coords_.data()) return; + const platform::WriteChunk payload[] = { + { coordHeader_, sizeof(coordHeader_) }, + { coords_.data(), static_cast(coordCount_) * 3 }, + }; + broadcaster_->broadcastBinary(payload, 2); } Buffer* sourceBuffer_ = nullptr; - PreviewFrame* frame_ = nullptr; - Buffer downsampled_; // owned; max-pooled RGB output buffer (one entry per stride cell) + BinaryBroadcaster* broadcaster_ = nullptr; + Buffer coords_; // owned; u8×3 positions, one per sent point + Buffer rgb_; // owned; u8×3 colours, one per sent point + uint8_t coordHeader_[8] = {}; + nrOfLightsType coordCount_ = 0; // points actually sent (after stride) + nrOfLightsType stride_ = 1; + uint8_t bx_ = 0, by_ = 0, bz_ = 0; + int32_t posScale_ = 0; // 0 = positions 1:1; else largest box edge (>255) to scale by uint32_t lastSendTime_ = 0; + uint32_t lastCoordTime_ = 0; }; } // namespace mm diff --git a/src/light/effects/EffectBase.h b/src/light/effects/EffectBase.h index 5260934e..403f7584 100644 --- a/src/light/effects/EffectBase.h +++ b/src/light/effects/EffectBase.h @@ -1,7 +1,7 @@ #pragma once #include "core/MoonModule.h" -#include "core/types.h" +#include "light/light_types.h" // lengthType, nrOfLightsType, Dim #include @@ -9,9 +9,9 @@ namespace mm { class Layer; // forward declaration -// Dim enum lives in core/types.h so both EffectBase and ModifierBase can refer -// to it without including each other. Used by Layer::extrude to fill unused axes -// (D1 row → y and z; D2 slice → z; D3 native) and by the UI to derive the +// Dim enum lives in light/light_types.h so both EffectBase and ModifierBase can +// refer to it without including each other. Used by Layer::extrude to fill unused +// axes (D1 row → y and z; D2 slice → z; D3 native) and by the UI to derive the // 📏/🟦/🧊 dimensional emoji (so it isn't repeated in each module's tags()). // ModuleFactory::registerType captures dim from a probe via if-constexpr — // no per-domain registration wrapper is needed. diff --git a/src/light/effects/GameOfLifeEffect.h b/src/light/effects/GameOfLifeEffect.h new file mode 100644 index 00000000..a081784d --- /dev/null +++ b/src/light/effects/GameOfLifeEffect.h @@ -0,0 +1,272 @@ +#pragma once + +#include "light/layers/Layer.h" +#include "core/color.h" +#include "platform/platform.h" + +#include + +namespace mm { + +// Conway's Game of Life on the XY plane (B3/S23). Two cell grids (cur/nxt) hold +// one byte per cell (0 dead, 1 alive); the step reads cur and writes nxt, then +// swaps. On extinction (no live cells) or stasis (no cell changed) the grid +// re-seeds from the PRNG so the effect never stops. +// +// Scope is deliberately the minimal classic rule — MoonLight's E_MoonModules +// GameOfLife adds rulesets, palette colouring, blur, mutation and pentomino +// seeding; those are out by design (concrete first). The simulation step is +// decoupled from colouring (one render line), so a future ruleset control or +// palette swap is a localised change. Prior art: MoonLight (Ewoud Wijma 2022, +// Brandon Butler 2024) and projectMM v1's GameOfLifeEffect. +class GameOfLifeEffect : public EffectBase { +public: + const char* tags() const override { return "🔬🌙"; } // cellular automaton · MoonLight / v1 lineage + // Iterates y and x only; Layer::extrude fills z on 3D layers. The cell grids + // cover only the z=0 plane (w*h), not the full 3D buffer. + Dim dimensions() const override { return Dim::D2; } + + uint8_t seed = 42; + bool wraparound = false; + uint8_t hue = 160; + uint8_t bpm = 60; // generation rate; ≈ bpm/8 generations per second + + void onBuildControls() override { + controls_.addUint8("seed", seed, 0, 255); + controls_.addBool("wraparound", wraparound); + controls_.addUint8("hue", hue, 0, 255); + controls_.addUint8("bpm", bpm, 1, 255); + } + + void onBuildState() override { + nrOfLightsType count = static_cast(width()) * height(); + if (enabled() && count > 0) { + if (count != cellCount_) { + releaseGrids(); + cur_ = static_cast(platform::alloc(count)); + nxt_ = static_cast(platform::alloc(count)); + if (cur_ && nxt_) { + cellCount_ = count; + reseed(); // fresh state for the new dimensions + } else { + releaseGrids(); // partial alloc → keep nothing + } + } + } else { + releaseGrids(); + } + // Two grids: report both so the UI's per-effect heap figure is honest. + setDynamicBytes(static_cast(cellCount_) * 2); + } + + void teardown() override { + releaseGrids(); + setDynamicBytes(0); + } + + ~GameOfLifeEffect() override { + releaseGrids(); + } + + void loop() override { + if (!cur_ || !nxt_) return; + + lengthType w = width(); + lengthType h = height(); + if (w <= 0 || h <= 0) return; + + // 1. Time-gate the generation rate so bpm controls speed independent of + // frame rate. Accumulate dt*bpm (ms·bpm) and spend whole "beats"; + // one beat = one generation. bpm/8 ≈ generations per second (bpm 8 → + // 1/s, 60 → ~7.5/s, 255 → ~32/s). Numerator-only accumulator, divide + // at the read site — same shape as CheckerboardEffect. + uint32_t now = elapsed(); + // Bootstrap on the first frame: lastElapsed_ starts at 0, so a raw + // now-0 would be a huge dt that pins stepAccum_ above the beat threshold + // for good (max-rate forever, bpm ignored). Seed the baseline and take + // dt=0 this frame instead. + uint32_t dt = startedClock_ ? (now - lastElapsed_) : 0; + startedClock_ = true; + lastElapsed_ = now; + stepAccum_ += static_cast(dt) * bpm; + constexpr uint64_t kMsPerBeat = 8000; // 8000 ms·bpm == one generation + // Cap catch-up so a long stall (e.g. tab hidden) can't run thousands of + // generations in one frame. + uint8_t budget = 4; + while (stepAccum_ >= kMsPerBeat && budget-- > 0) { + stepAccum_ -= kMsPerBeat; + advance(); + } + + // 2. Render the current grid EVERY frame. The Layer clears the buffer + // before each effect runs, so skipping the paint on non-step frames + // would leave the buffer black — the grid only advances on beats, but + // it must be drawn on every frame to stay visible between them. + uint8_t* buf = buffer(); + uint8_t cpl = channelsPerLight(); + for (lengthType y = 0; y < h; y++) { + for (lengthType x = 0; x < w; x++) { + size_t off = static_cast(idx(x, y, w)) * cpl; + if (cur_[idx(x, y, w)]) { + RGB c = hsvToRgb(static_cast(hue + x * 3 + y * 5), 200, 255); + if (cpl >= 1) buf[off + 0] = c.r; + if (cpl >= 2) buf[off + 1] = c.g; + if (cpl >= 3) buf[off + 2] = c.b; + } else { + if (cpl >= 1) buf[off + 0] = 0; + if (cpl >= 2) buf[off + 1] = 0; + if (cpl >= 3) buf[off + 2] = 0; + } + } + } + } + + // --- Test helpers (deterministic stepping without rendering) ------------- + // Mirror the v1 effect's test surface so the rule can be pinned directly. + void setCell(lengthType x, lengthType y, bool alive) { + if (cur_ && x >= 0 && y >= 0 && x < width() && y < height()) + cur_[idx(x, y, width())] = alive ? 1 : 0; + } + bool getCell(lengthType x, lengthType y) const { + if (!cur_ || x < 0 || y < 0 || x >= width() || y >= height()) return false; + return cur_[idx(x, y, width())] != 0; + } + nrOfLightsType liveCount() const { + nrOfLightsType n = 0; + for (nrOfLightsType i = 0; i < cellCount_; i++) n += (cur_[i] != 0); + return n; + } + void clearGrid() { + if (cur_) std::memset(cur_, 0, cellCount_); + } + // Advance one B3/S23 generation (no re-seed, no render); returns the number + // of cells that changed and, if `aliveOut` is given, writes the live count. + // loop() calls this then re-seeds on extinction/stasis. Public so tests can + // step a known pattern deterministically. + nrOfLightsType stepOnce(nrOfLightsType* aliveOut = nullptr) { + if (!cur_ || !nxt_) { if (aliveOut) *aliveOut = 0; return 0; } + lengthType w = width(); + lengthType h = height(); + nrOfLightsType alive = 0, changed = 0; + for (lengthType y = 0; y < h; y++) { + for (lengthType x = 0; x < w; x++) { + uint8_t n = neighbors(x, y, w, h); + uint8_t self = cur_[idx(x, y, w)]; + // Birth on exactly 3 neighbours; survival on 2 or 3. + uint8_t next = (n == 3 || (self && n == 2)) ? 1 : 0; + nxt_[idx(x, y, w)] = next; + if (next) alive++; + if (next != self) changed++; + } + } + uint8_t* tmp = cur_; cur_ = nxt_; nxt_ = tmp; // swap: cur = new gen + if (aliveOut) *aliveOut = alive; + return changed; + } + +private: + // One production generation plus the liveliness logic. A random soup always + // decays to sparse still-lifes + a few blinkers (changed never hits 0, so a + // plain stasis check won't fire) — so we re-seed when the colony goes + // extinct, thins below a density floor, or stops growing for a while. That + // keeps gliders and chaos coming instead of a frozen field. (MoonLight does + // the richer version with pentomino injection + CRC cycle detection; this + // is the minimal equivalent — see the spec's Extending note.) + void advance() { + nrOfLightsType alive = 0; + stepOnce(&alive); + generation_++; + + nrOfLightsType floor = cellCount_ / 32; // ~3% of the grid + if (alive <= floor) { + reseed(); + return; + } + // Stagnation: if the live count barely moves over a window, the colony + // has settled into still-lifes + oscillators. Re-seed to revive it. + nrOfLightsType delta = (alive > lastAlive_) + ? static_cast(alive - lastAlive_) + : static_cast(lastAlive_ - alive); + if (delta <= (cellCount_ / 256 + 1)) { // <0.4% change this generation + if (++stagnantGens_ >= 32) reseed(); + } else { + stagnantGens_ = 0; + } + lastAlive_ = alive; + } + + uint8_t* cur_ = nullptr; + uint8_t* nxt_ = nullptr; + nrOfLightsType cellCount_ = 0; + uint32_t rngState_ = 0; + + uint8_t rand8() { + rngState_ = rngState_ * 1103515245u + 12345u; + return static_cast((rngState_ >> 16) & 0xFF); + } + + // Row-major cell index (z=0 plane only). + static nrOfLightsType idx(lengthType x, lengthType y, lengthType w) { + return static_cast(y) * w + x; + } + + // Count of the 8 Moore neighbours that are alive. Edges either wrap or are + // treated as dead, per the wraparound control. + uint8_t neighbors(lengthType x, lengthType y, lengthType w, lengthType h) const { + uint8_t n = 0; + for (int8_t dy = -1; dy <= 1; dy++) { + for (int8_t dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; + lengthType nx = static_cast(x + dx); + lengthType ny = static_cast(y + dy); + if (wraparound) { + if (nx < 0) nx = static_cast(w - 1); + else if (nx >= w) nx = 0; + if (ny < 0) ny = static_cast(h - 1); + else if (ny >= h) ny = 0; + } else if (nx < 0 || ny < 0 || nx >= w || ny >= h) { + continue; // out-of-bounds counts as dead + } + n = static_cast(n + (cur_[idx(nx, ny, w)] != 0)); + } + } + return n; + } + + // Random initial state. The very first seeding (per grid) starts the PRNG + // from the `seed` control so a given seed gives a reproducible opening; + // later re-seeds continue the same stream so each revival differs — without + // that, every reseed would replay the identical soup and the effect would + // loop forever. ~31% alive: dense enough to evolve, sparse enough to avoid + // instant gridlock. + void reseed() { + if (!cur_) return; + if (!seeded_) { + rngState_ = 0x9E3779B9u ^ (static_cast(seed) * 2654435761u); + seeded_ = true; + } + for (nrOfLightsType i = 0; i < cellCount_; i++) { + cur_[i] = (rand8() < 80) ? 1 : 0; // 80/256 ≈ 31% + } + lastAlive_ = 0; + stagnantGens_ = 0; + } + + void releaseGrids() { + if (cur_) { platform::free(cur_); cur_ = nullptr; } + if (nxt_) { platform::free(nxt_); nxt_ = nullptr; } + cellCount_ = 0; + seeded_ = false; // a fresh grid re-derives the seed + } + + // Generation-pacing + liveliness state. + uint32_t lastElapsed_ = 0; + bool startedClock_ = false; // false until the first loop seeds lastElapsed_ + uint64_t stepAccum_ = 0; // accumulated dt*bpm (ms·bpm) + bool seeded_ = false; // first reseed derives from `seed` + uint32_t generation_ = 0; + nrOfLightsType lastAlive_ = 0; + uint16_t stagnantGens_ = 0; +}; + +} // namespace mm diff --git a/src/light/layers/Buffer.h b/src/light/layers/Buffer.h index 1e56a4ed..b01a2b39 100644 --- a/src/light/layers/Buffer.h +++ b/src/light/layers/Buffer.h @@ -1,6 +1,6 @@ #pragma once -#include "core/types.h" +#include "light/light_types.h" // nrOfLightsType #include "platform/platform.h" #include diff --git a/src/light/layers/Layer.h b/src/light/layers/Layer.h index 1ec413f5..64d88c4f 100644 --- a/src/light/layers/Layer.h +++ b/src/light/layers/Layer.h @@ -15,6 +15,7 @@ namespace mm { class Layer : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Layer; } + const char* acceptsChildRoles() const override { return "effect,modifier"; } // start/end carve a region of the shared Layouts into this Layer's buffer, // expressed as **percentages of the physical extent on each axis**. @@ -60,6 +61,9 @@ class Layer : public MoonModule { } void setLayouts(Layouts* lg) { layouts_ = lg; } + // The active Layouts, for consumers that need per-light coordinates (e.g. + // PreviewDriver builds its coordinate table from layouts()->forEachCoord). + Layouts* layouts() const { return layouts_; } void setChannelsPerLight(uint8_t cpl) { channelsPerLight_ = cpl; } void onBuildState() override { @@ -201,14 +205,35 @@ class Layer : public MoonModule { } } + // Box vs real-light count. The render grid is the layout's bounding box + // (physicalWidth_×Height_×Depth_); the DRIVER buffer holds only the real + // lights (Layouts::totalLightCount). For a dense GridLayout every box + // cell is a light → boxCount == driverCount and the LUT is identity (the + // fast memcpy path). For a sparse layout (sphere, ring) driverCount < + // boxCount: most box cells map to NO light, so we always build a LUT + // whose destinations are DRIVER indices [0..driverCount). This makes the + // driver/output buffer — what ArtNet, LEDs, and the preview consume — + // exactly the real lights, never the padded box. + nrOfLightsType boxCount = static_cast(physicalWidth_) * physicalHeight_ * physicalDepth_; + nrOfLightsType driverCount = physicalLightCount(); // == Layouts::totalLightCount() + const bool sparse = (driverCount != boxCount); + if (!mod) { - // No modifiers: 1:1 unshuffled, logical == physical + // No modifier: logical == physical box. Effects render into the dense + // box buffer; the LUT extracts the real lights into the driver buffer. width_ = physicalWidth_; height_ = physicalHeight_; depth_ = physicalDepth_; - nrOfLightsType count = static_cast(width_) * height_ * depth_; - lut_.setIdentity(count); - allocateBuffer(count); + if (!sparse) { + // Dense grid: box cell i IS light i. Identity (memcpy) — unchanged. + lut_.setIdentity(boxCount); + allocateBuffer(boxCount); + return; + } + // Sparse: build box→driver LUT (each box cell → its driver index, or + // nothing). logicalCount = boxCount, ≤1 destination per cell. + buildSparseIdentityLUT(boxCount, driverCount); + allocateBuffer(boxCount); // layer (render) buffer stays the dense box return; } @@ -217,40 +242,52 @@ class Layer : public MoonModule { width_, height_, depth_); nrOfLightsType logicalCount = static_cast(width_) * height_ * depth_; - nrOfLightsType physicalCount = static_cast(physicalWidth_) * physicalHeight_ * physicalDepth_; - // Estimate max destinations from modifier's multiplier + // Degrade target on OOM: a sparse layout degrades to the (cheaper) + // box→driver identity LUT so the driver buffer stays the real lights; a + // dense grid degrades to the plain identity/memcpy path as before. + auto degradeIdentity = [&]() { + width_ = physicalWidth_; + height_ = physicalHeight_; + depth_ = physicalDepth_; + if (sparse) { buildSparseIdentityLUT(boxCount, driverCount); } + else { lut_.setIdentity(boxCount); } + allocateBuffer(boxCount); + }; + + // maxDest in DRIVER-index terms. The modifier's multiplier bounds how + // many destinations a logical cell fans out to; clamp to 2× the real + // light count (was box count — driverCount is the true ceiling now). nrOfLightsType maxDest = logicalCount * mod->maxMultiplier(); - if (maxDest > physicalCount * 2) maxDest = physicalCount * 2; + if (maxDest > driverCount * 2) maxDest = driverCount * 2; size_t lutBytes = MappingLUT::estimateBytes(logicalCount, maxDest); if (!canAllocate(lutBytes)) { - // Not enough memory for LUT — degrade to 1:1 std::printf(" DEGRADE LUT skipped (need %u, free %u)\n", static_cast(lutBytes), static_cast(platform::freeHeap())); lutSkipped_ = true; setStatus("modifier LUT skipped — not enough memory", Severity::Warning); - width_ = physicalWidth_; - height_ = physicalHeight_; - depth_ = physicalDepth_; - lut_.setIdentity(physicalCount); - allocateBuffer(physicalCount); + degradeIdentity(); return; } if (!lut_.build(logicalCount, maxDest)) { - // build() failed (allocation) — degrade to 1:1 identity lutSkipped_ = true; setStatus("modifier LUT build failed — not enough memory", Severity::Warning); - width_ = physicalWidth_; - height_ = physicalHeight_; - depth_ = physicalDepth_; - lut_.setIdentity(physicalCount); - allocateBuffer(physicalCount); + degradeIdentity(); return; } + // Box→driver translation: the modifier reasons in box geometry and emits + // box-cell indices; the driver buffer is indexed by real-light index. A + // box→driver map (driverIdx per box cell, or "none") translates the + // modifier's output into driver-index space. For a dense grid this map + // is identity; for a sparse layout it drops box cells that aren't lights + // (e.g. a mirror destination landing off the sphere shell). Built from + // Layouts::forEachCoord. nullptr → dense grid → no translation needed. + nrOfLightsType* boxToDriver = sparse ? buildBoxToDriver(boxCount) : nullptr; + // Fill LUT by iterating all logical coordinates nrOfLightsType physicals[8]; nrOfLightsType count; @@ -262,16 +299,70 @@ class Layer : public MoonModule { count = 0; mod->mapToPhysical(x, y, z, physicalWidth_, physicalHeight_, physicalDepth_, physicals, count, 8); + if (boxToDriver) { + // Translate box indices → driver indices, dropping cells + // that map to no real light (kNoDriver). + nrOfLightsType kept = 0; + for (nrOfLightsType i = 0; i < count; i++) { + nrOfLightsType d = (physicals[i] < boxCount) ? boxToDriver[physicals[i]] : kNoDriver; + if (d != kNoDriver) physicals[kept++] = d; + } + count = kept; + } lut_.setMapping(logIdx, physicals, count); logIdx++; } } } + if (boxToDriver) platform::free(boxToDriver); lut_.finalize(); allocateBuffer(logicalCount); } + // Sentinel: a box cell that is not a real light (no driver index). + static constexpr nrOfLightsType kNoDriver = static_cast(-1); + + // Allocate + fill a box-cell → driver-index map from the layout's real + // lights (Layouts::forEachCoord emits (driverIdx, x, y, z) in driver order). + // Cells with no light hold kNoDriver. Caller owns the returned block + // (platform::free). Returns nullptr on OOM. Box index = z*W*H + y*W + x. + nrOfLightsType* buildBoxToDriver(nrOfLightsType boxCount) const { + auto* map = static_cast(platform::alloc(static_cast(boxCount) * sizeof(nrOfLightsType))); + if (!map) return nullptr; + for (nrOfLightsType i = 0; i < boxCount; i++) map[i] = kNoDriver; + struct Ctx { nrOfLightsType* map; lengthType w, h; nrOfLightsType box; }; + Ctx ctx{map, physicalWidth_, physicalHeight_, boxCount}; + layouts_->forEachCoord([](void* c, nrOfLightsType driverIdx, lengthType x, lengthType y, lengthType z) { + auto* k = static_cast(c); + nrOfLightsType box = static_cast(z) * k->w * k->h + + static_cast(y) * k->w + x; + if (box < k->box) k->map[box] = driverIdx; + }, &ctx); + return map; + } + + // No-modifier sparse case: LUT maps each box cell → its driver index (≤1 + // destination), so the driver buffer holds only the real lights. logicalCount + // = boxCount; built in box order so setMapping's sequential contract holds. + void buildSparseIdentityLUT(nrOfLightsType boxCount, nrOfLightsType driverCount) { + nrOfLightsType* boxToDriver = buildBoxToDriver(boxCount); + if (!boxToDriver || !lut_.build(boxCount, driverCount)) { + // OOM — fall back to dense identity (sends the box; correct-not-crash). + if (boxToDriver) platform::free(boxToDriver); + lutSkipped_ = true; + setStatus("sparse LUT build failed — not enough memory", Severity::Warning); + lut_.setIdentity(boxCount); + return; + } + for (nrOfLightsType box = 0; box < boxCount; box++) { + nrOfLightsType d = boxToDriver[box]; + lut_.setMapping(box, &d, d == kNoDriver ? 0 : 1); + } + lut_.finalize(); + platform::free(boxToDriver); + } + private: Layouts* layouts_ = nullptr; Buffer buffer_; @@ -291,8 +382,8 @@ class Layer : public MoonModule { size_t availableHeap = platform::freeHeap(); if (availableHeap == 0) return true; // desktop: unlimited size_t internalHeap = platform::freeInternalHeap(); - if (internalHeap > 0 && internalHeap <= HEAP_RESERVE) return false; - size_t budget = availableHeap > HEAP_RESERVE ? availableHeap - HEAP_RESERVE : 0; + if (internalHeap > 0 && internalHeap <= platform::HEAP_RESERVE) return false; + size_t budget = availableHeap > platform::HEAP_RESERVE ? availableHeap - platform::HEAP_RESERVE : 0; return budget >= bytesNeeded && platform::maxAllocBlock() >= bytesNeeded; } diff --git a/src/light/layers/Layers.h b/src/light/layers/Layers.h index 7dfe1cd0..87868d76 100644 --- a/src/light/layers/Layers.h +++ b/src/light/layers/Layers.h @@ -17,6 +17,8 @@ namespace mm { // pipeline byte-for-byte. The container itself owns no buffer. class Layers : public MoonModule { public: + const char* acceptsChildRoles() const override { return "layer"; } + // Wire the shared Layouts. Propagates to every child Layer so their // onBuildState() can size buffers from it. Idempotent — call again // after adding a Layer child to wire the new one. Non-Layer children diff --git a/src/light/layers/MappingLUT.h b/src/light/layers/MappingLUT.h index 274c773e..9a897e58 100644 --- a/src/light/layers/MappingLUT.h +++ b/src/light/layers/MappingLUT.h @@ -1,6 +1,6 @@ #pragma once -#include "core/types.h" +#include "light/light_types.h" // nrOfLightsType #include "platform/platform.h" #include diff --git a/src/light/layouts/GridLayout.h b/src/light/layouts/GridLayout.h index 93213103..ebf7420c 100644 --- a/src/light/layouts/GridLayout.h +++ b/src/light/layouts/GridLayout.h @@ -3,9 +3,16 @@ #include #include #include "light/layouts/Layouts.h" +#include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { +// Default grid edge. Small by default so a fresh device shows a manageable +// 16×16×1 (and the desktop boot grid the same); users scale up. Owned by +// GridLayout — it's this layout's default, also read by the composition roots +// (main.cpp / main_desktop.cpp) to size the boot grid. +constexpr lengthType defaultGridSize = 16; + class GridLayout : public LayoutBase { public: lengthType width = defaultGridSize; diff --git a/src/light/layouts/Layouts.h b/src/light/layouts/Layouts.h index 4ba08bba..d0cdeadb 100644 --- a/src/light/layouts/Layouts.h +++ b/src/light/layouts/Layouts.h @@ -1,10 +1,15 @@ #pragma once #include "core/MoonModule.h" -#include "core/types.h" +#include "light/light_types.h" // lengthType, nrOfLightsType namespace mm { +// Callback for layout coordinate iteration — a layout walks its positions and +// invokes this per light with the physical index and (x,y,z). Owned by +// LayoutBase: it's the signature of forEachCoord, which every layout overrides. +using CoordCallback = void(*)(void* ctx, nrOfLightsType idx, lengthType x, lengthType y, lengthType z); + class LayoutBase : public MoonModule { public: ModuleRole role() const override { return ModuleRole::Layout; } @@ -22,6 +27,8 @@ class LayoutBase : public MoonModule { // one Layouts describing the physical setup, multiple Layers render into it. class Layouts : public MoonModule { public: + const char* acceptsChildRoles() const override { return "layout"; } + // Disabled children are skipped, same gate Layer/Layers/Drivers apply to their // children. Indices of subsequent enabled layouts shift down to close the gap — // disable Layout A and Layout B's lights move to indices 0..N. Users who need diff --git a/src/light/layouts/SphereLayout.h b/src/light/layouts/SphereLayout.h new file mode 100644 index 00000000..949dfc08 --- /dev/null +++ b/src/light/layouts/SphereLayout.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include "light/layouts/Layouts.h" +#include "light/light_types.h" // lengthType, nrOfLightsType + +namespace mm { + +// A hollow sphere: lights sit on the surface only (a one-light-thick shell), +// not the interior. Lattice layout — every light is at an integer (x,y,z) in a +// (2r+1)^3 bounding box, centred at (r,r,r). A lattice point is on the shell +// when its distance from the centre rounds to `radius`, i.e. it falls in the +// half-open band [radius-0.5, radius+0.5). The same band predicate drives both +// lightCount() (count) and forEachCoord() (emit), so they never disagree. +// +// Distances are compared squared (integer math, no sqrt/float per light) — the +// hot-path discipline (integer math, no float per light) applies here even +// though layout iteration is a cold path, because the same pattern reads +// uniformly across the codebase. +class SphereLayout : public LayoutBase { +public: + // Surface radius in light-units. Min 1 (the smallest hollow sphere: 18 + // lights — the 6 axis-neighbours at d^2=1 plus the 12 edge-neighbours at + // d^2=2, all rounding to distance 1 under the band predicate below). + // Max 64 keeps the (2*64+1)^3 bounding-box scan bounded. + lengthType radius = 4; + + void onBuildControls() override { + controls_.addInt16("radius", radius, 1, 64); + } + + nrOfLightsType lightCount() const override { + // Count the shell points. Cheap relative to rendering, recomputed only + // on a radius change (controlChangeTriggersBuildState → rebuild). + nrOfLightsType n = 0; + forEachShellPoint([](void*, nrOfLightsType, lengthType, lengthType, lengthType) {}, nullptr, &n); + return n; + } + + void forEachCoord(CoordCallback cb, void* ctx) const override { + forEachShellPoint(cb, ctx, nullptr); + } + +private: + // Walk the (2r+1)^3 lattice, invoking cb for each shell point with a + // sequential index. When `count` is non-null, also tally the points (so + // lightCount() reuses the exact same predicate as the iterator). Either cb + // or count (or both) may be active; cb is a no-op lambda in the count path. + void forEachShellPoint(CoordCallback cb, void* ctx, nrOfLightsType* count) const { + const int32_t r = radius; + // Half-open band [r-0.5, r+0.5) compared in squared integer space: + // lo = (2r-1)^2 / 4 ... but keep it integer by comparing 4*d^2 to + // (2r-1)^2 and (2r+1)^2 — multiply the whole inequality by 4. + const int32_t lo = (2 * r - 1) * (2 * r - 1); // 4*(r-0.5)^2 + const int32_t hi = (2 * r + 1) * (2 * r + 1); // 4*(r+0.5)^2 + nrOfLightsType idx = 0; + for (int32_t z = 0; z <= 2 * r; z++) { + const int32_t dz = z - r; + for (int32_t y = 0; y <= 2 * r; y++) { + const int32_t dy = y - r; + for (int32_t x = 0; x <= 2 * r; x++) { + const int32_t dx = x - r; + const int32_t d4 = 4 * (dx * dx + dy * dy + dz * dz); + if (d4 < lo || d4 >= hi) continue; + cb(ctx, idx, + static_cast(x), + static_cast(y), + static_cast(z)); + idx++; + } + } + } + if (count) *count = idx; + } +}; + +} // namespace mm diff --git a/src/light/light_types.h b/src/light/light_types.h new file mode 100644 index 00000000..5f2c093d --- /dev/null +++ b/src/light/light_types.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include "platform_config.h" + +// The foundational coordinate / count / dimension types of the light domain, +// shared by ~20 files (every effect, modifier, layout, layer, driver, buffer) +// with no single owner — so they live in one shared header rather than being +// scattered into an arbitrary one. Symbols that DO have a single owner live +// there instead (defaultGridSize in GridLayout.h, CoordCallback in Layouts.h). +// This header includes only the platform layer, never core — the boundary +// stays one-directional. Core names none of these types: ModuleFactory captures +// a module's dimensionality via a return-type-agnostic `dimensions()` probe, so +// even `Dim` stays here. + +namespace mm { + +// A grid coordinate. int16_t everywhere — int8_t can't hold a 128-edge grid, +// and a smaller type would only save in MappingLUT, which doesn't yet do that +// size optimisation (it stores nrOfLightsType indices, not coordinates). +using lengthType = int16_t; + +// Count of lights, and the index type MappingLUT stores. uint32_t with PSRAM +// (10K+ lights, large installations), uint16_t without — the narrower index +// keeps MappingLUT's CSR arrays half the size on no-PSRAM boards. Selected at +// compile time from platform_config.h's hasPsram flag. +using nrOfLightsType = std::conditional_t; + +// Dimensional support. Effects use this to declare which axes they iterate so the +// Layer can extrude lower-dimensional output across unused axes (D1 row → fill y/z, +// D2 slice → fill z, D3 → no extrusion). Modifiers use it to advertise which axes +// they can transform. The UI derives the 📏/🟦/🧊 chip from it (captured generically +// by core's ModuleFactory, which detects a `dimensions()` method without naming +// this type — so the enum stays in the light domain). EffectBase and ModifierBase +// both refer to it without one including the other. +enum class Dim : uint8_t { + D1 = 1, + D2 = 2, + D3 = 3, +}; + +} // namespace mm diff --git a/src/light/modifiers/ModifierBase.h b/src/light/modifiers/ModifierBase.h index 1ee01c8e..885496a4 100644 --- a/src/light/modifiers/ModifierBase.h +++ b/src/light/modifiers/ModifierBase.h @@ -1,7 +1,7 @@ #pragma once #include "core/MoonModule.h" -#include "core/types.h" +#include "light/light_types.h" // lengthType, nrOfLightsType, Dim namespace mm { diff --git a/src/main.cpp b/src/main.cpp index cb00d0ee..c6c4dd0a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ #include "core/Scheduler.h" #include "light/layers/Layers.h" #include "light/layouts/GridLayout.h" +#include "light/layouts/SphereLayout.h" #include "light/effects/LinesEffect.h" #include "light/effects/RainbowEffect.h" #include "light/effects/NoiseEffect.h" @@ -14,11 +15,11 @@ #include "light/effects/SpiralEffect.h" #include "light/effects/RipplesEffect.h" #include "light/effects/LavaLampEffect.h" +#include "light/effects/GameOfLifeEffect.h" #include "light/modifiers/MirrorModifier.h" #include "light/drivers/ArtNetSendDriver.h" #include "light/drivers/PreviewDriver.h" #include "core/HttpServerModule.h" -#include "core/PreviewFrame.h" // used directly here; HttpServerModule.h no longer brings it transitively #include "core/SystemModule.h" #include "core/BoardModule.h" #include "core/FirmwareUpdateModule.h" @@ -43,6 +44,7 @@ static void registerModuleTypes() { // if-constexpr when present — EffectBase and ModifierBase both expose one, // so the UI's 📏/🟦/🧊 chip lights up without any per-domain wrapper. mm::ModuleFactory::registerType("GridLayout", "light/layouts/GridLayout.md"); + mm::ModuleFactory::registerType("SphereLayout", "light/layouts/SphereLayout.md"); mm::ModuleFactory::registerType("LinesEffect", "light/effects/LinesEffect.md"); mm::ModuleFactory::registerType("RainbowEffect", "light/effects/RainbowEffect.md"); mm::ModuleFactory::registerType("NoiseEffect", "light/effects/NoiseEffect.md"); @@ -56,6 +58,7 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("SpiralEffect", "light/effects/SpiralEffect.md"); mm::ModuleFactory::registerType("RipplesEffect", "light/effects/RipplesEffect.md"); mm::ModuleFactory::registerType("LavaLampEffect", "light/effects/LavaLampEffect.md"); + mm::ModuleFactory::registerType("GameOfLifeEffect", "light/effects/GameOfLifeEffect.md"); mm::ModuleFactory::registerType("MirrorModifier", "light/modifiers/MirrorModifier.md"); mm::ModuleFactory::registerType("ArtNetSendDriver", "light/drivers/ArtNetSendDriver.md"); mm::ModuleFactory::registerType("PreviewDriver", "light/drivers/PreviewDriver.md"); @@ -83,7 +86,7 @@ static void printModuleMetrics(mm::MoonModule* mod, int depth) { } } -void mm_main(volatile bool& keepRunning, mm::lengthType gridW, mm::lengthType gridH, uint16_t httpPort) { +void mm_main(volatile bool& keepRunning, uint16_t httpPort) { registerModuleTypes(); mm::Scheduler scheduler; @@ -169,11 +172,11 @@ void mm_main(volatile bool& keepRunning, mm::lengthType gridW, mm::lengthType gr improvModule->markWiredByCode(); } - // Layouts: top-level container; one or more layouts. Today one GridLayout. + // Layouts: top-level container; one or more layouts. Today one GridLayout, + // which self-initialises to defaultGridSize (persistence overlays any saved + // size before setup()). No boot-time dimensions threaded in here. auto* layouts = static_cast(mm::ModuleFactory::create("Layouts")); auto* grid = static_cast(mm::ModuleFactory::create("GridLayout")); - grid->width = gridW; - grid->height = gridH; layouts->addChild(grid); // Layers: top-level container; one or more layers, each rendering @@ -199,19 +202,26 @@ void mm_main(volatile bool& keepRunning, mm::lengthType gridW, mm::lengthType gr auto* artnet = mm::ModuleFactory::create("ArtNetSendDriver"); drivers->addChild(artnet); // name = "ArtNetSend" (factory default) — disambiguates from a future ArtNetReceive + artnet->markWiredByCode(); - auto* previewFrame = new mm::PreviewFrame(); auto* preview = static_cast(mm::ModuleFactory::create("PreviewDriver")); - // PreviewDriver reads physical dimensions from the active Layer at frame - // time (via Drivers' setLayer wiring) so runtime grid resizes show in the - // preview header. - preview->setPreviewFrame(previewFrame); + // PreviewDriver reads the active Layer (via Drivers' setLayer wiring) for the + // light positions and the sparse driver buffer it streams; it owns its own + // scratch buffers, so no externally-owned frame is needed. drivers->addChild(preview); + // These two drivers are wired by code here (preview gets its broadcaster set + // below, once httpServer exists; ArtNet its IP). Mark them wired-by-code so a + // persistence load can't replace the wired instances with fresh factory ones + // that lost that wiring — same protection BoardModule / ImprovProvisioning use. + preview->markWiredByCode(); auto* httpServer = static_cast(mm::ModuleFactory::create("HttpServerModule")); httpServer->port = httpPort; httpServer->setScheduler(&scheduler); - httpServer->setPreviewFrame(previewFrame); + // PreviewDriver pushes the coordinate table + per-frame RGB to the HTTP + // server's WS broadcaster (HttpServerModule is-a BinaryBroadcaster). Light + // owns the preview wire format end to end; core just writes the bytes. + preview->setBroadcaster(httpServer); // Register top-level modules with scheduler (scheduler deletes on teardown). // Order matters: filesystem first (load hook runs before any module's setup), @@ -291,5 +301,4 @@ void mm_main(volatile bool& keepRunning, mm::lengthType gridW, mm::lengthType gr std::printf("\nShutting down.\n"); scheduler.teardown(); - delete previewFrame; } diff --git a/src/platform/desktop/main_desktop.cpp b/src/platform/desktop/main_desktop.cpp index ab83f5ee..3d1459a0 100644 --- a/src/platform/desktop/main_desktop.cpp +++ b/src/platform/desktop/main_desktop.cpp @@ -1,5 +1,3 @@ -#include "core/types.h" - #include #include #include @@ -7,7 +5,7 @@ #include #include -extern void mm_main(volatile bool& keepRunning, mm::lengthType gridW, mm::lengthType gridH, uint16_t httpPort); +extern void mm_main(volatile bool& keepRunning, uint16_t httpPort); static volatile bool running = true; static bool cleanExit = false; @@ -78,7 +76,7 @@ int main() { std::printf("projectMM started at %s\n", tbuf); std::printf("Press Ctrl-C to stop.\n"); - mm_main(running, mm::defaultGridSize, mm::defaultGridSize, 8080); + mm_main(running, 8080); cleanExit = true; t = std::time(nullptr); diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index f3e84e32..35b0a1a2 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -687,7 +687,48 @@ WriteResult TcpConnection::writeChunks(const WriteChunk* chunks, int count) { } if (n == 0) return WriteResult::WouldBlock; if (static_cast(n) == total) return WriteResult::Complete; - return WriteResult::Partial; + + // Partial: some bytes of this WS frame went out, so we MUST finish the rest + // — a half-sent frame corrupts the stream and the caller would otherwise + // drop the connection. This happens under backpressure when the link is + // saturated (e.g. ArtNet + a large preview frame on a slow 128×128 tick): + // the lwIP send buffer can't take the whole frame at once but drains in + // microseconds. Drain the unsent tail with a bounded retry loop; only if it + // still can't complete (a genuinely stuck socket) do we report Partial so + // the caller closes. lwIP exposes no free-TX-space query (SO_SNDBUF is + // unimplemented), so a pre-check isn't possible — finishing the write is the + // way to keep the stream intact without dropping. + size_t sent = static_cast(n); + // Small cap: a partial tail (a few KB) drains in 1-2 ms as TCP ACKs arrive; + // 8 ms is plenty for a transient. A genuinely saturated link blows past it — + // then we give up (report Partial → caller closes, browser reconnects), + // rather than stall the render tick. Bounds the worst-case tick hit to ~8 ms. + constexpr int kMaxDrainTries = 8; // each yields up to 1ms + for (int tries = 0; sent < total && tries < kMaxDrainTries; ) { + // Locate the chunk + offset where `sent` lands, write its remaining tail. + size_t acc = 0; + const uint8_t* p = nullptr; size_t remain = 0; + for (int i = 0; i < count; i++) { + if (sent < acc + chunks[i].len) { + size_t off = sent - acc; + p = chunks[i].data + off; + remain = chunks[i].len - off; + break; + } + acc += chunks[i].len; + } + if (!p) break; // shouldn't happen (sent < total) + ssize_t w = lwip_write(fd_, p, remain); + if (w > 0) { + sent += static_cast(w); + } else if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + vTaskDelay(pdMS_TO_TICKS(1)); // buffer full — let it drain + tries++; + } else { + return WriteResult::Error; // real socket error + } + } + return (sent == total) ? WriteResult::Complete : WriteResult::Partial; } void TcpConnection::close() { diff --git a/src/platform/platform.h b/src/platform/platform.h index a6e38a4d..563f6128 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -3,6 +3,7 @@ #include #include #include +#include "platform_config.h" // hasOta / hasPsram / … — flags this header's contract refers to namespace mm::platform { @@ -33,6 +34,11 @@ size_t maxInternalAllocBlock(); // largest contiguous block in INTERNAL RAM only size_t totalHeap(); // total heap capacity (internal + PSRAM) size_t totalInternalHeap(); // total internal heap capacity +// Heap to keep free for stack, HTTP, WiFi, and overhead when sizing buffers — +// a platform memory constraint, not a domain one (it guards core subsystems). +// Any allocator checks free heap against this reserve before committing. +constexpr size_t HEAP_RESERVE = 32768; + void getMacAddress(uint8_t mac[6]); const char* chipModel(); const char* sdkVersion(); diff --git a/src/ui/app.js b/src/ui/app.js index 4fea3336..8741ad5f 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -4,6 +4,7 @@ // installer (first flash via Web Serial). Module loading is deferred by // default; entry-point is the WS init at the bottom — no ordering surprises. import { installPicker } from "/install-picker.js"; +import { preview } from "/preview3d.js"; // Sections (top to bottom): // 1. State + storage @@ -13,9 +14,9 @@ import { installPicker } from "/install-picker.js"; // 5. State patching (no-rebuild contract): updateValues() + updateModuleControls() // 6. Type picker // 7. Drag-to-reorder (HTML5 DnD on desktop; touchstart-gated on mobile) -// 8. 3D WebGL preview (sticky + scroll-shrink, sparse vertex buffer, frame cache) -// 9. Status bar wiring (device name, sys stats, theme, reboot) -// 10. Boot +// (3D WebGL preview lives in preview3d.js — imported as `preview`) +// 8. Status bar wiring (device name, sys stats, theme, reboot) +// 9. Boot // // Load-bearing invariants: // - dragTs[mid:key] cooldown: ignore WS pushes for a control the user has touched @@ -87,7 +88,7 @@ function connectWs() { ws.onmessage = (e) => { if (wsPaused) return; if (e.data instanceof ArrayBuffer) { - renderPreviewFrame(e.data); + preview.onBinaryMessage(e.data); return; } try { @@ -167,8 +168,8 @@ async function init() { document.getElementById("main").textContent = "Error: " + err.message; } connectWs(); - initWebGL(); - setupPreviewShrink(); + preview.init(); + preview.setupShrink(); } async function sendControl(moduleName, controlName, value) { @@ -561,9 +562,11 @@ function createCard(mod, depth) { // Enable checkbox joins the right-hand action cluster, before ✎/×. title.appendChild(enabled); - // Action buttons for reorderable children (Effect/Modifier roles). - // Top-level modules aren't reorderable in this iteration (fixed in main.cpp). - if (depth > 0 && (mod.role === "effect" || mod.role === "modifier")) { + // Delete / replace buttons for user-managed children (any role a container + // accepts, minus modules that opted out via userEditable=false). Top-level + // modules are fixed in main.cpp; code-wired children declare userEditable + // false or carry a role no container accepts. See isUserEditableChild. + if (isUserEditableChild(mod, depth)) { const actions = createActionButtons(mod); title.appendChild(actions); } @@ -709,7 +712,9 @@ function createCard(mod, depth) { } // -- Drag-to-reorder (HTML5 DnD on desktop; touchstart-gated on mobile) -- - if (depth > 0 && (mod.role === "effect" || mod.role === "modifier")) { + // Same gate as the delete/replace buttons: a user-managed child is also + // reorderable within its parent. + if (isUserEditableChild(mod, depth)) { attachDragHandlers(card, mod); } @@ -842,23 +847,54 @@ function hasNestedChildren(mod) { return (mod.children && mod.children.length > 0) || acceptsNewChildren(mod); } -// Whether the UI's "+ add child" affordance applies to this parent. Light-pipeline -// containers only — Layers → layer, Layer → effect+modifier, Drivers → driver, -// Layouts → layout. System modules like Network that host a code-wired child -// (Improv) are deliberately NOT in this list — the child is fixed-shape. +// Roles this parent accepts as user-added children, from the device's +// `acceptsChildRoles` (per-type in /api/types — e.g. Layer → "effect,modifier"). +// Domain-neutral: the UI no longer hardcodes which module types are containers; +// the device declares it via MoonModule::acceptsChildRoles(). "" → [] (accepts +// none), which is also the default for modules whose type isn't loaded yet. +function rolesAcceptedBy(parentMod) { + const t = availableTypes.find(t => t.name === parentMod.type); + const csv = (t && t.acceptsChildRoles) ? t.acceptsChildRoles : ""; + return csv ? csv.split(",") : []; +} + +// Whether the "+ add child" affordance applies — derived from acceptsChildRoles +// being non-empty, so there's a single source of truth (no separate list). function acceptsNewChildren(mod) { - return mod.type === "Layers" || - mod.type === "Layer" || - mod.type === "Drivers" || - mod.type === "Layouts"; + return rolesAcceptedBy(mod).length > 0; } -function rolesAcceptedBy(parentMod) { - if (parentMod.type === "Layers") return ["layer"]; - if (parentMod.type === "Layer") return ["effect", "modifier"]; - if (parentMod.type === "Drivers") return ["driver"]; - if (parentMod.type === "Layouts") return ["layout"]; - return []; +// The set of child roles ANY loaded type accepts — the union of every type's +// acceptsChildRoles. A module is "user-managed as a child" iff its role is in +// this set, which is how the UI decides to show delete/replace/drag without +// hardcoding role names. Code-wired children (ImprovProvisioning, BoardModule +// — roles no container declares) correctly fall outside it. +function allAcceptedChildRoles() { + const roles = new Set(); + for (const t of availableTypes) { + const csv = t.acceptsChildRoles || ""; + if (csv) csv.split(",").forEach(r => roles.add(r)); + } + return roles; +} + +// Whether the UI shows delete / replace / drag for this module. True when it's +// a nested module (depth > 0) whose role is one some container accepts AND it +// hasn't opted out via the device's userEditable=false (e.g. PreviewDriver). +// Replaces the old hardcoded `role === "effect" || "modifier"` gate — now any +// add-accepted role (driver, layout, …) is editable, and the child itself can +// veto via userEditable. +// +// We test mod.role against the UNION of all containers' acceptsChildRoles, not +// against this module's specific parent. That's exact while the role→container +// mapping is 1:1 (effect→Layer, driver→Drivers, layout→Layouts, layer→Layers) — +// a child of an add-accepted role is always under the one container that +// accepts it. If a role ever becomes accepted by more than one container, this +// would need the parent threaded in to scope the check to the actual parent. +function isUserEditableChild(mod, depth) { + return depth > 0 + && mod.userEditable !== false + && allAcceptedChildRoles().has(mod.role); } // --------------------------------------------------------------------------- @@ -1755,373 +1791,12 @@ function attachDragHandlers(card, mod) { } // --------------------------------------------------------------------------- -// 8. 3D WebGL preview -// --------------------------------------------------------------------------- - -let gl = null; -let glProgram = null; -let glBuffer = null; -let glLocs = null; // cached attrib/uniform locations -let glLoopRunning = false; // continuous rAF render loop active -const _cam = JSON.parse(localStorage.getItem("mm_cam") || "null"); -let camTheta = _cam ? _cam.t : Math.PI; -let camPhi = _cam ? _cam.p : 0.4; -let camDist = _cam ? _cam.d : 2.5; -let camAutoFit = !_cam; // fit on first frame when no saved position -function saveCam() { localStorage.setItem("mm_cam", JSON.stringify({t: camTheta, p: camPhi, d: camDist})); } -let lastVerts = null; // cached vertex array for orbit-without-server-frame -let lastVertCount = 0; -let lastMaxDim = 1; -let vertsBuf = null; // reused worst-case Float32Array; grows but never shrinks - -function initWebGL() { - const canvas = document.getElementById("preview"); - if (!canvas) return; - gl = canvas.getContext("webgl", {alpha: false}); - if (!gl) return; - - const vsrc = ` - attribute vec3 aPos; - attribute vec3 aCol; - varying vec3 vCol; - uniform mat4 uMVP; - uniform float uPointSize; - void main() { - vCol = aCol; - gl_Position = uMVP * vec4(aPos, 1.0); - // Depth-corrected point size — closer LEDs render larger - gl_PointSize = uPointSize / gl_Position.w; - } - `; - const fsrc = ` - precision mediump float; - varying vec3 vCol; - void main() { - float d = length(gl_PointCoord - vec2(0.5)); - if (d > 0.5) discard; - float a = 1.0 - smoothstep(0.25, 0.5, d); - // Gamma 0.7 lifts mid-greys so dim effects stay readable in the preview; not sRGB-correct - vec3 bright = pow(vCol, vec3(0.7)); - gl_FragColor = vec4(bright * a, a); - } - `; - - const vs = gl.createShader(gl.VERTEX_SHADER); - gl.shaderSource(vs, vsrc); gl.compileShader(vs); - const fs = gl.createShader(gl.FRAGMENT_SHADER); - gl.shaderSource(fs, fsrc); gl.compileShader(fs); - glProgram = gl.createProgram(); - gl.attachShader(glProgram, vs); gl.attachShader(glProgram, fs); - gl.linkProgram(glProgram); gl.useProgram(glProgram); - - glBuffer = gl.createBuffer(); - glLocs = { - aPos: gl.getAttribLocation(glProgram, "aPos"), - aCol: gl.getAttribLocation(glProgram, "aCol"), - uMVP: gl.getUniformLocation(glProgram, "uMVP"), - uPointSize:gl.getUniformLocation(glProgram, "uPointSize"), - }; - gl.enable(gl.DEPTH_TEST); - gl.enable(gl.BLEND); - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); - - // Orbit controls (mouse + touch) - let dragging = false, lastX = 0, lastY = 0; - canvas.addEventListener("mousedown", (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; }); - canvas.addEventListener("mousemove", (e) => { - if (!dragging) return; - camTheta += (e.clientX - lastX) * 0.01; - camPhi = Math.max(-1.5, Math.min(1.5, camPhi - (e.clientY - lastY) * 0.01)); - lastX = e.clientX; lastY = e.clientY; - redrawCached(); - }); - canvas.addEventListener("mouseup", () => { dragging = false; saveCam(); }); - canvas.addEventListener("mouseleave", () => { dragging = false; saveCam(); }); - canvas.addEventListener("wheel", (e) => { - camDist = Math.max(0.5, Math.min(10, camDist + e.deltaY * 0.005)); - e.preventDefault(); - redrawCached(); - saveCam(); - }, {passive: false}); - - // Touch: single-finger orbit, two-finger pinch zoom. touch-action: none on - // #preview keeps the browser's own scroll/zoom from firing first. - let pinchDist = 0; - const touchDistance = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); - - canvas.addEventListener("touchstart", (e) => { - if (e.touches.length === 1) { - dragging = true; - lastX = e.touches[0].clientX; lastY = e.touches[0].clientY; - } else if (e.touches.length >= 2) { - dragging = false; // hand off from orbit to pinch - pinchDist = touchDistance(e.touches[0], e.touches[1]); - e.preventDefault(); - } - }, {passive: false}); - canvas.addEventListener("touchmove", (e) => { - if (e.touches.length === 1 && dragging) { - const t = e.touches[0]; - camTheta += (t.clientX - lastX) * 0.01; - camPhi = Math.max(-1.5, Math.min(1.5, camPhi - (t.clientY - lastY) * 0.01)); - lastX = t.clientX; lastY = t.clientY; - redrawCached(); - e.preventDefault(); - } else if (e.touches.length >= 2 && pinchDist > 0) { - // Pinch zoom: ratio of finger-distance change scales camDist - // (fingers apart → zoom in / camDist down). Guard against the - // degenerate case where both fingers report identical coords - // (d === 0): division would produce Infinity and snap camDist - // to its clamp boundary. Skip the update and let the next move - // produce a sane d. - const d = touchDistance(e.touches[0], e.touches[1]); - if (d > 0) { - const ratio = pinchDist / d; - camDist = Math.max(0.5, Math.min(10, camDist * ratio)); - pinchDist = d; - redrawCached(); - } - e.preventDefault(); - } - }, {passive: false}); - canvas.addEventListener("touchend", (e) => { - if (e.touches.length === 0) { dragging = false; pinchDist = 0; saveCam(); } - // 2→1 touches: stay in pinch (let user finish lifting); pinchDist stays - // valid for the remaining finger? No — drop pinch, but don't start - // orbit either, to avoid a jump when one finger lifts. - else if (e.touches.length === 1) { pinchDist = 0; dragging = false; saveCam(); } - }); - -} - -// Scroll-shrink preview: 0..1 ratio over 0..300px of main scroll. -function setupPreviewShrink() { - const canvas = document.getElementById("preview"); - if (!canvas) return; - let naturalMaxH = null; - let ticking = false; - const SHRINK_OVER = 300; - function apply() { - ticking = false; - if (!naturalMaxH) { - naturalMaxH = canvas.getBoundingClientRect().height || (window.innerHeight * 0.5); - } - const r = Math.min(1, Math.max(0, window.scrollY / SHRINK_OVER)); - canvas.style.maxHeight = Math.round(naturalMaxH * (1 - r * 0.5)) + "px"; - if (lastVerts) redrawCached(); - } - window.addEventListener("scroll", () => { - if (!ticking) { requestAnimationFrame(apply); ticking = true; } - }, {passive: true}); - window.addEventListener("resize", () => { - naturalMaxH = null; - canvas.style.maxHeight = ""; - if (lastVerts) redrawCached(); - }); -} - -// Read the Preview module's "decompress" control from the latest state push. -function previewDecompressOn() { - if (!state || !state.modules) return false; - let found = false; - (function walk(mods) { - for (const m of mods) { - // Match on type only — the name is user-editable, the type is not. - if (m.type === "PreviewDriver") { - const c = (m.controls || []).find(c => c.name === "decompress"); - if (c) found = !!c.value; - } - if (m.children) walk(m.children); - } - })(state.modules); - return found; -} - -function renderPreviewFrame(buf) { - if (!gl) initWebGL(); - if (!gl) return; - - // 13-byte header: [0x02][dw16][dh16][dd16][ow16][oh16][od16], little-endian. - // dw/dh/dd = dimensions of the data in this frame; ow/oh/od = original grid. - if (buf.byteLength < 13) return; - const view = new DataView(buf); - if (view.getUint8(0) !== 0x02) return; - const dw = view.getUint16(1, true); - const dh = view.getUint16(3, true); - const dd = view.getUint16(5, true); - const ow = view.getUint16(7, true); - const oh = view.getUint16(9, true); - const od = view.getUint16(11, true); - if (dw === 0 || dh === 0 || dd === 0) return; - const total = dw * dh * dd; - if (buf.byteLength < 13 + total * 3) return; - const pixels = new Uint8Array(buf, 13); - - // Decompress: when on, reconstruct the original grid by block-replicating - // each received voxel across its stride×stride×stride original cells, so the - // preview shows the same voxel count as the real layout. When off, render the - // downsampled cloud directly. - const decompress = previewDecompressOn() - && ow >= dw && oh >= dh && od >= dd - && (ow > dw || oh > dh || od > dd); - const w = decompress ? ow : dw; - const h = decompress ? oh : dh; - const d = decompress ? od : dd; - - const colorAt = decompress - // map original cell (ix,iy,iz) → nearest downsampled voxel - ? (ix, iy, iz) => { - const sx = Math.min(dw - 1, (ix * dw / ow) | 0); - const sy = Math.min(dh - 1, (iy * dh / oh) | 0); - const sz = Math.min(dd - 1, (iz * dd / od) | 0); - return (sz * dh * dw + sy * dw + sx) * 3; - } - : (ix, iy, iz) => (iz * dh * dw + iy * dw + ix) * 3; - - // Single-pass: reuse a module-scope buffer sized to the worst-case voxel - // count; reallocate only when the grid grows. No pre-pass to count - // non-black — eliminates double iteration / blank-frame race on sparse effects. - const maxDim = Math.max(w, h, d); - const needed = w * h * d * 6; - if (!vertsBuf || vertsBuf.length < needed) vertsBuf = new Float32Array(needed); - let vi = 0; - for (let iz = 0; iz < d; iz++) { - for (let iy = 0; iy < h; iy++) { - for (let ix = 0; ix < w; ix++) { - const idx = colorAt(ix, iy, iz); - const r = pixels[idx], g = pixels[idx + 1], b = pixels[idx + 2]; - if (!(r | g | b)) continue; - vertsBuf[vi++] = (ix / maxDim) - 0.5 * w / maxDim; - vertsBuf[vi++] = (iy / maxDim) - 0.5 * h / maxDim; - vertsBuf[vi++] = (iz / maxDim) - 0.5 * d / maxDim; - vertsBuf[vi++] = r / 255; - vertsBuf[vi++] = g / 255; - vertsBuf[vi++] = b / 255; - } - } - } - const vertCount = vi / 6; - - if (vi === 0) return; // all-black frame — don't update lastVerts, let rAF loop idle - lastVerts = vertsBuf.subarray(0, vi); // trim zero-filled tail — bufferData uploads only live verts - lastVertCount = vertCount; - lastMaxDim = maxDim; - - if (camAutoFit) { - camAutoFit = false; - const canvas = document.getElementById("preview"); - const fov = 0.8; - const aspect = canvas ? canvas.clientWidth / Math.max(1, canvas.clientHeight) : 1; - const halfExtent = 0.5 * Math.sqrt(w * w + h * h + d * d) / maxDim; - const fitDist = halfExtent / Math.tan(fov / 2) * (aspect < 1 ? 1 / aspect : 1) * 1.1; - camDist = Math.max(0.5, Math.min(10, fitDist)); - } - - if (!glLoopRunning) startRenderLoop(); -} - -function redrawCached() { - if (!lastVerts) return; - if (!glLoopRunning) startRenderLoop(); -} - -function startRenderLoop() { - if (glLoopRunning) return; - glLoopRunning = true; - function loop() { - if (!lastVerts) { glLoopRunning = false; return; } - drawVerts(); - requestAnimationFrame(loop); - } - requestAnimationFrame(loop); -} - -function drawVerts() { - if (!gl || !lastVerts || !glLocs) return; - const canvas = document.getElementById("preview"); - const cw = Math.round(canvas.clientWidth), ch = Math.round(canvas.clientHeight); - if (canvas.width !== cw || canvas.height !== ch) { - canvas.width = cw; - canvas.height = ch; - } - gl.viewport(0, 0, canvas.width, canvas.height); - - const cx = camDist * Math.cos(camPhi) * Math.sin(camTheta); - const cy = camDist * Math.sin(camPhi); - const cz = camDist * Math.cos(camPhi) * Math.cos(camTheta); - const mvp = buildMVP(cx, cy, cz, canvas.width / Math.max(1, canvas.height)); - - // alpha:false context — clear to page background colour so the canvas - // blends seamlessly in both light and dark themes. - const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg-0").trim(); - const m = bg.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); - if (m) gl.clearColor(parseInt(m[1],16)/255, parseInt(m[2],16)/255, parseInt(m[3],16)/255, 1.0); - gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); - - gl.bindBuffer(gl.ARRAY_BUFFER, glBuffer); - gl.bufferData(gl.ARRAY_BUFFER, lastVerts, gl.DYNAMIC_DRAW); - - gl.enableVertexAttribArray(glLocs.aPos); - gl.enableVertexAttribArray(glLocs.aCol); - gl.vertexAttribPointer(glLocs.aPos, 3, gl.FLOAT, false, 24, 0); - gl.vertexAttribPointer(glLocs.aCol, 3, gl.FLOAT, false, 24, 12); - - gl.uniformMatrix4fv(glLocs.uMVP, false, mvp); - const pointSize = Math.max(2, canvas.width * 0.8 / lastMaxDim); - gl.uniform1f(glLocs.uPointSize, pointSize); - - gl.drawArrays(gl.POINTS, 0, lastVertCount); -} - -function buildMVP(ex, ey, ez, aspect) { - const fLen = Math.sqrt(ex*ex + ey*ey + ez*ez) || 1; - const fx = -ex/fLen, fy = -ey/fLen, fz = -ez/fLen; - // Right = cross(forward, (0,1,0)) - let rx = fz, ry = 0, rz = -fx; - const rLen = Math.sqrt(rx*rx + ry*ry + rz*rz) || 1; - rx /= rLen; ry /= rLen; rz /= rLen; - // Up = cross(right, forward) - const ux = ry*fz - rz*fy, uy = rz*fx - rx*fz, uz = rx*fy - ry*fx; - - const view = [ - rx, ux, -fx, 0, - ry, uy, -fy, 0, - rz, uz, -fz, 0, - -(rx*ex+ry*ey+rz*ez), -(ux*ex+uy*ey+uz*ez), (fx*ex+fy*ey+fz*ez), 1 - ]; - - const near = 0.1, far = 50, fov = 0.8; - const f = 1 / Math.tan(fov / 2); - const proj = [ - f/aspect, 0, 0, 0, - 0, f, 0, 0, - 0, 0, (far+near)/(near-far), -1, - 0, 0, 2*far*near/(near-far), 0 - ]; - - const m = new Float32Array(16); - for (let i = 0; i < 4; i++) { - for (let j = 0; j < 4; j++) { - m[j*4+i] = 0; - for (let k = 0; k < 4; k++) { - m[j*4+i] += proj[k*4+i] * view[j*4+k]; - } - } - } - return m; -} - -// --------------------------------------------------------------------------- -// 9. Status bar wiring +// 8. Status bar wiring // --------------------------------------------------------------------------- function setupStatusBarButtons() { document.getElementById("preview-reset")?.addEventListener("click", () => { - localStorage.removeItem("mm_cam"); - camTheta = Math.PI; - camPhi = 0.4; - camAutoFit = true; - if (lastVerts) redrawCached(); + preview.resetCamera(); }); // Reboot: press once to arm, again to confirm — see armPressTwice. The glyph @@ -2177,16 +1852,27 @@ function updateStatusBar() { } } - // System stats: "uptime · NN KB heap" + // System stats: "uptime · 🧠 NNK · 🧱 NNKB" + // 🧠 = internal-RAM free (heap progress total−used); 🧱 = largest contiguous + // internal-RAM block (the maxBlock control, already maxInternalAllocBlock). + // Both matter: free can be ample while fragmentation leaves no single block + // big enough for the next allocation. const uptimeCtrl = ctrls.find(c => c.name === "uptime"); const heapCtrl = ctrls.find(c => c.name === "heap"); + const blockCtrl = ctrls.find(c => c.name === "maxBlock"); const statsEl = document.getElementById("sys-stats"); if (statsEl) { const parts = []; if (uptimeCtrl) parts.push(uptimeCtrl.value); if (heapCtrl && heapCtrl.value !== undefined && heapCtrl.total) { const freeKb = Math.round((heapCtrl.total - heapCtrl.value) / 1024); - parts.push(freeKb + "K free"); + parts.push("🧠 " + freeKb + "K"); + } + // Skip on desktop, where the platform stub reports "0KB" (no real + // block measurement / unlimited heap) — same reason heap free above + // only shows when the heap progress control is present. + if (blockCtrl && blockCtrl.value && blockCtrl.value !== "0KB") { + parts.push("🧱 " + blockCtrl.value); } statsEl.textContent = parts.join(" · "); } @@ -2210,7 +1896,7 @@ function updateStatusBar() { } // --------------------------------------------------------------------------- -// 10. Boot +// 9. Boot // --------------------------------------------------------------------------- document.addEventListener("DOMContentLoaded", init); diff --git a/src/ui/embed_ui.cmake b/src/ui/embed_ui.cmake index 4f05141d..cd62f01d 100644 --- a/src/ui/embed_ui.cmake +++ b/src/ui/embed_ui.cmake @@ -1,10 +1,37 @@ # Generate C header with embedded UI files as byte arrays # Usage: cmake -P embed_ui.cmake -DUI_DIR=src/ui -DOUT=src/ui/ui_embedded.h +# +# Text assets are gzipped at build time and served with Content-Encoding: gzip +# (HttpServerModule); the browser inflates natively, so the firmware carries the +# UI ~3.5x smaller with no device-side or front-end decompressor. Standard +# embedded-web-UI practice (WLED, ESPHome, ESP-IDF examples all pre-gzip assets). +# Compression runs on the BUILD HOST via python3's gzip module — stdlib, +# guaranteed on every ESP-IDF/desktop build host (chosen over a `gzip` binary +# that Windows toolchains may lack). The PNG is already compressed, so it stays +# raw. -file(READ "${UI_DIR}/index.html" INDEX_HTML HEX) -file(READ "${UI_DIR}/app.js" APP_JS HEX) -file(READ "${UI_DIR}/style.css" STYLE_CSS HEX) -file(READ "${UI_DIR}/install-picker.js" INSTALL_PICKER_JS HEX) +# Gzip ${UI_DIR}/ to ${OUT_DIR}/.gz and HEX-read it into OUT_VAR. +function(gzip_file_hex NAME OUT_VAR) + set(src "${UI_DIR}/${NAME}") + # Write the temp .gz next to the generated header (a writable build dir). + get_filename_component(out_dir "${OUT}" DIRECTORY) + set(gz "${out_dir}/${NAME}.gz") + execute_process( + COMMAND python3 -c "import sys,gzip; open(sys.argv[2],'wb').write(gzip.compress(open(sys.argv[1],'rb').read(),9))" "${src}" "${gz}" + RESULT_VARIABLE rc) + if(NOT rc EQUAL 0) + message(FATAL_ERROR "gzip of ${src} failed (python3 rc=${rc})") + endif() + file(READ "${gz}" hex HEX) + file(REMOVE "${gz}") + set(${OUT_VAR} "${hex}" PARENT_SCOPE) +endfunction() + +gzip_file_hex("index.html" INDEX_HTML) +gzip_file_hex("app.js" APP_JS) +gzip_file_hex("style.css" STYLE_CSS) +gzip_file_hex("install-picker.js" INSTALL_PICKER_JS) +gzip_file_hex("preview3d.js" PREVIEW3D_JS) file(READ "${UI_DIR}/moonlight-logo.png" LOGO_PNG HEX) # Convert hex string to C array initializer @@ -26,17 +53,20 @@ hex_to_c_array("${INDEX_HTML}" "indexHtml" INDEX_ARRAY) hex_to_c_array("${APP_JS}" "appJs" APP_ARRAY) hex_to_c_array("${STYLE_CSS}" "styleCss" STYLE_ARRAY) hex_to_c_array("${INSTALL_PICKER_JS}" "installPickerJs" INSTALL_PICKER_ARRAY) +hex_to_c_array("${PREVIEW3D_JS}" "preview3dJs" PREVIEW3D_ARRAY) hex_to_c_array("${LOGO_PNG}" "logoPng" LOGO_ARRAY) string(LENGTH "${INDEX_HTML}" INDEX_HEX_LEN) string(LENGTH "${APP_JS}" APP_HEX_LEN) string(LENGTH "${STYLE_CSS}" STYLE_HEX_LEN) string(LENGTH "${INSTALL_PICKER_JS}" INSTALL_PICKER_HEX_LEN) +string(LENGTH "${PREVIEW3D_JS}" PREVIEW3D_HEX_LEN) string(LENGTH "${LOGO_PNG}" LOGO_HEX_LEN) math(EXPR INDEX_LEN "${INDEX_HEX_LEN} / 2") math(EXPR APP_LEN "${APP_HEX_LEN} / 2") math(EXPR STYLE_LEN "${STYLE_HEX_LEN} / 2") math(EXPR INSTALL_PICKER_LEN "${INSTALL_PICKER_HEX_LEN} / 2") +math(EXPR PREVIEW3D_LEN "${PREVIEW3D_HEX_LEN} / 2") math(EXPR LOGO_LEN "${LOGO_HEX_LEN} / 2") file(WRITE "${OUT}" "// Auto-generated — do not edit. Rebuild to update.\n") @@ -49,6 +79,8 @@ file(APPEND "${OUT}" "constexpr uint8_t styleCss[] = {${STYLE_ARRAY}};\n") file(APPEND "${OUT}" "constexpr size_t styleCssLen = ${STYLE_LEN};\n") file(APPEND "${OUT}" "constexpr uint8_t installPickerJs[] = {${INSTALL_PICKER_ARRAY}};\n") file(APPEND "${OUT}" "constexpr size_t installPickerJsLen = ${INSTALL_PICKER_LEN};\n") +file(APPEND "${OUT}" "constexpr uint8_t preview3dJs[] = {${PREVIEW3D_ARRAY}};\n") +file(APPEND "${OUT}" "constexpr size_t preview3dJsLen = ${PREVIEW3D_LEN};\n") file(APPEND "${OUT}" "constexpr uint8_t logoPng[] = {${LOGO_ARRAY}};\n") file(APPEND "${OUT}" "constexpr size_t logoPngLen = ${LOGO_LEN};\n") file(APPEND "${OUT}" "} // namespace mm::ui\n") diff --git a/src/ui/preview3d.js b/src/ui/preview3d.js new file mode 100644 index 00000000..47292ff0 --- /dev/null +++ b/src/ui/preview3d.js @@ -0,0 +1,378 @@ +// 3D WebGL preview — renders the light pipeline's output as an orbit-able point +// cloud. Extracted from app.js as a self-contained module (same pattern as +// install-picker.js): app.js wires it at three points only — +// preview.init() once, after the canvas exists +// preview.setupShrink() once, for the scroll-shrink behaviour +// preview.onBinaryMessage(buf) per WebSocket binary frame +// It owns its own GL context, camera, and geometry; it talks to the rest of the +// app only through the DOM (#preview canvas, --bg-0 theme colour) and +// localStorage (mm_cam). No app.js state crosses the boundary. + +let gl = null; +let glProgram = null; +let glBuffer = null; +let glLocs = null; // cached attrib/uniform locations +let glLoopRunning = false; // continuous rAF render loop active +// Parse the persisted camera, tolerating a malformed/corrupt value so a bad +// localStorage entry can't throw during module init. Falls back to defaults. +const _cam = (() => { + try { + const c = JSON.parse(localStorage.getItem("mm_cam") || "null"); + if (c && typeof c.t === "number" && typeof c.p === "number" && typeof c.d === "number") return c; + } catch { /* corrupt value — ignore, use defaults */ } + return null; +})(); +let camTheta = _cam ? _cam.t : Math.PI; +let camPhi = _cam ? _cam.p : 0.4; +let camDist = _cam ? _cam.d : 2.5; +let camAutoFit = !_cam; // fit on first frame when no saved position +function saveCam() { localStorage.setItem("mm_cam", JSON.stringify({t: camTheta, p: camPhi, d: camDist})); } +let lastVerts = null; // cached vertex array for orbit-without-server-frame +let lastVertCount = 0; +let lastMaxDim = 1; +let vertsBuf = null; // reused worst-case Float32Array; grows but never shrinks +// True-shape preview geometry, set from the 0x03 coordinate table and reused +// across 0x02 colour frames (positions change only on a layout/LUT rebuild). +let previewCoords_ = null; // Float32Array[count*3], normalised + box-centred positions +let previewCoordCount_ = 0; +let previewMaxDim_ = 1; +let previewBox_ = null; // {x,y,z} bounding-box extent for camera auto-fit + +function initWebGL() { + const canvas = document.getElementById("preview"); + if (!canvas) return; + gl = canvas.getContext("webgl", {alpha: false}); + if (!gl) return; + + const vsrc = ` + attribute vec3 aPos; + attribute vec3 aCol; + varying vec3 vCol; + uniform mat4 uMVP; + uniform float uPointSize; + void main() { + vCol = aCol; + gl_Position = uMVP * vec4(aPos, 1.0); + // Depth-corrected point size — closer LEDs render larger + gl_PointSize = uPointSize / gl_Position.w; + } + `; + const fsrc = ` + precision mediump float; + varying vec3 vCol; + void main() { + float d = length(gl_PointCoord - vec2(0.5)); + if (d > 0.5) discard; + float a = 1.0 - smoothstep(0.25, 0.5, d); + // Gamma 0.7 lifts mid-greys so dim effects stay readable in the preview; not sRGB-correct + vec3 bright = pow(vCol, vec3(0.7)); + gl_FragColor = vec4(bright * a, a); + } + `; + + const vs = gl.createShader(gl.VERTEX_SHADER); + gl.shaderSource(vs, vsrc); gl.compileShader(vs); + const fs = gl.createShader(gl.FRAGMENT_SHADER); + gl.shaderSource(fs, fsrc); gl.compileShader(fs); + glProgram = gl.createProgram(); + gl.attachShader(glProgram, vs); gl.attachShader(glProgram, fs); + gl.linkProgram(glProgram); gl.useProgram(glProgram); + + glBuffer = gl.createBuffer(); + glLocs = { + aPos: gl.getAttribLocation(glProgram, "aPos"), + aCol: gl.getAttribLocation(glProgram, "aCol"), + uMVP: gl.getUniformLocation(glProgram, "uMVP"), + uPointSize:gl.getUniformLocation(glProgram, "uPointSize"), + }; + gl.enable(gl.DEPTH_TEST); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + // Orbit controls (mouse + touch) + let dragging = false, lastX = 0, lastY = 0; + canvas.addEventListener("mousedown", (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; }); + canvas.addEventListener("mousemove", (e) => { + if (!dragging) return; + camTheta += (e.clientX - lastX) * 0.01; + camPhi = Math.max(-1.5, Math.min(1.5, camPhi - (e.clientY - lastY) * 0.01)); + lastX = e.clientX; lastY = e.clientY; + redrawCached(); + }); + canvas.addEventListener("mouseup", () => { dragging = false; saveCam(); }); + canvas.addEventListener("mouseleave", () => { dragging = false; saveCam(); }); + canvas.addEventListener("wheel", (e) => { + camDist = Math.max(0.5, Math.min(10, camDist + e.deltaY * 0.005)); + e.preventDefault(); + redrawCached(); + saveCam(); + }, {passive: false}); + + // Touch: single-finger orbit, two-finger pinch zoom. touch-action: none on + // #preview keeps the browser's own scroll/zoom from firing first. + let pinchDist = 0; + const touchDistance = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); + + canvas.addEventListener("touchstart", (e) => { + if (e.touches.length === 1) { + dragging = true; + lastX = e.touches[0].clientX; lastY = e.touches[0].clientY; + } else if (e.touches.length >= 2) { + dragging = false; // hand off from orbit to pinch + pinchDist = touchDistance(e.touches[0], e.touches[1]); + e.preventDefault(); + } + }, {passive: false}); + canvas.addEventListener("touchmove", (e) => { + if (e.touches.length === 1 && dragging) { + const t = e.touches[0]; + camTheta += (t.clientX - lastX) * 0.01; + camPhi = Math.max(-1.5, Math.min(1.5, camPhi - (t.clientY - lastY) * 0.01)); + lastX = t.clientX; lastY = t.clientY; + redrawCached(); + e.preventDefault(); + } else if (e.touches.length >= 2 && pinchDist > 0) { + // Pinch zoom: ratio of finger-distance change scales camDist + // (fingers apart → zoom in / camDist down). Guard against the + // degenerate case where both fingers report identical coords + // (d === 0): division would produce Infinity and snap camDist + // to its clamp boundary. Skip the update and let the next move + // produce a sane d. + const d = touchDistance(e.touches[0], e.touches[1]); + if (d > 0) { + const ratio = pinchDist / d; + camDist = Math.max(0.5, Math.min(10, camDist * ratio)); + pinchDist = d; + redrawCached(); + } + e.preventDefault(); + } + }, {passive: false}); + canvas.addEventListener("touchend", (e) => { + if (e.touches.length === 0) { dragging = false; pinchDist = 0; saveCam(); } + // 2→1 touches: stay in pinch (let user finish lifting); pinchDist stays + // valid for the remaining finger? No — drop pinch, but don't start + // orbit either, to avoid a jump when one finger lifts. + else if (e.touches.length === 1) { pinchDist = 0; dragging = false; saveCam(); } + }); + +} + +// Scroll-shrink preview: 0..1 ratio over 0..300px of main scroll. +function setupPreviewShrink() { + const canvas = document.getElementById("preview"); + if (!canvas) return; + let naturalMaxH = null; + let ticking = false; + const SHRINK_OVER = 300; + function apply() { + ticking = false; + if (!naturalMaxH) { + naturalMaxH = canvas.getBoundingClientRect().height || (window.innerHeight * 0.5); + } + const r = Math.min(1, Math.max(0, window.scrollY / SHRINK_OVER)); + canvas.style.maxHeight = Math.round(naturalMaxH * (1 - r * 0.5)) + "px"; + if (lastVerts) redrawCached(); + } + window.addEventListener("scroll", () => { + if (!ticking) { requestAnimationFrame(apply); ticking = true; } + }, {passive: true}); + window.addEventListener("resize", () => { + naturalMaxH = null; + canvas.style.maxHeight = ""; + if (lastVerts) redrawCached(); + }); +} + +// True-shape preview: two binary message types on the preview WebSocket. +// 0x03 coordinate table (once per layout/LUT rebuild + ~1 Hz keepalive): +// [0x03][count:u16][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count] +// Stores the real lights' normalised positions in previewCoords_ (the +// geometry); per-frame 0x02 messages then just recolour those points. +// 0x02 per-frame channels: [0x02][count:u16][stride:u16][(r,g,b) × count] +// Colour for light i sits at position previewCoords_[i]. +// Light index i in the 0x02 stream matches coordinate-table entry i (both are +// every stride-th driver light, in the same order) — no dense grid, no decompress. +function renderPreviewBinary(buf) { + if (buf.byteLength < 1) return; + const view = new DataView(buf); + const type = view.getUint8(0); + if (type === 0x03) { parsePreviewCoords(view, buf); return; } + if (type === 0x02) { renderPreviewFrame(view, buf); return; } +} + +// Parse + cache the coordinate table: normalised (x,y,z) per point, centred on +// the bounding box so the cloud sits around the origin like the old grid did. +function parsePreviewCoords(view, buf) { + if (buf.byteLength < 8) return; + const count = view.getUint16(1, true); + const bx = view.getUint8(3), by = view.getUint8(4), bz = view.getUint8(5); + if (buf.byteLength < 8 + count * 3) return; + const pos = new Uint8Array(buf, 8); + const maxDim = Math.max(1, bx, by, bz); + previewMaxDim_ = maxDim; + previewCoords_ = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + previewCoords_[i * 3 + 0] = (pos[i * 3 + 0] / maxDim) - 0.5 * bx / maxDim; + previewCoords_[i * 3 + 1] = (pos[i * 3 + 1] / maxDim) - 0.5 * by / maxDim; + previewCoords_[i * 3 + 2] = (pos[i * 3 + 2] / maxDim) - 0.5 * bz / maxDim; + } + previewCoordCount_ = count; + previewBox_ = { x: bx, y: by, z: bz }; +} + +function renderPreviewFrame(view, buf) { + if (!gl) initWebGL(); + if (!gl) return; + // Hold frames until positions have arrived (the table is sent on rebuild + + // ~1 Hz, so a fresh client catches up within a second). + if (!previewCoords_ || previewCoordCount_ === 0) return; + if (buf.byteLength < 5) return; + const count = view.getUint16(1, true); + if (buf.byteLength < 5 + count * 3) return; + const rgb = new Uint8Array(buf, 5); + // RGB[i] colours the light at previewCoords_[i]. If the frame and table + // counts disagree (a rebuild in flight), plot the overlap only. + const n = Math.min(count, previewCoordCount_); + + if (!vertsBuf || vertsBuf.length < n * 6) vertsBuf = new Float32Array(n * 6); + let vi = 0; + for (let i = 0; i < n; i++) { + const r = rgb[i * 3], g = rgb[i * 3 + 1], b = rgb[i * 3 + 2]; + if (!(r | g | b)) continue; // skip dark points + vertsBuf[vi++] = previewCoords_[i * 3 + 0]; + vertsBuf[vi++] = previewCoords_[i * 3 + 1]; + vertsBuf[vi++] = previewCoords_[i * 3 + 2]; + vertsBuf[vi++] = r / 255; + vertsBuf[vi++] = g / 255; + vertsBuf[vi++] = b / 255; + } + const vertCount = vi / 6; + + if (vi === 0) return; // all-dark frame — keep the last geometry, let rAF idle + lastVerts = vertsBuf.subarray(0, vi); + lastVertCount = vertCount; + lastMaxDim = previewMaxDim_; + + if (camAutoFit && previewBox_) { + camAutoFit = false; + const canvas = document.getElementById("preview"); + const fov = 0.8; + const aspect = canvas ? canvas.clientWidth / Math.max(1, canvas.clientHeight) : 1; + const bx = previewBox_.x, by = previewBox_.y, bz = previewBox_.z; + const halfExtent = 0.5 * Math.sqrt(bx * bx + by * by + bz * bz) / previewMaxDim_; + const fitDist = halfExtent / Math.tan(fov / 2) * (aspect < 1 ? 1 / aspect : 1) * 1.1; + camDist = Math.max(0.5, Math.min(10, fitDist)); + } + + if (!glLoopRunning) startRenderLoop(); +} + +function redrawCached() { + if (!lastVerts) return; + if (!glLoopRunning) startRenderLoop(); +} + +// Status-bar "reset preview" button: forget the saved camera and re-fit on the +// next frame. Owns the camera, so the reset lives here, not in app.js. +function resetCamera() { + localStorage.removeItem("mm_cam"); + camTheta = Math.PI; + camPhi = 0.4; + camAutoFit = true; + if (lastVerts) redrawCached(); +} + +function startRenderLoop() { + if (glLoopRunning) return; + glLoopRunning = true; + function loop() { + if (!lastVerts) { glLoopRunning = false; return; } + drawVerts(); + requestAnimationFrame(loop); + } + requestAnimationFrame(loop); +} + +function drawVerts() { + if (!gl || !lastVerts || !glLocs) return; + const canvas = document.getElementById("preview"); + const cw = Math.round(canvas.clientWidth), ch = Math.round(canvas.clientHeight); + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw; + canvas.height = ch; + } + gl.viewport(0, 0, canvas.width, canvas.height); + + const cx = camDist * Math.cos(camPhi) * Math.sin(camTheta); + const cy = camDist * Math.sin(camPhi); + const cz = camDist * Math.cos(camPhi) * Math.cos(camTheta); + const mvp = buildMVP(cx, cy, cz, canvas.width / Math.max(1, canvas.height)); + + // alpha:false context — clear to page background colour so the canvas + // blends seamlessly in both light and dark themes. + const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg-0").trim(); + const m = bg.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); + if (m) gl.clearColor(parseInt(m[1],16)/255, parseInt(m[2],16)/255, parseInt(m[3],16)/255, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + gl.bindBuffer(gl.ARRAY_BUFFER, glBuffer); + gl.bufferData(gl.ARRAY_BUFFER, lastVerts, gl.DYNAMIC_DRAW); + + gl.enableVertexAttribArray(glLocs.aPos); + gl.enableVertexAttribArray(glLocs.aCol); + gl.vertexAttribPointer(glLocs.aPos, 3, gl.FLOAT, false, 24, 0); + gl.vertexAttribPointer(glLocs.aCol, 3, gl.FLOAT, false, 24, 12); + + gl.uniformMatrix4fv(glLocs.uMVP, false, mvp); + const pointSize = Math.max(2, canvas.width * 0.8 / lastMaxDim); + gl.uniform1f(glLocs.uPointSize, pointSize); + + gl.drawArrays(gl.POINTS, 0, lastVertCount); +} + +function buildMVP(ex, ey, ez, aspect) { + const fLen = Math.sqrt(ex*ex + ey*ey + ez*ez) || 1; + const fx = -ex/fLen, fy = -ey/fLen, fz = -ez/fLen; + // Right = cross(forward, (0,1,0)) + let rx = fz, ry = 0, rz = -fx; + const rLen = Math.sqrt(rx*rx + ry*ry + rz*rz) || 1; + rx /= rLen; ry /= rLen; rz /= rLen; + // Up = cross(right, forward) + const ux = ry*fz - rz*fy, uy = rz*fx - rx*fz, uz = rx*fy - ry*fx; + + const view = [ + rx, ux, -fx, 0, + ry, uy, -fy, 0, + rz, uz, -fz, 0, + -(rx*ex+ry*ey+rz*ez), -(ux*ex+uy*ey+uz*ez), (fx*ex+fy*ey+fz*ez), 1 + ]; + + const near = 0.1, far = 50, fov = 0.8; + const f = 1 / Math.tan(fov / 2); + const proj = [ + f/aspect, 0, 0, 0, + 0, f, 0, 0, + 0, 0, (far+near)/(near-far), -1, + 0, 0, 2*far*near/(near-far), 0 + ]; + + const m = new Float32Array(16); + for (let i = 0; i < 4; i++) { + for (let j = 0; j < 4; j++) { + m[j*4+i] = 0; + for (let k = 0; k < 4; k++) { + m[j*4+i] += proj[k*4+i] * view[j*4+k]; + } + } + } + return m; +} + +// Public surface — the only three entry points app.js touches. +export const preview = { + init: initWebGL, + setupShrink: setupPreviewShrink, + onBinaryMessage: renderPreviewBinary, + resetCamera: resetCamera, +}; diff --git a/src/ui/style.css b/src/ui/style.css index c8c88683..f694bffa 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -130,8 +130,9 @@ body { align-items: flex-start; } -/* Side nav: a static left column on wide screens. The hamburger toggles the - body.nav-open class — see the wide-screen and <820px rules below. */ +/* Side nav: a static left column on wide screens (always visible — no toggle + needed, so the hamburger is hidden here; see #nav-toggle below). On <820px + it becomes a slide-in drawer the hamburger opens (see the @media block). */ #nav { position: sticky; top: 44px; @@ -145,10 +146,9 @@ body { overflow-y: auto; } -/* Wide screens: nav shown by default; body.nav-open collapses it. */ -body.nav-open #nav { - display: none; -} +/* Wide screens: the nav is permanently present, so the hamburger is redundant + chrome — hide it. It reappears only at <820px where the nav is a drawer. */ +#nav-toggle { display: none; } #nav-overlay { display: none; } @@ -725,6 +725,9 @@ body.nav-open #nav { .main-area { padding: 8px; } .card-children { margin-left: 6px; } + /* Narrow screens: the nav is a drawer, so the hamburger is needed again. */ + #nav-toggle { display: revert; } + /* Narrow screens: the nav is a slide-in drawer, hidden until the hamburger opens it. body.nav-open shows the drawer over a dimming overlay. */ #nav { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 992eebad..558bf5b0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -28,12 +28,16 @@ add_executable(mm_tests unit/light/unit_Correction.cpp unit/light/unit_Drivers_container.cpp unit/light/unit_FireEffect.cpp + unit/light/unit_GameOfLifeEffect.cpp unit/light/unit_GridLayout.cpp + unit/light/unit_SphereLayout.cpp unit/light/unit_Layer_extrude.cpp + unit/light/unit_Layer_sparse_mapping.cpp unit/light/unit_Layer_phase_animation.cpp unit/light/unit_Layer_zero_grid.cpp unit/light/unit_Layers_container.cpp unit/light/unit_Layouts_container.cpp + unit/light/unit_Layouts_mutation.cpp unit/light/unit_Layouts_toggle_cycle.cpp unit/light/unit_MetaballsEffect.cpp unit/light/unit_MirrorModifier.cpp diff --git a/test/scenario_runner.cpp b/test/scenario_runner.cpp index ef166043..203aa0ed 100644 --- a/test/scenario_runner.cpp +++ b/test/scenario_runner.cpp @@ -7,6 +7,7 @@ #include "core/Control.h" #include "core/JsonSink.h" #include "light/layouts/GridLayout.h" +#include "light/layouts/SphereLayout.h" #include "light/layers/Layer.h" #include "light/layouts/Layouts.h" #include "light/layers/Layers.h" @@ -16,7 +17,6 @@ #include "light/drivers/Drivers.h" #include "light/drivers/ArtNetSendDriver.h" #include "light/drivers/PreviewDriver.h" -#include "core/PreviewFrame.h" #include "platform/platform.h" #include @@ -153,6 +153,7 @@ static void registerScenarioTypes() { if (done) return; mm::ModuleFactory::registerType("Layouts"); mm::ModuleFactory::registerType("GridLayout"); + mm::ModuleFactory::registerType("SphereLayout"); mm::ModuleFactory::registerType("Layers"); mm::ModuleFactory::registerType("Layer"); mm::ModuleFactory::registerType("RainbowEffect"); @@ -164,13 +165,6 @@ static void registerScenarioTypes() { done = true; } -// PreviewDriver needs a frame to write into; main.cpp owns it. In the scenario -// runner we own a single static frame so mutate scenarios with PreviewDriver in -// their fixture work without a separate `setPreviewFrame` step. -static mm::PreviewFrame& scenarioPreviewFrame() { - static mm::PreviewFrame frame; - return frame; -} // Target key for the per-step expected[] lookup. The in-process runner // builds for the host only — there's no cross-compiled scenario_runner — so the @@ -276,18 +270,25 @@ struct ScenarioContext { auto* layerModule = static_cast(modules[props["layer"].str]); if (layerModule) static_cast(mod)->setLayer(layerModule); } + } else if (std::strcmp(type, "GridLayout") == 0) { + // Grid dimensions set at construct time (the fixture phase runs + // before the scheduler starts, so set_control can't apply them + // yet). Without this, props.width/height were silently ignored + // and the grid stayed at GridLayout's default — masking the real + // scenario size. + auto* grid = static_cast(mod); + if (props.has("width")) grid->width = static_cast(props["width"].num); + if (props.has("height")) grid->height = static_cast(props["height"].num); + if (props.has("depth")) grid->depth = static_cast(props["depth"].num); } } - // PreviewDriver always needs its scenario-static PreviewFrame target - // wired — independent of any step "props" the scenario provides. The - // driver reads no other init from props (no layouts/parent like Layer - // or Drivers do), but its loop() early-outs without a frame_, so - // setPreviewFrame must run unconditionally for honest tick measurement. - // Production wires this via HttpServerModule on the device. - if (std::strcmp(type, "PreviewDriver") == 0) { - static_cast(mod)->setPreviewFrame(&scenarioPreviewFrame()); - } + // PreviewDriver needs no scenario-specific wiring: it reads its Layer + + // sparse source buffer through Drivers' passBufferToDrivers (set when a + // Drivers fixture exists), and owns its own scratch buffers. No + // broadcaster is wired here (the harness has no WS server) — sendFrame / + // sendCoordTable early-return when the broadcaster is null, but the + // light-extraction work still runs for honest tick measurement. } }; @@ -484,6 +485,56 @@ static int runScenario(const char* path) { } else { std::printf(" SET %s (%s.%s)\n", name, targetId, key); } + } else if (std::strcmp(op, "remove_module") == 0) { + // Remove a child module from its parent — mirrors + // HttpServerModule::handleDeleteModule (remove from parent, + // teardown + recursive delete, rebuild pipeline state). Only child + // modules can be removed; top-level modules are policy-fixed. + const char* targetId = step["id"].c_str(); + auto* target = ctx.modules.count(targetId) ? ctx.modules[targetId] : nullptr; + if (!target || !target->parent()) { + std::printf(" - %s — %s not found or top-level, skipped\n", name, targetId); + continue; + } + auto* parent = target->parent(); + parent->removeChild(target); + target->teardown(); + mm::Scheduler::deleteTree(target); + ctx.modules.erase(targetId); + if (schedulerStarted) ctx.scheduler.buildState(); + std::printf(" - %s (%s)\n", name, targetId); + } else if (std::strcmp(op, "replace_module") == 0) { + // Replace a child with a fresh module of another type at the same + // slot — mirrors HttpServerModule::handleReplaceModule. The new + // module re-registers under the SAME scenario id so later steps + // (set_control, remove) still address it by that id. + const char* targetId = step["id"].c_str(); + const char* newType = step["type"].c_str(); + auto* target = ctx.modules.count(targetId) ? ctx.modules[targetId] : nullptr; + if (!target || !target->parent()) { + std::printf(" ~ %s — %s not found or top-level, skipped\n", name, targetId); + continue; + } + auto* parent = target->parent(); + uint8_t index = 0; bool found = false; + for (uint8_t i = 0; i < parent->childCount(); i++) { + if (parent->child(i) == target) { index = i; found = true; break; } + } + auto* fresh = ctx.createModule(newType); + if (!found || !fresh) { + if (fresh) mm::Scheduler::deleteTree(fresh); + std::printf(" ~ %s — slot not found or unknown type %s, skipped\n", name, newType); + continue; + } + fresh->setName(targetId); + mm::MoonModule* old = parent->replaceChildAt(index, fresh); + fresh->onBuildControls(); + fresh->setup(); + fresh->onBuildState(); + if (old) { old->teardown(); mm::Scheduler::deleteTree(old); } + ctx.modules[targetId] = fresh; + if (schedulerStarted) ctx.scheduler.buildState(); + std::printf(" ~ %s (%s → %s)\n", name, targetId, newType); } else if (std::strcmp(op, "measure") == 0) { // Pure measurement step — no side effects. op:"measure" is the // implicit-measure shape so scenarios can interleave snapshots diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json index cc3bd4c6..01f0989d 100644 --- a/test/scenarios/core/scenario_MoonModule_control_change.json +++ b/test/scenarios/core/scenario_MoonModule_control_change.json @@ -135,7 +135,7 @@ "pc-macos": { "tick_us": [ 97, - 208 + 209 ], "free_heap": [ 0, @@ -147,7 +147,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-06-05" ] }, "esp32-eth-wifi": { @@ -232,7 +232,7 @@ "pc-macos": { "tick_us": [ 181, - 239 + 275 ], "free_heap": [ 0, @@ -244,7 +244,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-06-05" ] }, "esp32-eth-wifi": { @@ -329,7 +329,7 @@ "pc-macos": { "tick_us": [ 346, - 489 + 522 ], "free_heap": [ 0, @@ -341,7 +341,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-06-05" ] }, "esp32-eth-wifi": { diff --git a/test/scenarios/light/scenario_GridLayout_grid_sizes.json b/test/scenarios/light/scenario_GridLayout_grid_sizes.json index 48f88daa..9fc48a52 100644 --- a/test/scenarios/light/scenario_GridLayout_grid_sizes.json +++ b/test/scenarios/light/scenario_GridLayout_grid_sizes.json @@ -291,7 +291,7 @@ "pc-macos": { "tick_us": [ 6, - 7 + 9 ], "free_heap": [ 0, @@ -303,7 +303,7 @@ ], "at": [ "2026-06-02", - "2026-06-02" + "2026-06-05" ] }, "esp32-eth-wifi": { @@ -427,7 +427,7 @@ "pc-macos": { "tick_us": [ 23, - 30 + 33 ], "free_heap": [ 0, @@ -439,7 +439,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-06-05" ] }, "esp32-eth-wifi": { @@ -563,7 +563,7 @@ "pc-macos": { "tick_us": [ 98, - 183 + 201 ], "free_heap": [ 0, @@ -575,7 +575,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-06-05" ] }, "esp32-eth-wifi": { diff --git a/test/scenarios/light/scenario_Layer_base_pipeline.json b/test/scenarios/light/scenario_Layer_base_pipeline.json index 521a863b..b94805f1 100644 --- a/test/scenarios/light/scenario_Layer_base_pipeline.json +++ b/test/scenarios/light/scenario_Layer_base_pipeline.json @@ -19,11 +19,15 @@ }, { "name": "add-grid", - "description": "Add a GridLayout child to Layouts (default 16x16x1).", + "description": "Add a 128x128 GridLayout child to Layouts. Set explicitly (the module default is 16x16x1) so the tick is above the host's microsecond clock resolution — a 16x16 grid renders in <1us on desktop, flooring tick to 0.", "op": "add_module", "id": "Grid", "type": "GridLayout", - "parent_id": "Layouts" + "parent_id": "Layouts", + "props": { + "width": 128, + "height": 128 + } }, { "name": "add-layer", @@ -56,7 +60,7 @@ }, { "name": "add-artnet", - "description": "Add ArtNetSendDriver and run the bounded FPS measurement (must stay at >=80% of the rated FPS for grid size 16x16).", + "description": "Add ArtNetSendDriver and run the bounded FPS measurement (expected to stay at >=80% of the rated FPS for the 128x128 grid this scenario builds; min_pct needs a live baseline, so it gates only on hardware and is skipped with a WARN in the desktop runner).", "op": "add_module", "id": "ArtNet", "type": "ArtNetSendDriver", @@ -79,7 +83,7 @@ "observed": { "pc-macos": { "tick_us": [ - 35, + 0, 132 ], "free_heap": [ @@ -92,7 +96,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-06-05" ] } } diff --git a/test/scenarios/light/scenario_Layer_buildup.json b/test/scenarios/light/scenario_Layer_buildup.json index 68239283..671eb7b9 100644 --- a/test/scenarios/light/scenario_Layer_buildup.json +++ b/test/scenarios/light/scenario_Layer_buildup.json @@ -52,14 +52,9 @@ }, { "name": "measure-minimum", - "description": "Baseline: 16x16 grid + Rainbow only. No Drivers yet (Layer renders into its own buffer).", + "description": "Baseline: 16x16 grid + Rainbow only. No Drivers yet (Layer renders into its own buffer). No fps floor asserted — a 16x16 grid renders in <1us on desktop, flooring the integer-us tick (and thus FPS) to 0; the per-target tick contract is the meaningful check here (heap deltas are asserted on the later buildup steps that add Drivers/LUT).", "op": "measure", "measure": true, - "bounds": { - "fps": { - "min": 1 - } - }, "contract": { "pc-macos": { "tick_us": 50, @@ -71,7 +66,7 @@ "observed": { "pc-macos": { "tick_us": [ - 35, + 0, 122 ], "free_heap": [ @@ -84,7 +79,7 @@ ], "at": [ "2026-06-02", - "2026-06-02" + "2026-06-05" ] } } @@ -109,13 +104,10 @@ }, { "name": "measure-full-16x16", - "description": "Full pipeline at 16x16. Heap delta vs previous measure-minimum step should stay within +8KB on ESP32 (Drivers + ArtNet overhead, no LUT yet).", + "description": "Full pipeline at 16x16. Heap delta vs previous measure-minimum step should stay within +8KB on ESP32 (Drivers + ArtNet overhead, no LUT yet). No fps floor — 16x16 ticks below the host's microsecond resolution on desktop; heap delta is the check here.", "op": "measure", "measure": true, "bounds": { - "fps": { - "min": 1 - }, "heap": { "max_delta_bytes": 8192 } @@ -131,7 +123,7 @@ "observed": { "pc-macos": { "tick_us": [ - 35, + 0, 183 ], "free_heap": [ @@ -144,7 +136,7 @@ ], "at": [ "2026-06-02", - "2026-06-02" + "2026-06-05" ] } } @@ -178,7 +170,7 @@ "observed": { "pc-macos": { "tick_us": [ - 44, + 0, 150 ], "free_heap": [ @@ -191,7 +183,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-06-05" ] } } diff --git a/test/scenarios/light/scenario_Layer_memory_1to1.json b/test/scenarios/light/scenario_Layer_memory_1to1.json index d3d57c3a..2a47ff7b 100644 --- a/test/scenarios/light/scenario_Layer_memory_1to1.json +++ b/test/scenarios/light/scenario_Layer_memory_1to1.json @@ -80,7 +80,7 @@ "observed": { "pc-macos": { "tick_us": [ - 35, + 0, 80 ], "free_heap": [ @@ -93,7 +93,7 @@ ], "at": [ "2026-06-02", - "2026-06-02" + "2026-06-05" ] } } diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json new file mode 100644 index 00000000..29445fcf --- /dev/null +++ b/test/scenarios/light/scenario_Layouts_mutation.json @@ -0,0 +1,218 @@ +{ + "name": "scenario_Layouts_mutation", + "module": "Layouts", + "mode": "mutate", + "also": [ + "GridLayout", + "SphereLayout", + "Layer", + "RainbowEffect", + "Drivers", + "ArtNetSendDriver" + ], + "description": "Tree mutation on the Layouts container while the pipeline runs: add a second layout (multiple layouts under one Layouts), replace a layout with a different type, and remove a layout. The check is that each mutation leaves the pipeline RENDERING — Layer + Drivers re-wire via buildState and the buffer stays non-null and non-zero. Mirrors the HTTP add/replace/delete handlers; exercises the runner's add_module / replace_module / remove_module ops. NOTE: the Layer renders a dense bounding-box buffer sized by the layouts' coordinate EXTENT, not the summed light count — layouts that overlap in coordinate space share voxels (two 64x64 grids both occupy x,y in 0..63). Independent placement awaits per-layout coordinate offsets (see docs/plan.md), so these steps assert liveness, not buffer-size arithmetic. Grids are 64x64 so the tick stays above the host's microsecond clock at every step.", + "fixture": [ + { + "name": "fix-layouts", + "op": "add_module", + "id": "Layouts", + "type": "Layouts" + }, + { + "name": "fix-grid-a", + "description": "First layout: a 64x64 grid.", + "op": "add_module", + "id": "GridA", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 64, + "height": 64 + } + }, + { + "name": "fix-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "props": { + "layouts": "Layouts", + "channelsPerLight": 3 + } + }, + { + "name": "fix-rainbow", + "op": "add_module", + "id": "Rainbow", + "type": "RainbowEffect", + "parent_id": "Layer" + }, + { + "name": "fix-drivers", + "op": "add_module", + "id": "Drivers", + "type": "Drivers", + "props": { + "layer": "Layer" + } + }, + { + "name": "fix-artnet", + "op": "add_module", + "id": "ArtNet", + "type": "ArtNetSendDriver", + "parent_id": "Drivers" + } + ], + "steps": [ + { + "name": "measure-one-layout", + "description": "Baseline: a single 64x64 grid layout drives the pipeline.", + "op": "measure", + "measure": true, + "bounds": { + "fps": { + "min": 1 + } + }, + "observed": { + "pc-macos": { + "tick_us": [ + 8, + 34 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-05", + "2026-06-05" + ] + } + } + }, + { + "name": "add-second-layout", + "description": "Add a SECOND layout (a 64x64 grid) under Layouts — two layouts now live under one container. buildState re-runs; the pipeline must still render. (Both grids share the 0..63 coordinate box, so the Layer buffer stays 64x64 — see the scenario NOTE.)", + "op": "add_module", + "id": "GridB", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 64, + "height": 64 + } + }, + { + "name": "measure-two-layouts", + "description": "Pipeline still renders with two layouts wired (buffer non-null, fps measurable).", + "op": "measure", + "measure": true, + "bounds": { + "fps": { + "min": 1 + } + }, + "observed": { + "pc-macos": { + "tick_us": [ + 9, + 30 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-05", + "2026-06-05" + ] + } + } + }, + { + "name": "replace-second-layout", + "description": "Replace the second grid with a SphereLayout (different type, same slot). The first grid is untouched; the pipeline re-wires to the new layout's light count.", + "op": "replace_module", + "id": "GridB", + "type": "SphereLayout" + }, + { + "name": "measure-after-replace", + "description": "Pipeline still renders after replacing a grid with a sphere (different layout type, same slot) — buffer re-wires without crashing.", + "op": "measure", + "measure": true, + "bounds": { + "fps": { + "min": 1 + } + }, + "observed": { + "pc-macos": { + "tick_us": [ + 10, + 87 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-05", + "2026-06-05" + ] + } + } + }, + { + "name": "remove-second-layout", + "description": "Remove the sphere — back to a single grid layout. Layer/Drivers shrink their buffers via buildState.", + "op": "remove_module", + "id": "GridB" + }, + { + "name": "measure-after-remove", + "description": "Pipeline renders with the single remaining grid, same as the baseline.", + "op": "measure", + "measure": true, + "bounds": { + "fps": { + "min": 1 + } + }, + "observed": { + "pc-macos": { + "tick_us": [ + 8, + 59 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-05", + "2026-06-05" + ] + } + } + } + ] +} diff --git a/test/scenarios/light/scenario_MirrorModifier_memory_lut.json b/test/scenarios/light/scenario_MirrorModifier_memory_lut.json index 8ec31ee2..0d435afe 100644 --- a/test/scenarios/light/scenario_MirrorModifier_memory_lut.json +++ b/test/scenarios/light/scenario_MirrorModifier_memory_lut.json @@ -89,7 +89,7 @@ "observed": { "pc-macos": { "tick_us": [ - 98, + 1, 301 ], "free_heap": [ @@ -102,7 +102,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-06-05" ] } } diff --git a/test/scenarios/light/scenario_MirrorModifier_pipeline.json b/test/scenarios/light/scenario_MirrorModifier_pipeline.json index 7db67e3e..18721603 100644 --- a/test/scenarios/light/scenario_MirrorModifier_pipeline.json +++ b/test/scenarios/light/scenario_MirrorModifier_pipeline.json @@ -18,11 +18,15 @@ }, { "name": "add-grid", - "description": "Add a GridLayout child to Layouts.", + "description": "Add a 128x128 GridLayout child to Layouts. Set explicitly (the module default is 16x16x1) so the tick is measurable above the host's microsecond clock.", "op": "add_module", "id": "Grid", "type": "GridLayout", - "parent_id": "Layouts" + "parent_id": "Layouts", + "props": { + "width": 128, + "height": 128 + } }, { "name": "add-layer", @@ -85,7 +89,7 @@ "observed": { "pc-macos": { "tick_us": [ - 99, + 1, 246 ], "free_heap": [ @@ -98,7 +102,7 @@ ], "at": [ "2026-06-02", - "2026-06-03" + "2026-06-05" ] } } diff --git a/test/scenarios/light/scenario_PreviewDriver_detail.json b/test/scenarios/light/scenario_PreviewDriver_detail.json deleted file mode 100644 index d6969cda..00000000 --- a/test/scenarios/light/scenario_PreviewDriver_detail.json +++ /dev/null @@ -1,600 +0,0 @@ -{ - "name": "scenario_PreviewDriver_detail", - "module": "PreviewDriver", - "mode": "mutate", - "description": "Toggle the Preview driver's detail and decompress controls and measure the render-FPS impact. detail 2/3 have a known, accepted downsample cost on the render task; decompress is purely client-side and cannot affect the render tick (see performance.md). All steps assert a relative bound (min_pct) only — a single ESP32 scenario step swings too much for an absolute FPS floor to be meaningful (the absolute throughput floor is enforced in collect_kpi.py --commit, which uses a settled reading). detail 3 gets a looser bound because its downsample cost is real and accepted.", - "fixture": [ - { - "name": "fix-layouts", - "op": "add_module", - "id": "Layouts", - "type": "Layouts" - }, - { - "name": "fix-grid", - "op": "add_module", - "id": "Grid", - "type": "GridLayout", - "parent_id": "Layouts", - "props": { - "width": 128, - "height": 128 - } - }, - { - "name": "fix-layer", - "op": "add_module", - "id": "Layer", - "type": "Layer", - "props": { - "layouts": "Layouts", - "channelsPerLight": 3 - } - }, - { - "name": "fix-noise", - "op": "add_module", - "id": "Noise", - "type": "NoiseEffect", - "parent_id": "Layer" - }, - { - "name": "fix-drivers", - "op": "add_module", - "id": "Drivers", - "type": "Drivers", - "props": { - "layer": "Layer" - } - }, - { - "name": "fix-preview", - "op": "add_module", - "id": "Preview", - "type": "PreviewDriver", - "parent_id": "Drivers" - } - ], - "reset": [ - { - "name": "reset-grid-width", - "op": "set_control", - "id": "Grid", - "key": "width", - "value": 128 - }, - { - "name": "reset-grid-height", - "op": "set_control", - "id": "Grid", - "key": "height", - "value": 128 - }, - { - "name": "reset-preview-detail", - "op": "set_control", - "id": "Preview", - "key": "detail", - "value": 3 - }, - { - "name": "reset-preview-decompress", - "op": "set_control", - "id": "Preview", - "key": "decompress", - "value": false - } - ], - "steps": [ - { - "name": "detail-1-coarse", - "description": "detail=1 (coarsest, 16x16 downsample on a 128 grid). Cheapest preview render.", - "op": "set_control", - "id": "Preview", - "key": "detail", - "value": 1, - "measure": true, - "bounds": { - "fps": { - "min_pct": 80 - } - }, - "contract": { - "pc-macos": { - "tick_us": 300, - "free_heap": 0, - "set_by": "2026-06-02", - "reason": "initial contract" - }, - "esp32-eth-wifi": { - "tick_us": 100000, - "free_heap": 105000, - "set_by": "2026-06-02", - "reason": "initial contract" - } - }, - "observed": { - "pc-macos": { - "tick_us": [ - 296, - 492 - ], - "free_heap": [ - 0, - 0 - ], - "max_alloc_block": [ - 0, - 0 - ], - "at": [ - "2026-06-02", - "2026-06-04" - ] - }, - "esp32-eth-wifi": { - "tick_us": [ - 81697, - 81697 - ], - "free_heap": [ - 95528, - 95528 - ], - "max_alloc_block": [ - 49152, - 49152 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - }, - "esp32-eth": { - "tick_us": [ - 94234, - 95024 - ], - "free_heap": [ - 135188, - 135208 - ], - "max_alloc_block": [ - 51200, - 53248 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - }, - "esp32": { - "tick_us": [ - 252071, - 252071 - ], - "free_heap": [ - 85252, - 85252 - ], - "max_alloc_block": [ - 49152, - 49152 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - } - } - }, - { - "name": "detail-2-medium", - "description": "detail=2 (medium, 32x32 downsample). Known accepted cost — still hits 80% of baseline.", - "op": "set_control", - "id": "Preview", - "key": "detail", - "value": 2, - "measure": true, - "bounds": { - "fps": { - "min_pct": 80 - } - }, - "contract": { - "pc-macos": { - "tick_us": 300, - "free_heap": 0, - "set_by": "2026-06-02", - "reason": "initial contract" - }, - "esp32-eth-wifi": { - "tick_us": 100000, - "free_heap": 105000, - "set_by": "2026-06-02", - "reason": "initial contract" - } - }, - "observed": { - "pc-macos": { - "tick_us": [ - 299, - 481 - ], - "free_heap": [ - 0, - 0 - ], - "max_alloc_block": [ - 0, - 0 - ], - "at": [ - "2026-06-02", - "2026-06-03" - ] - }, - "esp32-eth-wifi": { - "tick_us": [ - 83088, - 83088 - ], - "free_heap": [ - 95420, - 95420 - ], - "max_alloc_block": [ - 49152, - 49152 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - }, - "esp32-eth": { - "tick_us": [ - 95647, - 96439 - ], - "free_heap": [ - 135188, - 135208 - ], - "max_alloc_block": [ - 51200, - 53248 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - }, - "esp32": { - "tick_us": [ - 202429, - 202429 - ], - "free_heap": [ - 85272, - 85272 - ], - "max_alloc_block": [ - 49152, - 49152 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - } - } - }, - { - "name": "detail-3-fine", - "description": "detail=3 (finest, 43x43 downsample). Looser bound (70%) because the downsample cost is real and accepted.", - "op": "set_control", - "id": "Preview", - "key": "detail", - "value": 3, - "measure": true, - "bounds": { - "fps": { - "min_pct": 70 - } - }, - "contract": { - "pc-macos": { - "tick_us": 320, - "free_heap": 0, - "set_by": "2026-06-02", - "reason": "initial contract" - }, - "esp32-eth-wifi": { - "tick_us": 100000, - "free_heap": 105000, - "set_by": "2026-06-02", - "reason": "initial contract" - } - }, - "observed": { - "pc-macos": { - "tick_us": [ - 296, - 476 - ], - "free_heap": [ - 0, - 0 - ], - "max_alloc_block": [ - 0, - 0 - ], - "at": [ - "2026-06-02", - "2026-06-03" - ] - }, - "esp32-eth-wifi": { - "tick_us": [ - 79135, - 79135 - ], - "free_heap": [ - 95420, - 95420 - ], - "max_alloc_block": [ - 49152, - 49152 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - }, - "esp32-eth": { - "tick_us": [ - 104311, - 105431 - ], - "free_heap": [ - 135188, - 135208 - ], - "max_alloc_block": [ - 51200, - 53248 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - }, - "esp32": { - "tick_us": [ - 222234, - 222234 - ], - "free_heap": [ - 84980, - 84980 - ], - "max_alloc_block": [ - 49152, - 49152 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - } - } - }, - { - "name": "decompress-on", - "description": "decompress=true. Client-side hint — does not affect the render tick.", - "op": "set_control", - "id": "Preview", - "key": "decompress", - "value": true, - "measure": true, - "bounds": { - "fps": { - "min_pct": 80 - } - }, - "contract": { - "pc-macos": { - "tick_us": 300, - "free_heap": 0, - "set_by": "2026-06-02", - "reason": "initial contract" - }, - "esp32-eth-wifi": { - "tick_us": 100000, - "free_heap": 105000, - "set_by": "2026-06-02", - "reason": "initial contract" - } - }, - "observed": { - "pc-macos": { - "tick_us": [ - 294, - 397 - ], - "free_heap": [ - 0, - 0 - ], - "max_alloc_block": [ - 0, - 0 - ], - "at": [ - "2026-06-02", - "2026-06-03" - ] - }, - "esp32-eth-wifi": { - "tick_us": [ - 81905, - 81905 - ], - "free_heap": [ - 95420, - 95420 - ], - "max_alloc_block": [ - 49152, - 49152 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - }, - "esp32-eth": { - "tick_us": [ - 94607, - 95185 - ], - "free_heap": [ - 135188, - 135208 - ], - "max_alloc_block": [ - 51200, - 53248 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - }, - "esp32": { - "tick_us": [ - 219045, - 219045 - ], - "free_heap": [ - 84980, - 84980 - ], - "max_alloc_block": [ - 49152, - 49152 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - } - } - }, - { - "name": "decompress-off", - "description": "decompress=false. Same as above — pure client-side, no render impact expected.", - "op": "set_control", - "id": "Preview", - "key": "decompress", - "value": false, - "measure": true, - "bounds": { - "fps": { - "min_pct": 80 - } - }, - "contract": { - "pc-macos": { - "tick_us": 300, - "free_heap": 0, - "set_by": "2026-06-02", - "reason": "initial contract" - }, - "esp32-eth-wifi": { - "tick_us": 100000, - "free_heap": 105000, - "set_by": "2026-06-02", - "reason": "initial contract" - } - }, - "observed": { - "pc-macos": { - "tick_us": [ - 298, - 386 - ], - "free_heap": [ - 0, - 0 - ], - "max_alloc_block": [ - 0, - 0 - ], - "at": [ - "2026-06-02", - "2026-06-03" - ] - }, - "esp32-eth-wifi": { - "tick_us": [ - 83247, - 83247 - ], - "free_heap": [ - 95420, - 95420 - ], - "max_alloc_block": [ - 49152, - 49152 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - }, - "esp32-eth": { - "tick_us": [ - 96801, - 96978 - ], - "free_heap": [ - 135188, - 135208 - ], - "max_alloc_block": [ - 51200, - 53248 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - }, - "esp32": { - "tick_us": [ - 209298, - 209298 - ], - "free_heap": [ - 84980, - 84980 - ], - "max_alloc_block": [ - 49152, - 49152 - ], - "at": [ - "2026-06-02", - "2026-06-02" - ] - } - } - } - ] -} diff --git a/test/unit/light/unit_GameOfLifeEffect.cpp b/test/unit/light/unit_GameOfLifeEffect.cpp new file mode 100644 index 00000000..1cdde866 --- /dev/null +++ b/test/unit/light/unit_GameOfLifeEffect.cpp @@ -0,0 +1,186 @@ +// @module GameOfLifeEffect + +#include "doctest.h" +#include "light/effects/GameOfLifeEffect.h" +#include "light/layouts/GridLayout.h" +#include "platform/platform.h" + +// Build a Layer with a w×h grid and a GameOfLifeEffect child, run onBuildState +// so the cell grids allocate. Returns by configuring the passed-in objects. +static void build(mm::Layouts& layouts, mm::GridLayout& grid, mm::Layer& layer, + mm::GameOfLifeEffect& gol, mm::lengthType w, mm::lengthType h) { + grid.width = w; + grid.height = h; + grid.depth = 1; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&gol); + layer.onBuildState(); +} + +// Two cell grids of width × height bytes each. +TEST_CASE("GameOfLifeEffect allocates two cell grids when enabled") { + mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; mm::GameOfLifeEffect gol; + build(layouts, grid, layer, gol, 16, 16); + CHECK(gol.dynamicBytes() == 16 * 16 * 2); +} + +// Disabling releases both grids (dynamicBytes drops to 0) via the parent lifecycle. +TEST_CASE("GameOfLifeEffect frees grids when disabled") { + mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; mm::GameOfLifeEffect gol; + build(layouts, grid, layer, gol, 8, 8); + CHECK(gol.dynamicBytes() > 0); + + gol.setEnabled(false); + layer.onBuildState(); + CHECK(gol.dynamicBytes() == 0); +} + +// A blinker (horizontal 3-in-a-row) oscillates with period 2 under B3/S23: +// it becomes a vertical 3-in-a-row, then back. Pins both birth (B3) and +// survival (S23) on a known pattern. +TEST_CASE("GameOfLifeEffect blinker oscillates period-2 (B3/S23)") { + mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; mm::GameOfLifeEffect gol; + build(layouts, grid, layer, gol, 5, 5); + + // Horizontal blinker centred at (2,2): (1,2)(2,2)(3,2) + gol.clearGrid(); + gol.setCell(1, 2, true); + gol.setCell(2, 2, true); + gol.setCell(3, 2, true); + CHECK(gol.liveCount() == 3); + + gol.stepOnce(); // → vertical: (2,1)(2,2)(2,3) + CHECK(gol.liveCount() == 3); + CHECK(gol.getCell(2, 1)); + CHECK(gol.getCell(2, 2)); + CHECK(gol.getCell(2, 3)); + CHECK_FALSE(gol.getCell(1, 2)); + CHECK_FALSE(gol.getCell(3, 2)); + + gol.stepOnce(); // → back to horizontal + CHECK(gol.getCell(1, 2)); + CHECK(gol.getCell(2, 2)); + CHECK(gol.getCell(3, 2)); +} + +// A 2×2 block is a still-life: every live cell has 3 neighbours (S3), no dead +// cell has exactly 3 (no B3), so stepOnce leaves it unchanged. +TEST_CASE("GameOfLifeEffect block is a still-life") { + mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; mm::GameOfLifeEffect gol; + build(layouts, grid, layer, gol, 6, 6); + + gol.clearGrid(); + gol.setCell(2, 2, true); + gol.setCell(3, 2, true); + gol.setCell(2, 3, true); + gol.setCell(3, 3, true); + + mm::nrOfLightsType alive = 0; + mm::nrOfLightsType changed = gol.stepOnce(&alive); + CHECK(changed == 0); + CHECK(alive == 4); + CHECK(gol.getCell(2, 2)); + CHECK(gol.getCell(3, 3)); +} + +// A lone cell dies (underpopulation: 0 neighbours, not S2/S3) → extinction. +TEST_CASE("GameOfLifeEffect lone cell dies") { + mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; mm::GameOfLifeEffect gol; + build(layouts, grid, layer, gol, 6, 6); + + gol.clearGrid(); + gol.setCell(3, 3, true); + mm::nrOfLightsType alive = 99; + gol.stepOnce(&alive); + CHECK(alive == 0); +} + +// Wraparound: a blinker on the right edge stays a valid 3-cell pattern because +// neighbours wrap, rather than losing cells to a hard edge. +TEST_CASE("GameOfLifeEffect wraparound wraps edges") { + mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; mm::GameOfLifeEffect gol; + build(layouts, grid, layer, gol, 5, 5); + gol.wraparound = true; + + // Horizontal blinker straddling the right edge: (3,2)(4,2)(0,2 via wrap) + gol.clearGrid(); + gol.setCell(4, 2, true); + gol.setCell(0, 2, true); + gol.setCell(3, 2, true); + gol.stepOnce(); + CHECK(gol.liveCount() == 3); // survives as a wrapped vertical blinker +} + +// Reallocation on dimension change: grids resize, byte count tracks new w×h. +TEST_CASE("GameOfLifeEffect reallocates on dimension change") { + mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; mm::GameOfLifeEffect gol; + build(layouts, grid, layer, gol, 8, 8); + CHECK(gol.dynamicBytes() == 8 * 8 * 2); + + grid.width = 16; + grid.height = 8; + layer.onBuildState(); + CHECK(gol.dynamicBytes() == 16 * 8 * 2); +} + +// Must not crash on a zero-size grid (no allocation, loop is a no-op). +TEST_CASE("GameOfLifeEffect survives 0x0 grid") { + mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; mm::GameOfLifeEffect gol; + build(layouts, grid, layer, gol, 0, 0); + CHECK(gol.dynamicBytes() == 0); + layer.loop(); // no crash + CHECK(gol.liveCount() == 0); +} + +// bpm time-gates the generation rate: a low bpm advances fewer generations per +// unit time than a high bpm over the same elapsed window. Drives time via the +// desktop millis() test seam (Layer reads platform::millis in loop()). +TEST_CASE("GameOfLifeEffect bpm controls generation rate") { + auto changesIn1s = [](uint8_t bpm) { + mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; mm::GameOfLifeEffect gol; + build(layouts, grid, layer, gol, 32, 32); + gol.bpm = bpm; + uint32_t t = 1000; + mm::platform::setTestNowMs(t); + layer.loop(); // first frame: initial render, no step yet + int changes = 0; + for (int f = 0; f < 25; f++) { // 25 × 40ms = 1 simulated second + t += 40; + mm::platform::setTestNowMs(t); + mm::nrOfLightsType before = gol.liveCount(); + layer.loop(); + if (gol.liveCount() != before) changes++; + } + mm::platform::setTestNowMs(0); // restore real clock + return changes; + }; + // Fast bpm steps every frame; slow bpm steps only a few times in the second. + CHECK(changesIn1s(255) > changesIn1s(8)); +} + +// Regression: the Layer clears the buffer before every effect frame, so the +// grid must be re-painted on EVERY frame, not just on the (rarer) beats where a +// generation advances. A bpm gate that skipped the paint left non-step frames +// black — visible as "a flash now and then" at low bpm. Drive several frames at +// a slow bpm (most are non-step) and require the buffer stays lit on all of them. +TEST_CASE("GameOfLifeEffect renders every frame between generations") { + mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; mm::GameOfLifeEffect gol; + build(layouts, grid, layer, gol, 32, 32); + gol.bpm = 8; // ~1 generation/sec: most 40ms frames do NOT step + uint32_t t = 1000; + + for (int f = 0; f < 10; f++) { + t += 40; + mm::platform::setTestNowMs(t); + layer.loop(); + auto& buf = layer.buffer(); + bool lit = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i]) { lit = true; break; } + } + CHECK(lit); // never black between beats + } + mm::platform::setTestNowMs(0); // restore real clock +} diff --git a/test/unit/light/unit_Layer_sparse_mapping.cpp b/test/unit/light/unit_Layer_sparse_mapping.cpp new file mode 100644 index 00000000..2eca583c --- /dev/null +++ b/test/unit/light/unit_Layer_sparse_mapping.cpp @@ -0,0 +1,106 @@ +// @module Layer + +#include "doctest.h" +#include "light/layers/Layer.h" +#include "light/layouts/Layouts.h" +#include "light/layouts/GridLayout.h" +#include "light/layouts/SphereLayout.h" +#include "light/modifiers/MirrorModifier.h" + +// The driver/output buffer must hold ONLY the real lights, never the dense +// bounding box. A sphere defines a 9x9x9 (=729) render grid but only 210 shell +// lights; the MappingLUT extracts those 210 into the driver buffer (what ArtNet +// and the preview consume). These tests pin: +// - sparse layout (sphere) builds a LUT whose destinations are driver indices +// in [0, lightCount) — never a box index >= lightCount (the latent overflow); +// - dense grid stays on the identity fast path (no LUT) — unchanged; +// - a sphere + Mirror modifier still maps into driver-index space. +// The Layer buffer (where effects render) stays the dense box in all cases. + +namespace { + +// Build a Layer over a single layout, run onBuildState, return it wired. +struct LayerRig { + mm::Layouts group; + mm::Layer layer; + + explicit LayerRig(mm::LayoutBase* layout, uint8_t cpl = 3) { + group.addChild(layout); + layer.setLayouts(&group); + layer.setChannelsPerLight(cpl); + layer.onBuildControls(); + layer.onBuildState(); + } +}; + +} // namespace + +// Dense grid: every box cell is a light, so no LUT — the identity/memcpy fast +// path is preserved exactly (the grid short-circuit). +TEST_CASE("Layer: dense grid stays on the identity path (no LUT)") { + mm::GridLayout g; + g.width = 8; g.height = 8; g.depth = 1; // 64 lights, box == sparse + LayerRig rig(&g); + + CHECK_FALSE(rig.layer.lut().hasLUT()); // identity, no table + CHECK(rig.layer.physicalLightCount() == 64); + CHECK(rig.layer.buffer().count() == 64); // render buffer == box == lights +} + +// Sparse sphere: a LUT is built; its destinations are driver indices in +// [0, lightCount), and the render buffer stays the dense bounding box. +TEST_CASE("Layer: sparse sphere builds a box->driver LUT, no out-of-range index") { + mm::SphereLayout s; + s.radius = 4; // 210 shell lights in a 9^3 box + const mm::nrOfLightsType N = s.lightCount(); + CHECK(N == 210); + LayerRig rig(&s); + + CHECK(rig.layer.lut().hasLUT()); // sparse → a real LUT exists + CHECK(rig.layer.physicalLightCount() == N); // driver buffer = real lights, not box + CHECK(rig.layer.buffer().count() == 9 * 9 * 9); // render buffer = dense box (729) + + // Every LUT destination is a driver index < N — the fix for the latent + // overflow where box indices (0..728) were written into an N-sized buffer. + const mm::MappingLUT& lut = rig.layer.lut(); + mm::nrOfLightsType maxDest = 0; + mm::nrOfLightsType totalDests = 0; + for (mm::nrOfLightsType li = 0; li < lut.logicalCount(); li++) { + lut.forEachDestination(li, [&](mm::nrOfLightsType d) { + if (d > maxDest) maxDest = d; + totalDests++; + }); + } + CHECK(totalDests == N); // exactly the 210 shell cells map to a light + CHECK(maxDest < N); // no destination is >= N (no overflow) + CHECK(lut.logicalCount() == 9 * 9 * 9); // one logical entry per box cell +} + +// Sphere + Mirror: the modifier's box-coordinate destinations are translated +// into driver-index space; no destination escapes [0, lightCount). +TEST_CASE("Layer: sphere + mirror maps into driver-index space") { + mm::SphereLayout s; + s.radius = 4; + const mm::nrOfLightsType N = s.lightCount(); + + mm::Layouts group; + group.addChild(&s); + mm::Layer layer; + layer.setLayouts(&group); + layer.setChannelsPerLight(3); + mm::MirrorModifier mirror; + mirror.mirrorX = true; + layer.addChild(&mirror); + layer.onBuildControls(); + layer.onBuildState(); + + CHECK(layer.lut().hasLUT()); + CHECK(layer.physicalLightCount() == N); + + const mm::MappingLUT& lut = layer.lut(); + for (mm::nrOfLightsType li = 0; li < lut.logicalCount(); li++) { + lut.forEachDestination(li, [&](mm::nrOfLightsType d) { + CHECK(d < N); // mirror destinations are driver indices, never box indices + }); + } +} diff --git a/test/unit/light/unit_Layouts_mutation.cpp b/test/unit/light/unit_Layouts_mutation.cpp new file mode 100644 index 00000000..c05528c6 --- /dev/null +++ b/test/unit/light/unit_Layouts_mutation.cpp @@ -0,0 +1,127 @@ +// @module Layouts + +#include "doctest.h" +#include "light/layouts/Layouts.h" +#include "light/layouts/GridLayout.h" +#include "light/layouts/SphereLayout.h" + +#include + +// Tree mutation on the Layouts container: add one, add more than one, replace a +// layout with a different type, and remove a layout. These exercise the same +// addChild / replaceChildAt / removeChild primitives the HTTP handlers +// (handleAddModule / handleReplaceModule / handleDeleteModule) drive, but at the +// container level so the light-count / coordinate stitching is verified too. +// +// Stack-allocated layouts here own themselves — removeChild / replaceChildAt do +// NOT delete (the caller owns), so no leak and no double-free on scope exit. + +namespace { + +mm::nrOfLightsType countCoords(const mm::Layouts& layouts) { + mm::nrOfLightsType n = 0; + layouts.forEachCoord([](void* ctx, mm::nrOfLightsType, mm::lengthType, mm::lengthType, mm::lengthType) { + (*static_cast(ctx))++; + }, &n); + return n; +} + +} // namespace + +// Add a single layout: the container reports its light count and iterates it. +TEST_CASE("Layouts add one layout") { + mm::Layouts layouts; + mm::GridLayout g; + g.width = 4; g.height = 4; g.depth = 1; // 16 lights + + REQUIRE(layouts.addChild(&g)); + CHECK(layouts.childCount() == 1); + CHECK(layouts.totalLightCount() == 16); + CHECK(countCoords(layouts) == 16); // iterator agrees with the count +} + +// Add more than one layout (mixed types): counts sum, indices stitch end-to-end. +TEST_CASE("Layouts add multiple layouts of different types") { + mm::Layouts layouts; + mm::GridLayout g; + g.width = 4; g.height = 1; g.depth = 1; // 4 lights, indices 0..3 + mm::SphereLayout s; + // SphereLayout radius=1: the shell band [r-0.5, r+0.5) holds the 6 axis + // neighbours (d^2=1) plus the 12 edge points (d^2=2) of the centre = 18 + // lights; stitched after the grid's 4, indices 4..21. + s.radius = 1; + + REQUIRE(layouts.addChild(&g)); + REQUIRE(layouts.addChild(&s)); + CHECK(layouts.childCount() == 2); + + const mm::nrOfLightsType expected = g.lightCount() + s.lightCount(); + CHECK(layouts.totalLightCount() == expected); + CHECK(countCoords(layouts) == expected); + + // Indices stitch: the sphere's lights start where the grid's end (no holes, + // no overlap). Collect indices and verify a contiguous 0..expected-1 range. + std::vector idxs; + layouts.forEachCoord([](void* ctx, mm::nrOfLightsType idx, mm::lengthType, mm::lengthType, mm::lengthType) { + static_cast*>(ctx)->push_back(idx); + }, &idxs); + REQUIRE(idxs.size() == expected); + for (mm::nrOfLightsType i = 0; i < expected; i++) CHECK(idxs[i] == i); +} + +// Replace a layout with a different type at the same slot: the other layouts and +// their order are preserved; only the replaced slot's contribution changes. +TEST_CASE("Layouts replace a layout with another type") { + mm::Layouts layouts; + mm::GridLayout a; + a.width = 2; a.height = 1; a.depth = 1; // 2 lights + mm::GridLayout b; + b.width = 5; b.height = 1; b.depth = 1; // 5 lights — the one we replace + layouts.addChild(&a); + layouts.addChild(&b); + CHECK(layouts.totalLightCount() == 7); + + // Replace slot 1 (the 5-light grid) with a sphere. replaceChildAt returns + // the old child (caller owns it; we don't delete — it's stack-allocated). + mm::SphereLayout s; + s.radius = 2; + mm::MoonModule* old = layouts.replaceChildAt(1, &s); + CHECK(old == &b); + CHECK(layouts.childCount() == 2); + CHECK(layouts.child(0) == &a); // slot 0 untouched + CHECK(layouts.child(1) == &s); // slot 1 now the sphere + + // Total is slot-0 grid (2) + the sphere's shell, and the iterator agrees. + const mm::nrOfLightsType expected = a.lightCount() + s.lightCount(); + CHECK(layouts.totalLightCount() == expected); + CHECK(countCoords(layouts) == expected); +} + +// Remove a layout: it leaves the tree, the remaining layouts shift to close the +// gap, and the total drops by exactly the removed layout's light count. +TEST_CASE("Layouts remove a layout") { + mm::Layouts layouts; + mm::GridLayout a; + a.width = 3; a.height = 1; a.depth = 1; // 3 lights + mm::GridLayout b; + b.width = 4; b.height = 1; b.depth = 1; // 4 lights + mm::GridLayout c; + c.width = 5; c.height = 1; c.depth = 1; // 5 lights + layouts.addChild(&a); + layouts.addChild(&b); + layouts.addChild(&c); + CHECK(layouts.totalLightCount() == 12); + + // Remove the middle one. removeChild does not delete (a/b/c are on the stack). + REQUIRE(layouts.removeChild(&b)); + CHECK(layouts.childCount() == 2); + CHECK(layouts.child(0) == &a); // surviving layouts keep order, gap closed + CHECK(layouts.child(1) == &c); + CHECK(layouts.totalLightCount() == 8); // 3 + 5, b's 4 gone + CHECK(countCoords(layouts) == 8); + + // Removing a layout not present returns false and changes nothing. + mm::GridLayout notAdded; + CHECK_FALSE(layouts.removeChild(¬Added)); + CHECK(layouts.childCount() == 2); +} diff --git a/test/unit/light/unit_PreviewDriver.cpp b/test/unit/light/unit_PreviewDriver.cpp index 59a50541..b18c7d86 100644 --- a/test/unit/light/unit_PreviewDriver.cpp +++ b/test/unit/light/unit_PreviewDriver.cpp @@ -2,155 +2,128 @@ #include "doctest.h" #include "light/drivers/PreviewDriver.h" +#include "light/drivers/Drivers.h" #include "light/layers/Layer.h" #include "light/layouts/Layouts.h" #include "light/layouts/GridLayout.h" -#include "core/PreviewFrame.h" +#include "light/layouts/SphereLayout.h" -// PreviewDriver downsamples the render buffer into a small RGB frame so the -// whole WebSocket message fits lwIP's TCP send buffer. These tests verify that -// the `detail` control selects the right voxel budget (and therefore stride), -// that the frame always carries the original grid dimensions (needed by the -// `decompress` UI hint), and that even the finest setting stays within the -// send-buffer payload limit. +#include +#include + +// PreviewDriver streams a true-shape point list: a one-time 0x03 coordinate +// table (positions of the real lights) + per-frame 0x02 RGB indexed by light. +// These tests pin: the table carries exactly lightCount positions (sphere → its +// shell count, NOT the bounding box), the per-frame RGB count matches, and a +// large layout is index-downsampled (stride > 1) to fit the send-buffer cap. namespace { -// Build a populated PreviewDriver against a GridLayout of the given size. -// Returns by wiring the caller's objects — keeps every object on the stack so -// there is no allocation outside PreviewDriver's own owned buffer. +// Captures the two preview message types so tests can inspect them. +struct CaptureBroadcaster : mm::BinaryBroadcaster { + int coordMsgs = 0, frameMsgs = 0; + std::vector lastCoord, lastFrame; + + void broadcastBinary(const mm::platform::WriteChunk* payload, int chunkCount) override { + std::vector buf; + for (int i = 0; i < chunkCount; i++) + buf.insert(buf.end(), payload[i].data, payload[i].data + payload[i].len); + if (buf.empty()) return; + if (buf[0] == 0x03) { coordMsgs++; lastCoord = buf; } + else if (buf[0] == 0x02) { frameMsgs++; lastFrame = buf; } + } + + int coordCount() const { return lastCoord.size() >= 3 ? lastCoord[1] | (lastCoord[2] << 8) : -1; } + int frameCount() const { return lastFrame.size() >= 3 ? lastFrame[1] | (lastFrame[2] << 8) : -1; } + int coordStride() const { return lastCoord.size() >= 8 ? lastCoord[6] | (lastCoord[7] << 8) : -1; } +}; + +// Wire PreviewDriver under Drivers, over a Layer + single layout, with a +// CaptureBroadcaster — the full real path (sparse driver buffer + layout coords). struct PreviewRig { - mm::GridLayout grid; mm::Layouts group; mm::Layer layer; - mm::Buffer source; - mm::PreviewFrame frame; - mm::PreviewDriver driver; + mm::Drivers drivers; + mm::PreviewDriver* preview; // owned by drivers' child array + CaptureBroadcaster cap; - PreviewRig(mm::lengthType w, mm::lengthType h, mm::lengthType d, uint8_t cpl) { - grid.width = w; - grid.height = h; - grid.depth = d; - group.addChild(&grid); + PreviewRig(mm::LayoutBase* layout, uint8_t cpl = 3) { + group.addChild(layout); layer.setLayouts(&group); layer.setChannelsPerLight(cpl); + layer.onBuildControls(); layer.onBuildState(); - source.allocate(static_cast(w) * h * d, cpl); - // Fill so the strided copy produces non-zero output. - for (size_t i = 0; i < source.bytes(); i++) source.data()[i] = 0x40; - - driver.setLayer(&layer); - driver.setSourceBuffer(&source); - driver.setPreviewFrame(&frame); - driver.onBuildControls(); - driver.onBuildState(); + preview = new mm::PreviewDriver(); + preview->setBroadcaster(&cap); + drivers.addChild(preview); + drivers.setLayer(&layer); // passBufferToDrivers wires preview's source + layer + drivers.onBuildControls(); + drivers.onBuildState(); } - // Produce one frame deterministically. renderFrame() bypasses loop()'s fps - // rate-limit, so the test never sleeps and never depends on wall-clock time. - void produceFrame() { - driver.renderFrame(); + void produce() { + preview->buildAndSendCoordTable(); + preview->sendFrame(); } }; } // namespace -// The three `detail` levels (1/2/3) downsample a 128-axis grid into 16/32/43 axes — distinct strides so the levels are visibly different. -TEST_CASE("PreviewDriver detail levels select distinct strides on a 128 grid") { - // 128-axis grid: detail 1/2/3 budgets (256/1024/1849) must land on - // distinct strides so the three settings are visibly different. - struct Case { uint8_t detail; mm::lengthType expectDim; }; - const Case cases[] = { - {1, 16}, // stride 8 → ceil(128/8) = 16 (256 voxels) - {2, 32}, // stride 4 → ceil(128/4) = 32 (1024 voxels) - {3, 43}, // stride 3 → ceil(128/3) = 43 (1849 voxels) - }; - - for (const auto& c : cases) { - PreviewRig rig(128, 128, 1, 3); - rig.driver.detail = c.detail; - rig.produceFrame(); - - REQUIRE(rig.frame.ready); - CHECK(rig.frame.width == c.expectDim); - CHECK(rig.frame.height == c.expectDim); - CHECK(rig.frame.depth == 1); - // Payload = downsampled voxels × 3 RGB bytes. - CHECK(rig.frame.dataLen == - static_cast(c.expectDim) * c.expectDim * 1 * 3); - } +// A sphere sends its SHELL lights (210), not the dense 9x9x9 box (729). +TEST_CASE("PreviewDriver coordinate table carries the real lights, not the box") { + mm::SphereLayout s; + s.radius = 4; // 210 shell lights, 9^3 box + PreviewRig rig(&s); + rig.produce(); + + REQUIRE(rig.cap.coordMsgs > 0); + CHECK(rig.cap.coordCount() == 210); // the shell, not 729 + CHECK(rig.cap.coordStride() == 1); // small → exact, no downsample + // 0x03 = [0x03][count:u16][bx][by][bz][stride:u16] + count*3 position bytes + CHECK(rig.cap.lastCoord.size() == 8u + 210u * 3u); } -// Even when downsampled, the frame carries the original grid dimensions so the UI's `decompress` hint can block-replicate back. -TEST_CASE("PreviewDriver frame carries the original grid dimensions") { - // The `decompress` UI hint block-replicates back to the real grid, so the - // frame must always report the original (pre-downsample) dimensions. - PreviewRig rig(128, 64, 1, 3); - rig.driver.detail = 1; // coarse → downsampled dims differ from original - rig.produceFrame(); - - REQUIRE(rig.frame.ready); - CHECK(rig.frame.origWidth == 128); - CHECK(rig.frame.origHeight == 64); - CHECK(rig.frame.origDepth == 1); - // Downsampled dims are smaller than the original. - CHECK(rig.frame.width < rig.frame.origWidth); - CHECK(rig.frame.height < rig.frame.origHeight); +// Per-frame 0x02 RGB count matches the coordinate-table count. +TEST_CASE("PreviewDriver per-frame RGB count matches the coordinate table") { + mm::SphereLayout s; + s.radius = 4; + PreviewRig rig(&s); + rig.produce(); + + REQUIRE(rig.cap.frameMsgs > 0); + CHECK(rig.cap.frameCount() == 210); + // 0x02 = [0x02][count:u16][stride:u16] + count*3 RGB bytes + CHECK(rig.cap.lastFrame.size() == 5u + 210u * 3u); } -// detail=3 (largest payload) stays under lwIP's ~5.7 KB TCP send buffer so writeChunks completes in one whole pass. -TEST_CASE("PreviewDriver finest detail stays within the send-buffer budget") { - // detail = 3 is the largest payload. 13-byte header + RGB must fit lwIP's - // ~5.7 KB TCP send buffer so the non-blocking writeChunks completes whole. - constexpr size_t MAX_VOXELS = 1849; - constexpr size_t HEADER = 13; - constexpr size_t SEND_BUFFER_LIMIT = 5760; - - PreviewRig rig(128, 128, 1, 3); - rig.driver.detail = 3; - rig.produceFrame(); - - REQUIRE(rig.frame.ready); - size_t voxels = static_cast(rig.frame.width) * - rig.frame.height * rig.frame.depth; - CHECK(voxels <= MAX_VOXELS); - CHECK(rig.frame.dataLen + HEADER <= SEND_BUFFER_LIMIT); -} +// A small grid sends every light at its grid position (stride 1, exact). +TEST_CASE("PreviewDriver small grid sends all lights exactly") { + mm::GridLayout g; + g.width = 8; g.height = 8; g.depth = 1; // 64 lights + PreviewRig rig(&g); + rig.produce(); -// A small grid (≤ budget) is copied 1:1 with no downsampling — preview matches the original exactly. -TEST_CASE("PreviewDriver small grid is copied 1:1 (stride 1)") { - // A grid well under the budget needs no downsampling — every voxel copied, - // so a less detailed preview means a higher driver FPS (fewer bytes). - PreviewRig rig(8, 8, 1, 3); - rig.driver.detail = 2; - rig.produceFrame(); - - REQUIRE(rig.frame.ready); - CHECK(rig.frame.width == 8); - CHECK(rig.frame.height == 8); - CHECK(rig.frame.dataLen == 8 * 8 * 3); + CHECK(rig.cap.coordCount() == 64); + CHECK(rig.cap.frameCount() == 64); + CHECK(rig.cap.coordStride() == 1); } -// On RGBW (4-channel) sources the preview keeps only the first 3 channels — the wire frame is always 3 bytes per voxel. -TEST_CASE("PreviewDriver channel-agnostic: RGBW source copies RGB only") { - // The strided copy takes the first 3 channels regardless of channelsPerLight, - // so RGBW / multi-channel layouts preview correctly (3 B per voxel on wire). - PreviewRig rig(16, 16, 1, 4); // RGBW - rig.driver.detail = 3; // small grid → stride 1, full copy - rig.produceFrame(); - - REQUIRE(rig.frame.ready); - CHECK(rig.frame.width == 16); - CHECK(rig.frame.height == 16); - CHECK(rig.frame.dataLen == 16 * 16 * 3); // 3 B/voxel even with 4-ch source +// A large layout is index-downsampled (stride > 1) so the payload fits the +// send-buffer cap — but at REAL positions, not a padded box. +TEST_CASE("PreviewDriver downsamples a large layout via index stride") { + mm::GridLayout g; + g.width = 128; g.height = 128; g.depth = 1; // 16384 lights > 1800-point cap + PreviewRig rig(&g); + rig.produce(); + + CHECK(rig.cap.coordStride() > 1); // strided + CHECK(rig.cap.coordCount() <= 1800); // fits the send-buffer cap + CHECK(rig.cap.coordCount() == rig.cap.frameCount()); // table + RGB agree } -// Default controls: fps=24 (preview stream rate), detail=3 (finest), decompress=true. -TEST_CASE("PreviewDriver fps default is the rate-limited preview rate") { - // fps default is the preview *stream* rate (independent of render FPS). +// Default fps is the rate-limited preview stream rate. +TEST_CASE("PreviewDriver fps default") { mm::PreviewDriver driver; CHECK(driver.fps == 24); - CHECK(driver.detail == 3); - CHECK(driver.decompress == true); } diff --git a/test/unit/light/unit_SphereLayout.cpp b/test/unit/light/unit_SphereLayout.cpp new file mode 100644 index 00000000..bf4147e2 --- /dev/null +++ b/test/unit/light/unit_SphereLayout.cpp @@ -0,0 +1,111 @@ +// @module SphereLayout + +#include "doctest.h" +#include "light/layouts/SphereLayout.h" + +#include + +// SphereLayout places lights on the surface of a hollow sphere — a one-light- +// thick lattice shell, centre excluded. These tests pin: the shell is hollow +// (no centre / interior point), lightCount() matches what forEachCoord emits, +// the points are symmetric about the centre, and the radius-1 base case. + +namespace { + +struct Pt { mm::lengthType x, y, z; }; + +std::vector collectPoints(const mm::SphereLayout& s) { + std::vector pts; + s.forEachCoord([](void* ctx, mm::nrOfLightsType, mm::lengthType x, mm::lengthType y, mm::lengthType z) { + static_cast*>(ctx)->push_back({x, y, z}); + }, &pts); + return pts; +} + +} // namespace + +// lightCount() must equal the number of points forEachCoord emits — they share +// one shell predicate, so allocation and fill can never disagree. +TEST_CASE("SphereLayout lightCount matches the iterator") { + for (mm::lengthType r : {1, 2, 4, 8}) { + mm::SphereLayout s; + s.radius = r; + auto pts = collectPoints(s); + CHECK(s.lightCount() == pts.size()); + CHECK(s.lightCount() > 0); // every radius produces a non-empty shell + } +} + +// The sphere is HOLLOW: the centre lattice point (r,r,r) is never emitted, and +// neither is any interior point (distance < radius-0.5 from centre). +TEST_CASE("SphereLayout is hollow — no centre or interior points") { + mm::SphereLayout s; + s.radius = 5; + const int r = 5; + auto pts = collectPoints(s); + + for (const auto& p : pts) { + const int dx = p.x - r, dy = p.y - r, dz = p.z - r; + const int d4 = 4 * (dx * dx + dy * dy + dz * dz); + // On the half-open shell band [r-0.5, r+0.5): 4d^2 in [(2r-1)^2,(2r+1)^2). + CHECK(d4 >= (2 * r - 1) * (2 * r - 1)); // not interior + CHECK(d4 < (2 * r + 1) * (2 * r + 1)); // not exterior + CHECK_FALSE((dx == 0 && dy == 0 && dz == 0)); // never the centre + } +} + +// radius = 1 is the smallest hollow sphere: the 6 axis neighbours (d^2=1) plus +// the 12 edge points (d^2=2) of the centre — 18 lights, no centre. +TEST_CASE("SphereLayout radius 1 is the 18-point base shell") { + mm::SphereLayout s; + s.radius = 1; + auto pts = collectPoints(s); + CHECK(pts.size() == 18); + // All within the 3x3x3 box (coords 0..2), none at the centre (1,1,1). + for (const auto& p : pts) { + CHECK(p.x >= 0); CHECK(p.x <= 2); + CHECK(p.y >= 0); CHECK(p.y <= 2); + CHECK(p.z >= 0); CHECK(p.z <= 2); + CHECK_FALSE((p.x == 1 && p.y == 1 && p.z == 1)); + } +} + +// The shell is symmetric about the centre: for every emitted point its mirror +// through the centre is also emitted (a sphere has no preferred direction). +TEST_CASE("SphereLayout shell is centre-symmetric") { + mm::SphereLayout s; + s.radius = 4; + const int r = 4; + auto pts = collectPoints(s); + + auto has = [&](mm::lengthType x, mm::lengthType y, mm::lengthType z) { + for (const auto& p : pts) if (p.x == x && p.y == y && p.z == z) return true; + return false; + }; + for (const auto& p : pts) { + // Mirror through centre: (r - d) on each axis. + CHECK(has(static_cast(2 * r - p.x), + static_cast(2 * r - p.y), + static_cast(2 * r - p.z))); + } +} + +// Physical indices are sequential 0..N-1 over the emitted shell points (no gaps +// from the unindexed lattice voids), so the buffer maps 1:1 to emitted lights. +TEST_CASE("SphereLayout emits sequential physical indices") { + mm::SphereLayout s; + s.radius = 3; + std::vector idxs; + s.forEachCoord([](void* ctx, mm::nrOfLightsType idx, mm::lengthType, mm::lengthType, mm::lengthType) { + static_cast*>(ctx)->push_back(idx); + }, &idxs); + REQUIRE(idxs.size() == s.lightCount()); + for (size_t i = 0; i < idxs.size(); i++) CHECK(idxs[i] == static_cast(i)); +} + +// Default radius is a sensible small sphere (not 0, not huge). +TEST_CASE("SphereLayout default radius") { + mm::SphereLayout s; + CHECK(s.radius == 4); + CHECK(s.lightCount() > 0); +}