From 3efe310a19d4eb18a74274fbca0104d83fbae067 Mon Sep 17 00:00:00 2001 From: Solmath <33658856+Solmath@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:04:54 +0200 Subject: [PATCH 1/9] feat(firmware): Add configuration parameters for metaballs speed and radius - Introduced methods to set and retrieve speed and radius factors from non-volatile storage. - Updated the `configure`, `setSpeed`, `setRadius`, and `transmit` methods to handle new parameters. - Adjusted calculations for speed and radius based on user-defined factors. --- firmware/include/modes/MetaballsMode.h | 27 +++-- firmware/src/modes/MetaballsMode.cpp | 146 ++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 11 deletions(-) diff --git a/firmware/include/modes/MetaballsMode.h b/firmware/include/modes/MetaballsMode.h index 5b17efae..73cb0ae9 100644 --- a/firmware/include/modes/MetaballsMode.h +++ b/firmware/include/modes/MetaballsMode.h @@ -10,14 +10,19 @@ class MetaballsMode final : public ModeModule { private: - static constexpr float radius{ + static constexpr float maxRadius{ min(static_cast(GRID_COLUMNS * PITCH_HORIZONTAL) / static_cast(PITCH_VERTICAL), - static_cast(GRID_ROWS *PITCH_VERTICAL) / static_cast(PITCH_HORIZONTAL)) / - 5.0F}; - static constexpr float radiusSq{radius * radius}; - static constexpr float speed{1e-6F * static_cast(GRID_COLUMNS * GRID_ROWS)}; + static_cast(GRID_ROWS *PITCH_VERTICAL) / static_cast(PITCH_HORIZONTAL))}; + static inline uint8_t radiusFactor{2U}; + static inline float radius{maxRadius / static_cast(radiusFactor)}; + static inline float radiusSq{radius * radius}; - static constexpr uint8_t multiplier{1U << 4U}; + static constexpr float baseSpeed{1e-6F * static_cast(GRID_COLUMNS * GRID_ROWS)}; + static inline uint8_t speedFactor{2U}; + static inline float speed{static_cast(speedFactor) * baseSpeed}; + + static constexpr uint8_t multiplier{1U << 3U}; + static constexpr uint8_t feathering{(GRID_COLUMNS * GRID_ROWS) - 1U}; struct Ball { @@ -27,16 +32,24 @@ class MetaballsMode final : public ModeModule float yVelocity; }; + static constexpr uint8_t numBalls{(GRID_COLUMNS * GRID_ROWS / 50U) - 1}; + std::array contributions{}; - std::array balls{}; + std::array balls{}; + + void setSpeed(uint8_t _speed); + void setRadius(uint8_t _radius); + void transmit(); public: static constexpr std::string_view name{"Metaballs"}; explicit MetaballsMode() : ModeModule(name) {}; + void configure() override; void begin() override; void handle() override; + void onReceive(JsonObjectConst payload, std::string_view source) override; }; #endif // MODE_METABALLS diff --git a/firmware/src/modes/MetaballsMode.cpp b/firmware/src/modes/MetaballsMode.cpp index 6802621a..a3ec1d17 100644 --- a/firmware/src/modes/MetaballsMode.cpp +++ b/firmware/src/modes/MetaballsMode.cpp @@ -2,12 +2,44 @@ #include "modes/MetaballsMode.h" +#include "services/DeviceService.h" #include "services/DisplayService.h" // NOLINT(misc-include-cleaner) #include "services/ExtensionsService.h" +#include + static_assert(GRID_COLUMNS * GRID_ROWS >= 50U, __STRING(MODE_METABALLS) " is not compatible with this device's display size."); +/** + * @brief Loads persisted metaballs settings and publishes the active configuration. + */ +void MetaballsMode::configure() +{ + nvs_handle_t handle{}; + if (nvs_open(name.data(), nvs_open_mode_t::NVS_READONLY, &handle) == ESP_OK) + { + uint8_t _speed{0U}; + if (nvs_get_u8(handle, "speed", &_speed) == ESP_OK) + { + speedFactor = _speed; + speed = static_cast(speedFactor) * baseSpeed; + } + uint8_t _radius{0U}; + if (nvs_get_u8(handle, "radius", &_radius) == ESP_OK) + { + radiusFactor = _radius; + radius = maxRadius / static_cast(radiusFactor); + radiusSq = radius * radius; + } + nvs_close(handle); + } + transmit(); +} + +/** + * @brief Initializes metaballs positions and velocities. + */ void MetaballsMode::begin() { for (size_t idx{0U}; idx < contributions.size(); ++idx) @@ -23,6 +55,12 @@ void MetaballsMode::begin() } } +/** + * @brief Updates the metaballs positions and calculates their contributions to the display. + * + * The metaballs are represented as circles that move across the display, and their brightness + * contributions are calculated based on their distance from each pixel. + */ void MetaballsMode::handle() { #if EXTENSION_MICROPHONE @@ -65,10 +103,10 @@ void MetaballsMode::handle() const float distanceSq{(xDistance * xDistance) + (yDistance * yDistance)}; if (distanceSq < radiusSq) { - brightness = static_cast( - min(static_cast(brightness) + - contributions[static_cast(distanceSq * (0b1U << 6U) / radiusSq)], - UINT8_MAX)); + brightness = static_cast(min( + static_cast(brightness) + contributions[static_cast(min( + distanceSq * feathering / radiusSq, 255.0F))], + UINT8_MAX)); if (brightness == UINT8_MAX) { break; @@ -106,4 +144,104 @@ void MetaballsMode::handle() } } +/** + * @brief Sets the base speed of the metaballs and stores it in non-volatile storage. + * + * Uses arbitrary units for speed, where 1 is the slowest and 11 is the fastest. + * The actual speed is calculated based on the base speed and the speed factor. + * + * @param _speed New speed factor for the metaballs. + */ +void MetaballsMode::setSpeed(uint8_t _speed) +{ + if (_speed < 1U) + { + speedFactor = 1U; + } + else if (_speed > 11U) + { + speedFactor = 11U; + } + else + { + speedFactor = _speed; + } + + speed = baseSpeed * static_cast(speedFactor); + + nvs_handle_t handle{}; + if (nvs_open(name.data(), nvs_open_mode_t::NVS_READWRITE, &handle) == ESP_OK) + { + nvs_set_u8(handle, "speed", static_cast(speedFactor)); + nvs_commit(handle); + nvs_close(handle); + } + transmit(); +} + +/** + * @brief Sets the radius of the metaballs and stores it in non-volatile storage. + * + * Uses arbitrary units for radius, where 1 is the smallest and 10 is the largest. + * The actual radius is calculated based on the maximum radius and the radius factor. + * + * @param _radius New radius factor for the metaballs. + */ +void MetaballsMode::setRadius(uint8_t _radius) +{ + if (_radius < 1U) + { + radiusFactor = 10U; + } + else if (_radius > 10U) + { + radiusFactor = 1U; + } + else + { + radiusFactor = 11U - _radius; + } + + radius = maxRadius / static_cast(radiusFactor); + radiusSq = radius * radius; + + nvs_handle_t handle{}; + if (nvs_open(name.data(), nvs_open_mode_t::NVS_READWRITE, &handle) == ESP_OK) + { + nvs_set_u8(handle, "radius", static_cast(radiusFactor)); + nvs_commit(handle); + nvs_close(handle); + } + transmit(); +} + +/** + * @brief Publishes the current base speed and ball radius in arbitrary units. + */ +void MetaballsMode::transmit() +{ + JsonDocument doc{}; + doc["speed"].set(speedFactor); + doc["radius"].set(11U - radiusFactor); + Device.transmit(doc.as(), name); +} + +/** + * @brief Applies speed and radius from a received payload. + * + * @param payload Received configuration fields. + * @param source Source identifier for the received payload. + */ +void MetaballsMode::onReceive(JsonObjectConst payload, std::string_view source) +{ + if (payload["speed"].is()) + { + setSpeed(payload["speed"].as()); + } + if (payload["radius"].is()) + { + setRadius(payload["radius"].as()); + } +} + #endif // MODE_METABALLS From c23f5464cb198056098cc93bf5c1923c4a163306 Mon Sep 17 00:00:00 2001 From: Solmath <33658856+Solmath@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:05:21 +0200 Subject: [PATCH 2/9] feat(webapp): Integrate metaballs sidebar and add configuration parameters --- webapp/src/modes/Metaballs.tsx | 72 ++++++++++++++++++++++++++++++++-- webapp/src/services/Modes.tsx | 11 +++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/webapp/src/modes/Metaballs.tsx b/webapp/src/modes/Metaballs.tsx index 62d22cad..9ef5080e 100644 --- a/webapp/src/modes/Metaballs.tsx +++ b/webapp/src/modes/Metaballs.tsx @@ -1,8 +1,74 @@ -import { mdiBasketball } from "@mdi/js"; -import type { Component } from "solid-js"; - +import { mdiBasketball, mdiCircleExpand, mdiSpeedometer } from "@mdi/js"; +import { type Component, createSignal } from "solid-js"; +import { Icon } from "../components/Icon"; +import { SidebarSection } from "../extensions/WebApp"; +import { WebSocketWS } from "../extensions/WebSocket"; import { MainComponent as ModesMainComponent } from "../services/Modes"; +import { Tooltip } from "../components/Tooltip"; export const name = "Metaballs"; +const [getSpeed, setSpeed] = createSignal(1); +const [getRadius, setRadius] = createSignal(5); + +export const receiver = (json: { speed?: number; radius?: number }) => { + json?.speed !== undefined && setSpeed(json.speed); + json?.radius !== undefined && setRadius(json.radius); +}; + export const Main: Component = () => ; + +export const Sidebar: Component = () => { + const handleSpeed = (speed: number) => { + setSpeed(speed); + WebSocketWS.send( + JSON.stringify({ + [name]: { + speed: getSpeed(), + }, + }), + ); + }; + + const handleRadius = (radius: number) => { + setRadius(radius); + WebSocketWS.send( + JSON.stringify({ + [name]: { + radius: getRadius(), + }, + }), + ); + }; + + return ( + +
+ + + handleSpeed(e.currentTarget.valueAsNumber)} + type="range" + value={getSpeed()} + /> + +
+
+ + + handleRadius(e.currentTarget.valueAsNumber)} + type="range" + value={getRadius()} + /> + +
+
+ ); +}; diff --git a/webapp/src/services/Modes.tsx b/webapp/src/services/Modes.tsx index 25ea8786..3bd54308 100644 --- a/webapp/src/services/Modes.tsx +++ b/webapp/src/services/Modes.tsx @@ -78,7 +78,11 @@ import { } from "../modes/HomeThermometer"; import { Main as ModeLeafFallMain, name as ModeLeafFallName } from "../modes/LeafFall"; import { Main as ModeLinesMain, name as ModeLinesName } from "../modes/Lines"; -import { Main as ModeMetaballsMain, name as ModeMetaballsName } from "../modes/Metaballs"; +import { + Main as ModeMetaballsMain, + name as ModeMetaballsName, + Sidebar as ModeMetaballsSidebar, +} from "../modes/Metaballs"; import { Main as ModeNoiseMain, name as ModeNoiseName } from "../modes/Noise"; import { Main as ModePingPongMain, name as ModePingPongName, Sidebar as ModePingPongSidebar } from "../modes/PingPong"; import { Main as ModePixelSequenceMain, name as ModePixelSequenceName } from "../modes/PixelSequence"; @@ -330,6 +334,11 @@ export const Sidebar: Component = () => { )} + {MODE_METABALLS && ( + + + + )} {MODE_PINGPONG && ( From c012793c7c3becba3f65dc74096ad5400e9d1437 Mon Sep 17 00:00:00 2001 From: Solmath <33658856+Solmath@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:04:56 +0200 Subject: [PATCH 3/9] feat(firmware): Refactor radius handling and update speed factor for metaballs --- firmware/include/modes/MetaballsMode.h | 3 ++- firmware/src/modes/MetaballsMode.cpp | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/firmware/include/modes/MetaballsMode.h b/firmware/include/modes/MetaballsMode.h index 73cb0ae9..78da5265 100644 --- a/firmware/include/modes/MetaballsMode.h +++ b/firmware/include/modes/MetaballsMode.h @@ -18,7 +18,7 @@ class MetaballsMode final : public ModeModule static inline float radiusSq{radius * radius}; static constexpr float baseSpeed{1e-6F * static_cast(GRID_COLUMNS * GRID_ROWS)}; - static inline uint8_t speedFactor{2U}; + static inline uint8_t speedFactor{4U}; static inline float speed{static_cast(speedFactor) * baseSpeed}; static constexpr uint8_t multiplier{1U << 3U}; @@ -39,6 +39,7 @@ class MetaballsMode final : public ModeModule void setSpeed(uint8_t _speed); void setRadius(uint8_t _radius); + void updateRadius(); void transmit(); public: diff --git a/firmware/src/modes/MetaballsMode.cpp b/firmware/src/modes/MetaballsMode.cpp index a3ec1d17..01075ae8 100644 --- a/firmware/src/modes/MetaballsMode.cpp +++ b/firmware/src/modes/MetaballsMode.cpp @@ -29,8 +29,7 @@ void MetaballsMode::configure() if (nvs_get_u8(handle, "radius", &_radius) == ESP_OK) { radiusFactor = _radius; - radius = maxRadius / static_cast(radiusFactor); - radiusSq = radius * radius; + updateRadius(); } nvs_close(handle); } @@ -53,6 +52,7 @@ void MetaballsMode::begin() ball.xVelocity = speed * static_cast(random(1, multiplier) * ((random(2) * 2) - 1)); ball.yVelocity = speed * static_cast(random(1, multiplier) * ((random(2) * 2) - 1)); } + Display.fillFrame(0U); } /** @@ -182,7 +182,7 @@ void MetaballsMode::setSpeed(uint8_t _speed) /** * @brief Sets the radius of the metaballs and stores it in non-volatile storage. * - * Uses arbitrary units for radius, where 1 is the smallest and 10 is the largest. + * Uses arbitrary units for _radius, where 1 is the smallest and 10 is the largest. * The actual radius is calculated based on the maximum radius and the radius factor. * * @param _radius New radius factor for the metaballs. @@ -202,8 +202,10 @@ void MetaballsMode::setRadius(uint8_t _radius) radiusFactor = 11U - _radius; } - radius = maxRadius / static_cast(radiusFactor); - radiusSq = radius * radius; + updateRadius(); + /* After changing the radius, we need to clear the display to avoid visual + artifacts from the previous radius. */ + Display.fillFrame(0U); nvs_handle_t handle{}; if (nvs_open(name.data(), nvs_open_mode_t::NVS_READWRITE, &handle) == ESP_OK) @@ -215,6 +217,12 @@ void MetaballsMode::setRadius(uint8_t _radius) transmit(); } +void MetaballsMode::updateRadius() +{ + radius = maxRadius / static_cast(radiusFactor); + radiusSq = radius * radius; +} + /** * @brief Publishes the current base speed and ball radius in arbitrary units. */ From 04ec884eb9814a0adbd6e895a80cd76fd60ed5f4 Mon Sep 17 00:00:00 2001 From: Solmath <33658856+Solmath@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:06:45 +0200 Subject: [PATCH 4/9] feat(webapp): Enhance metaballs sidebar with radius selection and speed tooltip --- webapp/src/modes/Metaballs.tsx | 49 +++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/webapp/src/modes/Metaballs.tsx b/webapp/src/modes/Metaballs.tsx index 9ef5080e..083f7e09 100644 --- a/webapp/src/modes/Metaballs.tsx +++ b/webapp/src/modes/Metaballs.tsx @@ -1,10 +1,10 @@ import { mdiBasketball, mdiCircleExpand, mdiSpeedometer } from "@mdi/js"; -import { type Component, createSignal } from "solid-js"; +import { type Component, createSignal, For } from "solid-js"; import { Icon } from "../components/Icon"; +import { Tooltip } from "../components/Tooltip"; import { SidebarSection } from "../extensions/WebApp"; import { WebSocketWS } from "../extensions/WebSocket"; import { MainComponent as ModesMainComponent } from "../services/Modes"; -import { Tooltip } from "../components/Tooltip"; export const name = "Metaballs"; @@ -18,6 +18,34 @@ export const receiver = (json: { speed?: number; radius?: number }) => { export const Main: Component = () => ; +// Radius values the visualization actually renders sensibly. +const radiusOptions = [ + { + value: 4, + label: "Tiny", + }, + { + value: 5, + label: "Small", + }, + { + value: 6, + label: "Medium", + }, + { + value: 7, + label: "Large", + }, + { + value: 8, + label: "Huge", + }, + { + value: 9, + label: "Massive", + }, +]; + export const Sidebar: Component = () => { const handleSpeed = (speed: number) => { setSpeed(speed); @@ -44,11 +72,13 @@ export const Sidebar: Component = () => { return (
- + + + handleSpeed(e.currentTarget.valueAsNumber)} type="range" @@ -59,14 +89,13 @@ export const Sidebar: Component = () => {
- handleRadius(e.currentTarget.valueAsNumber)} - type="range" + onchange={(e) => handleRadius(Number(e.currentTarget.value))} value={getRadius()} - /> + > + {({ value, label }) => } +
From 33bf29b9a9cf54cd1bf84c95f4ef3da043430ca2 Mon Sep 17 00:00:00 2001 From: Solmath <33658856+Solmath@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:21:11 +0200 Subject: [PATCH 5/9] fix(webapp): Correct tooltip text for speed and radius in metaballs sidebar --- webapp/src/modes/Metaballs.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/modes/Metaballs.tsx b/webapp/src/modes/Metaballs.tsx index 083f7e09..a1c27cef 100644 --- a/webapp/src/modes/Metaballs.tsx +++ b/webapp/src/modes/Metaballs.tsx @@ -72,7 +72,7 @@ export const Sidebar: Component = () => { return (
- + @@ -88,7 +88,7 @@ export const Sidebar: Component = () => {
- + handleSpeed(e.currentTarget.valueAsNumber)} - type="range" + onInput={(e) => handleSpeed(e.currentTarget.valueAsNumber, false)} + onKeyUp={() => handleSpeed(getSpeed(), true)} + onPointerUp={() => handleSpeed(getSpeed(), true)} value={getSpeed()} /> @@ -91,6 +96,7 @@ export const Sidebar: Component = () => {