diff --git a/README.md b/README.md
index 708a0a9..8c7c7e1 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,110 @@
+# GuppyScreen — ke-advanced-3d-bedmesh branch
+
+> **This is a custom branch** for the **Creality Ender-3 V3 KE**, adding an advanced interactive 3D bed mesh visualization.
+
+## Fork lineage
+
+```
+ballaswag/guppyscreen (original)
+ └── probielodan/guppyscreen (KE-focused fork, adds features below)
+ └── coreflake1/guppyscreen ← this repo
+ └── ke-advanced-3d-bedmesh ← this branch (adds 3D bed mesh)
+```
+
+### What probielodan added over ballaswag
+
+- **Major UI rework** — prompt panel, sensor/slider containers, file panel, homing panel, print panel, and more, all reworked for reliability and the KE's display
+- **Better print status** — improved information display during a print
+- **Auto-reload file list** — file list refreshes automatically
+- **Absolute positioning display** — shows absolute axis coordinates
+- **Invert Z direction** option
+- **WiFi disconnect and forget-network buttons**
+- **Chamber temperature** shown on the print screen
+- **Default extruder temperatures** configurable
+- **Android support dropped** (the KE runs on a dedicated MIPS/ARM SoC, not Android)
+
+### What this branch adds on top
+
+- 3D feature cherry-picked from [prestonbrown/guppyscreen](https://github.com/prestonbrown/guppyscreen) (commit `bced7f7`)
+
+## What's different on this branch
+
+- Real-time 3D rendered mesh surface with colour-gradient height map
+- Interactive rotation, zoom (Z-scale and FOV), and pan via touch or mouse
+- Toggle between 2D and 3D views with a single button
+- Multitouch gesture support
+
+**Changed files vs base:**
+- `src/bedmesh_panel.cpp` — full 3D rendering engine
+- `src/bedmesh_panel.h` — new structs (`Point3D`, `Vertex3D`, `Quad3D`) and method declarations
+- `src/button_container.cpp` — added `get_label()` and `set_text()` helpers
+- `src/button_container.h` — corresponding declarations
+
+## Building from source
+
+### 1. Clone with submodules
+
+```bash
+git clone --recurse-submodules https://github.com/coreflake1/guppyscreen.git
+cd guppyscreen
+git checkout ke-advanced-3d-bedmesh
+```
+
+### 2. Build bundled libraries
+
+```bash
+make libhv.a
+make libspdlog.a
+make wpaclient
+```
+
+### 3a. Simulator build (Linux desktop, for testing)
+
+Install SDL2 dev headers:
+```bash
+# Fedora / Nobara
+sudo dnf install sdl2-compat-devel
+
+# Debian / Ubuntu / Raspbian
+sudo apt install libsdl2-dev
+```
+
+Build:
+```bash
+make -j$(nproc) \
+ CFLAGS="-O3 -g0 -MD -MP -I./ -I./lvgl/ -I/usr/include/SDL2 -D_GNU_SOURCE=1 -Wno-incompatible-pointer-types" \
+ LDFLAGS="-lm -Llibhv/lib -Lspdlog/build -l:libhv.a -latomic -lpthread -Lwpa_supplicant/wpa_supplicant/ -l:libwpa_client.a -lstdc++fs -l:libspdlog.a"
+```
+
+> The `-Wno-incompatible-pointer-types` flag is required on GCC 14+ due to a pre-existing upstream
+> strictness issue in `lv_touch_calibration`. It is unrelated to this branch's changes.
+
+Binary: `build/bin/guppyscreen`
+
+### 3b. Cross-compile for Ender-3 V3 KE (MIPS target hardware)
+
+The Ender-3 V3 KE runs an **Ingenic XBurst2 X2000 SoC (MIPS, mipsel little-endian, kernel 4.4.94)**.
+The `ballaswag/guppydev` Docker image contains the required `mipsel-linux-` musl toolchain.
+
+Use the provided script (handles library rebuilds automatically):
+
+```bash
+bash scripts/build-mips.sh
+```
+
+Binary: `build/bin/guppyscreen` — verified as `ELF 32-bit LSB executable, MIPS, MIPS32 version 1 (SYSV), statically linked`.
+
+> **Note**: `installer-deb.sh` targets aarch64/systemd/Debian and will immediately exit with
+> `"Found arch mips / Terminating"` on this printer. The correct installer for the KE is
+> `installer.sh` (K1/MIPS/BusyBox init path).
+
+**Package a release tarball** (creates `guppyscreen-smallscreen.tar.gz`):
+```bash
+GUPPYSCREEN_VERSION=0.1.0-ke-bedmesh GUPPY_THEME=blue bash scripts/release.sh guppyscreen-smallscreen
+```
+
+---
+
# Guppy Screen for Klipper
Guppy Screen is a touch UI for Klipper using APIs exposed by Moonraker. It builds on LVGL as a standalone executable, has no dependency on any display servers such as X/Wayland.
@@ -20,45 +127,62 @@ Guppy Screen is a touch UI for Klipper using APIs exposed by Moonraker. It build
## Installation / Update
-Run the following interactive script via SSH on your K1/Max/CR-10 SE/Ender 3 V3 KE/Nebula Pad to install Guppy Screen.
-#### Material Design Theme
-```
-sh -c "$(wget --no-check-certificate -qO - https://raw.githubusercontent.com/probielodan/guppyscreen/main/scripts/installer.sh)"
-```
+> **Hardware**: Ender-3 V3 KE — Ingenic XBurst2 X2000, MIPS (mipsel), kernel 4.4.94.
+> This installer targets the K1/MIPS/BusyBox-init path. It is **untested on hardware** as
+> of this writing. **Back up your printer config before proceeding.**
-#### Z-Bolt Theme (Only for the K1/Max)
-```
-sh -c "$(wget --no-check-certificate -qO - https://raw.githubusercontent.com/probielodan/guppyscreen/main/scripts/installer.sh)" -s zbolt
-```
+SSH into your Ender-3 V3 KE and run:
-### Install on a PI (Debian/Raspbian)
-Tested on a BTT Pad 7. Please install with care and make sure you're okay with resetting your setup if things break.
-```
-wget -O - https://raw.githubusercontent.com/probielodan/guppyscreen/main/scripts/installer-deb.sh | bash
+```sh
+sh -c "$(wget --no-check-certificate -qO - https://raw.githubusercontent.com/coreflake1/guppyscreen/ke-advanced-3d-bedmesh/scripts/installer.sh)"
```
-### Nightly Builds
-#### Material (Nightly)
-```
-sh -c "$(wget --no-check-certificate -qO - https://raw.githubusercontent.com/probielodan/guppyscreen/main/scripts/installer.sh)" -s nightly
-```
+> **Do not use `installer-deb.sh`** — it targets aarch64/systemd/Debian and will immediately
+> exit with `"Found arch mips / Terminating"` on the Ender-3 V3 KE.
+>
+> **Do not use probielodan's installer** — it downloads from probielodan's
+> releases and does not include the 3D bed mesh feature.
+
+The installer downloads release `v0.1.0-ke-bedmesh` (pinned tag — not `latest`).
+
+**What the installer changes on your printer:**
+
+| Change | Rolled back by uninstall? |
+|---|---|
+| Extracts `/usr/data/guppyscreen/` | Optional (prompts) |
+| Installs `/etc/init.d/S99guppyscreen` | Yes — removed |
+| Replaces `S50dropbear` SSH init script¹ | **No** — original saved to `/usr/data/guppyify-backup/` |
+| Moves `S12boot_display` to backup (disables boot display) | Partially — restored from backup |
+| Backs up + removes `S99start_app` (optional, prompted) | Partially — restored from backup |
+| Renames `Monitor` + `display-server` to `.disable` (if Creality kept) | **No** — restore manually |
+| Adds `[include GuppyScreen/*.cfg]` to `printer.cfg` (after `gcode_macro` include) | Yes — line removed |
+| Creates `printer_data/config/GuppyScreen/` | Yes — directory removed |
+| Skips `gcode_shell_command.py` (already present on KE) | — |
+| Overwrites `calibrate_shaper_config.py` in Klipper extras | **No** — original saved to backup |
+| Symlinks `guppy_module_loader.py`, `guppy_config_helper.py`, `tmcstatus.py` | Yes — removed |
+| Replaces `ft2font.so` (matplotlib 2.2.3 confirmed installed on KE) | Partially — original moved to backup |
+
+> ¹ The KE's stock `S50dropbear` has the `start` call **commented out**, so SSH does not
+> auto-start on a clean reboot. The guppy version fixes this — SSH will auto-start after install.
+
+Backup files are saved to `/usr/data/guppyify-backup/` before any destructive change,
+including a copy of `printer.cfg` taken **before** the include line is added.
-#### Z-Bolt (Nightly)
-```
-sh -c "$(wget --no-check-certificate -qO - https://raw.githubusercontent.com/probielodan/guppyscreen/main/scripts/installer.sh)" -s zbolt nightly
-```
+## Uninstall
-#### Raspbian Variant (Nightly)
-```
-wget -O - https://raw.githubusercontent.com/probielodan/guppyscreen/main/scripts/installer-deb.sh | bash -s nightly
+```sh
+sh -c "$(wget --no-check-certificate -qO - https://raw.githubusercontent.com/coreflake1/guppyscreen/ke-advanced-3d-bedmesh/scripts/installer.sh)" uninstall
```
-## Uninstall
-ssh into your K1/Max and run the following command:
-```
-/usr/data/guppyscreen/reinstall-creality.sh
-```
+Uninstall stops GuppyScreen, removes `/etc/init.d/S99guppyscreen`, restores
+`S12boot_display` and `S99start_app` from backup, removes `[include GuppyScreen/*.cfg]`
+from `printer.cfg`, removes the GuppyScreen config directory, and removes Klipper symlinks.
+Things **not** automatically restored:
+- `gcode_shell_command.py` and `calibrate_shaper_config.py` in Klipper extras
+- `Monitor` / `display-server` if renamed to `.disable` (restore manually)
+- `S50dropbear` (guppy version stays; original is in `/usr/data/guppyify-backup/`)
+- Reboot required to restore display services
## Features
:white_check_mark: Console/Macro Shell\
diff --git a/debian/guppyconfig.json b/debian/guppyconfig.json
index 713337e..9a97567 100644
--- a/debian/guppyconfig.json
+++ b/debian/guppyconfig.json
@@ -1,6 +1,6 @@
{
"default_printer": "default_printer",
- "display_rotate": 0,
+ "display_rotate": 2,
"log_path": "/logs/guppyscreen.log",
"printers": {
"default_printer": {
diff --git a/docs/dev-notes.md b/docs/dev-notes.md
new file mode 100644
index 0000000..27198da
--- /dev/null
+++ b/docs/dev-notes.md
@@ -0,0 +1,366 @@
+# Developer Notes — ke-advanced-3d-bedmesh
+
+Accumulated discoveries from building and packaging this branch for the Ender-3 V3 KE.
+
+## Fork lineage
+
+```
+ballaswag/guppyscreen (original)
+ └── probielodan/guppyscreen (KE-focused UI rework)
+ └── coreflake1/guppyscreen
+ └── ke-advanced-3d-bedmesh ← this branch
+```
+
+3D bed mesh panel cherry-picked from prestonbrown/guppyscreen commit `bced7f7`.
+
+---
+
+## Target hardware — Ender-3 V3 KE
+
+| Property | Value |
+|---|---|
+| SoC | Ingenic XBurst2 X2000 |
+| Architecture | **MIPS (mipsel little-endian)** — NOT aarch64 |
+| Kernel | 4.4.94 |
+| Screen | 480×272 physical (framebuffer geometry 480×544 — double-buffer, two 272-line halves) |
+| libc | /lib/ld-2.29.so (musl 2.29) |
+| Init system | /etc/init.d/ BusyBox-style (S##name scripts) |
+| systemctl | Present at /usr/bin/systemctl but NOT the primary init |
+| Data root | /usr/data/ |
+| Printer config | /usr/data/printer_data/config/printer.cfg |
+
+`systemctl` is present but a shim — the real init is BusyBox `/etc/init.d/`.
+
+---
+
+## Binary
+
+- **Build script**: `scripts/build-mips.sh` (handles all library rebuilds automatically)
+- **Docker image**: `ballaswag/guppydev:latest`
+- **Toolchain**: `/toolchains/mips32el--musl--stable-2024.02-1/bin/mipsel-linux-`
+- **Flags**: `GUPPY_SMALL_SCREEN=1` required for 480×544
+- **Output**: `build/bin/guppyscreen`
+- **Verified**: `ELF 32-bit LSB executable, MIPS, MIPS32 version 1 (SYSV), statically linked` (~6.5 MB)
+- **No dynamic deps** — `readelf -d` shows no NEEDED entries (musl static)
+
+---
+
+## Release
+
+- **Tag**: `v0.1.0-ke-bedmesh`
+- **Correct asset**: `guppyscreen-smallscreen.tar.gz` (MIPS)
+- **Wrong asset**: `guppyscreen-arm.tar.gz` (aarch64, will not run on KE)
+- **Package command**:
+ ```sh
+ GUPPYSCREEN_VERSION=0.1.0-ke-bedmesh GUPPY_THEME=blue bash scripts/release.sh guppyscreen-smallscreen
+ ```
+
+---
+
+## Installer
+
+- **Correct installer**: `scripts/installer.sh` (K1/MIPS/BusyBox path)
+- **Wrong installer**: `scripts/installer-deb.sh` — immediately exits with
+ `"Found arch mips / Terminating"` on the KE. Do not use on this printer.
+
+**Install**:
+```sh
+sh -c "$(wget --no-check-certificate -qO - https://raw.githubusercontent.com/coreflake1/guppyscreen/ke-advanced-3d-bedmesh/scripts/installer.sh)"
+```
+
+**Uninstall**:
+```sh
+sh -c "$(wget --no-check-certificate -qO - https://raw.githubusercontent.com/coreflake1/guppyscreen/ke-advanced-3d-bedmesh/scripts/installer.sh)" uninstall
+```
+
+The installer always fetches from the branch URL (not from the tarball), so it is
+always the latest version of the script. `PINNED_RELEASE="v0.1.0-ke-bedmesh"` in the
+script pins which tarball it downloads.
+
+---
+
+## What installer.sh does (complete)
+
+1. Checks arch == mips, screen < 800px → selects smallscreen asset
+2. Checks `/lib/ld-2.29.so` present
+3. Checks Moonraker reachable; queries klipper_path and config_file
+4. Downloads tarball → extracts to `/usr/data/`
+5. Substitutes ``/`` in `debian/guppyconfig.json` → `/usr/data/guppyscreen/guppyconfig.json`
+6. Creates `/usr/data/guppyscreen/thumbnails/`
+7. Smoke-tests the binary (runs 1s, checks `ps`)
+8. Copies `gcode_shell_command.py` to Klipper extras (if not already present)
+9. Creates `printer_data/config/GuppyScreen/scripts/`
+10. Copies `scripts/*.cfg` and `*.py` to GuppyScreen config dir
+11. **Backs up `printer.cfg`** → `/usr/data/guppyify-backup/printer.cfg.bak` (before modifying)
+12. Adds `[include GuppyScreen/*.cfg]` to `printer.cfg`
+ - If `[include gcode_macro.cfg]` exists: inserts after that line
+ - Otherwise: **prepends** to the file (KE may not have that line)
+13. Symlinks `/tmp/udisk` → gcodes dir `/usb`
+14. Backs up (first run only, guarded by absence of `S50dropbear` in backup):
+ - **Moves** `S12boot_display` → backup (disables boot display init)
+ - Copies `S50dropbear` → backup
+ - Copies `S99start_app` → backup (if file exists; KE may not have it)
+15. Backs up `ft2font.so` (moves to backup)
+16. **Overwrites** `/etc/init.d/S50dropbear` with guppy version
+ - **Why**: The KE's stock `S50dropbear` has the `start` call inside `case start)` **commented
+ out** (`# start`). This means `S50dropbear start` is a no-op — SSH does **not**
+ auto-start on a clean reboot. Guppy's version has the `start` uncommented, so SSH reliably
+ starts on every boot after install. Currently SSH works on this printer because the
+ helper-script started dropbear directly (not via the init script).
+17. Interactive: disable all Creality services?
+ - Y → removes `S99start_app` (guarded: only if exists)
+ - N → renames `Monitor` and `display-server` to `.disable` (guarded: only if exist)
+18. Installs `/etc/init.d/S99guppyscreen`
+19. Backs up existing `calibrate_shaper_config.py` → backup dir; then overwrites it
+20. Symlinks `guppy_module_loader.py`, `guppy_config_helper.py`, `tmcstatus.py` → Klipper extras
+21. Replaces `ft2font.cpython-38-mipsel-linux-gnu.so` if matplotlib is installed
+22. Symlinks `libeinfo.so.1`, `librc.so.1` → `/lib/` (needed by supervise-daemon)
+23. Optionally restarts Klipper
+24. Kills Creality processes if chosen
+25. Starts `/etc/init.d/S99guppyscreen`; partial rollback if final ps check fails
+
+---
+
+## Backup directory: `/usr/data/guppyify-backup/`
+
+| File | How saved | Notes |
+|---|---|---|
+| `printer.cfg.bak` | copied | Taken before include line is added |
+| `S50dropbear` | copied | Original Creality SSH init |
+| `S12boot_display` | **moved** | Only copy is in backup |
+| `S99start_app` | copied | May be absent on KE — guarded |
+| `ft2font.cpython-38-mipsel-linux-gnu.so` | **moved** | Only copy is in backup |
+| `calibrate_shaper_config.py.bak` | copied | Taken before unconditional overwrite |
+
+---
+
+## What uninstall does NOT restore
+
+- `gcode_shell_command.py` in Klipper extras (left in place)
+- `/etc/init.d/S50dropbear` (guppy version stays; restore manually: `cp /usr/data/guppyify-backup/S50dropbear /etc/init.d/S50dropbear`)
+- `Monitor` / `display-server` if renamed to `.disable` (restore manually: `mv /usr/bin/Monitor.disable /usr/bin/Monitor`)
+- `libeinfo.so.1`, `librc.so.1` symlinks in `/lib/`
+- Reboot required after uninstall to restore display services
+
+---
+
+## guppyconfig.json
+
+- In tarball at `./guppyscreen/debian/guppyconfig.json`
+- Contains placeholders `` and ``
+- installer.sh substitutes both before copying to `/usr/data/guppyscreen/guppyconfig.json`
+- `guppy_init_script`: `"service guppyscreen"` (invokes the init script via service command)
+- `log_path`: resolves to `/usr/data/printer_data/logs/guppyscreen.log`
+
+---
+
+## S99guppyscreen
+
+- Hardcoded paths: `GUPPY_DIR=/usr/data/guppyscreen`, log at `/usr/data/printer_data/logs/guppyscreen.log`
+- Uses `supervise-daemon` from `k1_mods/respawn/` for auto-restart on crash
+- Requires `libeinfo.so.1` and `librc.so.1` symlinked in `/lib/`
+- `RC_DIR=/run/openrc` created on start (needed by OpenRC supervise-daemon)
+
+---
+
+## Simulator build (Linux desktop)
+
+```sh
+sudo dnf install sdl2-compat-devel # Fedora/Nobara
+make libhv.a && make libspdlog.a && make wpaclient
+make -j$(nproc) \
+ CFLAGS="-O3 -g0 -MD -MP -I./ -I./lvgl/ -I/usr/include/SDL2 -D_GNU_SOURCE=1 -Wno-incompatible-pointer-types" \
+ LDFLAGS="-lm -Llibhv/lib -Lspdlog/build -l:libhv.a -latomic -lpthread -Lwpa_supplicant/wpa_supplicant/ -l:libwpa_client.a -lstdc++fs -l:libspdlog.a"
+```
+
+- Binary needs `/usr/data/printer_data/logs/` to exist or it crashes on startup
+ (`spdlog_ex: Failed opening file ... for writing`)
+- Without a printer connected, websocket reconnects every 2s — this is normal
+- Simulator shows 480×800 (not 480×544); smallscreen layout only applies at runtime
+ when `GUPPY_SMALL_SCREEN=1` is compiled in
+- Mouse wheel zoom works in simulator (SIMULATOR flag enables it with 50ms timer)
+
+---
+
+## Confirmed on-device state (verified via SSH 2026-05-25)
+
+Hostname: `Ender3V3KE-4C14`
+
+### printer.cfg
+- `[include gcode_macro.cfg]` **IS present** (line 8) — installer sed path will work; prepend fallback is unused but kept as insurance
+- Config dir confirmed: `/usr/data/printer_data/config/`
+- Klipper path confirmed: `/usr/share/klipper`
+- Has `[bed_mesh]`, `[input_shaper]`, `[bltouch]`, `[prtouch_v2]`, `[adxl345]`
+- Saved 5×5 bed mesh already present in `SAVE_CONFIG` block
+- Saved input shaper: X=ei/55.4Hz, Y=ei/37.6Hz
+
+### Init scripts — all confirmed present
+- `S12boot_display` — minimal: just runs `/usr/bin/boot_display display &`
+- `S50dropbear` — **start case is COMMENTED OUT** (see S50dropbear section above)
+- `S99start_app` — substantial script starting 8 Creality services (master-server, audio-server, wifi-server, app-server, display-server, upgrade-server, web-server, Monitor)
+
+### Binaries — all confirmed present
+- `/usr/bin/Monitor` — 45 KB
+- `/usr/bin/display-server` — 6.5 MB
+- `/usr/sbin/dropbear` — running as PID 2124 (started by helper-script, not by init.d)
+
+### Klipper extras — already present
+- `gcode_shell_command.py` — already installed (installer will skip, if-not-exists guard)
+- `calibrate_shaper_config.py` — already installed (will be backed up then overwritten)
+- No guppy modules yet (`guppy_module_loader`, `guppy_config_helper`, `tmcstatus`)
+
+### Matplotlib
+- **Version 2.2.3 installed** → `ft2font.so` replacement will fire
+- `ft2font.cpython-38-mipsel-linux-gnu.so` dated **Apr 15 2025** — post-firmware date,
+ meaning it was already replaced once (likely by helper-script). Our install will back
+ it up and replace it again with the guppy MIPS version.
+
+### Helper script
+- `/usr/data/helper-script/` is present (KIAUH-equivalent for K1/KE)
+- This is what enabled SSH (started dropbear directly; init.d start case is commented out)
+- `/usr/data/helper-script-backup/` also present
+
+---
+
+## Display orientation — confirmed fix (2026-05-25)
+
+The KE screen is physically mounted **upside-down (180° rotated)** inside the chassis.
+The kernel display driver compensates by rotating the framebuffer 180°.
+GuppyScreen/LVGL must therefore **pre-rotate 180°** so the kernel's rotation cancels out.
+
+**Fix**: `display_rotate: 2` in `debian/guppyconfig.json` (committed `8bed38d`).
+
+How it propagates:
+- `display_rotate: 2` → `sw_rotate=1`, `rotated=LV_DISP_ROT_180`
+- LVGL renders content upside-down into the framebuffer
+- Kernel rotates 180° → net 0° on the physical screen
+
+Debugging history:
+- `display_rotate:0` → screen upside-down (LVGL and kernel both unrotated, but hardware is inverted)
+- `display_rotate:1` → 90° rotated (wrong axis)
+- `display_rotate:2` → correct orientation ✓
+- White screen on restart (without full reboot) was a framebuffer state artefact — full reboot clears it
+
+`lv_disp_get_physical_hor_res()` returns **480** (not 272) for ROT_180 because a
+180° rotation does not swap width/height, unlike 90°/270°.
+
+---
+
+## Font and layout tuning (2026-05-25)
+
+### LVGL font architecture — critical lesson
+
+GuppyScreen is compiled with `GUPPY_SMALL_SCREEN=1`. Before the fix, all widget
+text rendered at **16 px** (`lv_font_montserrat_16`), which is too large for a
+272 px tall screen — labels wrapped, buttons truncated text, table cells were
+unreadable.
+
+**Root cause**: `LV_FONT_DEFAULT` in `lv_conf.h` was unconditionally
+`&lv_font_montserrat_16` — no `GUPPY_SMALL_SCREEN` guard existed.
+
+The `font` parameter passed to `lv_theme_default_init()` in `src/main.cpp` is
+almost irrelevant: the LVGL v8 default theme only applies that font to the
+checked-state marker of checkbox widgets (`cb_marker_checked`). It does **not**
+set a default `text_font` on labels, buttons, tables, or any other widget. Those
+all fall back to `LV_FONT_DEFAULT`.
+
+**Fix** (`lv_conf.h`, line ~379):
+```c
+// Before:
+#define LV_FONT_DEFAULT &lv_font_montserrat_16
+
+// After:
+#ifdef GUPPY_SMALL_SCREEN
+ #define LV_FONT_DEFAULT &lv_font_montserrat_12
+#else
+ #define LV_FONT_DEFAULT &lv_font_montserrat_16
+#endif
+```
+
+This is a 25% reduction (16 → 12 px) that propagates to every widget which does
+not have an explicit `lv_obj_set_style_text_font()` call.
+
+### Scale factors (reference)
+
+```
+width_scale = hor_res / 800.0 = 480/800 = 0.600
+height_scale = ver_res / 480.0 = 272/480 = 0.567
+```
+
+Widgets with explicit hardcoded fonts (NOT affected by `LV_FONT_DEFAULT`):
+| File | Widget | Font |
+|---|---|---|
+| `console_panel.cpp` | keyboard, send button, log label | `montserrat_16` |
+| `macro_item.cpp` | run (play) button | `montserrat_16` |
+| `macros_panel.cpp` | keyboard | `montserrat_16` |
+| `bedmesh_panel.cpp` | 3D mesh labels | `montserrat_12` / `montserrat_16` |
+
+### Remaining layout issues (lower priority)
+
+| Screen | Issue | File | Notes |
+|---|---|---|---|
+| Temperature chart | Y-axis tick labels overlap at 6 ticks on 272px height | `src/main_panel.cpp` L227 | Reduce `major_cnt` 6→3 in `lv_chart_set_axis_tick`; set `LV_PART_TICKS` font to `montserrat_8` |
+| Limits sliders | 4 sliders in COLUMN layout overflow 272px; bottom slider(s) clipped | `src/limits_panel.cpp` | Switch `limit_cont` to 2×2 grid (2 FR cols × 2 FR rows) |
+| ~~Bed mesh table~~ | ~~Cells unreadable~~ | `src/bedmesh_panel.cpp` | **Resolved** — table view is now the default; see GUI pass below |
+
+---
+
+## GUI & functional pass (2026-05-26)
+
+A round of usability fixes driven by on-device review. Verified in the SDL simulator
+(`build-sim/`, `GUPPY_SMALL_SCREEN=1`).
+
+### Print-state safety locks
+Panels are now gated by `KUtils::is_printing()` (true when `print_stats/state` is
+`printing` or `paused`) so a touch mid-print can't ruin the job. Helpers live in
+`src/utils.cpp`: `is_printing()`, `notify_locked()` (modal toast), and
+`confirm_if_printing(msg, cb)` (Confirm/Cancel override; runs `cb` immediately when idle).
+
+- **Block entry + toast** (nothing useful to see mid-print): Homing, Extrude
+ (`main_panel.cpp`); Bed Mesh, Input Shaper, Belts/Shake, TMC Autotune
+ (`printertune_panel.cpp`).
+- **Confirm-with-override**: Console (per command, `console_panel.cpp`), Macros
+ (per run, `macro_item.cpp`), Limits & Power (on entry, `printertune_panel.cpp`).
+- **Left open** (designed for mid-print use): Fine Tune, Fan, LED, Print Status, file browsing.
+- Note: a new-print start was *already* guarded in `print_panel.cpp` — it shows a
+ "Printing in progress…" dialog instead of starting a second job. (`View Job` / `Queue Job`
+ in that dialog are unimplemented stubs.)
+
+### Layout / UX
+- **LED ↔ Fine Tune swap**: Fine Tune moved to the home screen (used during prints);
+ LED moved to the Tune tab. `LedPanel` is owned by `MainPanel` and now passed by
+ reference into `PrinterTunePanel` (where `FineTunePanel&` used to go).
+- **Bed Mesh redesign** (`bedmesh_panel.cpp`): **table view is now the default**. The
+ 3D view is an opt-in fullscreen mode — pressing "3D View" hides the profile list and
+ the other controls; the canvas fills the left and a narrow right-side strip holds the
+ zoom +/- and Back buttons. Back from 3D returns to table view; Back from table exits.
+ `resize_canvas()` floor lowered for <800px screens (the old 300px min overflowed 272px).
+- **Fine Tune readouts** (`finetune_panel.cpp`): Z/PA precision trimmed `{:.5}`→`{:.3f}`
+ and readout icon zoom 150→100 so long values (e.g. `0.123 mm/s`) no longer collide
+ with the icon.
+- **Belt shake** (`belts_calibration_panel.cpp`): excite-frequency readout moved from
+ 35px below the slider (clipped by the cramped grid cell) to the top-right of the control.
+- **System panel** (`sysinfo_panel.cpp`): Factory Reset moved to the top-right corner
+ (away from Back / settings rows to avoid accidental presses); network/version text
+ width-constrained so it can't slide under the button. Factory Reset already had a
+ "Reset GuppyScreen to default settings?" confirm prompt.
+- **Mini print-status popup** (`mini_print_status.cpp`): aligned `TOP_RIGHT` (over the
+ now-locked Home/Fine-Tune buttons) instead of `TOP_LEFT` where it overlapped the temps.
+- **MCU temp default** (`src/config.cpp`): shipped default sensor changed from
+ `temperature_sensor chamber_temp`/"Chamber" to `temperature_sensor mcu_temp`/"MCU Temp".
+ Affects fresh installs only — existing on-device `guppyconfig.json` must be edited to
+ update a printer that already has a config.
+
+---
+
+## KE-specific gotchas (resolved vs remaining)
+
+- ~~`[include gcode_macro.cfg]` may not be in the KE's printer.cfg~~ **CONFIRMED PRESENT**
+- ~~`S99start_app` may not exist on KE firmware~~ **CONFIRMED PRESENT**
+- ~~`/usr/bin/Monitor` and `/usr/bin/display-server` may not exist~~ **BOTH CONFIRMED PRESENT**
+- ~~Hardware install is untested~~ **CONFIRMED WORKING** — GuppyScreen installed and running
+- `installer-deb.sh` is also present in the release tarball (stale, old version
+ without safety fixes) — it won't be run by the standard install flow but could
+ confuse someone who tries to run it from the extracted `/usr/data/guppyscreen/` dir
+- ft2font.so was already replaced once by helper-script (Apr 2025); our install will
+ replace it again — the backup step ensures the current version is preserved
diff --git a/lv_conf.h b/lv_conf.h
index 3b2793d..891bb36 100644
--- a/lv_conf.h
+++ b/lv_conf.h
@@ -376,7 +376,13 @@ extern uint32_t custom_tick_get(void);
#define LV_FONT_CUSTOM_DECLARE
/*Always set a default font*/
-#define LV_FONT_DEFAULT &lv_font_montserrat_16
+#ifdef GUPPY_SMALL_SCREEN
+ /* m10 keeps file lists, btnmatrix labels (e.g. "Reload"/"Modified"/"A-Z"),
+ * and other un-styled labels readable without truncating at 480x272. */
+ #define LV_FONT_DEFAULT &lv_font_montserrat_10
+#else
+ #define LV_FONT_DEFAULT &lv_font_montserrat_16
+#endif
/*Enable handling large font and/or fonts with a lot of characters.
*The limit depends on the font size, font face and bpp.
diff --git a/lv_touch_calibration/lv_tc_screen.c b/lv_touch_calibration/lv_tc_screen.c
index 2192db8..216b4ad 100644
--- a/lv_touch_calibration/lv_tc_screen.c
+++ b/lv_touch_calibration/lv_tc_screen.c
@@ -297,7 +297,7 @@ static void lv_tc_screen_recalibrate_timer(lv_timer_t *timer) {
if(timer->repeat_count == 0) {
//Restart when timed out
- lv_tc_screen_start(tCScreenObj);
+ lv_tc_screen_start((lv_obj_t *)tCScreenObj);
return;
}
lv_label_set_text_fmt(lv_obj_get_child(tCScreenObj->recalibrateBtnObj, 0), LV_TC_RECALIBRATE_TXT LV_TC_RECALIBRATE_TIMEOUT_FORMAT, (int)timer->repeat_count);
@@ -307,5 +307,5 @@ static void lv_tc_screen_start_delay_timer(lv_timer_t *timer) {
lv_tc_screen_t *tCScreenObj = (lv_tc_screen_t*)timer->user_data;
lv_point_t point = {0, 0};
- lv_tc_screen_step(tCScreenObj, STEP_FIRST, point);
+ lv_tc_screen_step((lv_obj_t *)tCScreenObj, STEP_FIRST, point);
}
diff --git a/scripts/build-mips.sh b/scripts/build-mips.sh
new file mode 100644
index 0000000..09847ba
--- /dev/null
+++ b/scripts/build-mips.sh
@@ -0,0 +1,85 @@
+#!/bin/bash
+# Cross-compile guppyscreen for MIPS (Ingenic X2000, K1/KE)
+# Run inside ballaswag/guppydev:latest Docker container
+set -e
+
+CROSS_COMPILE="${CROSS_COMPILE:-mipsel-linux-}"
+GUPPYSCREEN_VERSION="${GUPPYSCREEN_VERSION:-0.1.0-ke-bedmesh}"
+GUPPY_THEME="${GUPPY_THEME:-blue}"
+JOBS="$(nproc)"
+WORKDIR="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$WORKDIR"
+
+echo "=== MIPS build: CROSS_COMPILE=$CROSS_COMPILE ==="
+
+# Backup aarch64 libraries
+cp libhv/lib/libhv.a /tmp/libhv.a.bak
+cp spdlog/build/libspdlog.a /tmp/libspdlog.a.bak
+cp wpa_supplicant/wpa_supplicant/libwpa_client.a /tmp/libwpa_client.a.bak
+
+restore_libs() {
+ echo "=== Restoring original libraries ==="
+ cp /tmp/libhv.a.bak libhv/lib/libhv.a
+ cp /tmp/libspdlog.a.bak spdlog/build/libspdlog.a
+ cp /tmp/libwpa_client.a.bak wpa_supplicant/wpa_supplicant/libwpa_client.a
+}
+trap restore_libs EXIT
+
+# --- libhv ---
+echo "=== Building libhv for MIPS ==="
+mkdir -p libhv/build-mips
+cd libhv/build-mips
+cmake .. \
+ -DCMAKE_C_COMPILER="${CROSS_COMPILE}gcc" \
+ -DCMAKE_CXX_COMPILER="${CROSS_COMPILE}g++" \
+ -DCMAKE_SYSTEM_NAME=Linux \
+ -DCMAKE_SYSTEM_PROCESSOR=mipsel \
+ -DBUILD_SHARED_LIBS=OFF \
+ -DBUILD_STATIC_LIBS=ON \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DWITH_SSL=OFF \
+ 2>&1 | tail -5
+make -j"$JOBS" hv_static 2>&1 | tail -5
+mkdir -p "$WORKDIR/libhv/lib"
+cp lib/libhv_static.a "$WORKDIR/libhv/lib/libhv.a"
+cd "$WORKDIR"
+
+# --- spdlog ---
+echo "=== Building spdlog for MIPS ==="
+mkdir -p spdlog/build-mips
+cd spdlog/build-mips
+cmake .. \
+ -DCMAKE_C_COMPILER="${CROSS_COMPILE}gcc" \
+ -DCMAKE_CXX_COMPILER="${CROSS_COMPILE}g++" \
+ -DCMAKE_SYSTEM_NAME=Linux \
+ -DCMAKE_SYSTEM_PROCESSOR=mipsel \
+ -DSPDLOG_BUILD_SHARED=OFF \
+ -DCMAKE_BUILD_TYPE=Release \
+ 2>&1 | tail -5
+make -j"$JOBS" 2>&1 | tail -5
+cp libspdlog.a "$WORKDIR/spdlog/build/libspdlog.a"
+cd "$WORKDIR"
+
+# --- libwpa_client ---
+echo "=== Building libwpa_client for MIPS ==="
+cd wpa_supplicant/wpa_supplicant
+WPA_CC="${CROSS_COMPILE}gcc"
+WPA_CFLAGS="-O2 -D_GNU_SOURCE -DCONFIG_CTRL_IFACE -DCONFIG_CTRL_IFACE_UNIX -I../src -I../src/utils -I../src/common"
+$WPA_CC $WPA_CFLAGS -c ../src/common/wpa_ctrl.c -o wpa_ctrl.o
+$WPA_CC $WPA_CFLAGS -c ../src/utils/os_unix.c -o os_unix.o
+$WPA_CC $WPA_CFLAGS -c ../src/utils/common.c -o common.o
+$WPA_CC $WPA_CFLAGS -c ../src/utils/wpa_debug.c -o wpa_debug.o
+${CROSS_COMPILE}ar crs libwpa_client.a wpa_ctrl.o os_unix.o common.o wpa_debug.o
+cd "$WORKDIR"
+
+# --- guppyscreen ---
+echo "=== Building guppyscreen for MIPS (GUPPY_SMALL_SCREEN=1) ==="
+make clean
+make -j"$JOBS" \
+ CROSS_COMPILE="$CROSS_COMPILE" \
+ GUPPY_SMALL_SCREEN=1 \
+ GUPPYSCREEN_VERSION="$GUPPYSCREEN_VERSION" \
+ GUPPY_THEME="$GUPPY_THEME"
+
+echo "=== Build complete ==="
+file build/bin/guppyscreen
diff --git a/scripts/installer-deb.sh b/scripts/installer-deb.sh
index c18bf7c..e46e649 100755
--- a/scripts/installer-deb.sh
+++ b/scripts/installer-deb.sh
@@ -12,6 +12,9 @@ SHAPER_CONFIG=$KLIPPY_EXTRA_DIR/calibrate_shaper_config.py
CONFIG_FILE="${HOME}/printer_data/config"
PRINTER_DATA_DIR="${HOME}/printer_data"
GUPPY_DIR="${HOME}/guppyscreen"
+BACKUP_DIR="${HOME}/guppyscreen-backup-$(date +%Y%m%d-%H%M%S)"
+PINNED_RELEASE="v0.1.0-ke-bedmesh"
+WPA_SVC=/lib/systemd/system/wpa_supplicant.service
has_moonraker() {
echo "Checking for a working Moonraker"
@@ -46,6 +49,18 @@ get_klipper_paths() {
}
+backup_existing() {
+ printf "${green}Creating pre-install backup at $BACKUP_DIR ${white}\n"
+ mkdir -p "$BACKUP_DIR"
+ [ -f "$CONFIG_DIR/printer.cfg" ] && cp "$CONFIG_DIR/printer.cfg" "$BACKUP_DIR/printer.cfg"
+ [ -f "$WPA_SVC" ] && cp "$WPA_SVC" "$BACKUP_DIR/wpa_supplicant.service"
+ [ -f /etc/systemd/system/guppyscreen.service ] && cp /etc/systemd/system/guppyscreen.service "$BACKUP_DIR/guppyscreen.service.bak"
+ [ -f /etc/systemd/system/disable_blinking_cursor.service ] && cp /etc/systemd/system/disable_blinking_cursor.service "$BACKUP_DIR/disable_blinking_cursor.service.bak"
+ [ -d "$CONFIG_DIR/GuppyScreen" ] && cp -r "$CONFIG_DIR/GuppyScreen" "$BACKUP_DIR/GuppyScreen"
+ printf "${green}Backup saved to $BACKUP_DIR ${white}\n"
+ printf "${yellow}NOTE: To manually restore printer.cfg: cp $BACKUP_DIR/printer.cfg $CONFIG_DIR/printer.cfg ${white}\n"
+}
+
install_services() {
sed -i "s||$USER|g" ${HOME}/guppyscreen/debian/guppyscreen.service
@@ -98,17 +113,72 @@ restart_services() {
service guppyscreen restart
}
+uninstall_guppy() {
+ printf "${green}Uninstalling Guppy Screen ${white}\n"
+
+ # Stop and remove services
+ sudo systemctl stop guppyscreen.service 2>/dev/null
+ sudo systemctl disable guppyscreen.service 2>/dev/null
+ sudo systemctl disable disable_blinking_cursor.service 2>/dev/null
+ sudo rm -f /etc/systemd/system/guppyscreen.service
+ sudo rm -f /etc/systemd/system/disable_blinking_cursor.service
+ sudo systemctl daemon-reload
+
+ # Re-enable KlipperScreen if it exists
+ sudo systemctl enable KlipperScreen.service 2>/dev/null && sudo systemctl start KlipperScreen.service 2>/dev/null
+
+ # Restore wpa_supplicant.service from most recent backup if available
+ LATEST_BACKUP=$(ls -dt "${HOME}"/guppyscreen-backup-* 2>/dev/null | head -1)
+ if [ -n "$LATEST_BACKUP" ] && [ -f "$LATEST_BACKUP/wpa_supplicant.service" ]; then
+ sudo cp "$LATEST_BACKUP/wpa_supplicant.service" "$WPA_SVC"
+ printf "${green}Restored wpa_supplicant.service from backup ${white}\n"
+ fi
+
+ # Remove GuppyScreen include from printer.cfg
+ K1_CONFIG_FILE=$(curl -s localhost:7125/printer/info 2>/dev/null | jq -r .result.config_file 2>/dev/null)
+ if [ -n "$K1_CONFIG_FILE" ] && [ -f "$K1_CONFIG_FILE" ]; then
+ sed -i '/\[include GuppyScreen/d' "$K1_CONFIG_FILE"
+ printf "${green}Removed GuppyScreen include from printer.cfg ${white}\n"
+ else
+ printf "${yellow}Could not auto-remove GuppyScreen from printer.cfg. Remove manually: [include GuppyScreen/*.cfg] ${white}\n"
+ fi
+
+ # Remove GuppyScreen config directory
+ if [ -n "$K1_CONFIG_FILE" ]; then
+ CONFIG_DIR=$(dirname "$K1_CONFIG_FILE")
+ if [ -d "$CONFIG_DIR/GuppyScreen" ]; then
+ rm -rf "$CONFIG_DIR/GuppyScreen"
+ printf "${green}Removed $CONFIG_DIR/GuppyScreen ${white}\n"
+ fi
+ fi
+
+ printf "${yellow}NOTE: Klipper extras (gcode_shell_command.py, calibrate_shaper_config.py) were NOT removed\n(they may be used by other tools). Remove manually if needed. ${white}\n"
+
+ printf "${yellow}Do you want to remove ~/guppyscreen? (y/n): ${white}"
+ read confirm
+ if [ "$confirm" = "y" -o "$confirm" = "Y" ]; then
+ rm -rf "${HOME}/guppyscreen"
+ printf "${green}Removed ~/guppyscreen ${white}\n"
+ fi
+ printf "${green}GuppyScreen uninstalled. Restart Klipper to apply printer.cfg changes. ${white}\n"
+}
+
ARCH=`uname -m`
echo "Found arch $ARCH"
if [ "$ARCH" = "aarch64" ]; then
- printf "${green}Installing Guppy Screen ${white}\n"
+ if [ "$1" = "uninstall" ]; then
+ uninstall_guppy
+ exit 0
+ fi
+
+ printf "${green}Installing Guppy Screen (coreflake1 fork - ke-advanced-3d-bedmesh) ${white}\n"
- ASSET_URL="https://github.com/probielodan/guppyscreen/releases/latest/download/guppyscreen-arm.tar.gz"
+ ASSET_URL="https://github.com/coreflake1/guppyscreen/releases/download/${PINNED_RELEASE}/guppyscreen-arm.tar.gz"
if [ "$1" = "nightly" ]; then
printf "${yellow}Installing nightly build ${white}\n"
- ASSET_URL="https://github.com/probielodan/guppyscreen/releases/download/nightly/guppyscreen-arm.tar.gz"
+ ASSET_URL="https://github.com/coreflake1/guppyscreen/releases/download/nightly/guppyscreen-arm.tar.gz"
fi
curl -s -L $ASSET_URL -o /tmp/guppyscreen.tar.gz
@@ -116,6 +186,7 @@ if [ "$ARCH" = "aarch64" ]; then
has_moonraker
get_klipper_paths
+ backup_existing
install_services
install_guppy_goodies
restart_services
diff --git a/scripts/installer.sh b/scripts/installer.sh
index b043b71..0417b8b 100755
--- a/scripts/installer.sh
+++ b/scripts/installer.sh
@@ -10,6 +10,85 @@ K1_GUPPY_DIR=/usr/data/guppyscreen
FT2FONT_PATH=/usr/lib/python3.8/site-packages/matplotlib/ft2font.cpython-38-mipsel-linux-gnu.so
ASSET_NAME="guppyscreen"
+uninstall_guppy() {
+ printf "${green}=== Uninstalling GuppyScreen ===${white}\n"
+
+ # Get klipper/config paths (same fallback logic as installer)
+ KLIPPER_PATH_U=$(curl localhost:7125/printer/info 2>/dev/null | jq -r .result.klipper_path 2>/dev/null)
+ if [ -z "$KLIPPER_PATH_U" ] || [ "$KLIPPER_PATH_U" = "null" ]; then
+ KLIPPER_PATH_U=/usr/share/klipper
+ fi
+ KLIPPY_EXTRA_DIR_U=$KLIPPER_PATH_U/klippy/extras
+
+ K1_CONFIG_FILE_U=$(curl localhost:7125/printer/info 2>/dev/null | jq -r .result.config_file 2>/dev/null)
+ if [ -z "$K1_CONFIG_FILE_U" ] || [ "$K1_CONFIG_FILE_U" = "null" ]; then
+ K1_CONFIG_DIR_U=/usr/data/printer_data/config
+ else
+ K1_CONFIG_DIR_U=$(dirname "$K1_CONFIG_FILE_U")
+ fi
+
+ # Stop and remove GuppyScreen
+ printf "${green}Stopping GuppyScreen...${white}\n"
+ [ -f /etc/init.d/S99guppyscreen ] && /etc/init.d/S99guppyscreen stop 2>/dev/null
+ killall -q guppyscreen 2>/dev/null
+ rm -f /etc/init.d/S99guppyscreen
+
+ # Restore S12boot_display from backup
+ if [ -f "$BACKUP_DIR/S12boot_display" ]; then
+ cp "$BACKUP_DIR/S12boot_display" /etc/init.d/S12boot_display
+ printf "${green}Restored S12boot_display${white}\n"
+ else
+ printf "${yellow}No S12boot_display backup found. Restore manually if needed.${white}\n"
+ fi
+
+ # Restore S99start_app from backup
+ if [ -f "$BACKUP_DIR/S99start_app" ] && [ ! -f /etc/init.d/S99start_app ]; then
+ cp "$BACKUP_DIR/S99start_app" /etc/init.d/S99start_app
+ printf "${green}Restored S99start_app${white}\n"
+ fi
+
+ # Remove [include GuppyScreen] from printer.cfg
+ if [ -f "$K1_CONFIG_DIR_U/printer.cfg" ]; then
+ sed -i '/\[include GuppyScreen/d' "$K1_CONFIG_DIR_U/printer.cfg"
+ printf "${green}Removed GuppyScreen include from printer.cfg${white}\n"
+ else
+ printf "${yellow}printer.cfg not found at $K1_CONFIG_DIR_U. Remove [include GuppyScreen/*.cfg] manually.${white}\n"
+ fi
+
+ # Remove GuppyScreen config directory
+ if [ -d "$K1_CONFIG_DIR_U/GuppyScreen" ]; then
+ rm -rf "$K1_CONFIG_DIR_U/GuppyScreen"
+ printf "${green}Removed GuppyScreen config directory${white}\n"
+ fi
+
+ # Remove Klipper symlinks
+ rm -f "$KLIPPY_EXTRA_DIR_U/guppy_module_loader.py"
+ rm -f "$KLIPPY_EXTRA_DIR_U/guppy_config_helper.py"
+ rm -f "$KLIPPY_EXTRA_DIR_U/tmcstatus.py"
+ printf "${green}Removed Klipper symlinks${white}\n"
+
+ # Optionally remove /usr/data/guppyscreen
+ printf "${yellow}Remove $K1_GUPPY_DIR (binary + themes + configs)? (y/n): ${white}"
+ read confirm_rm
+ echo
+ if [ "$confirm_rm" = "y" ] || [ "$confirm_rm" = "Y" ]; then
+ rm -rf "$K1_GUPPY_DIR"
+ printf "${green}Removed $K1_GUPPY_DIR${white}\n"
+ fi
+
+ printf "${yellow}NOTE: calibrate_shaper_config.py and gcode_shell_command.py NOT removed (may be shared with Klipper).${white}\n"
+ printf "${yellow}NOTE: If Monitor/display-server were renamed to .disable, restore manually:${white}\n"
+ printf "${yellow} mv /usr/bin/Monitor.disable /usr/bin/Monitor${white}\n"
+ printf "${yellow} mv /usr/bin/display-server.disable /usr/bin/display-server${white}\n"
+ printf "${yellow}NOTE: Backup files remain at $BACKUP_DIR (ft2font.so, printer.cfg.bak, etc.)${white}\n"
+ printf "${green}GuppyScreen uninstalled. Reboot printer to restore display services.${white}\n"
+}
+
+if [ "$1" = "uninstall" ]; then
+ uninstall_guppy
+ exit 0
+fi
+
if [ "$1" = "zbolt" ]; then
ASSET_NAME="$ASSET_NAME-zbolt"
fi
@@ -82,12 +161,8 @@ rm -rf /root/.cache
wget -q --no-check-certificate https://raw.githubusercontent.com/ballaswag/k1-discovery/main/bin/curl -O /tmp/curl
chmod +x /tmp/curl
-ASSET_URL="https://github.com/probielodan/guppyscreen/releases/latest/download/$ASSET_NAME.tar.gz"
-
-if [ "$1" = "nightly" ] || [ "$2" = "nightly" ]; then
- printf "${yellow}Installing nightly build ${white}\n"
- ASSET_URL="https://github.com/probielodan/guppyscreen/releases/download/nightly/$ASSET_NAME.tar.gz"
-fi
+PINNED_RELEASE="v0.1.0-ke-bedmesh"
+ASSET_URL="https://github.com/coreflake1/guppyscreen/releases/download/${PINNED_RELEASE}/$ASSET_NAME.tar.gz"
printf "${green} Downloading asset: $ASSET_NAME.tar.gz ${white}\n"
@@ -95,6 +170,14 @@ printf "${green} Downloading asset: $ASSET_NAME.tar.gz ${white}\n"
/tmp/curl -s -L $ASSET_URL -o /tmp/guppyscreen.tar.gz
tar xf /tmp/guppyscreen.tar.gz -C /usr/data/
+# substitute paths in guppyconfig.json
+if [ -f "$K1_GUPPY_DIR/debian/guppyconfig.json" ]; then
+ PRINTER_DATA_DIR=$(dirname "$K1_CONFIG_DIR")
+ sed -i "s||$K1_GUPPY_DIR|g; s||$PRINTER_DATA_DIR|g" "$K1_GUPPY_DIR/debian/guppyconfig.json"
+ cp "$K1_GUPPY_DIR/debian/guppyconfig.json" "$K1_GUPPY_DIR/guppyconfig.json"
+fi
+mkdir -p "$K1_GUPPY_DIR/thumbnails"
+
if [ ! -f "$K1_GUPPY_DIR/guppyscreen" ]; then
printf "${red}Did not find guppyscreen in $K1_GUPPY_DIR. GuppyScreen must be extracted in $K1_GUPPY_DIR ${white}\n"
exit 1
@@ -129,25 +212,35 @@ mkdir -p $K1_CONFIG_DIR/GuppyScreen/scripts
cp $K1_GUPPY_DIR/scripts/*.cfg $K1_CONFIG_DIR/GuppyScreen
cp $K1_GUPPY_DIR/scripts/*.py $K1_CONFIG_DIR/GuppyScreen/scripts
-## includ guppyscreen *.cfg in printer.cfg
+## backup printer.cfg before any modification
+mkdir -p $BACKUP_DIR
+if [ ! -f "$BACKUP_DIR/printer.cfg.bak" ]; then
+ cp "$K1_CONFIG_DIR/printer.cfg" "$BACKUP_DIR/printer.cfg.bak" 2>/dev/null || true
+ printf "${green}Backed up printer.cfg to $BACKUP_DIR/printer.cfg.bak ${white}\n"
+fi
+
+## include guppyscreen *.cfg in printer.cfg
if grep -q "include GuppyScreen" $K1_CONFIG_DIR/printer.cfg ; then
echo "printer.cfg already includes GuppyScreen cfgs"
-else
- printf "${green}Including guppyscreen cfgs in printer.cfg ${white}\n"
+elif grep -q "include gcode_macro" $K1_CONFIG_DIR/printer.cfg ; then
+ printf "${green}Including guppyscreen cfgs in printer.cfg (after gcode_macro include) ${white}\n"
sed -i '/\[include gcode_macro\.cfg\]/a \[include GuppyScreen/*\.cfg\]' $K1_CONFIG_DIR/printer.cfg
+else
+ printf "${green}Including guppyscreen cfgs in printer.cfg (prepended, no gcode_macro include found) ${white}\n"
+ sed -i '1s;^;\[include GuppyScreen/*\.cfg\]\n;' $K1_CONFIG_DIR/printer.cfg
fi
## symlink usb
K1_GCODE_DIR=$(dirname "$K1_CONFIG_DIR")/gcodes
ln -sf /tmp/udisk $K1_GCODE_DIR/usb
-if [ ! -d "$BACKUP_DIR" ]; then
+if [ ! -f "$BACKUP_DIR/S50dropbear" ]; then
printf "${green} Backing up original K1 files ${white}\n"
mkdir -p $BACKUP_DIR
- mv /etc/init.d/S12boot_display $BACKUP_DIR
+ [ -f /etc/init.d/S12boot_display ] && mv /etc/init.d/S12boot_display $BACKUP_DIR
cp /etc/init.d/S50dropbear $BACKUP_DIR
- cp /etc/init.d/S99start_app $BACKUP_DIR
+ [ -f /etc/init.d/S99start_app ] && cp /etc/init.d/S99start_app $BACKUP_DIR
fi
if [ ! -f $BACKUP_DIR/ft2font.cpython-38-mipsel-linux-gnu.so ]; then
@@ -168,16 +261,18 @@ echo
if [ "$confirm_decreality" = "y" -o "$confirm_decreality" = "Y" ]; then
printf "${green}Disabling Creality services ${white}\n"
- rm /etc/init.d/S99start_app
+ [ -f /etc/init.d/S99start_app ] && rm /etc/init.d/S99start_app
else
- # disables only display-server and Monitor
- mv /usr/bin/Monitor /usr/bin/Monitor.disable
- mv /usr/bin/display-server /usr/bin/display-server.disable
+ # disables only display-server and Monitor (guard: may not exist on all firmwares)
+ [ -f /usr/bin/Monitor ] && mv /usr/bin/Monitor /usr/bin/Monitor.disable
+ [ -f /usr/bin/display-server ] && mv /usr/bin/display-server /usr/bin/display-server.disable
fi
printf "${green}Setting up GuppyScreen ${white}\n"
cp $K1_GUPPY_DIR/k1_mods/S99guppyscreen /etc/init.d/S99guppyscreen
+# backup existing calibrate_shaper_config.py before unconditional overwrite
+[ -f "$SHAPER_CONFIG" ] && [ ! -f "$BACKUP_DIR/calibrate_shaper_config.py.bak" ] && cp "$SHAPER_CONFIG" "$BACKUP_DIR/calibrate_shaper_config.py.bak"
cp $K1_GUPPY_DIR/k1_mods/calibrate_shaper_config.py $SHAPER_CONFIG
ln -sf $K1_GUPPY_DIR/k1_mods/guppy_module_loader.py $KLIPPY_EXTRA_DIR/guppy_module_loader.py
diff --git a/scripts/release.sh b/scripts/release.sh
index 6815322..dfa82da 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -7,11 +7,15 @@ mkdir -p $RELEASES_DIR
ASSET_NAME=$1
"$CROSS_COMPILE"strip ./build/bin/guppyscreen
-"$CROSS_COMPILE"strip ./build/bin/kd_graphic_mode
cp ./build/bin/guppyscreen $RELEASES_DIR/guppyscreen
+if [ -f ./build/bin/kd_graphic_mode ]; then
+ "$CROSS_COMPILE"strip ./build/bin/kd_graphic_mode
+ cp ./build/bin/kd_graphic_mode $RELEASES_DIR/debian
+fi
cp -r ./k1/k1_mods $RELEASES_DIR
cp -r ./k1/scripts $RELEASES_DIR
cp -r ./themes $RELEASES_DIR
+cp ./scripts/installer-deb.sh $RELEASES_DIR
cp ./scripts/installer.sh $RELEASES_DIR
cp ./scripts/update.sh $RELEASES_DIR
if [ -f ./custom_upgrade.sh ]; then
@@ -19,7 +23,6 @@ if [ -f ./custom_upgrade.sh ]; then
fi
cp scripts/reinstall-creality.sh $RELEASES_DIR
cp -r ./debian $RELEASES_DIR
-cp ./build/bin/kd_graphic_mode $RELEASES_DIR/debian
echo "{\"version\": \"$GUPPYSCREEN_VERSION\", \"theme\": \"$GUPPY_THEME\", \"asset_name\": \"$ASSET_NAME.tar.gz\"}" > $RELEASES_DIR/.version
diff --git a/src/bedmesh_panel.cpp b/src/bedmesh_panel.cpp
index 86f17a1..d8474b2 100644
--- a/src/bedmesh_panel.cpp
+++ b/src/bedmesh_panel.cpp
@@ -7,6 +7,31 @@
#include
#include
+#ifndef SIMULATOR
+#include
+#include
+#include
+#include
+
+// Helper macros for bit testing
+#define NLONGS(x) (((x) + 8*sizeof(long) - 1) / (8*sizeof(long)))
+#define test_bit(nr, addr) (((1UL << ((nr) % (8*sizeof(long)))) & ((addr)[(nr) / (8*sizeof(long))])) != 0)
+#endif
+
+LV_IMG_DECLARE(chart_img);
+
+// Z zoom limits
+const double MIN_Z_SCALE = 0.1;
+const double MAX_Z_SCALE = 5.0;
+
+// FOV/Camera distance limits
+const double MIN_CAMERA_DISTANCE = 200.0;
+const double MAX_CAMERA_DISTANCE = 800.0;
+
+#ifndef M_PI
+#define M_PI 3.14159265358979323846
+#endif
+
LV_IMG_DECLARE(back);
LV_IMG_DECLARE(delete_img);
LV_IMG_DECLARE(bedmesh_img);
@@ -14,6 +39,7 @@ LV_IMG_DECLARE(sd_img);
static lv_color_t color_gradient(double offset);
+static lv_color_t color_gradient_enhanced(double offset, double min_val, double max_val);
BedMeshPanel::BedMeshPanel(KWebSocketClient &c, std::mutex &l)
: NotifyConsumer(l)
@@ -21,7 +47,14 @@ BedMeshPanel::BedMeshPanel(KWebSocketClient &c, std::mutex &l)
, cont(lv_obj_create(lv_scr_act()))
, prompt(lv_obj_create(lv_scr_act()))
, top_cont(lv_obj_create(cont))
- , mesh_table(lv_table_create(top_cont))
+ , display_cont(lv_obj_create(top_cont))
+ , mesh_table(lv_table_create(display_cont))
+ , mesh_canvas(lv_canvas_create(display_cont))
+ , rotation_cont(nullptr)
+ , z_zoom_in_btn(lv_btn_create(display_cont))
+ , z_zoom_out_btn(lv_btn_create(display_cont))
+ , fov_zoom_in_btn(lv_btn_create(display_cont))
+ , fov_zoom_out_btn(lv_btn_create(display_cont))
, profile_cont(lv_obj_create(top_cont))
, profile_table(lv_table_create(profile_cont))
, profile_info(lv_table_create(profile_cont))
@@ -29,10 +62,30 @@ BedMeshPanel::BedMeshPanel(KWebSocketClient &c, std::mutex &l)
, save_btn(controls_cont, &sd_img, "Save Profile", &BedMeshPanel::_handle_callback, this)
, clear_btn(controls_cont, &delete_img, "Clear Profile", &BedMeshPanel::_handle_callback, this)
, calibrate_btn(controls_cont, &bedmesh_img, "Calibrate", &BedMeshPanel::_handle_callback, this)
+ , toggle_view_btn(controls_cont, &chart_img, "3D View", &BedMeshPanel::_handle_toggle_view, this)
, back_btn(controls_cont, &back, "Back", &BedMeshPanel::_handle_callback, this)
, msgbox(lv_obj_create(prompt))
, input(lv_textarea_create(msgbox))
, kb(lv_keyboard_create(prompt))
+ , current_view_angle(VIEW_ANGLE_Z_DEGREES)
+ , canvas_draw_buf(nullptr)
+ , show_3d_view(false)
+ , z_display_scale(2.4)
+ , camera_distance(CAMERA_DISTANCE)
+ , mesh_min_x(0.0)
+ , mesh_max_x(0.0)
+ , mesh_min_y(0.0)
+ , mesh_max_y(0.0)
+ , is_dragging(false)
+ , drag_start_x(0)
+ , last_drag_x(0)
+ , drag_start_y(0)
+ , last_drag_y(0)
+ , current_x_angle(VIEW_ANGLE_X_DEGREES) // Start with the default viewing angle
+ , has_multitouch(false)
+ , use_gesture_zoom(false)
+ , mouse_over_canvas(false)
+ , scroll_check_timer(nullptr)
{
lv_obj_move_background(cont);
@@ -48,6 +101,13 @@ BedMeshPanel::BedMeshPanel(KWebSocketClient &c, std::mutex &l)
lv_obj_set_flex_grow(top_cont, 1);
lv_obj_set_style_pad_all(top_cont, 0, 0);
+ lv_obj_set_height(display_cont, LV_PCT(100));
+ lv_obj_clear_flag(display_cont, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_set_flex_flow(display_cont, LV_FLEX_FLOW_COLUMN);
+ lv_obj_set_flex_align(display_cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
+ lv_obj_set_flex_grow(display_cont, 1);
+ lv_obj_set_style_pad_all(display_cont, 1, 0); // Absolute minimal padding for maximum canvas space
+
auto screen_width = lv_disp_get_physical_hor_res(NULL);
if (screen_width < 800) {
lv_obj_set_style_text_font(mesh_table, &lv_font_montserrat_8, LV_STATE_DEFAULT);
@@ -56,9 +116,116 @@ BedMeshPanel::BedMeshPanel(KWebSocketClient &c, std::mutex &l)
}
// mesh table
- lv_obj_set_height(mesh_table, LV_PCT(100));
- lv_obj_clear_flag(mesh_table, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_set_width(mesh_table, LV_PCT(100));
lv_obj_set_flex_grow(mesh_table, 1);
+ lv_obj_clear_flag(mesh_table, LV_OBJ_FLAG_SCROLLABLE);
+
+ // Setup 3D canvas - start with larger initial size
+ auto canvas_width = 400;
+ auto canvas_height = 400;
+
+ canvas_draw_buf = lv_mem_alloc(LV_CANVAS_BUF_SIZE_TRUE_COLOR(canvas_width, canvas_height));
+ lv_canvas_set_buffer(mesh_canvas, canvas_draw_buf, canvas_width, canvas_height, LV_IMG_CF_TRUE_COLOR);
+ lv_obj_set_size(mesh_canvas, canvas_width, canvas_height);
+ lv_obj_set_flex_grow(mesh_canvas, 1);
+ lv_obj_set_align(mesh_canvas, LV_ALIGN_CENTER);
+
+ // Fill canvas with dark slate gray background initially
+ lv_canvas_fill_bg(mesh_canvas, lv_color_make(40, 40, 40), LV_OPA_COVER);
+
+ spdlog::debug("Initial canvas size: {}x{}", canvas_width, canvas_height);
+
+ // Add resize callback to update canvas size when container changes
+ lv_obj_add_event_cb(display_cont, [](lv_event_t *e) {
+ BedMeshPanel *panel = static_cast(lv_event_get_user_data(e));
+ panel->resize_canvas();
+ }, LV_EVENT_SIZE_CHANGED, this);
+
+ // Create an invisible overlay button on top of canvas to capture input events
+ // Canvas objects don't receive input events well in LVGL
+ // Create it as a child of the canvas itself to ensure proper positioning
+ lv_obj_t *canvas_overlay = lv_btn_create(mesh_canvas);
+
+ // Position it to cover the entire canvas area
+ lv_obj_set_size(canvas_overlay, LV_PCT(100), LV_PCT(100));
+ lv_obj_set_pos(canvas_overlay, 0, 0);
+
+ // Make the overlay invisible but clickable
+ lv_obj_set_style_bg_opa(canvas_overlay, LV_OPA_TRANSP, 0);
+ lv_obj_set_style_border_opa(canvas_overlay, LV_OPA_TRANSP, 0);
+ lv_obj_set_style_shadow_opa(canvas_overlay, LV_OPA_TRANSP, 0);
+ lv_obj_set_style_outline_opa(canvas_overlay, LV_OPA_TRANSP, 0);
+
+ // Remove any padding/margin
+ lv_obj_set_style_pad_all(canvas_overlay, 0, 0);
+
+ // Ensure it doesn't interfere with layout
+ lv_obj_add_flag(canvas_overlay, LV_OBJ_FLAG_FLOATING);
+
+ // Add drag event handler to the overlay button
+ lv_obj_add_event_cb(canvas_overlay, &BedMeshPanel::_handle_canvas_drag, LV_EVENT_PRESSED, this);
+ lv_obj_add_event_cb(canvas_overlay, &BedMeshPanel::_handle_canvas_drag, LV_EVENT_PRESSING, this);
+ lv_obj_add_event_cb(canvas_overlay, &BedMeshPanel::_handle_canvas_drag, LV_EVENT_RELEASED, this);
+
+ // Detect input capabilities and configure zoom behavior
+#ifdef SIMULATOR
+ // In simulation mode, add mouse wheel zoom support via encoder polling
+ use_gesture_zoom = true;
+ // Create timer to continuously check for mouse wheel input in 3D view mode
+ scroll_check_timer = lv_timer_create(&BedMeshPanel::_scroll_check_timer_cb, 50, this); // Check every 50ms
+ spdlog::debug("Simulator mode detected - mouse wheel zoom enabled (use_gesture_zoom={}) with 50ms timer always active", use_gesture_zoom);
+#else
+ // On real hardware, check for multi-touch support
+ has_multitouch = detect_multitouch_support();
+ if (has_multitouch) {
+ use_gesture_zoom = true;
+ lv_obj_add_event_cb(canvas_overlay, &BedMeshPanel::_handle_canvas_gesture, LV_EVENT_GESTURE, this);
+ spdlog::debug("Multi-touch detected: pinch zoom enabled");
+ } else {
+ spdlog::debug("No multi-touch support: using zoom buttons");
+ }
+#endif
+
+
+ // Create single controls container for all 3D controls
+ rotation_cont = lv_obj_create(display_cont);
+ lv_obj_set_size(rotation_cont, LV_PCT(100), LV_SIZE_CONTENT);
+ lv_obj_set_flex_flow(rotation_cont, LV_FLEX_FLOW_ROW);
+ lv_obj_set_flex_align(rotation_cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
+ lv_obj_clear_flag(rotation_cont, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_set_style_pad_all(rotation_cont, 15, 0); // More padding to prevent slider clipping
+ lv_obj_set_style_pad_column(rotation_cont, 10, 0); // Add spacing between items
+
+ // Move all controls to the single container - Z buttons first, then FOV buttons
+ lv_obj_set_parent(z_zoom_out_btn, rotation_cont); // Z- (minus) first
+ lv_obj_set_parent(z_zoom_in_btn, rotation_cont); // Z+ (plus) second
+ lv_obj_set_parent(fov_zoom_out_btn, rotation_cont); // FOV- (minus) third
+ lv_obj_set_parent(fov_zoom_in_btn, rotation_cont); // FOV+ (plus) fourth
+
+
+ // Setup Z zoom buttons with correct symbols
+ setup_button_with_label(z_zoom_in_btn, LV_SYMBOL_PLUS, 40, 30, &BedMeshPanel::_handle_z_zoom_in);
+ setup_button_with_label(z_zoom_out_btn, LV_SYMBOL_MINUS, 40, 30, &BedMeshPanel::_handle_z_zoom_out);
+
+ // Setup FOV zoom buttons with correct symbols (hidden for now, kept for future debugging)
+ setup_button_with_label(fov_zoom_in_btn, LV_SYMBOL_UP, 40, 30, &BedMeshPanel::_handle_fov_zoom_in);
+ setup_button_with_label(fov_zoom_out_btn, LV_SYMBOL_DOWN, 40, 30, &BedMeshPanel::_handle_fov_zoom_out);
+
+ // Hide FOV buttons (debugging only)
+ lv_obj_add_flag(fov_zoom_in_btn, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(fov_zoom_out_btn, LV_OBJ_FLAG_HIDDEN);
+
+ // Hide Z zoom buttons if gesture zoom is available
+ if (use_gesture_zoom) {
+ lv_obj_add_flag(z_zoom_in_btn, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(z_zoom_out_btn, LV_OBJ_FLAG_HIDDEN);
+ spdlog::debug("Z zoom buttons hidden - using gesture zoom");
+ }
+
+ // Start in table view by default; 3D is an opt-in fullscreen mode.
+ lv_obj_clear_flag(mesh_table, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(mesh_canvas, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(rotation_cont, LV_OBJ_FLAG_HIDDEN);
// profile container
lv_obj_set_height(profile_cont, LV_PCT(100));
@@ -72,9 +239,7 @@ BedMeshPanel::BedMeshPanel(KWebSocketClient &c, std::mutex &l)
lv_obj_add_event_cb(profile_cont, [](lv_event_t *e) {
lv_obj_t *profile_cont = lv_event_get_target(e);
lv_obj_t *profile_table = static_cast(lv_event_get_user_data(e));
-
int profile_cont_width = lv_obj_get_width(profile_cont);
-
lv_table_set_col_width(profile_table, 0, profile_cont_width * 0.7);
lv_table_set_col_width(profile_table, 1, profile_cont_width * 0.15);
lv_table_set_col_width(profile_table, 2, profile_cont_width * 0.15);
@@ -86,9 +251,7 @@ BedMeshPanel::BedMeshPanel(KWebSocketClient &c, std::mutex &l)
lv_obj_add_event_cb(profile_cont, [](lv_event_t *e) {
lv_obj_t *profile_cont = lv_event_get_target(e);
lv_obj_t *profile_info = static_cast(lv_event_get_user_data(e));
-
int profile_cont_width = lv_obj_get_width(profile_cont);
-
lv_table_set_col_width(profile_info, 0, profile_cont_width * 0.5);
lv_table_set_col_width(profile_info, 1, profile_cont_width * 0.5);
}, LV_EVENT_SIZE_CHANGED, profile_info);
@@ -107,6 +270,7 @@ BedMeshPanel::BedMeshPanel(KWebSocketClient &c, std::mutex &l)
lv_obj_set_flex_grow(save_btn.get_container(), 1);
lv_obj_set_flex_grow(clear_btn.get_container(), 1);
lv_obj_set_flex_grow(calibrate_btn.get_container(), 1);
+ lv_obj_set_flex_grow(toggle_view_btn.get_container(), 1);
lv_obj_set_flex_grow(back_btn.get_container(), 1);
lv_obj_add_event_cb(mesh_table, &BedMeshPanel::_mesh_draw_cb, LV_EVENT_DRAW_PART_BEGIN, this);
@@ -163,6 +327,19 @@ BedMeshPanel::BedMeshPanel(KWebSocketClient &c, std::mutex &l)
}
BedMeshPanel::~BedMeshPanel() {
+ if (canvas_draw_buf != nullptr) {
+ lv_mem_free(canvas_draw_buf);
+ canvas_draw_buf = nullptr;
+ }
+
+#ifdef SIMULATOR
+ if (scroll_check_timer != nullptr) {
+ lv_timer_del(scroll_check_timer);
+ scroll_check_timer = nullptr;
+ spdlog::debug("Scroll check timer cleaned up");
+ }
+#endif
+
if (cont != NULL) {
lv_obj_del(cont);
cont = NULL;
@@ -201,7 +378,6 @@ void BedMeshPanel::refresh_views(json &bm) {
save_btn.enable();
refresh_profile_info(active_profile);
- lv_obj_clear_flag(mesh_table, LV_OBJ_FLAG_HIDDEN);
auto mesh_json = bm["/probed_matrix"_json_pointer];
if (mesh_json.is_null()) {
@@ -209,31 +385,83 @@ void BedMeshPanel::refresh_views(json &bm) {
}
mesh = mesh_json.template get>>();
+ spdlog::debug("Loaded mesh data: {}x{} points", mesh.size(), mesh.empty() ? 0 : mesh[0].size());
+
+ // Draw the 3D mesh
+ draw_3d_mesh();
+
+ // Force complete layout update chain to ensure proper table sizing
+ lv_obj_update_layout(cont); // Update main container first
+ lv_obj_update_layout(top_cont); // Then top container
+ lv_obj_update_layout(display_cont); // Then display container
+
+ // Add a small delay to let the layout settle completely
+ // lv_timer_t* timer = lv_timer_create([](lv_timer_t* timer) {
+ // BedMeshPanel* panel = static_cast(timer->user_data);
+ // panel->delayed_table_setup();
+ // lv_timer_del(timer);
+ // }, 10, this);
// calculate cell width
if (mesh.size() > 0 && mesh[0].size() > 0) {
+ // Force table to use available height
+ auto display_height = lv_obj_get_height(display_cont);
+ auto target_table_height = display_height - 60; // Leave room for controls
+ lv_obj_set_height(mesh_table, target_table_height);
+
auto height = lv_obj_get_height(mesh_table);
- int cel_height = std::max(1, (int)(height / mesh.size() / 2 - 5));
auto width = lv_obj_get_width(mesh_table);
- int col_width = std::max(4, (int)(width / mesh[0].size()));
+ spdlog::debug("Table dimensions: {}x{} (forced to {}), display_cont height: {}, mesh size: {}x{}", width, height, target_table_height, display_height, mesh[0].size(), mesh.size());
+
+ // Adjust spacing based on mesh size
+ bool is_large_mesh = (mesh.size() > 6 || mesh[0].size() > 6);
+
+ int cel_height, col_width;
+ // Calculate actual row height needed: total height divided by number of rows
+ // Each row needs space for text + top padding + bottom padding
+ int available_row_height = height / mesh.size();
+
+ if (is_large_mesh) {
+ // Minimal padding for large meshes to fit more data
+ cel_height = std::max(1, available_row_height / 3);
+ col_width = std::max(35, (int)(width / mesh[0].size()));
+ } else {
+ // More comfortable padding for small meshes
+ cel_height = std::max(2, available_row_height / 2);
+ col_width = std::max(4, (int)(width / mesh[0].size()));
+ }
lv_obj_set_style_pad_top(mesh_table, cel_height, LV_PART_ITEMS | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(mesh_table, cel_height, LV_PART_ITEMS | LV_STATE_DEFAULT);
+ // Reduce left/right padding for large meshes to prevent text wrapping
+ if (is_large_mesh) {
+ lv_obj_set_style_pad_left(mesh_table, 1, LV_PART_ITEMS | LV_STATE_DEFAULT);
+ lv_obj_set_style_pad_right(mesh_table, 1, LV_PART_ITEMS | LV_STATE_DEFAULT);
+ } else {
+ lv_obj_set_style_pad_left(mesh_table, 3, LV_PART_ITEMS | LV_STATE_DEFAULT);
+ lv_obj_set_style_pad_right(mesh_table, 3, LV_PART_ITEMS | LV_STATE_DEFAULT);
+ }
+
lv_table_set_col_cnt(mesh_table, mesh[0].size());
for (int i = 0; i < mesh[0].size(); i++) {
lv_table_set_col_width(mesh_table, i, col_width);
}
}
+ // Use minimum 10pt font for readability, only drop to 8pt for very large meshes
+ if (mesh.size() > 9 || mesh[0].size() > 9) {
+ lv_obj_set_style_text_font(mesh_table, &lv_font_montserrat_8, LV_STATE_DEFAULT);
+ } else {
+ lv_obj_set_style_text_font(mesh_table, &lv_font_montserrat_10, LV_STATE_DEFAULT);
+ }
+
for (int i = mesh.size() - 1; i >= 0; i--) {
auto row = mesh[i];
for (int j = 0; j < row.size(); j++) {
- if (mesh.size() < 6 && row.size() < 6) {
- lv_table_set_cell_value(mesh_table, row_idx, j, fmt::format("{:.2f}", row[j]).c_str());
- } else {
- lv_table_set_cell_value(mesh_table, row_idx, j, "");
- }
+ // Use shorter format for large meshes to reduce wrapping
+ const char* format = (mesh.size() > 6 || mesh[0].size() > 6) ? "{:.1f}" : "{:.2f}";
+ lv_table_set_cell_value(mesh_table, row_idx, j, fmt::format(format, row[j]).c_str());
}
row_idx++;
}
@@ -301,11 +529,16 @@ void BedMeshPanel::refresh_profile_info(std::string profile) {
}
std::vector xvalues;
- for (auto ¶m : {"min_x", "max_x", "x_count", "mesh_x_pps"}) {
- v = mesh_params[json::json_pointer(fmt::format("/{}", param))];
- if (!v.is_null()) {
- xvalues.push_back(v.template get());
- }
+ std::vector x_params = {"min_x", "max_x", "x_count", "mesh_x_pps"};
+ extract_mesh_parameter_values(mesh_params, x_params, xvalues);
+
+ v = mesh_params["/min_x"_json_pointer];
+ if (!v.is_null()) {
+ mesh_min_x = v.template get();
+ }
+ v = mesh_params["/max_x"_json_pointer];
+ if (!v.is_null()) {
+ mesh_max_x = v.template get();
}
lv_table_set_cell_value(profile_info, rowidx, 0, "X (min, max, count, pps)");
@@ -313,15 +546,20 @@ void BedMeshPanel::refresh_profile_info(std::string profile) {
rowidx++;
std::vector yvalues;
- for (auto ¶m : {"min_y", "max_y", "y_count", "mesh_y_pps"}) {
- v = mesh_params[json::json_pointer(fmt::format("/{}", param))];
- if (!v.is_null()) {
- yvalues.push_back(v.template get());
- }
+ std::vector y_params = {"min_y", "max_y", "y_count", "mesh_y_pps"};
+ extract_mesh_parameter_values(mesh_params, y_params, yvalues);
+
+ v = mesh_params["/min_y"_json_pointer];
+ if (!v.is_null()) {
+ mesh_min_y = v.template get();
+ }
+ v = mesh_params["/max_y"_json_pointer];
+ if (!v.is_null()) {
+ mesh_max_y = v.template get();
}
lv_table_set_cell_value(profile_info, rowidx, 0, "Y (min, max, count, pps)");
- lv_table_set_cell_value(profile_info, rowidx, 1, fmt::format("{}", fmt::join(xvalues, ", ")).c_str());
+ lv_table_set_cell_value(profile_info, rowidx, 1, fmt::format("{}", fmt::join(yvalues, ", ")).c_str());
rowidx++;
}
}
@@ -330,6 +568,7 @@ void BedMeshPanel::foreground() {
auto bm = State::get_instance()->get_data("/printer_state/bed_mesh"_json_pointer);
spdlog::trace("bm {}", bm.dump());
refresh_views(bm);
+ show_table_view();
lv_obj_move_foreground(cont);
}
@@ -365,7 +604,12 @@ void BedMeshPanel::handle_callback(lv_event_t *event) {
} else if (btn == back_btn.get_container()) {
spdlog::trace("back button pressed");
- lv_obj_move_background(cont);
+ if (show_3d_view) {
+ // From fullscreen 3D, Back returns to the table view rather than exiting.
+ show_table_view();
+ } else {
+ lv_obj_move_background(cont);
+ }
}
}
@@ -464,6 +708,880 @@ void BedMeshPanel::mesh_draw_cb(lv_event_t *e)
}
}
+void BedMeshPanel::resize_canvas()
+{
+ if (canvas_draw_buf == nullptr) {
+ return;
+ }
+
+ // Canvas fills the display container. In fullscreen 3D the zoom controls live
+ // in the right-side strip, so no bottom reservation is needed here.
+ auto cont_width = lv_obj_get_width(display_cont);
+ auto cont_height = lv_obj_get_height(display_cont);
+
+ // Floor must stay below the small-screen height (272) or the canvas overflows.
+ int min_dim = (lv_disp_get_physical_hor_res(NULL) < 800) ? 100 : 300;
+ auto canvas_width = std::max(min_dim, cont_width);
+ auto canvas_height = std::max(min_dim, std::min(cont_height, canvas_width));
+
+ // Only resize if significantly different to avoid constant redraws
+ auto current_width = lv_obj_get_width(mesh_canvas);
+ auto current_height = lv_obj_get_height(mesh_canvas);
+
+ if (abs(canvas_width - current_width) > 10 || abs(canvas_height - current_height) > 10) {
+ // Free old buffer and create new one
+ lv_mem_free(canvas_draw_buf);
+ canvas_draw_buf = lv_mem_alloc(LV_CANVAS_BUF_SIZE_TRUE_COLOR(canvas_width, canvas_height));
+ lv_canvas_set_buffer(mesh_canvas, canvas_draw_buf, canvas_width, canvas_height, LV_IMG_CF_TRUE_COLOR);
+ lv_obj_set_size(mesh_canvas, canvas_width, canvas_height);
+
+ // Fill with dark slate gray background
+ lv_canvas_fill_bg(mesh_canvas, lv_color_make(40, 40, 40), LV_OPA_COVER);
+
+ spdlog::debug("Resized canvas from {}x{} to {}x{}", current_width, current_height, canvas_width, canvas_height);
+
+ // Redraw with new size
+ if (show_3d_view && !mesh.empty()) {
+ draw_3d_mesh();
+ }
+ }
+}
+
+void BedMeshPanel::draw_mesh_surface(const std::vector>& points,
+ int rows, int cols, int canvas_width, int canvas_height,
+ double min_z, double max_z)
+{
+ // Draw surface quads with proper size and connection
+ for (int row = 0; row < rows - 1; row++) {
+ for (int col = 0; col < cols - 1; col++) {
+ // Get the four corner points of the current quad
+ const auto& p1 = points[row][col]; // bottom-left
+ const auto& p2 = points[row][col+1]; // bottom-right
+ const auto& p3 = points[row+1][col]; // top-left
+ const auto& p4 = points[row+1][col+1]; // top-right
+
+ // Calculate average height for color
+ double avg_height = (mesh[row][col] + mesh[row][col+1] +
+ mesh[row+1][col] + mesh[row+1][col+1]) / 4.0;
+
+ lv_color_t surface_color = color_gradient_enhanced(avg_height, min_z, max_z);
+
+ // Create a larger, more connected surface quad
+ int min_x = std::min({p1.screen_x, p2.screen_x, p3.screen_x, p4.screen_x});
+ int max_x = std::max({p1.screen_x, p2.screen_x, p3.screen_x, p4.screen_x});
+ int min_y = std::min({p1.screen_y, p2.screen_y, p3.screen_y, p4.screen_y});
+ int max_y = std::max({p1.screen_y, p2.screen_y, p3.screen_y, p4.screen_y});
+
+ // Expand the quad to ensure surface connection
+ int width = std::max(8, max_x - min_x + 4); // Minimum width for visibility
+ int height = std::max(8, max_y - min_y + 4); // Minimum height for visibility
+
+ // Center the expanded quad
+ int center_x = (min_x + max_x) / 2;
+ int center_y = (min_y + max_y) / 2;
+ min_x = center_x - width / 2;
+ min_y = center_y - height / 2;
+
+ // Check bounds
+ if (min_x >= 0 && min_y >= 0 &&
+ min_x + width < canvas_width && min_y + height < canvas_height) {
+
+ lv_draw_rect_dsc_t rect_dsc;
+ lv_draw_rect_dsc_init(&rect_dsc);
+ rect_dsc.bg_color = surface_color;
+ rect_dsc.bg_opa = LV_OPA_90;
+ rect_dsc.border_width = 0;
+
+ lv_canvas_draw_rect(mesh_canvas, min_x, min_y, width, height, &rect_dsc);
+ }
+ }
+ }
+}
+
+void BedMeshPanel::draw_mesh_wireframe(const std::vector>& points,
+ int rows, int cols, int canvas_width, int canvas_height)
+{
+ lv_draw_line_dsc_t line_dsc;
+ lv_draw_line_dsc_init(&line_dsc);
+ line_dsc.color = lv_color_make(80, 80, 80); // Dark gray wireframe
+ line_dsc.width = 1;
+ line_dsc.opa = LV_OPA_60;
+
+ // Draw horizontal grid lines
+ for (int row = 0; row < rows; row++) {
+ for (int col = 0; col < cols - 1; col++) {
+ const auto& p1 = points[row][col];
+ const auto& p2 = points[row][col + 1];
+
+ // Check bounds
+ if (p1.screen_x >= 0 && p1.screen_x < canvas_width && p1.screen_y >= 0 && p1.screen_y < canvas_height &&
+ p2.screen_x >= 0 && p2.screen_x < canvas_width && p2.screen_y >= 0 && p2.screen_y < canvas_height) {
+ lv_point_t line_points[2] = {{p1.screen_x, p1.screen_y}, {p2.screen_x, p2.screen_y}};
+ lv_canvas_draw_line(mesh_canvas, line_points, 2, &line_dsc);
+ }
+ }
+ }
+
+ // Draw vertical grid lines
+ for (int col = 0; col < cols; col++) {
+ for (int row = 0; row < rows - 1; row++) {
+ const auto& p1 = points[row][col];
+ const auto& p2 = points[row + 1][col];
+
+ // Check bounds
+ if (p1.screen_x >= 0 && p1.screen_x < canvas_width && p1.screen_y >= 0 && p1.screen_y < canvas_height &&
+ p2.screen_x >= 0 && p2.screen_x < canvas_width && p2.screen_y >= 0 && p2.screen_y < canvas_height) {
+ lv_point_t line_points[2] = {{p1.screen_x, p1.screen_y}, {p2.screen_x, p2.screen_y}};
+ lv_canvas_draw_line(mesh_canvas, line_points, 2, &line_dsc);
+ }
+ }
+ }
+}
+
+void BedMeshPanel::draw_axes_and_labels(const std::vector>& points,
+ int rows, int cols, int canvas_width, int canvas_height,
+ double min_z, double max_z, double z_scale)
+{
+ // Draw coordinate axes and labels with wireframe style
+ lv_draw_line_dsc_t axis_dsc;
+ lv_draw_line_dsc_init(&axis_dsc);
+ axis_dsc.color = lv_color_make(160, 160, 160); // Slightly darker gray
+ axis_dsc.width = 1; // Thin lines
+ axis_dsc.opa = LV_OPA_70; // More visible but still subtle
+
+ lv_draw_label_dsc_t label_dsc;
+ lv_draw_label_dsc_init(&label_dsc);
+ label_dsc.color = lv_color_white();
+ label_dsc.font = &lv_font_montserrat_10;
+
+
+ // Draw Z-axis (vertical) - connect to X/Y axes origin with centered Z range
+ // Use the mesh corner coordinates for X/Y positioning (same as X/Y axes)
+ double mesh_origin_x = (0 - cols / 2.0) * MESH_SCALE;
+ double mesh_origin_y = (0 - rows / 2.0) * MESH_SCALE;
+
+ // Draw wireframe grid for X-Y plane at the same Z level as the bottom of the Z-axis (minimum Z value)
+ double z_range = max_z - min_z;
+ double grid_reference_z = (-z_range * Z_AXIS_EXTENSION) * z_scale; // At the bottom of the visualization
+ spdlog::debug("Drawing grid at Z={}, z_range={}, z_scale={}, (matches Z-axis bottom/min)", grid_reference_z, z_range, z_scale);
+
+ // Draw grid lines parallel to X-axis and Y-axis
+ draw_grid_lines_parallel_to_axis(rows, false, rows, cols, grid_reference_z, canvas_width, canvas_height, axis_dsc);
+ draw_grid_lines_parallel_to_axis(cols, true, rows, cols, grid_reference_z, canvas_width, canvas_height, axis_dsc);
+
+ // Draw wireframe grid for X/Z plane (vertical plane at Y edge)
+ double y_edge = (0 - rows / 2.0) * MESH_SCALE; // Front edge of mesh (minimum Y)
+ for (int col = 0; col < cols; col++) {
+ double grid_x = (col - cols / 2.0) * MESH_SCALE;
+ Point3D start = project_3d_to_2d(grid_x, y_edge, (-z_range * Z_AXIS_EXTENSION) * z_scale, canvas_width, canvas_height);
+ Point3D end = project_3d_to_2d(grid_x, y_edge, (z_range * Z_AXIS_EXTENSION) * z_scale, canvas_width, canvas_height);
+
+
+ lv_point_t grid_line[2] = {{start.screen_x, start.screen_y}, {end.screen_x, end.screen_y}};
+ lv_canvas_draw_line(mesh_canvas, grid_line, 2, &axis_dsc);
+ }
+
+ // Add horizontal lines to complete X/Z plane wireframe with multiple Z divisions
+ double x_left = (0 - cols / 2.0) * MESH_SCALE;
+ double x_right = ((cols-1) - cols / 2.0) * MESH_SCALE;
+
+ // Use same number of Z divisions as X/Y grid divisions
+ int z_divisions = std::max(rows, cols);
+ double z_min = (-z_range * Z_AXIS_EXTENSION) * z_scale;
+ double z_max = (z_range * Z_AXIS_EXTENSION) * z_scale;
+
+ for (int z_div = 0; z_div <= z_divisions; z_div++) {
+ double z_level = z_min + (z_div * (z_max - z_min) / z_divisions);
+
+ Point3D xz_left = project_3d_to_2d(x_left, y_edge, z_level, canvas_width, canvas_height);
+ Point3D xz_right = project_3d_to_2d(x_right, y_edge, z_level, canvas_width, canvas_height);
+ lv_point_t xz_line[2] = {{xz_left.screen_x, xz_left.screen_y}, {xz_right.screen_x, xz_right.screen_y}};
+ lv_canvas_draw_line(mesh_canvas, xz_line, 2, &axis_dsc);
+ }
+
+ // Draw wireframe grid for Y/Z plane (vertical plane at X edge)
+ double x_edge = (0 - cols / 2.0) * MESH_SCALE; // Left edge of mesh (minimum X)
+ for (int row = 0; row < rows; row++) {
+ double grid_y = (row - rows / 2.0) * MESH_SCALE;
+ Point3D start = project_3d_to_2d(x_edge, grid_y, (-z_range * Z_AXIS_EXTENSION) * z_scale, canvas_width, canvas_height);
+ Point3D end = project_3d_to_2d(x_edge, grid_y, (z_range * Z_AXIS_EXTENSION) * z_scale, canvas_width, canvas_height);
+
+
+ lv_point_t grid_line[2] = {{start.screen_x, start.screen_y}, {end.screen_x, end.screen_y}};
+ lv_canvas_draw_line(mesh_canvas, grid_line, 2, &axis_dsc);
+ }
+
+ // Add horizontal lines to complete Y/Z plane wireframe with multiple Z divisions
+ double y_front = (0 - rows / 2.0) * MESH_SCALE;
+ double y_back = ((rows-1) - rows / 2.0) * MESH_SCALE;
+
+ for (int z_div = 0; z_div <= z_divisions; z_div++) {
+ double z_level = z_min + (z_div * (z_max - z_min) / z_divisions);
+
+ Point3D yz_front = project_3d_to_2d(x_edge, y_front, z_level, canvas_width, canvas_height);
+ Point3D yz_back = project_3d_to_2d(x_edge, y_back, z_level, canvas_width, canvas_height);
+ lv_point_t yz_line[2] = {{yz_front.screen_x, yz_front.screen_y}, {yz_back.screen_x, yz_back.screen_y}};
+ lv_canvas_draw_line(mesh_canvas, yz_line, 2, &axis_dsc);
+ }
+
+ // Z-axis using extended range for better visibility
+ Point3D z_bottom = project_3d_to_2d(mesh_origin_x, mesh_origin_y, (-z_range * Z_AXIS_EXTENSION) * z_scale, canvas_width, canvas_height);
+ Point3D z_top = project_3d_to_2d(mesh_origin_x, mesh_origin_y, (z_range * Z_AXIS_EXTENSION) * z_scale, canvas_width, canvas_height);
+
+ // Check if both points are reasonably within view (with culling margin)
+ if (z_bottom.screen_x >= -CULLING_MARGIN && z_bottom.screen_x < canvas_width + CULLING_MARGIN &&
+ z_bottom.screen_y >= -CULLING_MARGIN && z_bottom.screen_y < canvas_height + CULLING_MARGIN &&
+ z_top.screen_x >= -CULLING_MARGIN && z_top.screen_x < canvas_width + CULLING_MARGIN &&
+ z_top.screen_y >= -CULLING_MARGIN && z_top.screen_y < canvas_height + CULLING_MARGIN) {
+
+ lv_point_t z_line[2] = {{z_bottom.screen_x, z_bottom.screen_y}, {z_top.screen_x, z_top.screen_y}};
+ lv_canvas_draw_line(mesh_canvas, z_line, 2, &axis_dsc);
+
+ // Draw X and Y axis lines
+ draw_horizontal_axes(mesh_origin_x, mesh_origin_y, grid_reference_z, rows, cols, canvas_width, canvas_height, axis_dsc);
+
+ // Add Z-axis labels along the front left edge (min X, max Y)
+ lv_draw_label_dsc_t z_label_dsc;
+ lv_draw_label_dsc_init(&z_label_dsc);
+ z_label_dsc.color = lv_color_white();
+ z_label_dsc.font = &lv_font_montserrat_10;
+
+ lv_draw_label_dsc_t z_axis_label_dsc;
+ lv_draw_label_dsc_init(&z_axis_label_dsc);
+ z_axis_label_dsc.color = lv_color_white();
+ z_axis_label_dsc.font = &lv_font_montserrat_10;
+
+ double left_x = (0 - cols / 2.0) * MESH_SCALE;
+ double front_y = ((rows-1) - rows / 2.0) * MESH_SCALE;
+
+ int z_divisions = std::max(rows, cols);
+ double z_min = (-z_range * Z_AXIS_EXTENSION) * z_scale;
+ double z_max = (z_range * Z_AXIS_EXTENSION) * z_scale;
+
+ // Draw Z-axis tick values
+ draw_z_axis_tick_values(left_x, front_y, z_min, z_max, z_divisions, z_scale, canvas_width, canvas_height, z_label_dsc, min_z, max_z);
+
+ // Draw Z-axis label
+ double middle_z_level = z_min + ((z_divisions/2) * (z_max - z_min) / z_divisions);
+ draw_axis_label("Z", left_x - TICK_MARK_LENGTH * 6, front_y, middle_z_level, -10, -10, canvas_width, canvas_height, z_axis_label_dsc);
+
+
+
+ }
+
+ // Add mesh info (left-justified) and Z-range info (right-justified) on same line using smaller font
+ char mesh_info[64];
+ snprintf(mesh_info, sizeof(mesh_info), "Mesh: %dx%d", cols, rows);
+
+ char z_info[64];
+ snprintf(z_info, sizeof(z_info), "Z: %.3f to %.3f", min_z, max_z);
+
+ // Use smaller font for both labels
+ lv_draw_label_dsc_t small_label_dsc;
+ lv_draw_label_dsc_init(&small_label_dsc);
+ small_label_dsc.color = lv_color_white();
+ small_label_dsc.font = &lv_font_montserrat_10;
+
+ // Left-justified mesh info
+ lv_area_t mesh_info_area;
+ if (create_and_check_label_area(mesh_info_area, 10, canvas_height - 25, 100, LABEL_HEIGHT, canvas_width, canvas_height)) {
+ lv_canvas_draw_text(mesh_canvas, mesh_info_area.x1, mesh_info_area.y1, 100, &small_label_dsc, mesh_info);
+ }
+
+ // Right-justified Z info
+ lv_draw_label_dsc_t right_label_dsc;
+ lv_draw_label_dsc_init(&right_label_dsc);
+ right_label_dsc.color = lv_color_white();
+ right_label_dsc.font = &lv_font_montserrat_10;
+
+#ifndef CROSS_COMPILE
+ // Debug info for simulation mode: show current viewing angles at top
+ char debug_info[128];
+ // The Y rotation is fixed at 0° since we only control X (tilt) and Z (spin) rotations
+ snprintf(debug_info, sizeof(debug_info), "View: X=%.1f° Y=0.0° Z=%.1f°",
+ current_x_angle, (double)current_view_angle);
+
+ lv_draw_label_dsc_t debug_label_dsc;
+ lv_draw_label_dsc_init(&debug_label_dsc);
+ debug_label_dsc.color = lv_color_make(255, 255, 0); // Yellow for visibility
+ debug_label_dsc.font = &lv_font_montserrat_10;
+
+ lv_area_t debug_area;
+ if (create_and_check_label_area(debug_area, 10, 10, 200, LABEL_HEIGHT, canvas_width, canvas_height)) {
+ lv_canvas_draw_text(mesh_canvas, debug_area.x1, debug_area.y1, 200, &debug_label_dsc, debug_info);
+ }
+#endif
+ right_label_dsc.align = LV_TEXT_ALIGN_RIGHT;
+
+ lv_area_t z_info_area;
+ if (create_and_check_label_area(z_info_area, canvas_width - 160, canvas_height - 25, 150, LABEL_HEIGHT, canvas_width, canvas_height)) {
+ lv_canvas_draw_text(mesh_canvas, z_info_area.x1, z_info_area.y1, 150, &right_label_dsc, z_info);
+ }
+}
+
+// Convert 3D world coordinates to 2D screen coordinates using perspective projection
+BedMeshPanel::Point3D BedMeshPanel::project_3d_to_2d(double x, double y, double z, int canvas_width, int canvas_height)
+{
+ // Camera parameters for perspective projection using dynamic camera distance
+ double fov_scale = this->fov_scale;
+
+ // Apply user-controlled Z-axis rotation (rotate around vertical axis in the horizontal X/Y plane)
+ double z_angle = current_view_angle * M_PI / 180.0;
+ double cos_z = cos(z_angle);
+ double sin_z = sin(z_angle);
+
+ // Rotate around Z-axis (this rotates in the X/Y horizontal plane)
+ double rotated_x = x * cos_z - y * sin_z;
+ double rotated_y = x * sin_z + y * cos_z;
+ double rotated_z = z * z_display_scale; // Apply Z display scaling
+
+ // Apply dynamic X-axis rotation controlled by user
+ double x_angle = current_x_angle * M_PI / 180.0;
+ double cos_x = cos(x_angle);
+ double sin_x = sin(x_angle);
+
+ double final_x = rotated_x;
+ double final_y = rotated_y * cos_x + rotated_z * sin_x; // Fixed: + instead of - for Z component
+ double final_z = rotated_y * sin_x - rotated_z * cos_x; // Fixed: - instead of + for Z component
+
+ // Move camera back to avoid clipping
+ final_z += this->camera_distance;
+
+ // Perspective projection: project 3D to 2D using similar triangles
+ // screen_pos = (3d_pos * focal_length) / depth
+ double perspective_x = (final_x * fov_scale) / final_z;
+ double perspective_y = (final_y * fov_scale) / final_z;
+
+ // Convert to screen coordinates with offset for gradient band space
+ // Center mesh in the available space (canvas minus gradient band area)
+ int effective_width = canvas_width - GRADIENT_BAND_TOTAL_WIDTH;
+ int screen_x = GRADIENT_BAND_TOTAL_WIDTH + effective_width / 2 + (int)perspective_x;
+ int screen_y = canvas_height * Z_ORIGIN_VERTICAL_POSITION + (int)perspective_y;
+
+ Point3D result;
+ result.x = final_x;
+ result.y = final_y;
+ result.z = final_z;
+ result.screen_x = screen_x;
+ result.screen_y = screen_y;
+ result.depth = final_z; // Store depth for sorting (larger = further away)
+
+ return result;
+}
+
+// Triangle rasterization using scan-line algorithm for proper 3D polygon filling
+void BedMeshPanel::fill_triangle(int x1, int y1, int x2, int y2, int x3, int y3, lv_color_t color, int canvas_width, int canvas_height)
+{
+ // Sort vertices by Y coordinate (y1 <= y2 <= y3)
+ if (y1 > y2) { std::swap(x1, x2); std::swap(y1, y2); }
+ if (y2 > y3) { std::swap(x2, x3); std::swap(y2, y3); }
+ if (y1 > y2) { std::swap(x1, x2); std::swap(y1, y2); }
+
+ // Skip degenerate triangles
+ if (y1 == y3) return;
+
+ // For each scanline, interpolate left and right X coordinates
+ for (int y = std::max(0, y1); y <= std::min(canvas_height - 1, y3); y++) {
+ int x_left, x_right;
+
+ if (y < y2) {
+ // Upper half of triangle
+ if (y2 != y1) {
+ x_left = x1 + (x2 - x1) * (y - y1) / (y2 - y1);
+ } else {
+ x_left = x1;
+ }
+ if (y3 != y1) {
+ x_right = x1 + (x3 - x1) * (y - y1) / (y3 - y1);
+ } else {
+ x_right = x1;
+ }
+ } else {
+ // Lower half of triangle
+ if (y3 != y2) {
+ x_left = x2 + (x3 - x2) * (y - y2) / (y3 - y2);
+ } else {
+ x_left = x2;
+ }
+ if (y3 != y1) {
+ x_right = x1 + (x3 - x1) * (y - y1) / (y3 - y1);
+ } else {
+ x_right = x1;
+ }
+ }
+
+ // Ensure x_left <= x_right
+ if (x_left > x_right) std::swap(x_left, x_right);
+
+ // Clamp to canvas bounds and draw horizontal line
+ x_left = std::max(0, x_left);
+ x_right = std::min(canvas_width - 1, x_right);
+
+ if (x_left <= x_right) {
+ // Draw horizontal line using LVGL rect (width 1 for line)
+ lv_draw_rect_dsc_t rect_dsc;
+ lv_draw_rect_dsc_init(&rect_dsc);
+ rect_dsc.bg_color = color;
+ rect_dsc.bg_opa = LV_OPA_90;
+ rect_dsc.border_width = 0;
+
+ int width = x_right - x_left + 1;
+ if (width > 0) {
+ lv_canvas_draw_rect(mesh_canvas, x_left, y, width, 1, &rect_dsc);
+ }
+ }
+ }
+}
+
+// Triangle rasterization with gradient interpolation between vertex colors
+void BedMeshPanel::fill_triangle_gradient(int x1, int y1, lv_color_t c1, int x2, int y2, lv_color_t c2, int x3, int y3, lv_color_t c3, int canvas_width, int canvas_height)
+{
+ // Helper struct to keep vertex data together during sorting
+ struct Vertex {
+ int x, y;
+ lv_color_t color;
+ };
+
+ // Create vertex array and sort by Y coordinate
+ Vertex vertices[3] = {{x1, y1, c1}, {x2, y2, c2}, {x3, y3, c3}};
+
+ // Sort vertices by Y coordinate (bubble sort for 3 elements)
+ if (vertices[0].y > vertices[1].y) std::swap(vertices[0], vertices[1]);
+ if (vertices[1].y > vertices[2].y) std::swap(vertices[1], vertices[2]);
+ if (vertices[0].y > vertices[1].y) std::swap(vertices[0], vertices[1]);
+
+ // Skip degenerate triangles
+ if (vertices[0].y == vertices[2].y) return;
+
+ // Extract RGB components for interpolation
+ auto extract_rgb = [](lv_color_t color) -> std::tuple {
+ return {LV_COLOR_GET_R(color), LV_COLOR_GET_G(color), LV_COLOR_GET_B(color)};
+ };
+
+ auto [r1, g1, b1] = extract_rgb(vertices[0].color);
+ auto [r2, g2, b2] = extract_rgb(vertices[1].color);
+ auto [r3, g3, b3] = extract_rgb(vertices[2].color);
+
+ // For each scanline, interpolate left and right X coordinates and colors
+ for (int y = std::max(0, vertices[0].y); y <= std::min(canvas_height - 1, vertices[2].y); y++) {
+ int x_left, x_right;
+ int r_left, g_left, b_left, r_right, g_right, b_right;
+
+ if (y < vertices[1].y) {
+ // Upper half of triangle: interpolate between v0-v1 and v0-v2
+ float t1 = (vertices[1].y != vertices[0].y) ? (float)(y - vertices[0].y) / (vertices[1].y - vertices[0].y) : 0.0f;
+ float t2 = (vertices[2].y != vertices[0].y) ? (float)(y - vertices[0].y) / (vertices[2].y - vertices[0].y) : 0.0f;
+
+ x_left = vertices[0].x + (int)(t1 * (vertices[1].x - vertices[0].x));
+ x_right = vertices[0].x + (int)(t2 * (vertices[2].x - vertices[0].x));
+
+ r_left = r1 + (int)(t1 * (r2 - r1));
+ g_left = g1 + (int)(t1 * (g2 - g1));
+ b_left = b1 + (int)(t1 * (b2 - b1));
+
+ r_right = r1 + (int)(t2 * (r3 - r1));
+ g_right = g1 + (int)(t2 * (g3 - g1));
+ b_right = b1 + (int)(t2 * (b3 - b1));
+ } else {
+ // Lower half of triangle: interpolate between v1-v2 and v0-v2
+ float t1 = (vertices[2].y != vertices[1].y) ? (float)(y - vertices[1].y) / (vertices[2].y - vertices[1].y) : 0.0f;
+ float t2 = (vertices[2].y != vertices[0].y) ? (float)(y - vertices[0].y) / (vertices[2].y - vertices[0].y) : 0.0f;
+
+ x_left = vertices[1].x + (int)(t1 * (vertices[2].x - vertices[1].x));
+ x_right = vertices[0].x + (int)(t2 * (vertices[2].x - vertices[0].x));
+
+ r_left = r2 + (int)(t1 * (r3 - r2));
+ g_left = g2 + (int)(t1 * (g3 - g2));
+ b_left = b2 + (int)(t1 * (b3 - b2));
+
+ r_right = r1 + (int)(t2 * (r3 - r1));
+ g_right = g1 + (int)(t2 * (g3 - g1));
+ b_right = b1 + (int)(t2 * (b3 - b1));
+ }
+
+ // Ensure x_left <= x_right (swap if needed, including colors)
+ if (x_left > x_right) {
+ std::swap(x_left, x_right);
+ std::swap(r_left, r_right);
+ std::swap(g_left, g_right);
+ std::swap(b_left, b_right);
+ }
+
+ // Clamp to canvas bounds
+ int x_start = std::max(0, x_left);
+ int x_end = std::min(canvas_width - 1, x_right);
+
+ // Draw horizontal line with approximated gradient using fewer segments
+ if (x_start <= x_end) {
+ int line_width = x_end - x_start + 1;
+
+ // For performance, use solid color for very thin lines
+ if (line_width <= GRADIENT_MIN_LINE_WIDTH) {
+ // Use average color for short lines
+ int r = (r_left + r_right) / 2;
+ int g = (g_left + g_right) / 2;
+ int b = (b_left + b_right) / 2;
+
+ r = std::max(0, std::min(255, r));
+ g = std::max(0, std::min(255, g));
+ b = std::max(0, std::min(255, b));
+
+ lv_color_t avg_color = lv_color_make(r, g, b);
+
+ lv_draw_rect_dsc_t rect_dsc;
+ lv_draw_rect_dsc_init(&rect_dsc);
+ rect_dsc.bg_color = avg_color;
+ rect_dsc.bg_opa = LV_OPA_90;
+ rect_dsc.border_width = 0;
+
+ lv_canvas_draw_rect(mesh_canvas, x_start, y, line_width, 1, &rect_dsc);
+ } else {
+ // For longer lines, draw gradient using segments (max GRADIENT_MAX_SEGMENTS for performance)
+ int segments = std::min(GRADIENT_MAX_SEGMENTS, line_width / 4);
+ segments = std::max(1, segments);
+
+ for (int seg = 0; seg < segments; seg++) {
+ int seg_start = x_start + (seg * line_width) / segments;
+ int seg_end = x_start + ((seg + 1) * line_width) / segments - 1;
+ int seg_width = seg_end - seg_start + 1;
+
+ if (seg_width <= 0) continue;
+
+ // Interpolate color at segment center
+ float t = (x_right != x_left) ? (float)(seg_start + seg_width/2 - x_left) / (x_right - x_left) : 0.0f;
+
+ int r = r_left + (int)(t * (r_right - r_left));
+ int g = g_left + (int)(t * (g_right - g_left));
+ int b = b_left + (int)(t * (b_right - b_left));
+
+ r = std::max(0, std::min(255, r));
+ g = std::max(0, std::min(255, g));
+ b = std::max(0, std::min(255, b));
+
+ lv_color_t seg_color = lv_color_make(r, g, b);
+
+ lv_draw_rect_dsc_t rect_dsc;
+ lv_draw_rect_dsc_init(&rect_dsc);
+ rect_dsc.bg_color = seg_color;
+ rect_dsc.bg_opa = LV_OPA_90;
+ rect_dsc.border_width = 0;
+
+ lv_canvas_draw_rect(mesh_canvas, seg_start, y, seg_width, 1, &rect_dsc);
+ }
+ }
+ }
+ }
+}
+
+// Generate 3D quads from mesh data with proper vertex positioning and colors
+std::vector BedMeshPanel::generate_mesh_quads(int rows, int cols, double min_z, double max_z)
+{
+ std::vector quads;
+
+ // Use the same dynamic Z scaling as draw_3d_mesh with defined constants
+ double z_range = max_z - min_z;
+ double z_center = (min_z + max_z) / 2.0; // Center of the data range
+ double z_scale = DEFAULT_Z_SCALE;
+ if (z_range > 0.001) { // Avoid division by zero for flat surfaces
+ z_scale = DEFAULT_Z_TARGET_HEIGHT / z_range;
+ z_scale = std::max(DEFAULT_Z_MIN_SCALE, std::min(DEFAULT_Z_MAX_SCALE, z_scale));
+ }
+
+ // Generate quads for each mesh cell using dynamic scaling
+ for (int row = 0; row < rows - 1; row++) {
+ for (int col = 0; col < cols - 1; col++) {
+ Quad3D quad;
+
+ // Calculate world positions for the four corners of this mesh cell
+ // Center the mesh around origin for better rotation
+ double base_x = (col - cols / 2.0) * MESH_SCALE; // Restore original X: col 0 = left (min X)
+ double base_y = ((rows - 1 - row) - rows / 2.0) * MESH_SCALE; // Invert Y: row 0 = front (min Y)
+
+ // Bottom-left vertex (row, col) - center Z values around zero
+ quad.vertices[0].x = base_x;
+ quad.vertices[0].y = base_y;
+ quad.vertices[0].z = (mesh[row][col] - z_center) * z_scale;
+ quad.vertices[0].color = color_gradient_enhanced(mesh[row][col], min_z, max_z);
+
+ // Bottom-right vertex (row, col+1)
+ quad.vertices[1].x = base_x + MESH_SCALE;
+ quad.vertices[1].y = base_y;
+ quad.vertices[1].z = (mesh[row][col + 1] - z_center) * z_scale;
+ quad.vertices[1].color = color_gradient_enhanced(mesh[row][col + 1], min_z, max_z);
+
+ // Top-left vertex (row+1, col) - use inverted Y for row+1
+ quad.vertices[2].x = base_x;
+ quad.vertices[2].y = ((rows - 1 - (row + 1)) - rows / 2.0) * MESH_SCALE;
+ quad.vertices[2].z = (mesh[row + 1][col] - z_center) * z_scale;
+ quad.vertices[2].color = color_gradient_enhanced(mesh[row + 1][col], min_z, max_z);
+
+ // Top-right vertex (row+1, col+1)
+ quad.vertices[3].x = base_x + MESH_SCALE;
+ quad.vertices[3].y = ((rows - 1 - (row + 1)) - rows / 2.0) * MESH_SCALE;
+ quad.vertices[3].z = (mesh[row + 1][col + 1] - z_center) * z_scale;
+ quad.vertices[3].color = color_gradient_enhanced(mesh[row + 1][col + 1], min_z, max_z);
+
+ // Calculate average height for center color (fallback)
+ double avg_height = (mesh[row][col] + mesh[row][col + 1] +
+ mesh[row + 1][col] + mesh[row + 1][col + 1]) / 4.0;
+ quad.center_color = color_gradient_enhanced(avg_height, min_z, max_z);
+
+ // Calculate average depth for sorting (will be updated after projection)
+ quad.avg_depth = 0; // Will be calculated during projection
+
+ quads.push_back(quad);
+ }
+ }
+
+ return quads;
+}
+
+// Sort quads by depth for proper back-to-front rendering (painter's algorithm)
+void BedMeshPanel::sort_quads_by_depth(std::vector& quads)
+{
+ // Sort by average depth - furthest away (largest depth) first
+ std::sort(quads.begin(), quads.end(), [](const Quad3D& a, const Quad3D& b) {
+ return a.avg_depth > b.avg_depth; // Render back-to-front
+ });
+}
+
+// Render a single 3D quad as two triangles using proper triangle rasterization
+void BedMeshPanel::draw_3d_quad(const Quad3D& quad, int canvas_width, int canvas_height)
+{
+ // Project all vertices to 2D screen coordinates
+ Point3D projected[4];
+ for (int i = 0; i < 4; i++) {
+ projected[i] = project_3d_to_2d(quad.vertices[i].x, quad.vertices[i].y, quad.vertices[i].z,
+ canvas_width, canvas_height);
+ }
+
+ // Simple culling with margin - skip if all vertices are far off-screen
+ // The margin allows partially visible triangles to be rendered
+ bool any_visible = false;
+ for (int i = 0; i < 4; i++) {
+ if (projected[i].screen_x >= -CULLING_MARGIN && projected[i].screen_x < canvas_width + CULLING_MARGIN &&
+ projected[i].screen_y >= -CULLING_MARGIN && projected[i].screen_y < canvas_height + CULLING_MARGIN) {
+ any_visible = true;
+ break;
+ }
+ }
+
+ if (!any_visible) return; // Skip if completely off-screen
+
+ // Use solid colors during dragging for performance, gradients when static
+ if (is_dragging) {
+ // Fast solid color rendering during drag operations
+ // Triangle 1: vertices 0, 1, 2 (bottom-left, bottom-right, top-left)
+ fill_triangle(projected[0].screen_x, projected[0].screen_y,
+ projected[1].screen_x, projected[1].screen_y,
+ projected[2].screen_x, projected[2].screen_y,
+ quad.center_color, canvas_width, canvas_height);
+
+ // Triangle 2: vertices 1, 2, 3 (bottom-right, top-left, top-right)
+ fill_triangle(projected[1].screen_x, projected[1].screen_y,
+ projected[2].screen_x, projected[2].screen_y,
+ projected[3].screen_x, projected[3].screen_y,
+ quad.center_color, canvas_width, canvas_height);
+ } else {
+ // High-quality gradient rendering when not dragging
+ // Triangle 1: vertices 0, 1, 2 (bottom-left, bottom-right, top-left)
+ fill_triangle_gradient(projected[0].screen_x, projected[0].screen_y, quad.vertices[0].color,
+ projected[1].screen_x, projected[1].screen_y, quad.vertices[1].color,
+ projected[2].screen_x, projected[2].screen_y, quad.vertices[2].color,
+ canvas_width, canvas_height);
+
+ // Triangle 2: vertices 1, 2, 3 (bottom-right, top-left, top-right)
+ fill_triangle_gradient(projected[1].screen_x, projected[1].screen_y, quad.vertices[1].color,
+ projected[2].screen_x, projected[2].screen_y, quad.vertices[2].color,
+ projected[3].screen_x, projected[3].screen_y, quad.vertices[3].color,
+ canvas_width, canvas_height);
+ }
+}
+
+void BedMeshPanel::draw_3d_mesh()
+{
+ if (mesh.empty()) {
+ spdlog::debug("Cannot draw 3D mesh: mesh data is empty");
+ return;
+ }
+
+ if (canvas_draw_buf == nullptr) {
+ spdlog::debug("Cannot draw 3D mesh: canvas buffer is null");
+ return;
+ }
+
+ spdlog::debug("draw_3d_mesh: z_display_scale={}", z_display_scale);
+
+ int canvas_width = lv_obj_get_width(mesh_canvas);
+ int canvas_height = lv_obj_get_height(mesh_canvas);
+ int mesh_rows = mesh.size();
+ int mesh_cols = mesh[0].size();
+
+ spdlog::debug("Drawing proper 3D mesh: canvas {}x{}, mesh {}x{}", canvas_width, canvas_height, mesh_rows, mesh_cols);
+
+ // Clear canvas with dark slate gray background
+ lv_canvas_fill_bg(mesh_canvas, lv_color_make(40, 40, 40), LV_OPA_COVER);
+
+ // Find min/max Z values for color mapping
+ double min_z = mesh[0][0], max_z = mesh[0][0];
+ for (const auto& row : mesh) {
+ for (double val : row) {
+ min_z = std::min(min_z, val);
+ max_z = std::max(max_z, val);
+ }
+ }
+
+ // Calculate dynamic Z scaling using defined constants
+ double z_range = max_z - min_z;
+ double z_scale = DEFAULT_Z_SCALE;
+ if (z_range > 0.001) { // Avoid division by zero for flat surfaces
+ z_scale = DEFAULT_Z_TARGET_HEIGHT / z_range;
+ z_scale = std::max(DEFAULT_Z_MIN_SCALE, std::min(DEFAULT_Z_MAX_SCALE, z_scale));
+ }
+
+ // Calculate dynamic FOV scale to fit mesh in canvas with padding
+ fov_scale = calculate_dynamic_fov_scale(mesh_rows, mesh_cols, canvas_width, canvas_height);
+
+ spdlog::debug("Bed mesh display: rotation={:.1f}°, Z range=[{:.3f} to {:.3f}]mm (span={:.3f}mm), scale={:.2f}, fov={:.1f}, camera_distance={:.1f}",
+ VIEW_ANGLE_X_DEGREES, min_z, max_z, z_range, z_scale, fov_scale, camera_distance);
+
+ // Generate 3D quads from mesh data
+ std::vector quads = generate_mesh_quads(mesh_rows, mesh_cols, min_z, max_z);
+
+ // Project quads to screen coordinates and calculate depths for sorting
+ for (auto& quad : quads) {
+ double total_depth = 0;
+ for (int i = 0; i < 4; i++) {
+ Point3D projected = project_3d_to_2d(quad.vertices[i].x, quad.vertices[i].y, quad.vertices[i].z,
+ canvas_width, canvas_height);
+ total_depth += projected.depth;
+ }
+ quad.avg_depth = total_depth / 4.0; // Average depth of all vertices
+ }
+
+ // Create point grid for wireframe and axes (convert quads back to point grid)
+ std::vector> point_grid(mesh_rows, std::vector(mesh_cols));
+
+ // Calculate Z center for consistent centering with quads
+ double z_center = (min_z + max_z) / 2.0;
+
+ for (int row = 0; row < mesh_rows; row++) {
+ for (int col = 0; col < mesh_cols; col++) {
+ double world_x = (col - mesh_cols / 2.0) * MESH_SCALE; // Restore original X: col 0 = left (min X)
+ double world_y = ((mesh_rows - 1 - row) - mesh_rows / 2.0) * MESH_SCALE; // Invert Y: row 0 = front (min Y)
+ double world_z = (mesh[row][col] - z_center) * z_scale; // Center Z values around zero
+
+ point_grid[row][col] = project_3d_to_2d(world_x, world_y, world_z, canvas_width, canvas_height);
+ }
+ }
+
+ // Draw coordinate axes and labels FIRST (behind the mesh)
+ draw_axes_and_labels(point_grid, mesh_rows, mesh_cols, canvas_width, canvas_height, min_z, max_z, z_scale);
+
+ // Sort quads by depth (back-to-front rendering for proper overlapping)
+ sort_quads_by_depth(quads);
+
+ // Render all quads in sorted order (ON TOP of axes/grid)
+ for (const auto& quad : quads) {
+ draw_3d_quad(quad, canvas_width, canvas_height);
+ }
+
+ // Draw wireframe grid overlay following the 3D surface (on top)
+ draw_mesh_wireframe(point_grid, mesh_rows, mesh_cols, canvas_width, canvas_height);
+
+ // Draw vertical color gradient band on the left side (on top of everything)
+ draw_color_gradient_band(canvas_width, canvas_height, min_z, max_z);
+
+ // Force canvas redraw
+ lv_obj_invalidate(mesh_canvas);
+ spdlog::debug("3D mesh rendering completed with {} quads", quads.size());
+}
+
+void BedMeshPanel::handle_z_zoom_in(lv_event_t *event)
+{
+ double old_display_scale = z_display_scale;
+
+ // Z+ = ZOOM IN: increase Z visual height only (not color sensitivity)
+ z_display_scale *= 1.1; // Increase Z display range by 10%
+
+ if (z_display_scale > MAX_Z_SCALE) z_display_scale = MAX_Z_SCALE;
+
+ spdlog::debug("Z+ ZOOM IN: display_scale {} -> {}",
+ old_display_scale, z_display_scale);
+
+ // Update button states
+ lv_obj_clear_state(z_zoom_out_btn, LV_STATE_DISABLED);
+ if (z_display_scale >= MAX_Z_SCALE) {
+ lv_obj_add_state(z_zoom_in_btn, LV_STATE_DISABLED);
+ }
+
+ draw_3d_mesh();
+}
+
+void BedMeshPanel::handle_z_zoom_out(lv_event_t *event)
+{
+ double old_display_scale = z_display_scale;
+
+ // Z- = ZOOM OUT: reduce Z visual height only (not color sensitivity)
+ z_display_scale *= 0.9; // Reduce Z display range by 10%
+
+ if (z_display_scale < MIN_Z_SCALE) z_display_scale = MIN_Z_SCALE;
+
+ spdlog::debug("Z- ZOOM OUT: display_scale {} -> {}",
+ old_display_scale, z_display_scale);
+
+ // Update button states
+ lv_obj_clear_state(z_zoom_in_btn, LV_STATE_DISABLED);
+ if (z_display_scale <= MIN_Z_SCALE) {
+ lv_obj_add_state(z_zoom_out_btn, LV_STATE_DISABLED);
+ }
+
+ draw_3d_mesh();
+}
+
+void BedMeshPanel::handle_fov_zoom_in(lv_event_t *event)
+{
+ double old_camera_distance = camera_distance;
+
+ // FOV+ = ZOOM IN: decrease camera distance for closer view (tighter FOV)
+ camera_distance *= 0.9; // Decrease distance by 10%
+
+ if (camera_distance < MIN_CAMERA_DISTANCE) camera_distance = MIN_CAMERA_DISTANCE;
+
+ spdlog::debug("FOV+ ZOOM IN: camera_distance {} -> {} (closer view)",
+ old_camera_distance, camera_distance);
+
+ // Update button states
+ lv_obj_clear_state(fov_zoom_out_btn, LV_STATE_DISABLED);
+ if (camera_distance <= MIN_CAMERA_DISTANCE) {
+ lv_obj_add_state(fov_zoom_in_btn, LV_STATE_DISABLED);
+ }
+
+ draw_3d_mesh();
+}
+
+void BedMeshPanel::handle_fov_zoom_out(lv_event_t *event)
+{
+ double old_camera_distance = camera_distance;
+
+ // FOV- = ZOOM OUT: increase camera distance for wider view (wider FOV)
+ camera_distance *= 1.1; // Increase distance by 10%
+
+ if (camera_distance > MAX_CAMERA_DISTANCE) camera_distance = MAX_CAMERA_DISTANCE;
+
+ spdlog::debug("FOV- ZOOM OUT: camera_distance {} -> {} (wider view)",
+ old_camera_distance, camera_distance);
+
+ // Update button states
+ lv_obj_clear_state(fov_zoom_in_btn, LV_STATE_DISABLED);
+ if (camera_distance >= MAX_CAMERA_DISTANCE) {
+ lv_obj_add_state(fov_zoom_out_btn, LV_STATE_DISABLED);
+ }
+
+ draw_3d_mesh();
+}
+
+
static lv_color_t color_gradient(double offset)
{
double red_max = 0.25;
@@ -478,3 +1596,709 @@ static lv_color_t color_gradient(double offset)
return lv_color_make(255, 255, 255);
}
+
+static lv_color_t color_gradient_enhanced(double offset, double min_val, double max_val)
+{
+ double range = max_val - min_val;
+ if (range == 0) {
+ return lv_color_make(0, 255, 0); // Green for flat surface
+ }
+
+ // Use a dynamic color scale range based on actual mesh data variation
+ // This automatically adapts to the data: flat beds get high contrast, varied beds get appropriate scaling
+ double data_range = range; // Full range of actual mesh data
+
+ // Apply compression factor for enhanced visual contrast
+ // Smaller factor = more dramatic colors for small variations
+ double adjusted_range = data_range * BedMeshPanel::COLOR_COMPRESSION_FACTOR;
+
+ // Ensure minimum range to prevent division by zero and ensure visible colors
+ const double min_color_range = 0.01; // mm
+ adjusted_range = std::max(adjusted_range, min_color_range);
+
+ // static int call_count = 0;
+ // call_count++;
+ // if (call_count <= 5 || call_count % 20 == 0) { // Log first 5 calls, then every 20th
+ // spdlog::debug("color_gradient_enhanced[{}]: data_range={:.3f}mm, compression_factor={}, adjusted_range={:.3f}mm",
+ // call_count, data_range, BedMeshPanel::COLOR_COMPRESSION_FACTOR, adjusted_range);
+ // }
+
+ // Center the data range within our color scale
+ double data_center = (min_val + max_val) / 2.0;
+ double color_scale_min = data_center - (adjusted_range / 2.0);
+
+ // Map offset to the adjusted color scale
+ double normalized = (offset - color_scale_min) / adjusted_range;
+ normalized = std::max(0.0, std::min(1.0, normalized)); // Clamp to [0,1]
+
+ // Create smooth scientific heat map gradient using continuous interpolation
+ // Classic "jet" colormap: Purple → Blue → Cyan → Green → Yellow → Orange → Red
+ // High normalized values = high heights = red, low normalized values = low heights = purple
+ uint8_t r, g, b;
+
+ // Use smooth continuous functions to eliminate banding
+ // Each transition point is carefully matched to ensure continuity
+
+ // Clamp to ensure we're in valid range
+ normalized = std::max(0.0, std::min(1.0, normalized));
+
+ if (normalized < 0.125) {
+ // Purple to Blue (0.0 to 0.125)
+ double t = normalized / 0.125;
+ r = (uint8_t)(128 * (1.0 - t)); // 128 → 0
+ g = (uint8_t)(0 + t * 128); // 0 → 128
+ b = (uint8_t)(255); // constant blue
+ } else if (normalized < 0.375) {
+ // Blue to Cyan (0.125 to 0.375)
+ double t = (normalized - 0.125) / 0.25;
+ r = (uint8_t)(0); // constant 0
+ g = (uint8_t)(128 + t * 127); // 128 → 255
+ b = (uint8_t)(255); // constant blue
+ } else if (normalized < 0.625) {
+ // Cyan to Yellow (0.375 to 0.625)
+ double t = (normalized - 0.375) / 0.25;
+ r = (uint8_t)(t * 255); // 0 → 255
+ g = (uint8_t)(255); // constant green
+ b = (uint8_t)(255 * (1.0 - t)); // 255 → 0
+ } else if (normalized < 0.875) {
+ // Yellow to Red (0.625 to 0.875)
+ double t = (normalized - 0.625) / 0.25;
+ r = (uint8_t)(255); // constant red
+ g = (uint8_t)(255 * (1.0 - t)); // 255 → 0
+ b = (uint8_t)(0); // constant 0
+ } else {
+ // Deep Red (0.875 to 1.0) - keep it pure red
+ r = (uint8_t)(255); // constant bright red
+ g = (uint8_t)(0); // keep green at 0 to avoid pink
+ b = (uint8_t)(0); // keep blue at 0 to avoid pink
+ }
+
+ // De-saturate all colors by 35% by blending with gray
+ const double desaturation = 0.35;
+ uint8_t gray = (uint8_t)((r + g + b) / 3); // Calculate grayscale value
+
+ r = (uint8_t)(r * (1.0 - desaturation) + gray * desaturation);
+ g = (uint8_t)(g * (1.0 - desaturation) + gray * desaturation);
+ b = (uint8_t)(b * (1.0 - desaturation) + gray * desaturation);
+
+ return lv_color_make(r, g, b);
+}
+
+void BedMeshPanel::draw_horizontal_axes(double mesh_origin_x, double mesh_origin_y, double grid_reference_z,
+ int rows, int cols, int canvas_width, int canvas_height,
+ const lv_draw_line_dsc_t& axis_dsc)
+{
+ // Draw X-axis (horizontal line extending in X direction from origin)
+ double x_axis_start = mesh_origin_x;
+ double x_axis_end = ((cols-1) - cols / 2.0) * MESH_SCALE; // Right edge of mesh
+ Point3D x_start = project_3d_to_2d(x_axis_start, mesh_origin_y, grid_reference_z, canvas_width, canvas_height);
+ Point3D x_end = project_3d_to_2d(x_axis_end, mesh_origin_y, grid_reference_z, canvas_width, canvas_height);
+
+ lv_point_t x_line[2] = {{x_start.screen_x, x_start.screen_y}, {x_end.screen_x, x_end.screen_y}};
+ lv_canvas_draw_line(mesh_canvas, x_line, 2, &axis_dsc);
+
+ // Draw Y-axis (horizontal line extending in Y direction from origin)
+ double y_axis_start = mesh_origin_y;
+ double y_axis_end = ((rows-1) - rows / 2.0) * MESH_SCALE; // Back edge of mesh
+ Point3D y_start = project_3d_to_2d(mesh_origin_x, y_axis_start, grid_reference_z, canvas_width, canvas_height);
+ Point3D y_end = project_3d_to_2d(mesh_origin_x, y_axis_end, grid_reference_z, canvas_width, canvas_height);
+
+ lv_point_t y_line[2] = {{y_start.screen_x, y_start.screen_y}, {y_end.screen_x, y_end.screen_y}};
+ lv_canvas_draw_line(mesh_canvas, y_line, 2, &axis_dsc);
+
+ // Add X-axis labels along the front edge (max Y)
+ lv_draw_label_dsc_t label_dsc;
+ lv_draw_label_dsc_init(&label_dsc);
+ label_dsc.color = lv_color_white();
+ label_dsc.font = &lv_font_montserrat_10;
+
+ double front_y = ((rows-1) - rows / 2.0) * MESH_SCALE;
+
+ // Draw X-axis tick values
+ draw_axis_tick_values(cols, true, rows, cols, grid_reference_z, canvas_width, canvas_height, label_dsc);
+
+ // Draw X-axis label
+ lv_draw_label_dsc_t axis_label_dsc;
+ lv_draw_label_dsc_init(&axis_label_dsc);
+ axis_label_dsc.color = lv_color_white();
+ axis_label_dsc.font = &lv_font_montserrat_10;
+
+ double middle_x_coord = ((cols/2) - cols / 2.0) * MESH_SCALE;
+ draw_axis_label("X", middle_x_coord, front_y + TICK_MARK_LENGTH * 6, grid_reference_z, -10, 0, canvas_width, canvas_height, axis_label_dsc);
+
+ // Draw Y-axis tick values
+ draw_axis_tick_values(rows, false, rows, cols, grid_reference_z, canvas_width, canvas_height, label_dsc);
+
+ // Draw Y-axis label
+ double right_x = ((cols-1) - cols / 2.0) * MESH_SCALE;
+ double middle_y_coord = ((rows/2) - rows / 2.0) * MESH_SCALE;
+ draw_axis_label("Y", right_x + TICK_MARK_LENGTH * 6, middle_y_coord, grid_reference_z, 0, -10, canvas_width, canvas_height, axis_label_dsc);
+
+}
+
+
+void BedMeshPanel::draw_color_gradient_band(int canvas_width, int canvas_height, double min_z, double max_z)
+{
+ // Calculate gradient band position and dimensions
+ int band_x = GRADIENT_BAND_MARGIN;
+ int band_width = GRADIENT_BAND_WIDTH;
+ int band_height = (canvas_height * GRADIENT_BAND_HEIGHT_RATIO) / 100;
+ int band_y = (canvas_height - band_height) / 2; // Center vertically
+
+ // Calculate the same extended color range that color_gradient_enhanced uses
+ double data_range = max_z - min_z;
+ double adjusted_range = data_range * COLOR_COMPRESSION_FACTOR;
+ const double min_color_range = 0.01; // mm (same as in color_gradient_enhanced)
+ adjusted_range = std::max(adjusted_range, min_color_range);
+
+ // Center the data range within our color scale (same logic as color_gradient_enhanced)
+ double data_center = (min_z + max_z) / 2.0;
+ double color_scale_min = data_center - (adjusted_range / 2.0);
+ double color_scale_max = data_center + (adjusted_range / 2.0);
+
+ // Draw the vertical gradient band pixel by pixel
+ for (int y = 0; y < band_height; y++) {
+ // Calculate the Z value this pixel represents using the extended color scale range
+ // Flip the gradient: top should be high Z (color_scale_max), bottom should be low Z (color_scale_min)
+ double progress = (double)y / (band_height - 1);
+ double z_value = color_scale_max - progress * (color_scale_max - color_scale_min); // Top = color_scale_max, bottom = color_scale_min
+
+ // Get the color for this Z value using the same function as the mesh
+ lv_color_t pixel_color = color_gradient_enhanced(z_value, min_z, max_z);
+
+ // Draw a horizontal line at this Y position across the band width
+ lv_draw_line_dsc_t line_dsc;
+ lv_draw_line_dsc_init(&line_dsc);
+ line_dsc.color = pixel_color;
+ line_dsc.width = 1;
+ line_dsc.opa = LV_OPA_COVER;
+
+ lv_point_t line_points[2];
+ line_points[0].x = band_x;
+ line_points[0].y = band_y + y;
+ line_points[1].x = band_x + band_width - 1;
+ line_points[1].y = band_y + y;
+
+ lv_canvas_draw_line(mesh_canvas, line_points, 2, &line_dsc);
+ }
+
+ // Add text labels for min and max values
+ lv_draw_label_dsc_t label_dsc;
+ lv_draw_label_dsc_init(&label_dsc);
+ label_dsc.color = lv_color_white();
+ label_dsc.opa = LV_OPA_COVER;
+ label_dsc.font = &lv_font_montserrat_10;
+
+ // Max value label at the top (showing extended color scale max)
+ char max_label[16];
+ snprintf(max_label, sizeof(max_label), "%.2f", color_scale_max);
+ lv_area_t max_area;
+ max_area.x1 = band_x + band_width + 5;
+ max_area.y1 = band_y - 5;
+ max_area.x2 = max_area.x1 + 40;
+ max_area.y2 = max_area.y1 + LABEL_HEIGHT;
+
+ if (max_area.x2 < canvas_width && max_area.y1 >= 0) {
+ lv_canvas_draw_text(mesh_canvas, max_area.x1, max_area.y1, 40, &label_dsc, max_label);
+ }
+
+ // Min value label at the bottom (showing extended color scale min)
+ char min_label[16];
+ snprintf(min_label, sizeof(min_label), "%.2f", color_scale_min);
+ lv_area_t min_area;
+ min_area.x1 = band_x + band_width + 5;
+ min_area.y1 = band_y + band_height - LABEL_HEIGHT + 5;
+ min_area.x2 = min_area.x1 + 40;
+ min_area.y2 = min_area.y1 + LABEL_HEIGHT;
+
+ if (min_area.x2 < canvas_width && min_area.y2 < canvas_height) {
+ lv_canvas_draw_text(mesh_canvas, min_area.x1, min_area.y1, 40, &label_dsc, min_label);
+ }
+}
+
+void BedMeshPanel::setup_button_with_label(lv_obj_t* btn, const char* text, int width, int height, lv_event_cb_t callback)
+{
+ lv_obj_set_size(btn, width, height);
+ lv_obj_t *label = lv_label_create(btn);
+ lv_label_set_text(label, text);
+ lv_obj_center(label);
+ lv_obj_add_event_cb(btn, callback, LV_EVENT_CLICKED, this);
+}
+
+void BedMeshPanel::draw_grid_lines_parallel_to_axis(int axis_count, bool is_x_axis, int rows, int cols, double grid_reference_z, int canvas_width, int canvas_height, const lv_draw_line_dsc_t& line_dsc)
+{
+ for (int i = 0; i < axis_count; i++) {
+ double start_x, start_y, end_x, end_y;
+
+ if (is_x_axis) {
+ // Draw lines parallel to X-axis (for each column)
+ start_x = (i - cols / 2.0) * MESH_SCALE;
+ start_y = (0 - rows / 2.0) * MESH_SCALE;
+ end_x = (i - cols / 2.0) * MESH_SCALE;
+ end_y = ((rows-1) - rows / 2.0) * MESH_SCALE;
+ } else {
+ // Draw lines parallel to Y-axis (for each row)
+ start_x = (0 - cols / 2.0) * MESH_SCALE;
+ start_y = (i - rows / 2.0) * MESH_SCALE;
+ end_x = ((cols-1) - cols / 2.0) * MESH_SCALE;
+ end_y = (i - rows / 2.0) * MESH_SCALE;
+ }
+
+ Point3D start = project_3d_to_2d(start_x, start_y, grid_reference_z, canvas_width, canvas_height);
+ Point3D end = project_3d_to_2d(end_x, end_y, grid_reference_z, canvas_width, canvas_height);
+
+
+ lv_point_t grid_line[2] = {{start.screen_x, start.screen_y}, {end.screen_x, end.screen_y}};
+ lv_canvas_draw_line(mesh_canvas, grid_line, 2, &line_dsc);
+ }
+}
+
+bool BedMeshPanel::create_and_check_label_area(lv_area_t& area, int x, int y, int width, int height, int canvas_width, int canvas_height)
+{
+ area.x1 = x;
+ area.y1 = y;
+ area.x2 = area.x1 + width;
+ area.y2 = area.y1 + height;
+ return (area.x1 >= 0 && area.x2 < canvas_width && area.y1 >= 0 && area.y2 < canvas_height);
+}
+
+void BedMeshPanel::extract_mesh_parameter_values(const json& mesh_params, const std::vector& param_names, std::vector& values)
+{
+ values.clear();
+ for (const auto& param : param_names) {
+ auto v = mesh_params[json::json_pointer(fmt::format("/{}", param))];
+ if (!v.is_null()) {
+ values.push_back(v.template get());
+ }
+ }
+}
+
+double BedMeshPanel::calculate_dynamic_fov_scale(int mesh_rows, int mesh_cols, int canvas_width, int canvas_height)
+{
+ // Calculate the 2D bounding box of the mesh in world coordinates
+ double mesh_world_width = (mesh_cols - 1) * MESH_SCALE;
+ double mesh_world_height = (mesh_rows - 1) * MESH_SCALE;
+
+ // Account for the rotation by using the diagonal as the conservative bound
+ double mesh_diagonal = sqrt(mesh_world_width * mesh_world_width + mesh_world_height * mesh_world_height);
+
+ // Calculate available canvas space after padding and gradient band
+ double effective_canvas_width = canvas_width - GRADIENT_BAND_TOTAL_WIDTH;
+ double available_width = effective_canvas_width * (1.0 - 2.0 * CANVAS_PADDING_RATIO);
+ double available_height = canvas_height * (1.0 - 2.0 * CANVAS_PADDING_RATIO);
+
+ // Use the smaller dimension to ensure the mesh fits in both directions
+ double available_space = std::min(available_width, available_height);
+
+ // Calculate the FOV scale needed to fit the mesh diagonal in the available space
+ // The formula is: fov_scale = (available_pixels * camera_distance) / world_size
+ double fov_scale = (available_space * CAMERA_DISTANCE) / mesh_diagonal;
+
+ return fov_scale;
+}
+
+void BedMeshPanel::handle_canvas_drag(lv_event_t *event)
+{
+ lv_event_code_t code = lv_event_get_code(event);
+
+ // Optional debug logging (disabled for now)
+ // if (code == LV_EVENT_PRESSED || code == LV_EVENT_PRESSING || code == LV_EVENT_RELEASED) {
+ // spdlog::debug("Canvas drag event: {}", (int)code);
+ // }
+
+ lv_indev_t *indev = lv_indev_get_act();
+ if (indev == nullptr) {
+ spdlog::debug("No active input device");
+ return;
+ }
+
+
+ lv_point_t point;
+ lv_indev_get_point(indev, &point);
+
+ if (code == LV_EVENT_PRESSED) {
+ // Start drag
+ is_dragging = true;
+ drag_start_x = point.x;
+ last_drag_x = point.x;
+ drag_start_y = point.y;
+ last_drag_y = point.y;
+ // spdlog::debug("Canvas drag started at x={}, y={}", point.x, point.y);
+ }
+ else if (code == LV_EVENT_PRESSING && is_dragging) {
+ // Continue drag - calculate rotation and tilt based on movement
+ int current_x = point.x;
+ int current_y = point.y;
+ int drag_distance_x = current_x - last_drag_x;
+ int drag_distance_y = current_y - last_drag_y;
+
+ bool mesh_changed = false;
+
+ // Horizontal drag = Z rotation (around vertical axis)
+ if (abs(drag_distance_x) > 0) {
+ // Convert horizontal drag distance to rotation angle
+ // Scale factor: 1 pixel = 0.5 degrees, reversed for natural feel
+ double z_angle_change = -drag_distance_x * 0.5;
+
+ // Update Z rotation angle (round to nearest integer)
+ current_view_angle += (int)round(z_angle_change);
+
+ // Wrap angle to 0-359 degrees
+ while (current_view_angle < 0) current_view_angle += 360;
+ while (current_view_angle >= 360) current_view_angle -= 360;
+
+ mesh_changed = true;
+ }
+
+ // Vertical drag = X tilt (tilt up/down)
+ if (abs(drag_distance_y) > 0) {
+ // Convert vertical drag distance to tilt angle
+ // Scale factor: 1 pixel = 0.5 degrees, natural direction (drag up = tilt up)
+ double x_angle_change = drag_distance_y * 0.5;
+
+ // Update X tilt angle with limits (-90 to -10 degrees for good viewing)
+ current_x_angle += x_angle_change;
+
+ // Clamp to reasonable viewing limits
+ const double MIN_X_ANGLE = -85.0; // Don't go too vertical
+ const double MAX_X_ANGLE = -10.0; // Don't go too horizontal
+ current_x_angle = std::max(MIN_X_ANGLE, std::min(MAX_X_ANGLE, current_x_angle));
+
+ mesh_changed = true;
+ }
+
+ // Update last position
+ last_drag_x = current_x;
+ last_drag_y = current_y;
+
+ // Redraw the mesh if any angle changed
+ if (mesh_changed) {
+ draw_3d_mesh();
+ }
+
+ // if (abs(drag_distance_x) > 0 || abs(drag_distance_y) > 0) {
+ // spdlog::debug("Canvas drag: x={}, y={}, z_angle={}, x_angle={:.1f}",
+ // current_x, current_y, current_view_angle, current_x_angle);
+ // }
+ }
+ else if (code == LV_EVENT_RELEASED) {
+ // End drag
+ is_dragging = false;
+
+ // Redraw with high-quality gradients now that dragging has stopped
+ if (show_3d_view && !mesh.empty()) {
+ draw_3d_mesh();
+ }
+ // spdlog::debug("Canvas drag ended at z_angle={}, x_angle={:.1f}", current_view_angle, current_x_angle);
+ }
+}
+
+// Helper function to draw axis tick values for X and Y axes
+void BedMeshPanel::draw_axis_tick_values(int count, bool is_x_axis, int rows, int cols, double grid_reference_z,
+ int canvas_width, int canvas_height, lv_draw_label_dsc_t& label_dsc) {
+ if (is_dragging) return; // Skip during drag for performance
+
+ double edge_coord = is_x_axis ? ((rows-1) - rows / 2.0) * MESH_SCALE // front_y for X-axis
+ : ((cols-1) - cols / 2.0) * MESH_SCALE; // right_x for Y-axis
+
+ for (int i = 0; i < count; i += 2) {
+ double coord, actual_value;
+ Point3D label_pos;
+
+ if (is_x_axis) {
+ // X-axis: labels along front edge
+ coord = (i - cols / 2.0) * MESH_SCALE;
+ actual_value = mesh_min_x + (i * (mesh_max_x - mesh_min_x) / (cols - 1));
+ label_pos = project_3d_to_2d(coord, edge_coord + TICK_MARK_LENGTH * 2, grid_reference_z, canvas_width, canvas_height);
+ } else {
+ // Y-axis: labels along right edge
+ coord = (i - rows / 2.0) * MESH_SCALE;
+ actual_value = mesh_min_y + (i * (mesh_max_y - mesh_min_y) / (rows - 1));
+ label_pos = project_3d_to_2d(edge_coord + TICK_MARK_LENGTH * 2, coord, grid_reference_z, canvas_width, canvas_height);
+ }
+
+ char label_text[16];
+ snprintf(label_text, sizeof(label_text), "%.0f", actual_value);
+
+ lv_area_t label_area;
+ int offset_x = is_x_axis ? -15 : 0;
+ int offset_y = is_x_axis ? 0 : -5;
+
+ if (create_and_check_label_area(label_area, label_pos.screen_x + offset_x, label_pos.screen_y + offset_y, 30, LABEL_HEIGHT, canvas_width, canvas_height)) {
+ lv_canvas_draw_text(mesh_canvas, label_area.x1, label_area.y1, 30, &label_dsc, label_text);
+ }
+ }
+}
+
+// Helper function to draw Z-axis tick values
+void BedMeshPanel::draw_z_axis_tick_values(double left_x, double front_y, double z_min, double z_max,
+ int z_divisions, double z_scale, int canvas_width, int canvas_height,
+ lv_draw_label_dsc_t& label_dsc, double min_z, double max_z) {
+ if (is_dragging) return; // Skip during drag for performance
+
+ for (int z_div = 0; z_div <= z_divisions; z_div += 2) {
+ double z_level = z_min + (z_div * (z_max - z_min) / z_divisions);
+ double actual_z = (z_level / z_scale / Z_AXIS_EXTENSION + (min_z + max_z) / 2.0);
+
+ Point3D label_pos = project_3d_to_2d(left_x - TICK_MARK_LENGTH * 2, front_y, z_level, canvas_width, canvas_height);
+
+ char label_text[16];
+ snprintf(label_text, sizeof(label_text), "%.2f", actual_z);
+
+ lv_area_t label_area;
+ if (create_and_check_label_area(label_area, label_pos.screen_x - 20, label_pos.screen_y - 5, 40, LABEL_HEIGHT, canvas_width, canvas_height)) {
+ lv_canvas_draw_text(mesh_canvas, label_area.x1, label_area.y1, 40, &label_dsc, label_text);
+ }
+ }
+}
+
+// Helper function to draw main axis labels (X, Y, Z)
+void BedMeshPanel::draw_axis_label(const char* label_text, double x, double y, double z, int offset_x, int offset_y,
+ int canvas_width, int canvas_height, lv_draw_label_dsc_t& label_dsc) {
+ Point3D label_pos = project_3d_to_2d(x, y, z, canvas_width, canvas_height);
+
+ lv_area_t label_area;
+ if (create_and_check_label_area(label_area, label_pos.screen_x + offset_x, label_pos.screen_y + offset_y, 20, LABEL_HEIGHT, canvas_width, canvas_height)) {
+ lv_canvas_draw_text(mesh_canvas, label_area.x1, label_area.y1, 20, &label_dsc, label_text);
+ }
+}
+
+void BedMeshPanel::handle_toggle_view(lv_event_t *event) {
+ if (lv_event_get_code(event) == LV_EVENT_CLICKED) {
+ show_3d_fullscreen();
+ }
+}
+
+// Table view: mesh values + profile list + all controls along the bottom.
+// This is the default state and uses the original vertical layout.
+void BedMeshPanel::show_table_view() {
+ show_3d_view = false;
+
+ // Vertical layout: top area over a bottom controls row.
+ lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
+
+ lv_obj_set_width(top_cont, LV_PCT(100));
+ lv_obj_set_height(top_cont, LV_SIZE_CONTENT);
+
+ // Zoom controls belong back inside the display area (hidden in table view).
+ lv_obj_set_parent(rotation_cont, display_cont);
+ lv_obj_set_flex_flow(rotation_cont, LV_FLEX_FLOW_ROW);
+
+ lv_obj_set_size(controls_cont, LV_PCT(100), LV_SIZE_CONTENT);
+ lv_obj_set_flex_flow(controls_cont, LV_FLEX_FLOW_ROW);
+ lv_obj_set_flex_align(controls_cont, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER);
+ lv_obj_set_flex_grow(back_btn.get_container(), 1);
+
+ lv_obj_clear_flag(mesh_table, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(mesh_canvas, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(rotation_cont, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_clear_flag(profile_cont, LV_OBJ_FLAG_HIDDEN);
+
+ lv_obj_clear_flag(save_btn.get_container(), LV_OBJ_FLAG_HIDDEN);
+ lv_obj_clear_flag(clear_btn.get_container(), LV_OBJ_FLAG_HIDDEN);
+ lv_obj_clear_flag(calibrate_btn.get_container(), LV_OBJ_FLAG_HIDDEN);
+ lv_obj_clear_flag(toggle_view_btn.get_container(), LV_OBJ_FLAG_HIDDEN);
+}
+
+// Fullscreen 3D: canvas fills the left, a narrow right-side strip holds the
+// zoom +/- and Back buttons. The profile list and other controls are hidden.
+void BedMeshPanel::show_3d_fullscreen() {
+ show_3d_view = true;
+
+ // Horizontal layout: [ canvas (grows) ][ control strip ].
+ lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_ROW);
+
+ lv_obj_set_width(top_cont, LV_SIZE_CONTENT); // flex_grow fills the width
+ lv_obj_set_height(top_cont, LV_PCT(100));
+
+ // Right strip: zoom controls at the top, Back at the bottom.
+ lv_obj_set_size(controls_cont, LV_SIZE_CONTENT, LV_PCT(100));
+ lv_obj_set_flex_flow(controls_cont, LV_FLEX_FLOW_COLUMN);
+ lv_obj_set_flex_align(controls_cont, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
+ lv_obj_set_flex_grow(back_btn.get_container(), 0);
+
+ lv_obj_set_parent(rotation_cont, controls_cont);
+ lv_obj_set_flex_flow(rotation_cont, LV_FLEX_FLOW_COLUMN);
+ lv_obj_move_to_index(rotation_cont, 0);
+
+ lv_obj_add_flag(mesh_table, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_clear_flag(mesh_canvas, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_clear_flag(rotation_cont, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(profile_cont, LV_OBJ_FLAG_HIDDEN);
+
+ lv_obj_add_flag(save_btn.get_container(), LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(clear_btn.get_container(), LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(calibrate_btn.get_container(), LV_OBJ_FLAG_HIDDEN);
+ lv_obj_add_flag(toggle_view_btn.get_container(), LV_OBJ_FLAG_HIDDEN);
+
+ // Recompute layout so the canvas resize fires against the new (larger) area.
+ lv_obj_update_layout(cont);
+ resize_canvas();
+ if (!mesh.empty()) {
+ draw_3d_mesh();
+ }
+}
+
+bool BedMeshPanel::detect_multitouch_support()
+{
+#ifdef SIMULATOR
+ return false; // Simulation mode doesn't support multi-touch
+#else
+ // Check if evdev device supports multi-touch
+ int fd = open("/dev/input/event0", O_RDONLY);
+ if (fd < 0) {
+ spdlog::debug("Cannot open input device for multi-touch detection");
+ return false;
+ }
+
+ unsigned long abs_bits[NLONGS(ABS_CNT)] = {0};
+ bool has_mt = false;
+
+ if (ioctl(fd, EVIOCGBIT(EV_ABS, sizeof(abs_bits)), abs_bits) >= 0) {
+ // Check for multi-touch absolute events
+ has_mt = test_bit(ABS_MT_POSITION_X, abs_bits) && test_bit(ABS_MT_POSITION_Y, abs_bits);
+ }
+
+ close(fd);
+ spdlog::debug("Multi-touch support detected: {}", has_mt);
+ return has_mt;
+#endif
+}
+
+void BedMeshPanel::handle_canvas_scroll(lv_event_t *event)
+{
+ if (!show_3d_view) {
+ spdlog::debug("handle_canvas_scroll: not in 3D view, ignoring");
+ return;
+ }
+
+#ifdef SIMULATOR
+ spdlog::debug("handle_canvas_scroll: checking for encoder input in simulation mode");
+
+ // In simulation mode, check for encoder (mouse wheel) input
+ // We need to find the encoder input device and check for changes
+ lv_indev_t *encoder_indev = nullptr;
+ lv_indev_t *indev = lv_indev_get_next(nullptr);
+ int indev_count = 0;
+
+ // Find the encoder input device (mouse wheel)
+ while (indev) {
+ lv_indev_type_t type = lv_indev_get_type(indev);
+ spdlog::debug("Found input device {}: type={}", indev_count, (int)type);
+
+ if (type == LV_INDEV_TYPE_ENCODER) {
+ encoder_indev = indev;
+ spdlog::debug("Found encoder device at index {}", indev_count);
+ break;
+ }
+ indev = lv_indev_get_next(indev);
+ indev_count++;
+ }
+
+ if (!encoder_indev) {
+ spdlog::debug("No encoder input device found after checking {} devices", indev_count);
+ return;
+ }
+
+ // Check if encoder has new data
+ lv_indev_data_t data;
+ encoder_indev->driver->read_cb(encoder_indev->driver, &data);
+
+ spdlog::debug("Encoder data: enc_diff={}, state={}", data.enc_diff, (int)data.state);
+
+ if (data.enc_diff != 0) {
+ double old_scale = z_display_scale;
+ spdlog::debug("Mouse wheel detected: enc_diff={}, current_scale={}", data.enc_diff, old_scale);
+
+ if (data.enc_diff > 0) {
+ // Scroll up = zoom in
+ z_display_scale *= 1.1;
+ if (z_display_scale > MAX_Z_SCALE) z_display_scale = MAX_Z_SCALE;
+ spdlog::debug("Zoom IN: {} -> {} (clamped to max {})", old_scale, z_display_scale, MAX_Z_SCALE);
+ } else if (data.enc_diff < 0) {
+ // Scroll down = zoom out
+ z_display_scale *= 0.9;
+ if (z_display_scale < MIN_Z_SCALE) z_display_scale = MIN_Z_SCALE;
+ spdlog::debug("Zoom OUT: {} -> {} (clamped to min {})", old_scale, z_display_scale, MIN_Z_SCALE);
+ }
+
+ if (old_scale != z_display_scale) {
+ spdlog::debug("Scale changed, redrawing 3D mesh");
+ draw_3d_mesh();
+ } else {
+ spdlog::debug("Scale unchanged (hit limit), no redraw needed");
+ }
+ } else {
+ spdlog::debug("No encoder movement detected");
+ }
+#else
+ spdlog::debug("handle_canvas_scroll: not in simulation mode, ignoring");
+#endif
+}
+
+void BedMeshPanel::scroll_check_timer_callback()
+{
+#ifdef SIMULATOR
+ if (!show_3d_view || !use_gesture_zoom) {
+ return;
+ }
+
+ // Don't process scroll events if the panel is not visible
+ if (!cont || lv_obj_has_flag(cont, LV_OBJ_FLAG_HIDDEN)) {
+ return;
+ }
+
+ // Find the encoder input device (mouse wheel)
+ lv_indev_t *encoder_indev = nullptr;
+ lv_indev_t *indev = lv_indev_get_next(nullptr);
+
+ while (indev) {
+ if (lv_indev_get_type(indev) == LV_INDEV_TYPE_ENCODER) {
+ encoder_indev = indev;
+ break;
+ }
+ indev = lv_indev_get_next(indev);
+ }
+
+ if (!encoder_indev) return;
+
+ // Check if encoder has new data
+ lv_indev_data_t data;
+ encoder_indev->driver->read_cb(encoder_indev->driver, &data);
+
+ if (data.enc_diff != 0) {
+ double old_scale = z_display_scale;
+ spdlog::debug("TIMER: Mouse wheel detected: enc_diff={}, current_scale={}", data.enc_diff, old_scale);
+
+ if (data.enc_diff > 0) {
+ // Positive enc_diff = zoom out
+ z_display_scale *= 0.9;
+ if (z_display_scale < MIN_Z_SCALE) z_display_scale = MIN_Z_SCALE;
+ spdlog::debug("TIMER: Zoom OUT: {} -> {} (clamped to min {})", old_scale, z_display_scale, MIN_Z_SCALE);
+ } else if (data.enc_diff < 0) {
+ // Negative enc_diff = zoom in
+ z_display_scale *= 1.1;
+ if (z_display_scale > MAX_Z_SCALE) z_display_scale = MAX_Z_SCALE;
+ spdlog::debug("TIMER: Zoom IN: {} -> {} (clamped to max {})", old_scale, z_display_scale, MAX_Z_SCALE);
+ }
+
+ if (old_scale != z_display_scale) {
+ spdlog::debug("TIMER: Scale changed, redrawing 3D mesh");
+ draw_3d_mesh();
+ }
+ }
+#endif
+}
+
+void BedMeshPanel::handle_canvas_gesture(lv_event_t *event)
+{
+ if (!show_3d_view) return; // Only zoom in 3D view mode
+
+ lv_indev_t * indev = lv_indev_get_act();
+ if (!indev) return;
+
+ // TODO: Implement pinch gesture detection
+ // This requires LVGL gesture recognition to be enabled
+ // and proper multi-touch input processing
+ spdlog::debug("Gesture event detected (pinch zoom not yet implemented)");
+}
diff --git a/src/bedmesh_panel.h b/src/bedmesh_panel.h
index 23b2d04..436954d 100644
--- a/src/bedmesh_panel.h
+++ b/src/bedmesh_panel.h
@@ -5,6 +5,7 @@
#include "notify_consumer.h"
#include "button_container.h"
#include "lvgl/lvgl.h"
+#include "spdlog/spdlog.h"
#include
#include
@@ -25,6 +26,133 @@ class BedMeshPanel : public NotifyConsumer {
void handle_prompt_cancel(lv_event_t *event);
void handle_kb_input(lv_event_t *e);
void mesh_draw_cb(lv_event_t *e);
+ void draw_3d_mesh();
+ void resize_canvas();
+ void handle_z_zoom_in(lv_event_t *event);
+ void handle_z_zoom_out(lv_event_t *event);
+ void handle_fov_zoom_in(lv_event_t *event);
+ void handle_fov_zoom_out(lv_event_t *event);
+ void handle_canvas_drag(lv_event_t *event);
+ void handle_toggle_view(lv_event_t *event);
+ // Table view is the default; the 3D view is a fullscreen mode (canvas + Back only).
+ void show_table_view();
+ void show_3d_fullscreen();
+ void handle_canvas_scroll(lv_event_t *event);
+ void handle_canvas_gesture(lv_event_t *event);
+ void scroll_check_timer_callback();
+ bool detect_multitouch_support();
+
+ // Basic 3D point with screen projection for mesh vertices
+ struct Point3D {
+ double x, y, z; // 3D world coordinates
+ int screen_x, screen_y; // 2D screen coordinates after perspective projection
+ double depth; // Z-depth for sorting (closer objects rendered last)
+ };
+
+ // 3D vertex with color information for smooth interpolation
+ struct Vertex3D {
+ double x, y, z; // 3D position in world space
+ lv_color_t color; // Color at this vertex (for gradient blending)
+ };
+
+ // 3D quadrilateral representing one mesh cell with depth sorting
+ struct Quad3D {
+ Vertex3D vertices[4]; // Four corners of the quad (bottom-left, bottom-right, top-left, top-right)
+ double avg_depth; // Average depth for back-to-front sorting
+ lv_color_t center_color; // Fallback color if vertex blending not used
+ };
+
+ void draw_mesh_wireframe(const std::vector>& points,
+ int rows, int cols, int canvas_width, int canvas_height);
+ void draw_axes_and_labels(const std::vector>& points,
+ int rows, int cols, int canvas_width, int canvas_height,
+ double min_z, double max_z, double z_scale);
+ void draw_mesh_surface(const std::vector>& points,
+ int rows, int cols, int canvas_width, int canvas_height,
+ double min_z, double max_z);
+
+ // 3D rendering pipeline functions
+ Point3D project_3d_to_2d(double x, double y, double z, int canvas_width, int canvas_height); // Perspective projection
+ void draw_3d_quad(const Quad3D& quad, int canvas_width, int canvas_height); // Render a single 3D quad
+ std::vector generate_mesh_quads(int rows, int cols, double min_z, double max_z); // Create 3D quads from mesh data
+ void sort_quads_by_depth(std::vector& quads); // Z-buffer sorting (back to front)
+
+ // Triangle rasterization for proper 3D shapes
+ void fill_triangle(int x1, int y1, int x2, int y2, int x3, int y3, lv_color_t color, int canvas_width, int canvas_height);
+
+ // Triangle rasterization with gradient interpolation between vertex colors
+ // Parameters: (x1,y1,c1), (x2,y2,c2), (x3,y3,c3) are the three vertices with positions and colors
+ void fill_triangle_gradient(int x1, int y1, lv_color_t c1, int x2, int y2, lv_color_t c2, int x3, int y3, lv_color_t c3, int canvas_width, int canvas_height);
+
+ // Color gradient band rendering
+ void draw_color_gradient_band(int canvas_width, int canvas_height, double min_z, double max_z);
+
+ // Axis drawing helper methods
+ void draw_horizontal_axes(double mesh_origin_x, double mesh_origin_y, double grid_reference_z,
+ int rows, int cols, int canvas_width, int canvas_height,
+ const lv_draw_line_dsc_t& axis_dsc);
+
+
+ // Helper methods for reducing code duplication
+ void setup_button_with_label(lv_obj_t* btn, const char* text, int width, int height, lv_event_cb_t callback);
+ void draw_grid_lines_parallel_to_axis(int axis_count, bool is_x_axis, int rows, int cols, double grid_reference_z, int canvas_width, int canvas_height, const lv_draw_line_dsc_t& line_dsc);
+ bool create_and_check_label_area(lv_area_t& area, int x, int y, int width, int height, int canvas_width, int canvas_height);
+ void extract_mesh_parameter_values(const json& mesh_params, const std::vector& param_names, std::vector& values);
+ double calculate_dynamic_fov_scale(int mesh_rows, int mesh_cols, int canvas_width, int canvas_height);
+
+ // Axis tick drawing helper functions
+ void draw_axis_tick_values(int count, bool is_x_axis, int rows, int cols, double grid_reference_z,
+ int canvas_width, int canvas_height, lv_draw_label_dsc_t& label_dsc);
+ void draw_z_axis_tick_values(double left_x, double front_y, double z_min, double z_max,
+ int z_divisions, double z_scale, int canvas_width, int canvas_height,
+ lv_draw_label_dsc_t& label_dsc, double min_z, double max_z);
+ void draw_axis_label(const char* label_text, double x, double y, double z, int offset_x, int offset_y,
+ int canvas_width, int canvas_height, lv_draw_label_dsc_t& label_dsc);
+
+ // Rendering constants
+ static constexpr int CULLING_MARGIN = 50; // Pixels beyond canvas bounds to include for triangle culling
+ static constexpr double MESH_SCALE = 50.0; // Base spacing between mesh points in 3D world units
+ static constexpr double DEFAULT_Z_SCALE = 60.0; // Height amplification factor for dramatic Z-axis visualization (increased for better visibility)
+
+ // Dynamic Z-scaling constants for adaptive height visualization
+ static constexpr double DEFAULT_Z_TARGET_HEIGHT = 80.0; // Target height range in world units for good visual impact
+ static constexpr double DEFAULT_Z_MIN_SCALE = 35.0; // Minimum Z-scale factor to prevent flat surfaces
+ static constexpr double DEFAULT_Z_MAX_SCALE = 120.0; // Maximum Z-scale factor to prevent extreme projections off-screen
+
+ // 3D projection constants for camera positioning and field of view
+ static constexpr double CAMERA_DISTANCE = 450.0; // Distance of camera from origin (affects perspective strength)
+ static constexpr double CANVAS_PADDING_RATIO = 0.1; // Padding as ratio of canvas size (0.1 = 10% padding on each side)
+ static constexpr double VIEW_ANGLE_X_DEGREES = -85.0; // Initial X-axis rotation (-85 degrees) for optimal perspective
+ static constexpr double VIEW_ANGLE_Z_DEGREES = 10.0; // Initial Z-axis rotation (10 degrees) for optimal perspective
+ static constexpr double Z_ORIGIN_VERTICAL_POSITION = 0.4; // Position of Z=0 plane in canvas (0.0=top, 0.4=upper center, 1.0=bottom)
+
+ // Z-axis range constants for 3D visualization
+ static constexpr double Z_AXIS_EXTENSION = 0.75; // How far to extend Z-axis beyond actual data range (as multiplier of z_range)
+
+ // Color gradient constants for enhanced visual contrast
+ static constexpr double COLOR_COMPRESSION_FACTOR = 0.8; // Fraction of data range used for color mapping (smaller = more dramatic colors)
+
+ // Axis label positioning constants
+ static constexpr int AXIS_LABEL_OFFSET_X = 5; // Horizontal offset of axis labels from axis line (pixels)
+ static constexpr int AXIS_LABEL_OFFSET_Y = 20; // Vertical offset of axis labels from axis line (pixels) - increased for better visibility
+ static constexpr int AXIS_LABEL_MARGIN = 15; // Additional margin for axis labels to avoid canvas edge (pixels)
+
+ // Axis tick mark constants
+ static constexpr int TICK_MARK_LENGTH = 6; // Length of perpendicular tick marks (pixels)
+ static constexpr int TICK_LABEL_OFFSET = 12; // Distance of tick labels from axis line (pixels)
+
+ // Label height constant
+ static constexpr int LABEL_HEIGHT = 15; // Standard height for text labels (pixels)
+
+ // Color gradient band constants
+ static constexpr int GRADIENT_BAND_WIDTH = 20; // Width of the vertical color gradient band (pixels)
+ static constexpr int GRADIENT_BAND_MARGIN = 10; // Margin from left edge of canvas (pixels)
+ static constexpr int GRADIENT_BAND_HEIGHT_RATIO = 80; // Percentage of canvas height to use for gradient band
+ static constexpr int GRADIENT_BAND_TOTAL_WIDTH = 85; // Total space reserved for gradient band (band + margin + labels)
+
+ // Triangle gradient performance constants
+ static constexpr int GRADIENT_MAX_SEGMENTS = 6; // Maximum segments per scanline for gradient approximation
+ static constexpr int GRADIENT_MIN_LINE_WIDTH = 3; // Minimum line width to use gradient (shorter lines use solid color)
static void _handle_callback(lv_event_t *event) {
BedMeshPanel *panel = (BedMeshPanel*)event->user_data;
@@ -56,12 +184,67 @@ class BedMeshPanel : public NotifyConsumer {
panel->mesh_draw_cb(e);
};
+ static void _handle_z_zoom_in(lv_event_t *event) {
+ spdlog::debug("_handle_z_zoom_in static wrapper called");
+ BedMeshPanel *panel = (BedMeshPanel*)event->user_data;
+ panel->handle_z_zoom_in(event);
+ };
+
+ static void _handle_z_zoom_out(lv_event_t *event) {
+ spdlog::debug("_handle_z_zoom_out static wrapper called");
+ BedMeshPanel *panel = (BedMeshPanel*)event->user_data;
+ panel->handle_z_zoom_out(event);
+ };
+
+ static void _handle_fov_zoom_in(lv_event_t *event) {
+ BedMeshPanel *panel = (BedMeshPanel*)event->user_data;
+ panel->handle_fov_zoom_in(event);
+ };
+
+ static void _handle_fov_zoom_out(lv_event_t *event) {
+ BedMeshPanel *panel = (BedMeshPanel*)event->user_data;
+ panel->handle_fov_zoom_out(event);
+ };
+
+ static void _handle_canvas_drag(lv_event_t *event) {
+ BedMeshPanel *panel = (BedMeshPanel*)event->user_data;
+ panel->handle_canvas_drag(event);
+ };
+
+ static void _handle_canvas_scroll(lv_event_t *event) {
+ BedMeshPanel *panel = (BedMeshPanel*)event->user_data;
+ panel->handle_canvas_scroll(event);
+ };
+
+ static void _handle_canvas_gesture(lv_event_t *event) {
+ BedMeshPanel *panel = (BedMeshPanel*)event->user_data;
+ panel->handle_canvas_gesture(event);
+ };
+
+ static void _scroll_check_timer_cb(lv_timer_t *timer) {
+ BedMeshPanel *panel = (BedMeshPanel*)timer->user_data;
+ panel->scroll_check_timer_callback();
+ };
+
+ static void _handle_toggle_view(lv_event_t *event) {
+ BedMeshPanel *panel = (BedMeshPanel*)event->user_data;
+ panel->handle_toggle_view(event);
+ };
+
+
private:
KWebSocketClient &ws;
lv_obj_t *cont;
lv_obj_t *prompt;
lv_obj_t *top_cont;
+ lv_obj_t *display_cont;
lv_obj_t *mesh_table;
+ lv_obj_t *mesh_canvas;
+ lv_obj_t *rotation_cont;
+ lv_obj_t *z_zoom_in_btn;
+ lv_obj_t *z_zoom_out_btn;
+ lv_obj_t *fov_zoom_in_btn;
+ lv_obj_t *fov_zoom_out_btn;
lv_obj_t *profile_cont;
lv_obj_t *profile_table;
lv_obj_t *profile_info;
@@ -69,12 +252,35 @@ class BedMeshPanel : public NotifyConsumer {
ButtonContainer save_btn;
ButtonContainer clear_btn;
ButtonContainer calibrate_btn;
+ ButtonContainer toggle_view_btn;
ButtonContainer back_btn;
lv_obj_t *msgbox;
lv_obj_t *input;
lv_obj_t *kb;
std::string active_profile;
std::vector> mesh;
+ int current_view_angle;
+ void *canvas_draw_buf;
+ bool show_3d_view;
+ double z_display_scale;
+ double fov_scale;
+ double camera_distance;
+ double mesh_min_x;
+ double mesh_max_x;
+ double mesh_min_y;
+ double mesh_max_y;
+
+ // Touch drag state for rotation control
+ bool is_dragging;
+ int drag_start_x;
+ int last_drag_x;
+ int drag_start_y;
+ int last_drag_y;
+ double current_x_angle; // Current X-axis viewing angle (tilt up/down)
+ bool has_multitouch;
+ bool use_gesture_zoom;
+ bool mouse_over_canvas;
+ lv_timer_t* scroll_check_timer;
};
#endif // __BEDMESH_PANEL_H__
diff --git a/src/belts_calibration_panel.cpp b/src/belts_calibration_panel.cpp
index 31619c1..dbf8c96 100644
--- a/src/belts_calibration_panel.cpp
+++ b/src/belts_calibration_panel.cpp
@@ -77,7 +77,7 @@ BeltsCalibrationPanel::BeltsCalibrationPanel(KWebSocketClient &c, std::mutex &l)
lv_obj_add_event_cb(excite_slider, &BeltsCalibrationPanel::_handle_update_slider,
LV_EVENT_VALUE_CHANGED, this);
- lv_obj_align_to(excite_label, excite_slider, LV_ALIGN_BOTTOM_MID, 0, 35 * hscale);
+ lv_obj_align(excite_label, LV_ALIGN_TOP_RIGHT, 0, 2 * hscale);
lv_label_set_text(excite_label, "1 hz");
lv_dropdown_set_options(excite_dd, fmt::format("{}", fmt::join(axes, "\n")).c_str());
diff --git a/src/button_container.cpp b/src/button_container.cpp
index cb9b5d1..6df9637 100644
--- a/src/button_container.cpp
+++ b/src/button_container.cpp
@@ -65,6 +65,13 @@ ButtonContainer::ButtonContainer(lv_obj_t *parent,
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(label, lv_palette_darken(LV_PALETTE_GREY, 1), LV_STATE_DISABLED);
+ /* On small screens (e.g. 480x272 KE) the theme's default m12 truncates 7-8 char
+ * button labels like "Spoolman"/"Cooldown"/"Reload"/"Modified". Use m10 globally
+ * so all panels get readable, non-wrapping labels without each having to override. */
+ if (lv_disp_get_physical_hor_res(NULL) <= 480) {
+ lv_obj_set_style_text_font(label, &lv_font_montserrat_10, 0);
+ }
+
// lv_obj_align_to(label, btn, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
// lv_obj_align(label, LV_ALIGN_BOTTOM_MID, 0, 5);
// lv_obj_set_style_border_width(btn_cont, 2, 0);
@@ -81,6 +88,14 @@ lv_obj_t *ButtonContainer::get_button() {
return btn;
}
+lv_obj_t *ButtonContainer::get_label() {
+ return label;
+}
+
+void ButtonContainer::set_text(const char *text) {
+ lv_label_set_text(label, text);
+}
+
void ButtonContainer::disable() {
lv_obj_add_state(btn, LV_STATE_DISABLED);
lv_obj_add_state(btn_cont, LV_STATE_DISABLED);
diff --git a/src/button_container.h b/src/button_container.h
index 16e546d..09af3e8 100644
--- a/src/button_container.h
+++ b/src/button_container.h
@@ -19,11 +19,13 @@ class ButtonContainer {
lv_obj_t *get_container();
lv_obj_t *get_button();
+ lv_obj_t *get_label();
void disable();
void enable();
void hide();
void set_image(const void *img);
+ void set_text(const char *text);
void handle_callback(lv_event_t *event);
diff --git a/src/config.cpp b/src/config.cpp
index b8bd873..3d48d2b 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -52,8 +52,8 @@ void Config::init(std::string config_path, const std::string thumbdir) {
{"color", "purple"}
},
{
- {"id", "temperature_sensor chamber_temp"},
- {"display_name", "Chamber"},
+ {"id", "temperature_sensor mcu_temp"},
+ {"display_name", "MCU Temp"},
{"controllable", false},
{"color", "blue"}
}
diff --git a/src/console_panel.cpp b/src/console_panel.cpp
index 54868b8..e940e95 100644
--- a/src/console_panel.cpp
+++ b/src/console_panel.cpp
@@ -1,5 +1,6 @@
#include "console_panel.h"
#include "state.h"
+#include "utils.h"
#include "spdlog/spdlog.h"
#include
@@ -42,7 +43,7 @@ ConsolePanel::ConsolePanel(KWebSocketClient &websocket_client, std::mutex &lock,
lv_obj_set_size(input_cont, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_t *send_btn = lv_btn_create(input_cont);
- lv_obj_set_style_text_font(send_btn, &lv_font_montserrat_16, LV_STATE_DEFAULT);
+ lv_obj_set_style_text_font(send_btn, &lv_font_montserrat_10, LV_STATE_DEFAULT);
lv_obj_set_width(send_btn, 100);
lv_obj_t *send_btn_label = lv_label_create(send_btn);
lv_label_set_text(send_btn_label, LV_SYMBOL_NEW_LINE);
@@ -50,7 +51,7 @@ ConsolePanel::ConsolePanel(KWebSocketClient &websocket_client, std::mutex &lock,
lv_obj_add_event_cb(send_btn , &ConsolePanel::_handle_send_macro, LV_EVENT_CLICKED, this);
lv_obj_add_flag(kb, LV_OBJ_FLAG_HIDDEN);
- lv_obj_set_style_text_font(kb, &lv_font_montserrat_16, LV_STATE_DEFAULT);
+ lv_obj_set_style_text_font(kb, &lv_font_montserrat_10, LV_STATE_DEFAULT);
lv_obj_add_event_cb(input, &ConsolePanel::_handle_kb_input, LV_EVENT_ALL, this);
lv_obj_set_size(macro_list, LV_PCT(40), LV_PCT(100));
@@ -61,7 +62,7 @@ ConsolePanel::ConsolePanel(KWebSocketClient &websocket_client, std::mutex &lock,
lv_obj_set_scroll_dir(macro_list, LV_DIR_TOP | LV_DIR_BOTTOM);
lv_obj_t *label = lv_label_create(input);
- lv_obj_set_style_text_font(label, &lv_font_montserrat_16, LV_STATE_DEFAULT);
+ lv_obj_set_style_text_font(label, &lv_font_montserrat_10, LV_STATE_DEFAULT);
lv_label_set_text(label, " " LV_SYMBOL_CLOSE " ");
lv_obj_align(label, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_flag(label, LV_OBJ_FLAG_CLICKABLE);
@@ -133,10 +134,14 @@ void ConsolePanel::handle_kb_input(lv_event_t *e)
return;
}
- lv_textarea_add_text(output,"> ");
- lv_textarea_add_text(output, cmd);
- lv_textarea_add_text(output,"\n");
- ws.gcode_script(cmd);
+ std::string command(cmd);
+ KUtils::confirm_if_printing("Printer is printing.\nSend this command anyway?",
+ [this, command]() {
+ lv_textarea_add_text(output, "> ");
+ lv_textarea_add_text(output, command.c_str());
+ lv_textarea_add_text(output, "\n");
+ ws.gcode_script(command);
+ });
if (!history.empty()) {
const auto &front = history.front();
diff --git a/src/extruder_panel.cpp b/src/extruder_panel.cpp
index d59b33e..16f94e6 100644
--- a/src/extruder_panel.cpp
+++ b/src/extruder_panel.cpp
@@ -71,12 +71,15 @@ ExtruderPanel::ExtruderPanel(KWebSocketClient &websocket_client,
lv_obj_set_flex_flow(rightside_btns_cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(rightside_btns_cont, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(rightside_btns_cont, LV_OBJ_FLAG_SCROLLABLE);
+ /* Zero padding so the inner buttons (LV_PCT(100)) actually fill the column. */
+ lv_obj_set_style_pad_all(rightside_btns_cont, 0, 0);
lv_obj_set_size(leftside_btns_cont, LV_PCT(20), LV_PCT(100));
// lv_obj_set_style_pad_row(leftside_btns_cont, 15, 0);
lv_obj_set_flex_flow(leftside_btns_cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(leftside_btns_cont, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(leftside_btns_cont, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_set_style_pad_all(leftside_btns_cont, 0, 0);
lv_obj_set_flex_grow(load_btn.get_container(), 1);
lv_obj_set_flex_grow(unload_btn.get_container(), 1);
@@ -86,11 +89,27 @@ ExtruderPanel::ExtruderPanel(KWebSocketClient &websocket_client,
lv_obj_set_flex_grow(retract_btn.get_container(), 1);
lv_obj_set_flex_grow(back_btn.get_container(), 1);
+ /* Stretch each button to fill its flex column. ButtonContainer's default
+ * 110*width_scale (~66 px on 480-wide) is too narrow for 7-8 char labels
+ * like "Spoolman" / "Cooldown" / "Extrude" — they truncate or wrap. */
+ lv_obj_t *btns[] = {
+ load_btn.get_container(), unload_btn.get_container(), cooldown_btn.get_container(),
+ spoolman_btn.get_container(), extrude_btn.get_container(),
+ retract_btn.get_container(), back_btn.get_container()
+ };
+ for (auto *b : btns) {
+ lv_obj_set_width(b, lv_pct(100));
+ }
+ /* Note: ButtonContainer applies the small-screen m10 font globally. */
+
spoolman_btn.disable();
static lv_coord_t grid_main_row_dsc[] = {LV_GRID_FR(3), LV_GRID_FR(6), LV_GRID_FR(6), LV_GRID_FR(6),
LV_GRID_TEMPLATE_LAST};
- static lv_coord_t grid_main_col_dsc[] = {LV_GRID_FR(2), LV_GRID_FR(7), LV_GRID_FR(2), LV_GRID_TEMPLATE_LAST};
+ /* FR(2)/FR(8)/FR(2): side cols ~80 px (still room for 8-char labels with
+ * pad_all=0 on the side containers) and middle ~320 px so each of the 7
+ * selector pills gets ~45 px — a usable touch target on the KE display. */
+ static lv_coord_t grid_main_col_dsc[] = {LV_GRID_FR(2), LV_GRID_FR(8), LV_GRID_FR(2), LV_GRID_TEMPLATE_LAST};
lv_obj_clear_flag(panel_cont, LV_OBJ_FLAG_SCROLLABLE);
diff --git a/src/file_panel.cpp b/src/file_panel.cpp
index 6926457..7174b4a 100644
--- a/src/file_panel.cpp
+++ b/src/file_panel.cpp
@@ -41,10 +41,12 @@ FilePanel::FilePanel(lv_obj_t *parent)
lv_label_set_long_mode(fname_label, LV_LABEL_LONG_SCROLL);
lv_obj_set_width(fname_label, LV_PCT(100));
lv_obj_set_style_pad_all(fname_label, 0, 0);
+ lv_obj_set_style_text_font(fname_label, &lv_font_montserrat_10, 0);
// Setup detail label
lv_obj_set_width(detail_label, LV_PCT(100));
lv_obj_set_style_pad_all(detail_label, 0, 0);
+ lv_obj_set_style_text_font(detail_label, &lv_font_montserrat_10, 0);
}
FilePanel::~FilePanel() {
diff --git a/src/finetune_panel.cpp b/src/finetune_panel.cpp
index 05d18e1..366a4c9 100644
--- a/src/finetune_panel.cpp
+++ b/src/finetune_panel.cpp
@@ -39,10 +39,10 @@ FineTunePanel::FineTunePanel(KWebSocketClient &websocket_client, std::mutex &l)
{"0.01", "0.05", "0.10", ""}, 0, 30, 15, &FineTunePanel::_handle_callback, this)
, multipler_selector(panel_cont, "Multipler Step (%)",
{"1", "5", "10", "25", ""}, 0, 40, 15, &FineTunePanel::_handle_callback, this)
- , z_offset(values_cont, &home_z, 150, 100, 15, "0.0 mm")
- , pa(values_cont, &pa_plus_img, 150, 100, 15, "0.0 mm/s")
- , speed_factor(values_cont, &speed_up_img, 150, 100, 15, "100%")
- , flow_factor(values_cont, &flow_up_img, 150, 100, 15, "100%")
+ , z_offset(values_cont, &home_z, 100, 100, 15, "0.0 mm")
+ , pa(values_cont, &pa_plus_img, 100, 100, 15, "0.0 mm/s")
+ , speed_factor(values_cont, &speed_up_img, 100, 100, 15, "100%")
+ , flow_factor(values_cont, &flow_up_img, 100, 100, 15, "100%")
{
lv_obj_move_background(panel_cont);
@@ -103,13 +103,13 @@ void FineTunePanel::foreground() {
auto v = State::get_instance()->get_data(
"/printer_state/gcode_move/homing_origin/2"_json_pointer);
if (!v.is_null()) {
- z_offset.update_label(fmt::format("{:.5} mm", v.template get()).c_str());
+ z_offset.update_label(fmt::format("{:.3f} mm", v.template get()).c_str());
}
v = State::get_instance()->get_data(
"/printer_state/extruder/pressure_advance"_json_pointer);
if (!v.is_null()) {
- pa.update_label(fmt::format("{:.5} mm/s", v.template get()).c_str());
+ pa.update_label(fmt::format("{:.3f} mm/s", v.template get()).c_str());
}
v = State::get_instance()->get_data(
@@ -133,12 +133,12 @@ void FineTunePanel::consume(json &j) {
std::lock_guard lock(lv_lock);
auto v = j["/params/0/gcode_move/homing_origin/2"_json_pointer];
if (!v.is_null()) {
- z_offset.update_label(fmt::format("{:.5} mm", v.template get()).c_str());
+ z_offset.update_label(fmt::format("{:.3f} mm", v.template get()).c_str());
}
v = j["/params/0/extruder/pressure_advance"_json_pointer];
if (!v.is_null()) {
- pa.update_label(fmt::format("{:.5} mm/s", v.template get()).c_str());
+ pa.update_label(fmt::format("{:.3f} mm/s", v.template get()).c_str());
}
v = j["/params/0/gcode_move/speed_factor"_json_pointer];
diff --git a/src/guppyscreen.cpp b/src/guppyscreen.cpp
index 0eb6e93..dfa1955 100644
--- a/src/guppyscreen.cpp
+++ b/src/guppyscreen.cpp
@@ -113,6 +113,13 @@ GuppyScreen *GuppyScreen::init(std::function hal_i
lv_style_init(&style_container);
lv_style_set_border_width(&style_container, 0);
lv_style_set_radius(&style_container, 0);
+ /* Force m10 on all lv_obj instances on small screens. Theme cascade alone
+ * doesn't reach plain lv_label children of lv_btn etc.; setting text_font
+ * on style_container (applied to every obj via theme_apply_cb) propagates
+ * via LV_STYLE_PROP_INHERIT to descendant labels too. */
+ if (lv_disp_get_physical_hor_res(NULL) <= 480) {
+ lv_style_set_text_font(&style_container, &lv_font_montserrat_10);
+ }
// lv_style_init(&style_imgbtn_default);
// lv_style_set_img_recolor_opa(&style_imgbtn_default, LV_OPA_100);
@@ -148,14 +155,21 @@ GuppyScreen *GuppyScreen::init(std::function hal_i
conf->get(conf->df() + "moonraker_host"),
conf->get(conf->df() + "moonraker_port"));
+#ifdef SIMULATOR
+ /* Don't actually connect in simulator mode — the failing connect's
+ * disconnected callback re-shows the init overlay over the mock data. */
+ spdlog::info("simulator mode — skipping printer websocket connection to {}", ws_url);
+#else
spdlog::info("connecting to printer at {}", ws_url);
gs->connect_ws(ws_url);
+#endif
}
#ifndef OS_ANDROID
screen_saver = lv_obj_create(lv_scr_act());
lv_obj_set_size(screen_saver, LV_PCT(100), LV_PCT(100));
+ lv_obj_set_style_bg_color(screen_saver, lv_color_black(), 0);
lv_obj_set_style_bg_opa(screen_saver, LV_OPA_100, 0);
lv_obj_move_background(screen_saver);
@@ -185,6 +199,13 @@ GuppyScreen *GuppyScreen::init(std::function hal_i
}
#endif // OS_ANDROID
+#ifdef SIMULATOR
+ /* No Moonraker in simulator mode — hide the init overlay and fabricate
+ * sensor data so the home panel renders something visible. */
+ gs->init_panel.sim_hide();
+ gs->main_panel.sim_setup_mock_data();
+#endif
+
return gs;
}
@@ -206,7 +227,9 @@ void GuppyScreen::loop() {
if (lv_disp_get_inactive_time(NULL) > display_sleep) {
if (!is_sleeping.load()) {
spdlog::debug("putting display to sleeping");
- fbdev_blank();
+ // fbdev_blank() uses FB_BLANK_POWERDOWN which the Ingenic X2000
+ // kernel 4.4.94 DSI driver cannot recover from via FB_BLANK_UNBLANK.
+ // Use only the LVGL screen_saver (black) for the visual sleep effect.
lv_obj_move_foreground(screen_saver);
// spdlog::debug("screen saver foreground");
is_sleeping = true;
@@ -216,6 +239,7 @@ void GuppyScreen::loop() {
spdlog::debug("waking up display");
fbdev_unblank();
lv_obj_move_background(screen_saver);
+ lv_obj_invalidate(lv_scr_act());
is_sleeping = false;
}
}
diff --git a/src/homing_panel.cpp b/src/homing_panel.cpp
index f7a4e9b..775ad76 100644
--- a/src/homing_panel.cpp
+++ b/src/homing_panel.cpp
@@ -45,7 +45,9 @@ HomingPanel::HomingPanel(KWebSocketClient &websocket_client, std::mutex &lock)
lv_obj_set_height(homing_cont, lv_pct(100));
lv_obj_set_width(homing_cont, lv_pct(100));
- static lv_coord_t grid_main_row_dsc[] = {LV_GRID_FR(4), LV_GRID_FR(4), LV_GRID_FR(2), LV_GRID_FR(2), LV_GRID_TEMPLATE_LAST};
+ /* Tuned for 480x272: buttons get 30% each, selectors 20% each so the
+ * "Move Speed"/"Move Distance" titles fit above their button matrices. */
+ static lv_coord_t grid_main_row_dsc[] = {LV_GRID_FR(3), LV_GRID_FR(3), LV_GRID_FR(2), LV_GRID_FR(2), LV_GRID_TEMPLATE_LAST};
static lv_coord_t grid_main_col_dsc[] = {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST};
lv_obj_set_grid_dsc_array(homing_cont, grid_main_col_dsc, grid_main_row_dsc);
diff --git a/src/init_panel.cpp b/src/init_panel.cpp
index 7e44360..7cc33df 100644
--- a/src/init_panel.cpp
+++ b/src/init_panel.cpp
@@ -135,6 +135,13 @@ void InitPanel::disconnected(KWebSocketClient &ws) {
lv_obj_move_foreground(cont);
}
+#ifdef SIMULATOR
+void InitPanel::sim_hide() {
+ lv_obj_add_flag(cont, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_move_background(cont);
+}
+#endif
+
void InitPanel::set_message(const char *message) {
lv_label_set_text(label, message);
}
diff --git a/src/init_panel.h b/src/init_panel.h
index 27e7121..3fb567e 100644
--- a/src/init_panel.h
+++ b/src/init_panel.h
@@ -17,6 +17,10 @@ class InitPanel {
void connected(KWebSocketClient &ws);
void disconnected(KWebSocketClient &ws);
void set_message(const char *message);
+#ifdef SIMULATOR
+ /* Hide the "Waiting for printer..." overlay in simulator mode. */
+ void sim_hide();
+#endif
private:
lv_obj_t *cont;
diff --git a/src/macro_item.cpp b/src/macro_item.cpp
index 4d3aa7c..9d3a3e6 100644
--- a/src/macro_item.cpp
+++ b/src/macro_item.cpp
@@ -1,4 +1,5 @@
#include "macro_item.h"
+#include "utils.h"
#include "spdlog/spdlog.h"
MacroItem::MacroItem(KWebSocketClient &c,
@@ -60,7 +61,7 @@ MacroItem::MacroItem(KWebSocketClient &c,
lv_obj_t *run_btn = lv_btn_create(top_cont);
lv_obj_align(run_btn, LV_ALIGN_RIGHT_MID, 0, 0);
- lv_obj_set_style_text_font(run_btn, &lv_font_montserrat_16, LV_STATE_DEFAULT);
+ lv_obj_set_style_text_font(run_btn, &lv_font_montserrat_10, LV_STATE_DEFAULT);
lv_obj_set_width(run_btn, 80);
lv_obj_t *run_btn_label = lv_label_create(run_btn);
lv_label_set_text(run_btn_label, LV_SYMBOL_PLAY);
@@ -149,8 +150,10 @@ void MacroItem::handle_send_macro(lv_event_t *e) {
}
}
- spdlog::trace("sending macro: {}", fmt::format("{}", fmt::join(kv, " ")));
- ws.gcode_script(fmt::format("{}", fmt::join(kv, " ")));
+ std::string command = fmt::format("{}", fmt::join(kv, " "));
+ spdlog::trace("sending macro: {}", command);
+ KUtils::confirm_if_printing("Printer is printing.\nRun this macro anyway?",
+ [this, command]() { ws.gcode_script(command); });
}
}
diff --git a/src/macros_panel.cpp b/src/macros_panel.cpp
index bf8d857..5e31464 100644
--- a/src/macros_panel.cpp
+++ b/src/macros_panel.cpp
@@ -33,7 +33,7 @@ MacrosPanel::MacrosPanel(KWebSocketClient &c, std::mutex &l, lv_obj_t *parent)
lv_obj_set_style_pad_row(top_cont, 0, 0);
lv_obj_add_flag(kb, LV_OBJ_FLAG_HIDDEN);
- lv_obj_set_style_text_font(kb, &lv_font_montserrat_16, LV_STATE_DEFAULT);
+ lv_obj_set_style_text_font(kb, &lv_font_montserrat_10, LV_STATE_DEFAULT);
}
MacrosPanel::~MacrosPanel()
diff --git a/src/main.cpp b/src/main.cpp
index 4406d9d..c45113e 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -85,7 +85,7 @@ static void hal_init(lv_color_t primary, lv_color_t secondary) {
spdlog::debug("resolution {} x {}", width, height);
lv_disp_t * disp = lv_disp_drv_register(&disp_drv);
lv_theme_t * th = height <= 480
- ? lv_theme_default_init(NULL, primary, secondary, true, &lv_font_montserrat_12)
+ ? lv_theme_default_init(NULL, primary, secondary, true, &lv_font_montserrat_10)
: lv_theme_default_init(NULL, primary, secondary, true, &lv_font_montserrat_20);
lv_disp_set_theme(disp, th);
@@ -142,7 +142,7 @@ static void hal_init(lv_color_t primary, lv_color_t secondary)
lv_disp_t * disp = lv_disp_drv_register(&disp_drv);
lv_theme_t * th = MONITOR_HOR_RES <= 480
- ? lv_theme_default_init(NULL, primary, secondary, true, &lv_font_montserrat_12)
+ ? lv_theme_default_init(NULL, primary, secondary, true, &lv_font_montserrat_10)
: lv_theme_default_init(NULL, primary, secondary, true, &lv_font_montserrat_16);
lv_disp_set_theme(disp, th);
diff --git a/src/main_panel.cpp b/src/main_panel.cpp
index beacc06..637a03d 100644
--- a/src/main_panel.cpp
+++ b/src/main_panel.cpp
@@ -1,12 +1,13 @@
#include "main_panel.h"
#include "state.h"
+#include "utils.h"
#include "lvgl/lvgl.h"
#include "spdlog/spdlog.h"
#include
LV_IMG_DECLARE(filament_img);
-LV_IMG_DECLARE(light_img);
+LV_IMG_DECLARE(fine_tune_img);
LV_IMG_DECLARE(move);
LV_IMG_DECLARE(print);
LV_IMG_DECLARE(extruder);
@@ -41,7 +42,7 @@ MainPanel::MainPanel(KWebSocketClient &websocket,
, main_cont(lv_obj_create(main_tab))
, print_status_panel(websocket, lock, main_cont)
, print_panel(ws, lock, print_status_panel)
- , printertune_panel(ws, lock, printertune_tab, print_status_panel.get_finetune_panel())
+ , printertune_panel(ws, lock, printertune_tab, led_panel)
, numpad(Numpad(main_cont))
, extruder_panel(ws, lock, numpad, sm)
, prompt_panel(websocket, lock, main_cont)
@@ -51,7 +52,7 @@ MainPanel::MainPanel(KWebSocketClient &websocket,
, homing_btn(main_cont, &move, "Homing", &MainPanel::_handle_homing_cb, this)
, extrude_btn(main_cont, &filament_img, "Extrude", &MainPanel::_handle_extrude_cb, this)
, action_btn(main_cont, &fan, "Fans", &MainPanel::_handle_fanpanel_cb, this)
- , led_btn(main_cont, &light_img, "LED", &MainPanel::_handle_ledpanel_cb, this)
+ , finetune_btn(main_cont, &fine_tune_img, "Fine Tune", &MainPanel::_handle_finetune_cb, this)
, print_btn(main_cont, &print, "Print", &MainPanel::_handle_print_cb, this)
{
lv_style_init(&style);
@@ -159,6 +160,10 @@ void MainPanel::handle_homing_cb(lv_event_t *event) {
spdlog::trace("clicked homing1");
if (lv_event_get_code(event) == LV_EVENT_CLICKED) {
spdlog::trace("clicked homing");
+ if (KUtils::is_printing()) {
+ KUtils::notify_locked();
+ return;
+ }
homing_panel.foreground();
}
}
@@ -166,6 +171,10 @@ void MainPanel::handle_homing_cb(lv_event_t *event) {
void MainPanel::handle_extrude_cb(lv_event_t *event) {
if (lv_event_get_code(event) == LV_EVENT_CLICKED) {
spdlog::trace("clicked extruder");
+ if (KUtils::is_printing()) {
+ KUtils::notify_locked();
+ return;
+ }
extruder_panel.foreground();
}
}
@@ -177,10 +186,10 @@ void MainPanel::handle_fanpanel_cb(lv_event_t *event) {
}
}
-void MainPanel::handle_ledpanel_cb(lv_event_t *event) {
+void MainPanel::handle_finetune_cb(lv_event_t *event) {
if (lv_event_get_code(event) == LV_EVENT_CLICKED) {
- spdlog::trace("clicked led panel");
- led_panel.foreground();
+ spdlog::trace("clicked fine tune");
+ print_status_panel.get_finetune_panel().foreground();
}
}
@@ -195,7 +204,9 @@ void MainPanel::create_main(lv_obj_t *parent)
{
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_ROW_WRAP);
- static lv_coord_t grid_main_row_dsc[] = {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST};
+ /* Row 0: sensors (CONTENT-sized). Row 1: chart fills the rest. The
+ * previous buffer row left a visible whitespace gap between sensors and chart. */
+ static lv_coord_t grid_main_row_dsc[] = {LV_GRID_CONTENT, LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST};
static lv_coord_t grid_main_col_dsc[] = {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1),
LV_GRID_TEMPLATE_LAST};
@@ -205,31 +216,63 @@ void MainPanel::create_main(lv_obj_t *parent)
lv_obj_set_flex_grow(main_cont, 1);
lv_obj_set_grid_dsc_array(main_cont, grid_main_col_dsc, grid_main_row_dsc);
- lv_obj_set_grid_cell(homing_btn.get_container(), LV_GRID_ALIGN_STRETCH, 2, 1, LV_GRID_ALIGN_STRETCH, 0, 1);
- lv_obj_set_grid_cell(extrude_btn.get_container(), LV_GRID_ALIGN_STRETCH, 3, 1, LV_GRID_ALIGN_STRETCH, 0, 1);
- lv_obj_set_grid_cell(action_btn.get_container(), LV_GRID_ALIGN_STRETCH, 2, 1, LV_GRID_ALIGN_STRETCH, 1, 1);
- lv_obj_set_grid_cell(led_btn.get_container(), LV_GRID_ALIGN_STRETCH, 3, 1, LV_GRID_ALIGN_STRETCH, 1, 1);
- lv_obj_set_grid_cell(print_btn.get_container(), LV_GRID_ALIGN_STRETCH, 2, 2, LV_GRID_ALIGN_STRETCH, 2, 1);
-
- // lv_obj_clear_flag(temp_cont, LV_OBJ_FLAG_SCROLLABLE);
- // lv_obj_set_size(temp_cont, LV_PCT(50), LV_PCT(50));
+ // Wrap all buttons in a container spanning cols 2-3 across all 3 rows so
+ // button heights are uniform and independent of the CONTENT-sized sensor row.
+ lv_obj_t *btn_wrapper = lv_obj_create(main_cont);
+ lv_obj_clear_flag(btn_wrapper, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_set_style_pad_all(btn_wrapper, 0, 0);
+ lv_obj_set_style_border_width(btn_wrapper, 0, 0);
+ lv_obj_set_style_bg_opa(btn_wrapper, LV_OPA_TRANSP, 0);
+ lv_obj_set_grid_cell(btn_wrapper, LV_GRID_ALIGN_STRETCH, 2, 2, LV_GRID_ALIGN_STRETCH, 0, 2);
+
+ static lv_coord_t btn_col_dsc[] = {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST};
+ static lv_coord_t btn_row_dsc[] = {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST};
+ lv_obj_set_grid_dsc_array(btn_wrapper, btn_col_dsc, btn_row_dsc);
+
+ lv_obj_set_parent(homing_btn.get_container(), btn_wrapper);
+ lv_obj_set_parent(extrude_btn.get_container(), btn_wrapper);
+ lv_obj_set_parent(action_btn.get_container(), btn_wrapper);
+ lv_obj_set_parent(finetune_btn.get_container(), btn_wrapper);
+ lv_obj_set_parent(print_btn.get_container(), btn_wrapper);
+
+ lv_obj_set_grid_cell(homing_btn.get_container(), LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_STRETCH, 0, 1);
+ lv_obj_set_grid_cell(extrude_btn.get_container(), LV_GRID_ALIGN_STRETCH, 1, 1, LV_GRID_ALIGN_STRETCH, 0, 1);
+ lv_obj_set_grid_cell(action_btn.get_container(), LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_STRETCH, 1, 1);
+ lv_obj_set_grid_cell(finetune_btn.get_container(), LV_GRID_ALIGN_STRETCH, 1, 1, LV_GRID_ALIGN_STRETCH, 1, 1);
+ lv_obj_set_grid_cell(print_btn.get_container(), LV_GRID_ALIGN_STRETCH, 0, 2, LV_GRID_ALIGN_STRETCH, 2, 1);
+
+ /* Disable scrolling on the sensor container — the 5px left border on each
+ * sensor row was tripping the horizontal scrollbar even though everything
+ * fits visually. */
+ lv_obj_clear_flag(temp_cont, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_set_scrollbar_mode(temp_cont, LV_SCROLLBAR_MODE_OFF);
lv_obj_set_style_pad_all(temp_cont, 0, 0);
+ /* Zero row gap so sensor rows stack flush — default flex pad_row eats vertical space. */
+ lv_obj_set_style_pad_row(temp_cont, 0, 0);
lv_obj_set_flex_flow(temp_cont, LV_FLEX_FLOW_COLUMN);
- lv_obj_set_grid_cell(temp_cont, LV_GRID_ALIGN_STRETCH, 0, 2, LV_GRID_ALIGN_STRETCH, 0, 2);
+ /* Row 0 is CONTENT-sized — it shrinks to fit only the sensor rows. */
+ lv_obj_set_grid_cell(temp_cont, LV_GRID_ALIGN_STRETCH, 0, 2, LV_GRID_ALIGN_STRETCH, 0, 1);
lv_obj_align(temp_chart, LV_ALIGN_CENTER, 0, 0);
- lv_obj_set_width(temp_chart, LV_PCT(45));//, LV_PCT(40));
+ lv_obj_set_width(temp_chart, LV_PCT(45));
lv_obj_set_style_size(temp_chart, 0, LV_PART_INDICATOR);
lv_chart_set_range(temp_chart, LV_CHART_AXIS_PRIMARY_Y, 0, 300);
- lv_obj_set_grid_cell(temp_chart, LV_GRID_ALIGN_END, 0, 2, LV_GRID_ALIGN_STRETCH, 2, 1);
- lv_chart_set_axis_tick(temp_chart, LV_CHART_AXIS_PRIMARY_Y, 0, 0, 6, 5, true, 50);
-
- lv_chart_set_div_line_count(temp_chart, 3, 8);
- lv_chart_set_point_count(temp_chart, 5000);
- lv_chart_set_zoom_x(temp_chart, 5000);
- lv_obj_scroll_to_x(temp_chart, LV_COORD_MAX, LV_ANIM_OFF);
+ /* Chart in row 1, stretches to fill remaining space below the sensor rows. */
+ lv_obj_set_grid_cell(temp_chart, LV_GRID_ALIGN_END, 0, 2, LV_GRID_ALIGN_STRETCH, 1, 1);
+ /* 7 major ticks = labels at 0,50,100,...,300 — 50° steps for easier reading. */
+ lv_chart_set_axis_tick(temp_chart, LV_CHART_AXIS_PRIMARY_Y, 0, 0, 7, 5, true, 25);
+ lv_obj_set_style_text_font(temp_chart, &lv_font_montserrat_8, LV_PART_TICKS);
+
+ /* 5 interior horizontal div lines align with the 50° tick interval (50,100,150,200,250). */
+ lv_chart_set_div_line_count(temp_chart, 5, 8);
+ /* point_count + zoom_x previously 5000 each, which compressed all data into
+ * the rightmost slice of the chart. With zoom 256 (1x) and a smaller point
+ * buffer, the pre-populated samples spread across the full chart width. */
+ lv_chart_set_point_count(temp_chart, 300);
+ lv_chart_set_zoom_x(temp_chart, 256);
+ /* Scroll is set in create_sensors() after sensors are added and layout is stable. */
}
void MainPanel::create_sensors(json &temp_sensors) {
@@ -277,11 +320,32 @@ void MainPanel::create_sensors(json &temp_sensors) {
lv_chart_series_t *temp_series =
lv_chart_add_series(temp_chart, color_code, LV_CHART_AXIS_PRIMARY_Y);
+ lv_chart_set_all_value(temp_chart, temp_series, 0);
- sensors.insert({key, std::make_shared(ws, temp_cont, sensor_img, 150,
+ auto sc = std::make_shared(ws, temp_cont, sensor_img, 150,
display_name.c_str(), color_code, controllable, false, numpad, sensor_name,
- temp_chart, temp_series, type)});
+ temp_chart, temp_series, type);
+
+ /* Controllable sensors (extruder, bed) get a second series rendered as a
+ * faint horizontal line at the target temperature so the user can see
+ * actual vs set at a glance. */
+ if (controllable) {
+ lv_color_t target_color = lv_color_mix(color_code, lv_color_white(), 128);
+ lv_chart_series_t *target_series =
+ lv_chart_add_series(temp_chart, target_color, LV_CHART_AXIS_PRIMARY_Y);
+ lv_chart_set_all_value(temp_chart, target_series, 0);
+ sc->set_target_series(target_series);
+ }
+
+ sensors.insert({key, sc});
}
+
+ /* Layout is now stable (sensors added to temp_cont, CONTENT row resolved).
+ * Scroll chart to show the newest data (right end of the ring buffer). */
+ /* Force main_cont grid to recalculate: row 0 was LV_GRID_CONTENT but temp_cont
+ * was empty when create_main() ran, so its height was 0. Now sensors are present. */
+ lv_obj_update_layout(main_cont);
+ lv_obj_scroll_to_x(temp_chart, LV_COORD_MAX, LV_ANIM_OFF);
}
void MainPanel::create_fans(json &fans) {
@@ -297,3 +361,79 @@ void MainPanel::enable_spoolman() {
setting_panel.enable_spoolman();
extruder_panel.enable_spoolman();
}
+
+#ifdef SIMULATOR
+#include "config.h"
+
+void MainPanel::sim_setup_mock_data() {
+ Config *conf = Config::get_instance();
+ json &user_sensors = conf->get_json(conf->df() + "monitored_sensors");
+ if (user_sensors.is_null()) {
+ spdlog::warn("sim_setup_mock_data: no monitored_sensors in config");
+ return;
+ }
+
+ /* Build a display_sensors json keyed by id, matching State::get_display_sensors
+ * shape — but bypass the Moonraker-dependent extruder/heater/sensor lookups. */
+ json display_sensors;
+ for (auto &s : user_sensors) {
+ std::string id = s["id"].template get();
+ display_sensors[id] = s;
+ }
+ create_sensors(display_sensors);
+
+ /* Set initial displayed values. */
+ for (auto &kv : sensors) {
+ const std::string &key = kv.first;
+ auto &s = kv.second;
+ if (key.find("extruder") != std::string::npos) {
+ s->update_value(205); s->update_target(210);
+ } else if (key.find("heater_bed") != std::string::npos) {
+ s->update_value(62); s->update_target(65);
+ } else {
+ s->update_value(38);
+ }
+ }
+
+ /* Pre-populate the chart with ~200 historical points so the graph
+ * looks alive immediately rather than starting empty. */
+ for (int i = 0; i < 200; i++) {
+ for (auto &kv : sensors) {
+ const std::string &key = kv.first;
+ auto &s = kv.second;
+ int v;
+ if (key.find("extruder") != std::string::npos) {
+ v = 200 + (i % 12) - 6;
+ } else if (key.find("heater_bed") != std::string::npos) {
+ v = 60 + (i % 6) - 3;
+ } else {
+ v = 35 + (i % 8) - 4;
+ }
+ s->update_series(v);
+ }
+ }
+
+ /* Faster timer (300ms) gives a denser live chart since SIMULATOR mode
+ * bypasses the 1s throttle in SensorContainer::update_series. */
+ lv_timer_t *t = lv_timer_create([](lv_timer_t *timer) {
+ auto *self = static_cast(timer->user_data);
+ static int n = 0;
+ n++;
+ for (auto &kv : self->sensors) {
+ const std::string &key = kv.first;
+ auto &s = kv.second;
+ int v;
+ if (key.find("extruder") != std::string::npos) {
+ v = 205 + (n % 12) - 6;
+ } else if (key.find("heater_bed") != std::string::npos) {
+ v = 62 + (n % 6) - 3;
+ } else {
+ v = 38 + (n % 8) - 4;
+ }
+ s->update_value(v);
+ s->update_series(v);
+ }
+ }, 300, this);
+ (void)t;
+}
+#endif
diff --git a/src/main_panel.h b/src/main_panel.h
index 39f3191..87b2b2c 100644
--- a/src/main_panel.h
+++ b/src/main_panel.h
@@ -41,10 +41,16 @@ class MainPanel : public NotifyConsumer {
void create_sensors(json &temp_sensors);
void create_fans(json &temp_fans);
void create_leds(json &leds);
+#ifdef SIMULATOR
+ /* Populate sensors directly from config and start a periodic timer that
+ * pushes mock temperature values, so the home panel is visible without
+ * a live Moonraker connection. */
+ void sim_setup_mock_data();
+#endif
void handle_homing_cb(lv_event_t *event);
void handle_extrude_cb(lv_event_t *event);
void handle_fanpanel_cb(lv_event_t *event);
- void handle_ledpanel_cb(lv_event_t *event);
+ void handle_finetune_cb(lv_event_t *event);
void handle_print_cb(lv_event_t *event);
lv_obj_t *create_button(lv_obj_t *parent,
@@ -72,9 +78,9 @@ class MainPanel : public NotifyConsumer {
panel->handle_fanpanel_cb(event);
};
- static void _handle_ledpanel_cb(lv_event_t *event) {
+ static void _handle_finetune_cb(lv_event_t *event) {
MainPanel *panel = (MainPanel*)event->user_data;
- panel->handle_ledpanel_cb(event);
+ panel->handle_finetune_cb(event);
};
static void _handle_print_cb(lv_event_t *event) {
@@ -116,7 +122,7 @@ class MainPanel : public NotifyConsumer {
ButtonContainer homing_btn;
ButtonContainer extrude_btn;
ButtonContainer action_btn;
- ButtonContainer led_btn;
+ ButtonContainer finetune_btn;
ButtonContainer print_btn;
};
#endif // __MAIN_PANEL_H__
diff --git a/src/mini_print_status.cpp b/src/mini_print_status.cpp
index 322e56a..f61bf9b 100644
--- a/src/mini_print_status.cpp
+++ b/src/mini_print_status.cpp
@@ -34,7 +34,7 @@ MiniPrintStatus::MiniPrintStatus(lv_obj_t *parent,
lv_obj_set_style_radius(cont, 4, 0);
lv_obj_add_flag(cont, LV_OBJ_FLAG_FLOATING);
- lv_obj_align(cont, LV_ALIGN_TOP_LEFT, 0, -14 * scale);
+ lv_obj_align(cont, LV_ALIGN_TOP_RIGHT, 0, -14 * scale);
lv_obj_add_flag(cont, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(cont, cb, LV_EVENT_CLICKED, user_data);
diff --git a/src/numpad.cpp b/src/numpad.cpp
index 3bdee53..683c6f0 100644
--- a/src/numpad.cpp
+++ b/src/numpad.cpp
@@ -103,9 +103,16 @@ void Numpad::handle_input(lv_event_t *e) {
// }
// }
-void Numpad::foreground_reset() {
- spdlog::trace("resetting foreground");
- lv_textarea_set_text(input, "");
+void Numpad::foreground_reset(int initial) {
+ spdlog::trace("resetting foreground with initial {}", initial);
+ if (initial >= 0) {
+ lv_textarea_set_text(input, fmt::format("{}", initial).c_str());
+ /* Place the cursor at the end so the next digit appends; selecting all
+ * would let a fresh number replace the value but feels surprising. */
+ lv_textarea_set_cursor_pos(input, LV_TEXTAREA_CURSOR_LAST);
+ } else {
+ lv_textarea_set_text(input, "");
+ }
lv_obj_clear_flag(edit_cont, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(edit_cont);
}
diff --git a/src/numpad.h b/src/numpad.h
index 92b0a37..4b288db 100644
--- a/src/numpad.h
+++ b/src/numpad.h
@@ -12,7 +12,9 @@ class Numpad {
void set_callback(std::function cb);
void handle_input(lv_event_t *event);
/* void handle_defocused(lv_event_t *event); */
- void foreground_reset();
+ /* initial >= 0 pre-fills the input field (useful for showing current set temp
+ * so the user can adjust rather than retype). Pass -1 (default) for blank. */
+ void foreground_reset(int initial = -1);
static void _handle_input(lv_event_t *event) {
Numpad *panel = (Numpad*)event->user_data;
diff --git a/src/print_panel.cpp b/src/print_panel.cpp
index 66b0ae0..55bd4f1 100644
--- a/src/print_panel.cpp
+++ b/src/print_panel.cpp
@@ -58,16 +58,22 @@ PrintPanel::PrintPanel(KWebSocketClient &websocket, std::mutex &lock, PrintStatu
// file view buttons
lv_obj_t *label = NULL;
+ /* Explicit m10 override — theme cascade alone isn't reaching these labels for
+ * some reason, possibly because they're children of plain lv_btn which carries
+ * its own default style. Forcing the font here matches the rest of the UI. */
label = lv_label_create(refresh_btn);
lv_label_set_text(label, LV_SYMBOL_REFRESH " Reload");
+ lv_obj_set_style_text_font(label, &lv_font_montserrat_10, 0);
lv_obj_center(label);
label = lv_label_create(modified_sort_btn);
lv_label_set_text(label, LV_SYMBOL_LIST " Modified");
+ lv_obj_set_style_text_font(label, &lv_font_montserrat_10, 0);
lv_obj_center(label);
label = lv_label_create(az_sort_btn);
lv_label_set_text(label, LV_SYMBOL_LIST " A-Z");
+ lv_obj_set_style_text_font(label, &lv_font_montserrat_10, 0);
lv_obj_center(label);
lv_obj_add_event_cb(refresh_btn, &PrintPanel::_handle_btns, LV_EVENT_CLICKED, this);
diff --git a/src/printertune_panel.cpp b/src/printertune_panel.cpp
index 2c3f15b..5a09158 100644
--- a/src/printertune_panel.cpp
+++ b/src/printertune_panel.cpp
@@ -1,5 +1,6 @@
#include "printertune_panel.h"
#include "state.h"
+#include "utils.h"
#include "spdlog/spdlog.h"
#include
@@ -7,7 +8,7 @@
namespace fs = std::experimental::filesystem;
LV_IMG_DECLARE(bedmesh_img);
-LV_IMG_DECLARE(fine_tune_img);
+LV_IMG_DECLARE(light_img);
LV_IMG_DECLARE(inputshaper_img);
LV_IMG_DECLARE(limit_img);
LV_IMG_DECLARE(motor_img);
@@ -20,10 +21,10 @@ LV_IMG_DECLARE(power_devices_img);
LV_IMG_DECLARE(print);
#endif
-PrinterTunePanel::PrinterTunePanel(KWebSocketClient &c, std::mutex &l, lv_obj_t *parent, FineTunePanel &finetune)
+PrinterTunePanel::PrinterTunePanel(KWebSocketClient &c, std::mutex &l, lv_obj_t *parent, LedPanel &led)
: cont(lv_obj_create(parent))
, bedmesh_panel(c, l)
- , finetune_panel(finetune)
+ , led_panel(led)
, limits_panel(c, l)
, inputshaper_panel(c, l)
, belts_calibration_panel(c, l)
@@ -31,7 +32,7 @@ PrinterTunePanel::PrinterTunePanel(KWebSocketClient &c, std::mutex &l, lv_obj_t
, tmc_status_panel(c, l)
, power_panel(c, l)
, bedmesh_btn(cont, &bedmesh_img, "Bed Mesh", &PrinterTunePanel::_handle_callback, this)
- , finetune_btn(cont, &fine_tune_img, "Fine Tune", &PrinterTunePanel::_handle_callback, this)
+ , led_btn(cont, &light_img, "LED", &PrinterTunePanel::_handle_callback, this)
, inputshaper_btn(cont, &inputshaper_img, "Input Shaper", &PrinterTunePanel::_handle_callback, this)
#ifndef ZBOLT
, belts_calibration_btn(cont, &belts_calibration_img, "Belts/Shake", &PrinterTunePanel::_handle_callback, this)
@@ -63,7 +64,7 @@ PrinterTunePanel::PrinterTunePanel(KWebSocketClient &c, std::mutex &l, lv_obj_t
// row 1
lv_obj_set_grid_cell(bedmesh_btn.get_container(), LV_GRID_ALIGN_STRETCH, 1, 1, LV_GRID_ALIGN_STRETCH, 1, 1);
- lv_obj_set_grid_cell(finetune_btn.get_container(), LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_STRETCH, 1, 1);
+ lv_obj_set_grid_cell(led_btn.get_container(), LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_STRETCH, 1, 1);
lv_obj_set_grid_cell(inputshaper_btn.get_container(), LV_GRID_ALIGN_STRETCH, 2, 1, LV_GRID_ALIGN_STRETCH, 1, 1);
lv_obj_set_grid_cell(belts_calibration_btn.get_container(), LV_GRID_ALIGN_STRETCH, 3, 1, LV_GRID_ALIGN_STRETCH, 1, 1);
@@ -117,30 +118,36 @@ void PrinterTunePanel::handle_callback(lv_event_t *event) {
if (lv_event_get_code(event) == LV_EVENT_CLICKED) {
lv_obj_t *btn = lv_event_get_current_target(event);
- if (btn == finetune_btn.get_container()) {
- spdlog::trace("tune finetune pressed");
- finetune_panel.foreground();
+ if (btn == led_btn.get_container()) {
+ spdlog::trace("tune led pressed");
+ led_panel.foreground();
} else if (btn == bedmesh_btn.get_container()) {
spdlog::trace("tune bedmesh pressed");
+ if (KUtils::is_printing()) { KUtils::notify_locked(); return; }
bedmesh_panel.foreground();
} else if (btn == inputshaper_btn.get_container()) {
spdlog::trace("tune inputshaper pressed");
+ if (KUtils::is_printing()) { KUtils::notify_locked(); return; }
inputshaper_panel.foreground();
} else if (btn == belts_calibration_btn.get_container()) {
spdlog::trace("tune belts pressed");
+ if (KUtils::is_printing()) { KUtils::notify_locked(); return; }
belts_calibration_panel.foreground();
} else if (btn == limits_btn.get_container()) {
spdlog::trace("limits pressed");
- limits_panel.foreground();
+ KUtils::confirm_if_printing("Printer is printing.\nAdjust limits anyway?",
+ [this]() { limits_panel.foreground(); });
} else if (btn == tmc_tune_btn.get_container()) {
spdlog::trace("tmc auto tune pressed");
+ if (KUtils::is_printing()) { KUtils::notify_locked(); return; }
tmc_tune_panel.foreground();
} else if (btn == tmc_status_btn.get_container()) {
spdlog::trace("tmc metrics pressed");
tmc_status_panel.foreground();
} else if (btn == power_devices_btn.get_container()) {
spdlog::trace("power devices pressed");
- power_panel.foreground();
+ KUtils::confirm_if_printing("Printer is printing.\nChange power devices anyway?",
+ [this]() { power_panel.foreground(); });
}
}
}
diff --git a/src/printertune_panel.h b/src/printertune_panel.h
index a10ee1b..8d1e4a4 100644
--- a/src/printertune_panel.h
+++ b/src/printertune_panel.h
@@ -1,7 +1,7 @@
#ifndef __PRINTERTUNE_PANEL_H__
#define __PRINTERTUNE_PANEL_H__
-#include "finetune_panel.h"
+#include "led_panel.h"
#include "limits_panel.h"
#include "bedmesh_panel.h"
#include "inputshaper_panel.h"
@@ -15,7 +15,7 @@
#include
class PrinterTunePanel {
public:
- PrinterTunePanel(KWebSocketClient &c, std::mutex &l, lv_obj_t *parent, FineTunePanel &);
+ PrinterTunePanel(KWebSocketClient &c, std::mutex &l, lv_obj_t *parent, LedPanel &);
~PrinterTunePanel();
lv_obj_t *get_container();
@@ -32,15 +32,15 @@ class PrinterTunePanel {
private:
lv_obj_t *cont;
BedMeshPanel bedmesh_panel;
- FineTunePanel &finetune_panel;
+ LedPanel &led_panel;
LimitsPanel limits_panel;
InputShaperPanel inputshaper_panel;
BeltsCalibrationPanel belts_calibration_panel;
TmcTunePanel tmc_tune_panel;
TmcStatusPanel tmc_status_panel;
PowerPanel power_panel;
- ButtonContainer bedmesh_btn;
- ButtonContainer finetune_btn;
+ ButtonContainer bedmesh_btn;
+ ButtonContainer led_btn;
ButtonContainer inputshaper_btn;
ButtonContainer belts_calibration_btn;
ButtonContainer limits_btn;
diff --git a/src/selector.cpp b/src/selector.cpp
index 299e6e2..4716ad1 100644
--- a/src/selector.cpp
+++ b/src/selector.cpp
@@ -24,15 +24,22 @@ Selector::Selector(lv_obj_t *parent,
lv_obj_set_style_pad_all(cont, 0, 0);
lv_obj_set_style_pad_row(cont, 0, 0);
- auto height = (double)lv_disp_get_physical_ver_res(NULL) * (height_pct / 100.0);
- height = height < 50 ? 50 : height;
+ auto ver_res = lv_disp_get_physical_ver_res(NULL);
+ bool small_screen = ver_res <= 320;
+ auto height = (double)ver_res * (height_pct / 100.0);
+ /* Lower the minimum on compact screens so the title label still fits
+ * alongside the button matrix in tight grid rows (e.g. 480x272). */
+ double min_height = small_screen ? 28.0 : 50.0;
+ if (height < min_height) height = min_height;
lv_obj_set_size(btnm, LV_PCT(100), height);
- lv_label_set_text(label, label_text);
- lv_obj_set_width(label, LV_PCT(100));
+ lv_label_set_text(label, label_text);
+ lv_obj_set_width(label, LV_PCT(100));
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0);
-
+
+ /* Theme default is m10 on small screens now — no need to override per-widget here. */
+
lv_btnmatrix_set_map(btnm, &map[0]);
- lv_obj_set_style_pad_all(btnm, 4, LV_PART_MAIN);
+ lv_obj_set_style_pad_all(btnm, small_screen ? 2 : 4, LV_PART_MAIN);
lv_obj_set_style_outline_width(btnm, 0, LV_PART_ITEMS | LV_STATE_FOCUS_KEY);
diff --git a/src/sensor_container.cpp b/src/sensor_container.cpp
index d85a2fb..bd9d69f 100644
--- a/src/sensor_container.cpp
+++ b/src/sensor_container.cpp
@@ -28,6 +28,7 @@ SensorContainer::SensorContainer(KWebSocketClient &c,
, id(name)
, chart(chart_chart)
, series(chart_series)
+ , target_series(NULL)
, last_updated_ts(std::time(nullptr))
, type(type)
{
@@ -43,31 +44,46 @@ SensorContainer::SensorContainer(KWebSocketClient &c,
auto width_scale = (double)lv_disp_get_physical_hor_res(NULL) / 800.0;
auto height_scale = (double)lv_disp_get_physical_ver_res(NULL) / 480.0;
- lv_obj_set_size(sensor_cont, 330 * width_scale, 60 * height_scale);
+ /* Tighter row: 48 (was 60) so three sensors + chart fit comfortably at 480x272. */
+ lv_obj_set_size(sensor_cont, 330 * width_scale, 48 * height_scale);
lv_obj_set_style_pad_all(sensor_cont, 0, 0);
+ /* Smaller, screen-aware font — on 480x272 the previous theme-default m12 felt oversized. */
+ const lv_font_t *row_font = (width_scale < 0.8) ? &lv_font_montserrat_10 : &lv_font_montserrat_14;
+
lv_img_set_src(sensor_img, img);
lv_obj_align(sensor_img, LV_ALIGN_LEFT_MID, 0, 0);
lv_label_set_text(sensor_label, text);
lv_obj_align_to(sensor_label, sensor_img, LV_ALIGN_OUT_RIGHT_MID, -7 * width_scale, 0);
-
+ lv_obj_set_style_text_font(sensor_label, row_font, 0);
+ // Clip long display names (e.g. "MCU Temperature") so they don't bleed into the value/target boxes.
+ lv_obj_set_width(sensor_label, 130 * width_scale);
+ lv_label_set_long_mode(sensor_label, LV_LABEL_LONG_DOT);
+
+ // Right-anchored block: [value] [/] [target]. Sized so 3-digit values ("210") fit inside
+ // the bordered target box without wrapping. Targets/values use LV_LABEL_LONG_CLIP to
+ // suppress wrap even if a future value somehow exceeds the box.
lv_label_set_text(value_label, "0");
- lv_obj_set_width(value_label, 55 * width_scale);
- lv_obj_align(value_label, LV_ALIGN_RIGHT_MID, -75 * width_scale, 0);
+ lv_obj_set_width(value_label, 50 * width_scale);
+ lv_obj_align(value_label, LV_ALIGN_RIGHT_MID, -90 * width_scale, 0);
lv_label_set_long_mode(value_label, LV_LABEL_LONG_CLIP);
- lv_obj_set_style_pad_all(value_label, 8 * width_scale, 0);
+ lv_obj_set_style_pad_all(value_label, 2 * width_scale, 0);
+ lv_obj_set_style_text_font(value_label, row_font, 0);
lv_label_set_text(divider_label, "/");
- lv_obj_set_width(divider_label, 50 * width_scale);
- lv_obj_align(divider_label, LV_ALIGN_RIGHT_MID, -32 * width_scale, 0);
- lv_obj_set_style_pad_all(divider_label, 8 * width_scale, 0);
+ lv_obj_set_width(divider_label, 12 * width_scale);
+ lv_obj_align(divider_label, LV_ALIGN_RIGHT_MID, -72 * width_scale, 0);
+ lv_obj_set_style_pad_all(divider_label, 0, 0);
+ lv_obj_set_style_text_font(divider_label, row_font, 0);
if (show_target || can_edit) {
lv_label_set_text(target_label, "0");
lv_obj_set_width(target_label, 60 * width_scale);
- lv_obj_align(target_label, LV_ALIGN_RIGHT_MID, 0, 0);
- lv_obj_set_style_pad_all(target_label, 8 * width_scale, 0);
+ lv_obj_align(target_label, LV_ALIGN_RIGHT_MID, -5 * width_scale, 0);
+ lv_obj_set_style_pad_all(target_label, 2 * width_scale, 0);
+ lv_obj_set_style_text_font(target_label, row_font, 0);
+ lv_label_set_long_mode(target_label, LV_LABEL_LONG_CLIP);
} else {
lv_obj_add_flag(target_label, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(divider_label, LV_OBJ_FLAG_HIDDEN);
@@ -113,6 +129,11 @@ SensorContainer::~SensorContainer() {
lv_chart_remove_series(chart, series);
series = NULL;
}
+
+ if (target_series != NULL && chart != NULL) {
+ lv_chart_remove_series(chart, target_series);
+ target_series = NULL;
+ }
}
lv_obj_t *SensorContainer::get_sensor() {
@@ -123,6 +144,18 @@ void SensorContainer::update_target(int new_target) {
if (new_target >= 0) {
target = new_target;
lv_label_set_text(target_label, fmt::format("{}", new_target).c_str());
+ /* Paint the entire target series at the new target value — gives a flat
+ * horizontal line at that temp so the user can see actual vs set at a glance. */
+ if (target_series != NULL && chart != NULL) {
+ lv_chart_set_all_value(chart, target_series, new_target);
+ }
+ }
+}
+
+void SensorContainer::set_target_series(lv_chart_series_t *ts) {
+ target_series = ts;
+ if (ts != NULL && chart != NULL && target >= 0) {
+ lv_chart_set_all_value(chart, ts, target);
}
}
@@ -135,11 +168,16 @@ void SensorContainer::update_value(int new_value) {
void SensorContainer::update_series(int v) {
if (series != NULL && chart != NULL) {
+#ifdef SIMULATOR
+ /* No throttling in simulator mode — driven entirely by the mock timer. */
+ lv_chart_set_next_value(chart, series, v);
+#else
auto delta = std::time(nullptr) - last_updated_ts;
if (delta > 1) {
lv_chart_set_next_value(chart, series, v);
last_updated_ts = std::time(nullptr);
}
+#endif
}
}
@@ -160,6 +198,8 @@ void SensorContainer::handle_edit(lv_event_t *e) {
}
ws.gcode_script(cmd);
});
- numpad.foreground_reset();
+ /* Pre-fill the numpad with the current target so the user can adjust
+ * from the existing value instead of retyping from scratch. */
+ numpad.foreground_reset(target);
}
}
diff --git a/src/sensor_container.h b/src/sensor_container.h
index 31e4a12..87d39a6 100644
--- a/src/sensor_container.h
+++ b/src/sensor_container.h
@@ -48,6 +48,9 @@ class SensorContainer {
void update_target(int new_target);
void update_value(int new_value);
void update_series(int value);
+ /* Attach a second chart series that draws a horizontal line at the current
+ * target temperature. Set once, updated whenever update_target() fires. */
+ void set_target_series(lv_chart_series_t *ts);
void handle_edit(lv_event_t *event);
static void _handle_edit(lv_event_t *event) {
@@ -69,6 +72,7 @@ class SensorContainer {
std::string id;
lv_obj_t *chart;
lv_chart_series_t *series;
+ lv_chart_series_t *target_series;
std::time_t last_updated_ts;
SensorType type;
};
diff --git a/src/sysinfo_panel.cpp b/src/sysinfo_panel.cpp
index 2be9268..fd8addf 100644
--- a/src/sysinfo_panel.cpp
+++ b/src/sysinfo_panel.cpp
@@ -249,11 +249,23 @@ SysInfoPanel::SysInfoPanel()
}
lv_obj_add_event_cb(def_temp_dd, &SysInfoPanel::_handle_callback, LV_EVENT_VALUE_CHANGED, this);
+ // Factory Reset lives alone in the top-right corner — kept away from Back and
+ // the settings rows to avoid accidental presses.
lv_obj_add_flag(factory_reset_btn.get_container(), LV_OBJ_FLAG_FLOATING);
- lv_obj_align(factory_reset_btn.get_container(), LV_ALIGN_BOTTOM_LEFT, 0, 0);
+ lv_obj_align(factory_reset_btn.get_container(), LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_add_flag(back_btn.get_container(), LV_OBJ_FLAG_FLOATING);
lv_obj_align(back_btn.get_container(), LV_ALIGN_BOTTOM_RIGHT, 0, 0);
+
+ // The network/version text is left-aligned and the Factory Reset button is
+ // narrow and pinned to the right, so they don't overlap. Reserve only the
+ // button's width on the right so a very long interface line can't slide under
+ // it — full vertical height stays available so the version never clips.
+ lv_obj_update_layout(cont);
+ lv_obj_set_width(network_label,
+ lv_obj_get_width(right_cont)
+ - lv_obj_get_width(factory_reset_btn.get_container()) - 4);
+ lv_label_set_long_mode(network_label, LV_LABEL_LONG_WRAP);
}
SysInfoPanel::~SysInfoPanel() {
diff --git a/src/utils.cpp b/src/utils.cpp
index 2c85d35..c2d2c9c 100644
--- a/src/utils.cpp
+++ b/src/utils.cpp
@@ -4,6 +4,7 @@
#include "state.h"
#include "spdlog/spdlog.h"
#include "platform.h"
+#include "lvgl/lvgl.h"
#include
#include
@@ -34,6 +35,65 @@ namespace KUtils {
return false;
}
+ bool is_printing() {
+ auto v = State::get_instance()
+ ->get_data("/printer_state/print_stats/state"_json_pointer);
+ if (!v.is_null()) {
+ std::string s = v.template get();
+ return s == "printing" || s == "paused";
+ }
+ return false;
+ }
+
+ // Shared styling so print-lock dialogs match the app's other msgboxes:
+ // centered body text and a floating, centered button row at the bottom.
+ static void style_lock_mbox(lv_obj_t *mbox, lv_coord_t btns_pct) {
+ lv_obj_t *msg = ((lv_msgbox_t *)mbox)->text;
+ lv_obj_set_style_text_align(msg, LV_TEXT_ALIGN_CENTER, 0);
+ lv_obj_set_width(msg, LV_PCT(100));
+ lv_obj_center(msg);
+
+ lv_obj_t *btnm = lv_msgbox_get_btns(mbox);
+ lv_obj_add_flag(btnm, LV_OBJ_FLAG_FLOATING);
+ lv_obj_align(btnm, LV_ALIGN_BOTTOM_MID, 0, 0);
+
+ auto hscale = (double)lv_disp_get_physical_ver_res(NULL) / 480.0;
+ lv_obj_set_size(btnm, LV_PCT(btns_pct), 50 * hscale);
+ lv_obj_set_size(mbox, LV_PCT(70), LV_PCT(45));
+ lv_obj_center(mbox);
+ }
+
+ void notify_locked() {
+ static const char *btns[] = {"OK", ""};
+ lv_obj_t *mbox = lv_msgbox_create(NULL, NULL,
+ "Unavailable while printing", btns, false);
+ lv_obj_add_event_cb(mbox, [](lv_event_t *e) {
+ lv_msgbox_close(lv_obj_get_parent(lv_event_get_target(e)));
+ }, LV_EVENT_VALUE_CHANGED, NULL);
+ style_lock_mbox(mbox, 50);
+ }
+
+ void confirm_if_printing(const std::string &msg, const std::function &cb) {
+ if (!is_printing()) {
+ cb();
+ return;
+ }
+
+ auto *heap_cb = new std::function(cb);
+ static const char *btns[] = {"Confirm", "Cancel", ""};
+ lv_obj_t *mbox = lv_msgbox_create(NULL, NULL, msg.c_str(), btns, false);
+ lv_obj_add_event_cb(mbox, [](lv_event_t *e) {
+ lv_obj_t *obj = lv_obj_get_parent(lv_event_get_target(e));
+ auto *fn = (std::function *)lv_event_get_user_data(e);
+ if (lv_msgbox_get_active_btn(obj) == 0 && fn != NULL) {
+ (*fn)();
+ }
+ delete fn;
+ lv_msgbox_close(obj);
+ }, LV_EVENT_VALUE_CHANGED, heap_cb);
+ style_lock_mbox(mbox, 90);
+ }
+
bool is_running_local() {
Config *conf = Config::get_instance();
std::string df_host = conf->get(conf->df() + "moonraker_host");
diff --git a/src/utils.h b/src/utils.h
index 18cc9fd..0330a4f 100644
--- a/src/utils.h
+++ b/src/utils.h
@@ -12,6 +12,14 @@ using json = nlohmann::json;
namespace KUtils {
bool is_homed();
+ // True while a print job is active (printing or paused). Panels that move axes,
+ // home, or run calibration must not act in this state.
+ bool is_printing();
+ // Modal "Unavailable while printing" notice (single OK button).
+ void notify_locked();
+ // Runs cb immediately when idle; while printing, shows a Confirm/Cancel modal
+ // and only runs cb if the user confirms the override.
+ void confirm_if_printing(const std::string &msg, const std::function &cb);
bool is_running_local();
std::string get_root_path(const std::string root_name);