From 5b64e49c53e3487694028b7de8f8aca5f0a93f30 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 11:12:00 +0800 Subject: [PATCH 01/47] docs: add AMS drying control design spec Port AMS filament drying control feature from BambuStudio. Covers data model changes, commands, UI dialog, and integration plan. --- .../2026-07-07-ams-drying-control-design.md | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-ams-drying-control-design.md diff --git a/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md b/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md new file mode 100644 index 0000000000..940534a501 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md @@ -0,0 +1,309 @@ +# AMS Drying Control — Design Spec + +**Date:** 2026-07-07 +**Source:** Port from BambuStudio `AMSDryControl` feature +**Branch:** `dev/ams-heat` + +## Overview + +Port the AMS filament drying control feature from BambuStudio into OrcaSlicer. This allows users to start, monitor, and stop AMS-based filament drying directly from the slicer UI. Currently OrcaSlicer can monitor drying state and stop drying, but cannot initiate it. This feature adds the missing start-drying command, the full drying control dialog, and the drying preset lookup infrastructure. + +**Target AMS models:** N3F (AMS 2 Pro, max 65°C) and N3S (AMS HT, max 85°C) — the only AMS units with heating hardware. + +## Architecture + +The feature spans three layers, matching the existing OrcaSlicer `DeviceCore/` pattern: + +``` +┌─────────────────────────────────────────────────────┐ +│ UI Layer │ +│ AMSDryControl.hpp/.cpp (new dialog) │ +│ AMSControl.cpp (hook: click → dialog) │ +│ DeviceErrorDialog.cpp (stop drying on error) │ +├─────────────────────────────────────────────────────┤ +│ Command/Control Layer │ +│ DevFilaSystemCtrl.cpp (start/stop drying) │ +│ DevUtilBackend.h/.cpp (preset lookup) │ +├─────────────────────────────────────────────────────┤ +│ Data Model Layer │ +│ DevFilaSystem.h/.cpp (enums, structs, parse) │ +│ DevDefs.h (DevAmsType enum) │ +└─────────────────────────────────────────────────────┘ +│ Communication Layer (already exists) │ +│ MachineObject::publish_json() → MQTT → printer │ +└─────────────────────────────────────────────────────┘ +``` + +### New files (4 source + headers) + +| File | Purpose | +|------|---------| +| `src/slic3r/GUI/AMSDryControl.hpp` | AMS drying control dialog class declarations | +| `src/slic3r/GUI/AMSDryControl.cpp` | Dialog implementation (~1800 lines) | +| `src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp` | `CtrlAmsStartDryingHour()` and `CtrlAmsStopDrying()` | +| `src/slic3r/GUI/DeviceCore/DevUtilBackend.h` | Static utility class declaring `GetFilamentDryingPreset()` | +| `src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp` | Reads filament drying presets from printer config | + +### Modified files + +| File | Changes | +|------|---------| +| `DevFilaSystem.h` | Add drying enums/structs to `DevAms`; add `DevFilamentDryingPreset` struct; add ctrl method declarations to `DevFilaSystem`; add `get_ams_drying_preset()` to `DevAmsTray` | +| `DevFilaSystem.cpp` | Parse dry status fields in `DevFilaSystemParser`; implement `IsSupportRemoteDry()`, `AmsIsDrying()`, `get_ams_drying_preset()` | +| `DevDefs.h` | Add `DevAmsType` as a global enum | +| `AMSControl.cpp` | Wire humidity indicator click to open `AMSDryCtrWin` for N3F/N3S AMS | +| `DeviceErrorDialog.cpp` | Update stop-drying to use `DevFilaSystem::CtrlAmsStopDrying()` | +| `DeviceCore/CMakeLists.txt` | Register new source files | + +### New image assets (~14) + +Humidity level icons (5), drying status per AMS type (8: heating/dehumidifying/error/cooling × N3F/N3S), heating icon, enable/disable indicators, guide image. + +## Data Model + +### `DevDefs.h` — Global `DevAmsType` enum + +Promoted from `DevAms::AmsType` (currently local to the class) to a global enum so `DevFilamentDryingPreset` and `DevUtilBackend` can reference it without depending on `DevAms`: + +```cpp +enum DevAmsType : int { + EXT_SPOOL = 0, + AMS = 1, + AMS_LITE = 2, + N3F = 3, // AMS 2 Pro + N3S = 4, // AMS HT +}; +``` + +### `DevAms` class additions (in `DevFilaSystem.h`) + +**New enums:** + +| Enum | Values | +|------|--------| +| `DryCtrlMode` | `Off=0, OnTime=1, OnHumidity=2` | +| `DryStatus` | `Off=0, Checking=1, Drying=2, Cooling=3, Stopping=4, Error=5, CannotStopHeatOutofControl=6, PrdTesting=7` | +| `DrySubStatus` | `Off=0, Heating=1, Dehumidify=2` | +| `DryFanStatus` | `Off=0, On=1` | +| `CannotDryReason` | `TaskOccupied=0, InsufficientPower=1, AmsBusy=2, ConsumableAtAmsOutlet=3, InitiatingAmsDrying=4, NotSupportedIn2dMode=5, DryingInProgress=6, Upgrading=7, InsufficientPowerNeedPluginPower=8, FilamentAtAmsOutletManualUnload=10` | + +**New struct:** + +```cpp +struct DrySettings { + std::string dry_filament; + int dry_temp = -1; // -1 means invalid + int dry_hour = -1; // hours +}; +``` + +**New getters on `DevAms`:** + +- `std::optional GetDryStatus() const` +- `std::optional GetDrySubStatus() const` +- `std::optional GetFan1Status() const` +- `std::optional GetFan2Status() const` +- `std::optional> GetCannotDryReason() const` +- `std::optional GetDrySettings() const` +- `bool IsSupportRemoteDry(const MachineObject* obj) const` +- `bool AmsIsDrying()` + +**New private members:** `m_dry_status`, `m_dry_sub_status`, `m_dry_fan1_status`, `m_dry_fan2_status`, `m_dry_cannot_reasons`, `m_dry_settings` — all `std::optional<>`. + +### `DevFilamentDryingPreset` struct (namespace scope in `DevFilaSystem.h`) + +```cpp +struct DevFilamentDryingPreset { + std::string filament_id; + std::unordered_set ams_limitations; + std::unordered_map filament_dev_ams_drying_time_on_idle; // hours + std::unordered_map filament_dev_ams_drying_temperature_on_idle; + std::unordered_map filament_dev_ams_drying_time_on_print; // hours + std::unordered_map filament_dev_ams_drying_temperature_on_print; + float filament_dev_drying_cooling_temperature; + float filament_dev_drying_softening_temperature; + float filament_dev_ams_drying_heat_distortion_temperature; +}; +``` + +Populated from filament preset config keys (`filament_dev_ams_drying_temperature`, `filament_dev_ams_drying_time`, `filament_dev_drying_softening_temperature`, etc.). + +### `DevAmsTray` addition + +```cpp +std::optional get_ams_drying_preset() const; +``` + +Delegates to `DevUtilBackend::GetFilamentDryingPreset(setting_id)`. + +### JSON parsing + +`DevFilaSystemParser::ParseV1_0()` extended to parse the following fields from the printer's AMS status JSON: + +| JSON field | DevAms member | +|------------|---------------| +| `dry_status` | `m_dry_status` (`DryStatus` enum) | +| `dry_sub_status` | `m_dry_sub_status` (`DrySubStatus` enum) | +| `dry_fan1_status` | `m_dry_fan1_status` | +| `dry_fan2_status` | `m_dry_fan2_status` | +| `dry_cannot_reasons` | `m_dry_cannot_reasons` (vector of `CannotDryReason`) | +| `dry_settings` | `m_dry_settings` (`DrySettings` struct) | + +## Commands + +### `DevFilaSystem::CtrlAmsStartDryingHour()` + +Publishes JSON command `"ams_filament_drying"` via `MachineObject::publish_json()`: + +```json +{ + "print": { + "command": "ams_filament_drying", + "sequence_id": "", + "ams_id": , + "mode": 1, + "filament": "", + "temp": , + "duration": , + "humidity": 0, + "rotate_tray": , + "cooling_temp": , + "close_power_conflict": false + } +} +``` + +### `DevFilaSystem::CtrlAmsStopDrying()` + +Same command with `mode: 0` (Off). + +### Existing `MachineObject::command_ams_drying_stop()` + +Kept for backward compatibility. The error dialog will be updated to call `CtrlAmsStopDrying()` instead, which is the preferred path. + +## Backend Utility + +### `DevUtilBackend` (new, `DeviceCore/DevUtilBackend.h/.cpp`) + +Static utility class. Only method needed for this feature: + +```cpp +static std::optional GetFilamentDryingPreset(const std::string& fila_id); +``` + +Iterates `wxGetApp().preset_bundle->filaments` to find the matching filament_id, then reads config keys into a `DevFilamentDryingPreset`. Uses a static map `{"0" → N3F, "1" → N3S}` for the `ams_limitations` string-to-enum conversion. + +## UI Dialog + +### `AMSDryCtrWin` (extends `DPIDialog`) + +Three pages in a `wxSimplebook`: + +#### Page 0: Main Page + +Split left/right layout: + +**Left panel — Status display:** +- Large humidity/drying state image: humidity level icon when idle; heating/dehumidifying/error animation when active +- Status label with animated heating icon ("Idle", "Drying-Heating", "Drying-Dehumidifying") +- Three stat labels: Humidity (%), Temperature (°C), Left Time (HH:MM). Left Time hidden when idle. + +**Right panel — Controls.** Three sub-panels shown/hidden based on `DryStatus` and `CannotDryReason`: + +| Sub-panel | When shown | Contents | +|-----------|------------|----------| +| Normal state | `DryStatus` is Off or Cooling | Filament type combobox → Temperature input (numeric, validated to AMS limits) → Time input (hours, 1–24) → Validation warning text → **Start** button | +| Cannot dry | `CannotDryReason` is non-empty (excluding only `DryingInProgress`) | Formatted reason text + **Unload** button (only for `ConsumableAtAmsOutlet` reason) | +| Drying error | `DryStatus` is Error | Error message + **Stop** button | +| Drying active | `DryStatus` is Checking/Drying/Stopping | Read-only temp/time display + **Stop** button | + +**Temperature validation rules:** +- Hardware limits: N3F 45–65°C, N3S 45–85°C +- Heat distortion check: temp must not exceed `filament_dev_ams_drying_heat_distortion_temperature` when filament is present +- Printing mode: temp must not exceed recommended drying temp when this AMS is actively feeding a print +- Time: 1–24 hours + +#### Page 1: Guide Page + +Shown after clicking Start on Page 0, before sending the command: +- Instructional text about removing heat-sensitive filament +- `AMSFilamentPanel` showing each tray's filament type with ✅/⚠ icons based on whether drying temp exceeds that filament's softening point +- "Rotate spool when drying" checkbox +- **Back** and **Start** buttons + +#### Page 2: Progress Page + +Shown while waiting for printer to confirm drying has started: +- Animated progress bar (cycles 0–99% until printer state changes) +- Cycling status messages +- Auto-transitions back to Page 0 when printer reports drying active + +### Helper classes (in `AMSDryControl.hpp`) + +- **`FilamentItemPanel`** — Renders a single filament item with icon and text, with custom rounded-rectangle border painting +- **`AMSFilamentPanel`** — Container for multiple `FilamentItemPanel` instances, labeled with AMS name +- **`DryingPreset` struct, `DryCtrState` enum, `DryCtrDev` enum** — Local types for tracking preset state + +### Dialog lifecycle + +- Created on first click of the AMS humidity indicator +- Updated on each `AMSControl::parse_object()` cycle via `update(fila_system, obj)` +- Auto-closes if: AMS is removed from the system, AMS no longer supports remote drying, or AMS ID becomes invalid +- Closed manually via close button or dialog destruction +- On close: stops progress timer, resets to main page, restores button states + +## Integration + +### Entry point: AMS humidity click → dialog + +In `AMSControl.cpp`, the `EVT_AMS_SHOW_HUMIDITY_TIPS` handler currently shows either `AmsHumidityTipPopup` or `uiAmsPercentHumidityDryPopup`. For N3F and N3S AMS types only, replace this with opening `AMSDryCtrWin`: + +1. Lazily create `AMSDryCtrWin*` member on `AMSControl` (like `m_percent_humidity_dry_popup` today) +2. Call `set_ams_id()` and `update(fila_system, obj)` before showing +3. Show the dialog modally or modelessly + +### Periodic updates + +`AMSControl::parse_object()` updates AMS state on a timer. Extend it to: +1. If `m_dry_ctr_win` is shown, call `update()` to refresh readings +2. Update the dialog's AMS ID if the user switches AMS view + +### Error dialog + +`DeviceErrorDialog.cpp` line 452 calls `m_obj->command_ams_drying_stop()`. Update to use `obj->GetFilaSystem()->CtrlAmsStopDrying(ams_id)` for the correct AMS ID. + +### CMakeLists + +- `src/slic3r/GUI/DeviceCore/CMakeLists.txt`: add `DevFilaSystemCtrl.cpp`, `DevUtilBackend.h`, `DevUtilBackend.cpp` +- GUI sources list: add `AMSDryControl.hpp`, `AMSDryControl.cpp` + +## Testing + +### Manual verification + +1. **Prerequisite:** Connect to a printer with N3F or N3S AMS hardware +2. Click AMS humidity indicator → verify dialog opens with correct humidity/temperature readings +3. Select a filament from the combobox → verify temp/time auto-fill from preset +4. Enter valid temp/time → verify Start button enables +5. Enter invalid temp (e.g., 100°C for N3F) → verify warning appears and button disables +6. Click Start → verify guide page shows filament tray status +7. Click Start on guide page → verify progress page appears, command is sent +8. Verify printer begins drying → dialog shows active drying state with countdown +9. Click Stop → verify drying stops +10. Test "cannot dry" states (e.g., during print, filament at outlet) + +### Code-level checks + +- Verify `CtrlAmsStartDryingHour` publishes correct JSON structure +- Verify `CtrlAmsStopDrying` publishes stop command +- Verify `GetFilamentDryingPreset` returns correct values for known filament IDs +- Verify dark mode rendering of all dialog pages + +## Constraints + +- **N3F/N3S only:** Standard AMS and AMS Lite do not have heating hardware; the dialog is not shown for them +- **Backward compatibility:** Existing `command_ams_drying_stop()` is preserved; new commands are additive +- **Cross-platform:** wxWidgets UI must work on Windows, macOS, Linux +- **Dark mode:** Dialog must support dark mode via `wxGetApp().dark_mode()` and `StateColor::darkModeColorFor()` +- **Profile compatibility:** Existing profiles with `filament_dev_ams_drying_*` keys (Qidi) continue to work; the feature reads the same keys +- **No printer-agent changes needed:** All commands go through existing `publish_json()` → `NetworkAgent` path, which already handles MQTT for connected printers From 885f14ed6533a472e394552d71b34ad55d6233c9 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 11:13:35 +0800 Subject: [PATCH 02/47] docs: fix spec self-review issues Clarify dialog modality, precise image asset list, DevAmsType naming consistency. --- .../2026-07-07-ams-drying-control-design.md | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md b/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md index 940534a501..114164a670 100644 --- a/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md +++ b/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md @@ -55,24 +55,38 @@ The feature spans three layers, matching the existing OrcaSlicer `DeviceCore/` p | `DeviceErrorDialog.cpp` | Update stop-drying to use `DevFilaSystem::CtrlAmsStopDrying()` | | `DeviceCore/CMakeLists.txt` | Register new source files | -### New image assets (~14) +### New image assets (14) -Humidity level icons (5), drying status per AMS type (8: heating/dehumidifying/error/cooling × N3F/N3S), heating icon, enable/disable indicators, guide image. +| Asset | Purpose | +|-------|---------| +| `hum_level1_no_num_light.svg` – `hum_level5_no_num_light.svg` | 5 humidity level icons | +| `dev_ams_dry_ctr_n3f_heating.png`, `_dehumidifying.png`, `_error.png`, `_cooling.png` | 4 N3F drying state images | +| `dev_ams_dry_ctr_n3s_heating.png`, `_dehumidifying.png`, `_error.png`, `_cooling.png` | 4 N3S drying state images | +| `dev_ams_dry_ctr_heating_icon.svg` | Animated heating indicator | +| `dev_ams_dry_ctr_filament_in_chamber.png` | Guide page illustration | +| `dev_ams_dry_ctr_enable.svg`, `dev_ams_dry_ctr_disable.svg` | Filament tray status icons | + +Additionally, OrcaSlicer already has `ams_drying.svg` and `ams_is_drying.svg`. ## Data Model ### `DevDefs.h` — Global `DevAmsType` enum -Promoted from `DevAms::AmsType` (currently local to the class) to a global enum so `DevFilamentDryingPreset` and `DevUtilBackend` can reference it without depending on `DevAms`: +Promoted from `DevAms::AmsType` (currently local to the class) to a global enum in `DevDefs.h` so `DevFilamentDryingPreset` and `DevUtilBackend` can reference it without depending on `DevAms`. `DevAms::AmsType` is updated to be a typedef for `DevAmsType`, and all existing references to `DevAms::AmsType` remain valid (it's the same type): ```cpp +// DevDefs.h — global enum using OrcaSlicer's existing names (same integer values as before) enum DevAmsType : int { - EXT_SPOOL = 0, + DUMMY = 0, AMS = 1, AMS_LITE = 2, N3F = 3, // AMS 2 Pro N3S = 4, // AMS HT }; + +// DevFilaSystem.h — DevAms class updated: +// Replace "enum AmsType { DUMMY=0, AMS=1, AMS_LITE=2, N3F=3, N3S=4 }" +// with "using AmsType = DevAmsType;" (keeps existing code compiling) ``` ### `DevAms` class additions (in `DevFilaSystem.h`) @@ -260,7 +274,7 @@ In `AMSControl.cpp`, the `EVT_AMS_SHOW_HUMIDITY_TIPS` handler currently shows ei 1. Lazily create `AMSDryCtrWin*` member on `AMSControl` (like `m_percent_humidity_dry_popup` today) 2. Call `set_ams_id()` and `update(fila_system, obj)` before showing -3. Show the dialog modally or modelessly +3. Show the dialog modally via `ShowModal()` (matching BambuStudio behavior) ### Periodic updates From e0d80ee953920eda6f015953a5a43663c196e0c0 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 11:21:00 +0800 Subject: [PATCH 03/47] docs: add BambuStudio reference path to spec --- .../specs/2026-07-07-ams-drying-control-design.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md b/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md index 114164a670..cecbf7852f 100644 --- a/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md +++ b/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md @@ -2,6 +2,7 @@ **Date:** 2026-07-07 **Source:** Port from BambuStudio `AMSDryControl` feature +**Reference:** `D:\projects\BambuStudio` — match its behavior as much as possible; when in doubt, check the BambuStudio source **Branch:** `dev/ams-heat` ## Overview @@ -75,9 +76,9 @@ Additionally, OrcaSlicer already has `ams_drying.svg` and `ams_is_drying.svg`. Promoted from `DevAms::AmsType` (currently local to the class) to a global enum in `DevDefs.h` so `DevFilamentDryingPreset` and `DevUtilBackend` can reference it without depending on `DevAms`. `DevAms::AmsType` is updated to be a typedef for `DevAmsType`, and all existing references to `DevAms::AmsType` remain valid (it's the same type): ```cpp -// DevDefs.h — global enum using OrcaSlicer's existing names (same integer values as before) +// DevDefs.h — global enum matching BambuStudio's DevAmsType naming enum DevAmsType : int { - DUMMY = 0, + EXT_SPOOL = 0, AMS = 1, AMS_LITE = 2, N3F = 3, // AMS 2 Pro @@ -86,7 +87,8 @@ enum DevAmsType : int { // DevFilaSystem.h — DevAms class updated: // Replace "enum AmsType { DUMMY=0, AMS=1, AMS_LITE=2, N3F=3, N3S=4 }" -// with "using AmsType = DevAmsType;" (keeps existing code compiling) +// with "using AmsType = DevAmsType;" +// Existing references to DevAms::DUMMY become DevAms::EXT_SPOOL (same value, different name). ``` ### `DevAms` class additions (in `DevFilaSystem.h`) From c983f60d00169a43bfb4f15b169b5e011c85ec80 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 11:26:38 +0800 Subject: [PATCH 04/47] docs: add AMS drying control implementation plan 12 tasks covering: data model changes, DevUtilBackend utility, drying commands, JSON parsing, UI dialog port, integration wiring, image assets, and verification. --- .../2026-07-07-ams-drying-control-plan.md | 1046 +++++++++++++++++ 1 file changed, 1046 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-ams-drying-control-plan.md diff --git a/docs/superpowers/plans/2026-07-07-ams-drying-control-plan.md b/docs/superpowers/plans/2026-07-07-ams-drying-control-plan.md new file mode 100644 index 0000000000..6cd673c5a6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-ams-drying-control-plan.md @@ -0,0 +1,1046 @@ +# AMS Drying Control — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port the AMS filament drying control feature from BambuStudio into OrcaSlicer, enabling users to start, monitor, and stop filament drying via the AMS unit from the slicer UI. + +**Architecture:** Add drying status enums/structs to the `DevAms` data model, extend the JSON parser to populate them, add `CtrlAmsStartDryingHour`/`CtrlAmsStopDrying` commands to `DevFilaSystem`, create a `DevUtilBackend` utility for filament drying preset lookup, and build the `AMSDryCtrWin` dialog. Wire the dialog to open when clicking the AMS humidity indicator for N3F/N3S AMS types. + +**Tech Stack:** C++17, wxWidgets, nlohmann/json, Boost + +## Global Constraints + +- **N3F/N3S only:** Standard AMS and AMS Lite do not have heating hardware; the dialog is not shown for them +- **Backward compatibility:** Existing `command_ams_drying_stop()` is preserved; new commands are additive +- **Cross-platform:** wxWidgets UI must work on Windows, macOS, Linux +- **Dark mode:** Dialog must support dark mode via `wxGetApp().dark_mode()` and `StateColor::darkModeColorFor()` +- **Profile compatibility:** Existing profiles with `filament_dev_ams_drying_*` keys (Qidi) continue to work; the feature reads the same keys +- **No printer-agent changes needed:** All commands go through existing `publish_json()` → `NetworkAgent` path +- **Reference:** `D:\projects\BambuStudio` — match its behavior as much as possible; when in doubt, check the BambuStudio source + +--- + +### Task 1: Global `DevAmsType` enum in `DevDefs.h` + +**Files:** +- Modify: `src/slic3r/GUI/DeviceCore/DevDefs.h` +- Modify: `src/slic3r/GUI/DeviceCore/DevFilaSystem.h` (replace local `AmsType` enum with typedef) +- Modify: `src/slic3r/GUI/DeviceCore/DevMapping.cpp` (rename `DevAms::DUMMY` → `DevAms::EXT_SPOOL`) + +**Interfaces:** +- Produces: Global `enum DevAmsType { EXT_SPOOL=0, AMS=1, AMS_LITE=2, N3F=3, N3S=4 }` in `DevDefs.h` +- Produces: `using AmsType = DevAmsType;` inside `DevAms` class (replaces old local enum) +- Note: Old `DevAms::DUMMY` becomes `DevAms::EXT_SPOOL`; all existing references update accordingly + +- [ ] **Step 1: Add global `DevAmsType` enum to `DevDefs.h`** + +In `src/slic3r/GUI/DeviceCore/DevDefs.h`, add before the closing `namespace Slic3r` or after existing enums: + +```cpp +enum DevAmsType : int +{ + EXT_SPOOL = 0, // EXT + AMS = 1, // AMS1 + AMS_LITE = 2, // AMS-Lite + N3F = 3, // N3F, AMS 2PRO + N3S = 4, // N3S, AMS HT +}; +``` + +- [ ] **Step 2: Replace `DevAms::AmsType` local enum with typedef** + +In `src/slic3r/GUI/DeviceCore/DevFilaSystem.h`, find the `DevAms` class and replace the local enum: + +```cpp +// Before (delete this): + enum AmsType : int + { + DUMMY = 0, + AMS = 1, // AMS + AMS_LITE = 2, // AMS-Lite + N3F = 3, // N3F + N3S = 4, // N3S + }; + +// After (replace with): + using AmsType = DevAmsType; +``` + +- [ ] **Step 3: Rename `DevAms::DUMMY` → `DevAms::EXT_SPOOL` in `DevMapping.cpp`** + +In `src/slic3r/GUI/DeviceCore/DevMapping.cpp` line 189: + +```cpp +// Before: +_parse_tray_info(atoi(tray.id.c_str()), 0, DevAms::DUMMY, tray, info); +// After: +_parse_tray_info(atoi(tray.id.c_str()), 0, DevAms::EXT_SPOOL, tray, info); +``` + +- [ ] **Step 4: Rename `DUMMY` → `EXT_SPOOL` in `DevFilaSystem.cpp`** + +In `src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp` line 114: + +```cpp +// Before: +assert(DUMMY < type && m_ams_type <= N3S); +// After: +assert(EXT_SPOOL < type && m_ams_type <= N3S); +``` + +- [ ] **Step 5: Verify the build compiles** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +Expected: build succeeds, no `DUMMY` or `AmsType` related compile errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/slic3r/GUI/DeviceCore/DevDefs.h src/slic3r/GUI/DeviceCore/DevFilaSystem.h src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp src/slic3r/GUI/DeviceCore/DevMapping.cpp +git commit -m "refactor: promote DevAmsType to global enum, rename DUMMY to EXT_SPOOL + +Matches BambuStudio's DevAmsType naming convention." +``` + +--- + +### Task 2: Drying enums and structs in `DevAms` + +**Files:** +- Modify: `src/slic3r/GUI/DeviceCore/DevFilaSystem.h` + +**Interfaces:** +- Produces: `DevAms::DryCtrlMode`, `DevAms::DryStatus`, `DevAms::DrySubStatus`, `DevAms::DryFanStatus`, `DevAms::CannotDryReason` enums +- Produces: `DevAms::DrySettings` struct +- Produces: Getters: `GetDryStatus()`, `GetDrySubStatus()`, `GetFan1Status()`, `GetFan2Status()`, `GetCannotDryReason()`, `GetDrySettings()` +- Produces: `IsSupportRemoteDry(const MachineObject*)`, `AmsIsDrying()` +- Produces: `SupportDrying()` — already exists as `m_ams_type > AMS_LITE`; update to check N3F/N3S +- Produces: Members: `m_dry_status`, `m_dry_sub_status`, `m_dry_fan1_status`, `m_dry_fan2_status`, `m_dry_cannot_reasons`, `m_dry_settings` + +**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceCore\DevFilaSystem.h` lines 119–262 + +- [ ] **Step 1: Add enums inside `DevAms` class** + +In `src/slic3r/GUI/DeviceCore/DevFilaSystem.h`, inside the `DevAms` class (after the `using AmsType = DevAmsType;` line, before the `public:` constructor section), add: + +```cpp +public: + + enum class DryCtrlMode : int + { + Off = 0, + OnTime = 1, + OnHumidity = 2, + }; + + enum class DryStatus : char + { + Off = 0, + Checking = 1, + Drying = 2, + Cooling = 3, + Stopping = 4, + Error = 5, + CannotStopHeatOutofControl = 6, + PrdTesting = 7, + }; + + enum class DrySubStatus + { + Off = 0, + Heating = 1, + Dehumidify = 2, + }; + + enum class DryFanStatus : char + { + Off = 0, + On = 1, + }; + + enum class CannotDryReason : int + { + TaskOccupied = 0, + InsufficientPower = 1, + AmsBusy = 2, + ConsumableAtAmsOutlet = 3, + InitiatingAmsDrying = 4, + NotSupportedIn2dMode = 5, + DryingInProgress = 6, + Upgrading = 7, + InsufficientPowerNeedPluginPower = 8, + FilamentAtAmsOutletManualUnload = 10, + }; + + struct DrySettings + { + std::string dry_filament; + int dry_temp = -1; // -1 means invalid + int dry_hour = -1; // -1 means invalid, hours + }; +``` + +- [ ] **Step 2: Add new getters to `DevAms`** + +After the existing `GetLeftDryTime()` getter, add: + +```cpp + // remote drying control + bool IsSupportRemoteDry(const MachineObject* obj) const; + std::optional GetDryStatus() const { return m_dry_status; }; + std::optional GetDrySubStatus() const { return m_dry_sub_status; } + std::optional GetFan1Status() const { return m_dry_fan1_status; } + std::optional GetFan2Status() const { return m_dry_fan2_status; } + std::optional> GetCannotDryReason() const { return m_dry_cannot_reasons; } + std::optional GetDrySettings() const { return m_dry_settings; }; + + bool AmsIsDrying(); +``` + +- [ ] **Step 3: Add new private members to `DevAms`** + +In the private section of `DevAms`, after `m_left_dry_time`, add: + +```cpp + // see is_support_remote_dry + std::optional m_dry_status; + std::optional m_dry_sub_status; + std::optional m_dry_fan1_status; + std::optional m_dry_fan2_status; + std::optional> m_dry_cannot_reasons; + std::optional m_dry_settings; +``` + +Note: In BambuStudio these members are `public:` (prefixed with `public:` before `m_dry_status`). Keep them private and use getters. The friends (`DevFilaSystemParser`) can access them. + +Also remove the duplicate `GetAmsType()` which currently returns `m_ams_type` — since `m_ams_type` is now `DevAmsType`, the getter still works. + +- [ ] **Step 4: Add forward declaration for `DevFilamentDryingPreset`** + +At the top of the file, after the existing forward declarations: + +```cpp +struct DevFilamentDryingPreset; +``` + +- [ ] **Step 5: Verify the header compiles** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +Expected: build succeeds. If there are compilation errors related to `GetAmsType()` return type, check that `AmsType` typedef resolves correctly. + +- [ ] **Step 6: Commit** + +```bash +git add src/slic3r/GUI/DeviceCore/DevFilaSystem.h +git commit -m "feat: add drying enums, structs, getters to DevAms + +Adds DryCtrlMode, DryStatus, DrySubStatus, DryFanStatus, +CannotDryReason enums and DrySettings struct for AMS drying control." +``` + +--- + +### Task 3: `DevFilamentDryingPreset` struct and `DevFilaSystem` ctrl declarations + +**Files:** +- Modify: `src/slic3r/GUI/DeviceCore/DevFilaSystem.h` + +**Interfaces:** +- Produces: `struct DevFilamentDryingPreset` at namespace scope +- Produces: `DevFilaSystem::CtrlAmsStartDryingHour(int ams_id, std::string filament_type, int tag_temp, int tag_duration_hour, bool rotate_tray, int cooling_temp, bool close_power_conflict = false) const` +- Produces: `DevFilaSystem::CtrlAmsStopDrying(int ams_id) const` +- Produces: `DevAmsTray::get_ams_drying_preset()` method + +**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceCore\DevFilaSystem.h` lines 350–362 + +- [ ] **Step 1: Add `DevFilamentDryingPreset` struct at namespace scope** + +In `src/slic3r/GUI/DeviceCore/DevFilaSystem.h`, add after the `DevFilaSystemParser` class (at the very end of the file, before `}// namespace Slic3r`): + +```cpp +struct DevFilamentDryingPreset +{ + std::string filament_id; + + std::unordered_set ams_limitations; // only use ams types in the set + std::unordered_map filament_dev_ams_drying_time_on_idle; // hour + std::unordered_map filament_dev_ams_drying_temperature_on_idle; + std::unordered_map filament_dev_ams_drying_time_on_print; // hour + std::unordered_map filament_dev_ams_drying_temperature_on_print; + float filament_dev_drying_cooling_temperature = 0.0f; + float filament_dev_drying_softening_temperature = 0.0f; + float filament_dev_ams_drying_heat_distortion_temperature = 0.0f; +}; +``` + +- [ ] **Step 2: Add control method declarations to `DevFilaSystem`** + +In the `DevFilaSystem` class, after the existing `CtrlAmsReset()` declaration: + +```cpp + // crtl + int CtrlAmsStartDryingHour(int ams_id, std::string filament_type, int tag_temp, int tag_duration_hour, bool rotate_tray, int cooling_temp, bool close_power_conflict = false) const; + int CtrlAmsStopDrying(int ams_id) const; +``` + +- [ ] **Step 3: Add `get_ams_drying_preset()` to `DevAmsTray`** + +In the `DevAmsTray` class, add after the `get_filament_type()` method: + +```cpp + std::optional get_ams_drying_preset() const; +``` + +Also add `#include ` and `#include ` to the includes if not already present. +Add `#include ` if not already present. + +- [ ] **Step 4: Verify build** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/slic3r/GUI/DeviceCore/DevFilaSystem.h +git commit -m "feat: add DevFilamentDryingPreset, ctrl method declarations, tray preset getter" +``` + +--- + +### Task 4: `DevUtilBackend` utility class + +**Files:** +- Create: `src/slic3r/GUI/DeviceCore/DevUtilBackend.h` +- Create: `src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp` +- Modify: `src/slic3r/GUI/DeviceCore/CMakeLists.txt` + +**Interfaces:** +- Produces: `DevUtilBackend::GetFilamentDryingPreset(const std::string& fila_id)` → `std::optional` + +**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceCore\DevUtilBackend.h`, `DevUtilBackend.cpp` + +- [ ] **Step 1: Create `DevUtilBackend.h`** + +Create `src/slic3r/GUI/DeviceCore/DevUtilBackend.h`: + +```cpp +/** + * @file DevUtilBackend.h + * @brief Provides common static utility methods for backend (preset/slicing). + */ + +#pragma once +#include "DevDefs.h" +#include "DevFilaSystem.h" + +#include +#include + +namespace Slic3r +{ + +class DevUtilBackend +{ +public: + DevUtilBackend() = delete; + +public: + // for filament preset + static std::optional GetFilamentDryingPreset(const std::string& fila_id); +}; + +}; // namespace Slic3r +``` + +- [ ] **Step 2: Create `DevUtilBackend.cpp`** + +Create `src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp`: + +```cpp +#include "DevUtilBackend.h" + +#include "slic3r/GUI/GUI_App.hpp" + +#include "libslic3r/Preset.hpp" + +#include + +namespace Slic3r +{ + +static std::unordered_map s_ams_type_map = { + {"0", DevAmsType::N3F}, + {"1", DevAmsType::N3S}, +}; + +std::optional DevUtilBackend::GetFilamentDryingPreset(const std::string& fila_id) +{ + if (fila_id.empty() || !GUI::wxGetApp().preset_bundle) { + return std::nullopt; + } + + for (auto iter = GUI::wxGetApp().preset_bundle->filaments.begin(); iter != GUI::wxGetApp().preset_bundle->filaments.end(); ++iter) { + const Preset& filament_preset = *iter; + const auto& config = filament_preset.config; + if (filament_preset.filament_id == fila_id) { + DevFilamentDryingPreset info; + info.filament_id = fila_id; + try { + if (config.has("filament_dev_ams_drying_ams_limitations")) { + std::vector types = config.option("filament_dev_ams_drying_ams_limitations")->values; + for (auto type : types) { + if (s_ams_type_map.count(type) == 0) { + continue; + } + info.ams_limitations.insert(s_ams_type_map[type]); + } + } + + if (config.has("filament_dev_ams_drying_temperature")) { + info.filament_dev_ams_drying_temperature_on_idle[DevAmsType::N3F] = config.option("filament_dev_ams_drying_temperature")->get_at(0); + info.filament_dev_ams_drying_temperature_on_idle[DevAmsType::N3S] = config.option("filament_dev_ams_drying_temperature")->get_at(1); + info.filament_dev_ams_drying_temperature_on_print[DevAmsType::N3F] = config.option("filament_dev_ams_drying_temperature")->get_at(2); + info.filament_dev_ams_drying_temperature_on_print[DevAmsType::N3S] = config.option("filament_dev_ams_drying_temperature")->get_at(3); + } + + if (config.has("filament_dev_ams_drying_time")) { + info.filament_dev_ams_drying_time_on_idle[DevAmsType::N3F] = config.option("filament_dev_ams_drying_time")->get_at(0); + info.filament_dev_ams_drying_time_on_idle[DevAmsType::N3S] = config.option("filament_dev_ams_drying_time")->get_at(1); + info.filament_dev_ams_drying_time_on_print[DevAmsType::N3F] = config.option("filament_dev_ams_drying_time")->get_at(2); + info.filament_dev_ams_drying_time_on_print[DevAmsType::N3S] = config.option("filament_dev_ams_drying_time")->get_at(3); + } + + if (config.has("filament_dev_drying_softening_temperature")) { + info.filament_dev_drying_softening_temperature = config.option("filament_dev_drying_softening_temperature")->get_at(0); + } + + if (config.has("filament_dev_ams_drying_heat_distortion_temperature")) { + info.filament_dev_ams_drying_heat_distortion_temperature = config.option("filament_dev_ams_drying_heat_distortion_temperature")->get_at(0); + } + + if (config.has("filament_dev_drying_cooling_temperature")) { + info.filament_dev_drying_cooling_temperature = config.option("filament_dev_drying_cooling_temperature")->get_at(0); + } + + return info; + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " exception: " << e.what(); + } + } + } + + return std::nullopt; +} + +}; // namespace Slic3r +``` + +- [ ] **Step 3: Register new files in CMakeLists.txt** + +In `src/slic3r/GUI/DeviceCore/CMakeLists.txt`, add the new files. After the existing `DevFilaSystemCtrl.cpp` line: + +```cmake + GUI/DeviceCore/DevUtilBackend.h + GUI/DeviceCore/DevUtilBackend.cpp +``` + +- [ ] **Step 4: Verify build** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +Expected: build succeeds. If `ConfigOptionStrings` or `ConfigOptionFloats` are not found, check the includes — they're in `libslic3r/Preset.hpp` and `libslic3r/PrintConfig.hpp`. + +- [ ] **Step 5: Commit** + +```bash +git add src/slic3r/GUI/DeviceCore/DevUtilBackend.h src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp src/slic3r/GUI/DeviceCore/CMakeLists.txt +git commit -m "feat: add DevUtilBackend with GetFilamentDryingPreset + +Reads filament_dev_ams_drying_* config keys from filament presets +and returns structured DevFilamentDryingPreset data." +``` + +--- + +### Task 5: JSON parsing for drying status fields + +**Files:** +- Modify: `src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp` + +**Interfaces:** +- Consumes: `DevAms::DryStatus`, `DevAms::DrySubStatus`, `DevAms::DryFanStatus`, `DevAms::CannotDryReason`, `DevAms::DrySettings` +- Parses from JSON: `dry_time`, info flag bits (dry_status/fan1/fan2/sub_status), `dry_setting`, `dry_sf_reason` + +**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceCore\DevFilaSystem.cpp` lines 690–712 + +- [ ] **Step 1: Locate the AMS parsing section in `DevFilaSystemParser::ParseV1_0()`** + +First, read `DevFilaSystem.cpp` to find where `m_left_dry_time` is parsed (look for "dry_time") and where the AMS info section is. The parsing function is `DevFilaSystemParser::ParseV1_0`. + +- [ ] **Step 2: Add drying status field parsing** + +After the existing `m_left_dry_time` parsing line (which parses `"dry_time"` from JSON), add the drying status parsing. The exact location will be in the AMS loop where `curr_ams` is populated. + +After `DevJsonValParser::ParseVal(j_ams, "dry_time", curr_ams->m_left_dry_time);` (or the equivalent line), add: + +```cpp + // Drying status — only parse if printer supports remote drying + if (obj->is_support_remote_dry) { + if (j_ams.contains("info")) { + const std::string& info = j_ams["info"].get(); + curr_ams->m_dry_status = (DevAms::DryStatus)DevUtil::get_flag_bits(info, 4, 4); + curr_ams->m_dry_fan1_status = (DevAms::DryFanStatus)DevUtil::get_flag_bits(info, 18, 2); + curr_ams->m_dry_fan2_status = (DevAms::DryFanStatus)DevUtil::get_flag_bits(info, 20, 2); + curr_ams->m_dry_sub_status = (DevAms::DrySubStatus)DevUtil::get_flag_bits(info, 22, 2); + } + + if (j_ams.contains("dry_setting")) { + const auto& j_dry_settings = j_ams["dry_setting"]; + DevAms::DrySettings dry_settings; + DevJsonValParser::ParseVal(j_dry_settings, "dry_filament", dry_settings.dry_filament); + DevJsonValParser::ParseVal(j_dry_settings, "dry_temperature", dry_settings.dry_temp); + DevJsonValParser::ParseVal(j_dry_settings, "dry_duration", dry_settings.dry_hour); + curr_ams->m_dry_settings = dry_settings; + } + + if (j_ams.contains("dry_sf_reason")) { + curr_ams->m_dry_cannot_reasons = DevJsonValParser::GetVal>(j_ams, "dry_sf_reason"); + } + } +``` + +Note: Check if `DevUtil::get_flag_bits` and `DevJsonValParser` exist in OrcaSlicer. If not, these utilities must be added. Check BambuStudio's `DevUtil.h` for `get_flag_bits` and `DevJsonValParser` for the parsing helper. If these are missing, add minimal versions or inline the parsing. + +Check specifically whether `DevUtil.h` in OrcaSlicer has `get_flag_bits`. The `DevUtil.h` exists in the CMakeLists. Check its contents — it should have a static `get_flag_bits(std::string, int, int)` method. If not present, port it from BambuStudio. + +- [ ] **Step 3: Implement `IsSupportRemoteDry()` and `AmsIsDrying()`** + +In the same file, add the implementations after the existing `DevAms` methods: + +```cpp +bool DevAms::IsSupportRemoteDry(const MachineObject* obj) const +{ + if (obj && obj->is_support_remote_dry) { + return SupportDrying(); + } + return false; +} + +bool DevAms::AmsIsDrying() +{ + if (!GetDryStatus().has_value()) { + return false; + } + + return GetDryStatus().value() == DevAms::DryStatus::Checking + || GetDryStatus().value() == DevAms::DryStatus::Drying + || GetDryStatus().value() == DevAms::DryStatus::Error + || GetDryStatus().value() == DevAms::DryStatus::CannotStopHeatOutofControl; +} +``` + +- [ ] **Step 4: Implement `DevAmsTray::get_ams_drying_preset()`** + +```cpp +std::optional DevAmsTray::get_ams_drying_preset() const +{ + return DevUtilBackend::GetFilamentDryingPreset(setting_id); +} +``` + +- [ ] **Step 5: Ensure `#include "DevUtilBackend.h"` is present in `DevFilaSystem.cpp`** + +Add at the top if not present. + +- [ ] **Step 6: Verify build** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp +git commit -m "feat: add drying status JSON parsing and DevAms helper methods + +Parse dry_status, dry_sub_status, dry_fan statuses, +dry_settings, and dry_cannot_reasons from printer JSON. +Implement IsSupportRemoteDry, AmsIsDrying, get_ams_drying_preset." +``` + +--- + +### Task 6: `is_support_remote_dry` flag on `MachineObject` + +**Files:** +- Modify: `src/slic3r/GUI/DeviceManager.hpp` (add field) +- Modify: `src/slic3r/GUI/DeviceManager.cpp` (parse the flag) + +**Interfaces:** +- Produces: `MachineObject::is_support_remote_dry` (bool) + +**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceManager.hpp` line 566, `DeviceManager.cpp` line 4403 + +- [ ] **Step 1: Add `is_support_remote_dry` field to `MachineObject`** + +In `src/slic3r/GUI/DeviceManager.hpp`, find the section with other `is_support_*` flags (near `is_support_ams_humidity`) and add: + +```cpp + bool is_support_remote_dry = false; +``` + +- [ ] **Step 2: Parse `is_support_remote_dry` from firmware flags** + +In `src/slic3r/GUI/DeviceManager.cpp`, find the `parse_version_func()` or `parse_new_info()` method where other `is_support_*` flags are set. Look for the `is_support_ams_humidity` parsing pattern. + +Add parsing for `is_support_remote_dry` from the `fun2` field. Check the BambuStudio reference: it uses `get_flag_bits_no_border(fun2, 5) == 1` for bit 5. The exact location depends on how OrcaSlicer parses firmware capability bits. + +If a `fun2` parsing block exists, add: +```cpp + is_support_remote_dry = (get_flag_bits_no_border(fun2, 5) == 1); +``` + +If `fun2` parsing or `get_flag_bits_no_border` don't exist yet in OrcaSlicer, check how similar flags like `is_support_ams_humidity` are parsed and follow the same pattern. If the needed helper methods are missing, port them from BambuStudio's `DeviceManager.cpp`. + +- [ ] **Step 3: Verify build** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/slic3r/GUI/DeviceManager.hpp src/slic3r/GUI/DeviceManager.cpp +git commit -m "feat: add is_support_remote_dry flag to MachineObject + +Parsed from firmware fun2 bit 5. Controls whether drying +status fields are parsed and drying commands are available." +``` + +--- + +### Task 7: Start/Stop drying commands in `DevFilaSystemCtrl.cpp` + +**Files:** +- Modify: `src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp` + +**Interfaces:** +- Consumes: `MachineObject::m_sequence_id`, `MachineObject::publish_json()` +- Produces: `DevFilaSystem::CtrlAmsStartDryingHour(...)`, `DevFilaSystem::CtrlAmsStopDrying(int)` + +**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceCore\DevFilaSystemCtrl.cpp` + +- [ ] **Step 1: Add `CtrlAmsStartDryingHour` and `CtrlAmsStopDrying`** + +In `src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp`, add after the existing `CtrlAmsReset()` method: + +```cpp +int DevFilaSystem::CtrlAmsStartDryingHour(int ams_id, + std::string filament_type, + int tag_temp, + int tag_duration_hour, + bool rotate_tray, + int cooling_temp, + bool close_power_conflict) const +{ + json jj_command; + jj_command["print"]["command"] = "ams_filament_drying"; + jj_command["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++); + jj_command["print"]["ams_id"] = ams_id; + jj_command["print"]["mode"] = DevAms::DryCtrlMode::OnTime; + jj_command["print"]["filament"] = filament_type; + jj_command["print"]["temp"] = tag_temp; + jj_command["print"]["duration"] = tag_duration_hour; + jj_command["print"]["humidity"] = 0; + jj_command["print"]["rotate_tray"] = rotate_tray; + jj_command["print"]["cooling_temp"] = cooling_temp; + jj_command["print"]["close_power_conflict"] = close_power_conflict; + return m_owner->publish_json(jj_command); +} + +int DevFilaSystem::CtrlAmsStopDrying(int ams_id) const +{ + json jj_command; + jj_command["print"]["command"] = "ams_filament_drying"; + jj_command["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++); + jj_command["print"]["ams_id"] = ams_id; + jj_command["print"]["mode"] = DevAms::DryCtrlMode::Off; + jj_command["print"]["filament"] = ""; + jj_command["print"]["temp"] = 0; + jj_command["print"]["duration"] = 0; + jj_command["print"]["humidity"] = 0; + jj_command["print"]["rotate_tray"] = false; + jj_command["print"]["cooling_temp"] = 0; + jj_command["print"]["close_power_conflict"] = false; + return m_owner->publish_json(jj_command); +} +``` + +Note: The `DevAms::DryCtrlMode` enum class uses `DevAms::DryCtrlMode::OnTime` in C++. When serialized to JSON (`jj_command["print"]["mode"] = DevAms::DryCtrlMode::OnTime`), nlohmann/json will convert the enum class to its underlying int (1). This matches the BambuStudio behavior. If the implicit conversion doesn't compile, add a `static_cast()` wrapper. + +- [ ] **Step 2: Verify build** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp +git commit -m "feat: add CtrlAmsStartDryingHour and CtrlAmsStopDrying commands + +Publish 'ams_filament_drying' JSON commands via MQTT for +starting and stopping AMS filament drying." +``` + +--- + +### Task 8: Copy image assets from BambuStudio + +**Files:** +- Copy 14 image files from `D:\projects\BambuStudio\resources\images\` to `resources\images\` + +**Assets to copy:** + +```powershell +Copy-Item "D:\projects\BambuStudio\resources\images\hum_level1_no_num_light.svg" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\hum_level2_no_num_light.svg" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\hum_level3_no_num_light.svg" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\hum_level4_no_num_light.svg" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\hum_level5_no_num_light.svg" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3f_heating.png" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3f_dehumidifying.png" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3f_error.png" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3f_cooling.png" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3s_heating.png" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3s_dehumidifying.png" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3s_error.png" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3s_cooling.png" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_heating_icon.svg" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_filament_in_chamber.png" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_enable.svg" -Destination "resources\images\" +Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_disable.svg" -Destination "resources\images\" +``` + +- [ ] **Step 1: Copy each asset** + +Run each Copy-Item command. If any file is missing from the source, note it — the dialog will degrade gracefully with null bitmaps. + +- [ ] **Step 2: Verify files exist** + +```powershell +Get-ChildItem resources\images\dev_ams_dry_ctr_*, resources\images\hum_level* | Select Name +``` + +Expected: all 17 files listed. + +- [ ] **Step 3: Commit** + +```bash +git add resources/images/dev_ams_dry_ctr_* resources/images/hum_level* +git commit -m "assets: add AMS drying control images from BambuStudio + +14 images: humidity level icons, drying state images per AMS type, +heating icon, guide illustration, tray status icons." +``` + +--- + +### Task 9: `AMSDryControl` UI dialog + +**Files:** +- Create: `src/slic3r/GUI/AMSDryControl.hpp` +- Create: `src/slic3r/GUI/AMSDryControl.cpp` + +**Interfaces:** +- Produces: `AMSDryCtrWin` dialog class extending `DPIDialog` +- Produces: `FilamentItemPanel`, `AMSFilamentPanel` helper widget classes +- Consumes: `DevFilaSystem`, `MachineObject`, `DevAms`, `DevUtilBackend::GetFilamentDryingPreset()` + +**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\AMSDryControl.hpp` and `AMSDryControl.cpp` + +- [ ] **Step 1: Create `AMSDryControl.hpp`** + +Create `src/slic3r/GUI/AMSDryControl.hpp`. Port the full header from BambuStudio `AMSDryControl.hpp`, adapting includes for OrcaSlicer paths: + +Key adaptations: +- Change `#include "GUI_ObjectLayers.hpp"` to appropriate OrcaSlicer equivalent (check if it exists; if not, it may not be needed or can be replaced with `wx/wx.h`) +- Keep includes for `slic3r/GUI/Widgets/AMSItem.hpp`, `Widgets/Label.hpp`, `Widgets/PopupWindow.hpp`, `wxExtensions.hpp`, `DeviceCore/DevFilaSystem.h` +- Keep the full class declarations for `FilamentItemPanel`, `AMSFilamentPanel`, `AMSDryCtrWin` + +The complete header from `D:\projects\BambuStudio\src\slic3r\GUI\AMSDryControl.hpp` (lines 1–252) should be ported with path adjustments. Due to the file's length (~250 lines), copy the full content from the reference and adapt. + +- [ ] **Step 2: Create `AMSDryControl.cpp`** + +Create `src/slic3r/GUI/AMSDryControl.cpp`. Port from `D:\projects\BambuStudio\src\slic3r\GUI\AMSDryControl.cpp` (lines 1–1860). Key adaptations: + +Replace these BambuStudio includes: +```cpp +#include "AMSDryControl.hpp" +#include "DeviceCore/DevFilaSystem.h" +#include "GUI_App.hpp" +#include "I18N.hpp" +#include "slic3r/GUI/DeviceCore/DevExtruderSystem.h" +#include "slic3r/GUI/DeviceCore/DevUpgrade.h" +#include "slic3r/GUI/DeviceCore/DevManager.h" +#include "slic3r/GUI/MsgDialog.hpp" +#include "slic3r/GUI/Widgets/AnimaController.hpp" +#include "slic3r/GUI/Widgets/Label.hpp" +#include "slic3r/GUI/Widgets/ComboBox.hpp" +#include "slic3r/GUI/Widgets/ProgressBar.hpp" +#include "DeviceCore/DevUtilBackend.h" +``` + +Adapt includes for OrcaSlicer paths. Check which of these already exist: +- `GUI_App.hpp` → existing +- `I18N.hpp` → existing +- `DeviceCore/DevFilaSystem.h` → existing +- `Widgets/Label.hpp`, `Widgets/ComboBox.hpp`, `Widgets/ProgressBar.hpp` → check existence in OrcaSlicer + +Since this file is ~1860 lines, port it in full from the BambuStudio reference, adapting only the include paths and namespace references as needed for OrcaSlicer. The implementation logic should be identical to BambuStudio. + +Key parts to verify during port: +- `AMS_ITEMS_PANEL_SIZE`, `AMS_CONTROL_DEF_BLOCK_BK_COLOUR`, `AMS_CONTROL_BRAND_COLOUR`, `AMS_CONTROL_DISABLE_COLOUR`, `AMS_CONTROL_WHITE_COLOUR` — these constants are defined in `AMSItem.hpp`. Verify they exist in OrcaSlicer's version. +- `_L()` and `_CTX_utf8()` — I18N macros; verify they work the same in OrcaSlicer +- `StateColor` — widget state color class; verify it exists in OrcaSlicer +- `ScalableBitmap` — verify it exists in OrcaSlicer +- `ComboBox` — OrcaSlicer custom combobox; verify path +- `ProgressBar` — OrcaSlicer custom progress bar; verify path +- `FromDIP()` — DPI scaling helper; should exist in OrcaSlicer +- `encode_path()` — path encoding utility; should exist +- `resources_dir()` — resources directory getter; verify it exists +- `bool is_support_user_preset` on `MachineObject` — verify this field exists + +- [ ] **Step 3: Register `AMSDryControl` in CMakeLists** + +In `src/slic3r/CMakeLists.txt`, add after the `AMSSetting` entries (around line 30): + +```cmake + GUI/AMSDryControl.cpp + GUI/AMSDryControl.hpp +``` + +- [ ] **Step 4: Verify build** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +Fix any compilation errors from missing includes or type mismatches between OrcaSlicer and BambuStudio APIs. + +- [ ] **Step 5: Commit** + +```bash +git add src/slic3r/GUI/AMSDryControl.hpp src/slic3r/GUI/AMSDryControl.cpp +git commit -m "feat: add AMSDryControl dialog for AMS filament drying + +Three-page wizard: main status/control page, guide page with +filament tray status, and progress page. Supports N3F and N3S AMS types." +``` + +--- + +### Task 10: Wire humidity click to open `AMSDryCtrWin` + +**Files:** +- Modify: `src/slic3r/GUI/Widgets/AMSControl.hpp` +- Modify: `src/slic3r/GUI/Widgets/AMSControl.cpp` + +**Interfaces:** +- Consumes: `AMSDryCtrWin` dialog class +- Modifies: `EVT_AMS_SHOW_HUMIDITY_TIPS` handler — for N3F/N3S AMS types, opens `AMSDryCtrWin` instead of the popup + +- [ ] **Step 1: Add member and include to `AMSControl.hpp`** + +In `src/slic3r/GUI/Widgets/AMSControl.hpp`: + +Add forward declaration: +```cpp +class AMSDryCtrWin; +``` + +Add member variable near the existing `m_percent_humidity_dry_popup`: +```cpp + AMSDryCtrWin* m_dry_ctr_win{nullptr}; +``` + +- [ ] **Step 2: Add include to `AMSControl.cpp`** + +```cpp +#include "slic3r/GUI/AMSDryControl.hpp" +``` + +- [ ] **Step 3: Modify the `EVT_AMS_SHOW_HUMIDITY_TIPS` handler** + +In the handler at around line 243, modify the `else` branch (for non-GENERIC_AMS types) to check if the AMS type is N3F/N3S and open the `AMSDryCtrWin` instead of the popup: + +```cpp + Bind(EVT_AMS_SHOW_HUMIDITY_TIPS, [this](wxCommandEvent& evt) { + uiAmsHumidityInfo *info = (uiAmsHumidityInfo *) evt.GetClientData(); + if (info) + { + if (info->ams_type == AMSModel::GENERIC_AMS) + { + wxPoint img_pos = ClientToScreen(wxPoint(0, 0)); + wxPoint popup_pos(img_pos.x - m_Humidity_tip_popup.GetSize().GetWidth() + FromDIP(150), img_pos.y - FromDIP(80)); + m_Humidity_tip_popup.Position(popup_pos, wxSize(0, 0)); + + int humidity_value = info->humidity_display_idx; + if (humidity_value > 0 && humidity_value <= 5) { m_Humidity_tip_popup.set_humidity_level(humidity_value); } + m_Humidity_tip_popup.Popup(); + } + else if (info->ams_type == AMSModel::N3F_AMS || info->ams_type == AMSModel::N3S_AMS) + { + // Open full drying control dialog for N3F/N3S AMS + if (!m_dry_ctr_win) { + m_dry_ctr_win = new AMSDryCtrWin(this); + } + m_dry_ctr_win->set_ams_id(info->ams_id); + // Get fila system and machine object via the parent chain + // The dialog will be updated in parse_object() + m_dry_ctr_win->ShowModal(); + } + else + { + m_percent_humidity_dry_popup->Update(info); + + wxPoint img_pos = ClientToScreen(wxPoint(0, 0)); + wxPoint popup_pos(img_pos.x - m_percent_humidity_dry_popup->GetSize().GetWidth() + FromDIP(150), img_pos.y - FromDIP(80)); + m_percent_humidity_dry_popup->Move(popup_pos); + m_percent_humidity_dry_popup->ShowModal(); + } + } + + delete info; + }); +``` + +Note: The `AMSDryCtrWin` needs `DevFilaSystem` and `MachineObject` references for its `update()` method. The dialog's `update()` should be called before `ShowModal()`. The `MachineObject*` can be obtained from the parent widget chain or stored as a member of `AMSControl`. Check how `AMSControl` currently accesses `MachineObject` (likely through `m_obj` or similar). Pass it to the dialog's `update()` call. + +- [ ] **Step 4: Add periodic update for the dialog in `parse_object()`** + +In the `parse_object()` method (around line 954), find the existing humidity popup update block and extend it to also update the dry control dialog: + +```cpp + /*update AMS dry control dialog*/ + if (m_dry_ctr_win && m_dry_ctr_win->IsShown()) + { + // Get the MachineObject and DevFilaSystem from the current device + // m_dry_ctr_win->update(obj->GetFilaSystem_ptr(), obj); + } +``` + +Note: The exact code depends on how `AMSControl` accesses `MachineObject*`. Check existing patterns — it likely has access through `m_obj` or a similar member. The update will need both the `DevFilaSystem` (as `shared_ptr`) and `MachineObject*`. + +- [ ] **Step 5: Verify build** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/slic3r/GUI/Widgets/AMSControl.hpp src/slic3r/GUI/Widgets/AMSControl.cpp +git commit -m "feat: wire AMS humidity click to open AMSDryControl dialog + +For N3F and N3S AMS types, clicking the humidity indicator +now opens the full drying control dialog instead of the popup." +``` + +--- + +### Task 11: Update error dialog stop-drying call + +**Files:** +- Modify: `src/slic3r/GUI/DeviceErrorDialog.cpp` + +**Interfaces:** +- Consumes: `DevFilaSystem::CtrlAmsStopDrying(int)` + +- [ ] **Step 1: Update `STOP_DRYING` case in `DeviceErrorDialog.cpp`** + +In `src/slic3r/GUI/DeviceErrorDialog.cpp` around line 452, update the stop drying action: + +```cpp + case DeviceErrorDialog::STOP_DRYING: { + // Use the canonical CtrlAmsStopDrying path + if (m_obj && m_obj->GetFilaSystem()) { + // Get the AMS ID — check how the dialog knows which AMS is involved + // For now, stop all AMS units or use the first one + m_obj->GetFilaSystem()->CtrlAmsStopDrying(0); + } + // Fallback to the old command for backward compatibility + m_obj->command_ams_drying_stop(); + break; + } +``` + +Note: The exact AMS ID to pass depends on the error context. Check how BambuStudio handles this — it may use a specific AMS ID from the error data, or stop all drying. The fallback to `command_ams_drying_stop()` ensures backward compatibility if `CtrlAmsStopDrying` fails or the fila system isn't available. + +- [ ] **Step 2: Verify build** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/slic3r/GUI/DeviceErrorDialog.cpp +git commit -m "feat: update error dialog stop-drying to use CtrlAmsStopDrying + +Keeps command_ams_drying_stop() fallback for backward compatibility." +``` + +--- + +### Task 12: Build, verify, and final integration test + +**Files:** All modified files + +- [ ] **Step 1: Full clean build** + +```powershell +cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m +``` + +Expected: Zero errors, zero warnings related to the new code. + +- [ ] **Step 2: Verify no regressions in existing AMS features** + +- Confirm the app launches +- Confirm AMS panel renders correctly for printers without N3F/N3S +- Confirm humidity popup still works for standard AMS and AMS Lite +- Confirm existing `command_ams_drying_stop()` still functions + +- [ ] **Step 3: Manual UI smoke test (requires N3F/N3S printer)** + +If a printer with N3F or N3S AMS is available: +1. Connect to the printer +2. Click AMS humidity indicator → verify `AMSDryCtrWin` opens +3. Verify humidity/temperature readings match printer data +4. Verify filament combobox is populated +5. Verify temp/time auto-fill when selecting a filament +6. Verify validation warnings for invalid temperatures +7. Verify the dialog closes cleanly + +- [ ] **Step 4: Commit any final fixes** + +```bash +git add -A +git commit -m "fix: final integration fixes for AMS drying control" +``` From 272e88a3379f3e8f537d34dc1f2a1137cb566b61 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 11:49:55 +0800 Subject: [PATCH 05/47] refactor: promote DevAmsType to global enum, rename DUMMY to EXT_SPOOL Matches BambuStudio's DevAmsType naming convention. --- src/slic3r/GUI/DeviceCore/DevDefs.h | 9 +++++++++ src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp | 2 +- src/slic3r/GUI/DeviceCore/DevFilaSystem.h | 14 ++++++-------- src/slic3r/GUI/DeviceCore/DevMapping.cpp | 2 +- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/slic3r/GUI/DeviceCore/DevDefs.h b/src/slic3r/GUI/DeviceCore/DevDefs.h index 468ed0184a..7cbf4d26c4 100644 --- a/src/slic3r/GUI/DeviceCore/DevDefs.h +++ b/src/slic3r/GUI/DeviceCore/DevDefs.h @@ -40,6 +40,15 @@ enum AmsStatusMain AMS_STATUS_MAIN_UNKNOWN = 0xFF, }; +enum DevAmsType : int +{ + EXT_SPOOL = 0, // EXT + AMS = 1, // AMS1 + AMS_LITE = 2, // AMS-Lite + N3F = 3, // N3F, AMS 2PRO + N3S = 4, // N3S, AMS HT +}; + // Slots and Tray #define VIRTUAL_TRAY_MAIN_ID 255 #define VIRTUAL_TRAY_DEPUTY_ID 254 diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp index 8f35ccf454..100e4c30e9 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp @@ -111,7 +111,7 @@ DevAms::DevAms(const std::string& ams_id, int nozzle_id, int type) m_ams_id = ams_id; m_ext_id = nozzle_id; m_ams_type = (AmsType)type; - assert(DUMMY < type && m_ams_type <= N3S); + assert(EXT_SPOOL < type && m_ams_type <= N3S); } DevAms::~DevAms() diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h index 37a93729c8..9d5e2f748c 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h @@ -130,14 +130,12 @@ class DevAms { friend class DevFilaSystemParser; public: - enum AmsType : int - { - DUMMY = 0, - AMS = 1, // AMS - AMS_LITE = 2, // AMS-Lite - N3F = 3, // N3F - N3S = 4, // N3S - }; + using AmsType = DevAmsType; + static constexpr AmsType EXT_SPOOL = DevAmsType::EXT_SPOOL; + static constexpr AmsType AMS = DevAmsType::AMS; + static constexpr AmsType AMS_LITE = DevAmsType::AMS_LITE; + static constexpr AmsType N3F = DevAmsType::N3F; + static constexpr AmsType N3S = DevAmsType::N3S; public: DevAms(const std::string& ams_id, int extruder_id, AmsType type); diff --git a/src/slic3r/GUI/DeviceCore/DevMapping.cpp b/src/slic3r/GUI/DeviceCore/DevMapping.cpp index af3539f372..a98b6402e8 100644 --- a/src/slic3r/GUI/DeviceCore/DevMapping.cpp +++ b/src/slic3r/GUI/DeviceCore/DevMapping.cpp @@ -186,7 +186,7 @@ namespace Slic3r } } FilamentInfo info; - _parse_tray_info(atoi(tray.id.c_str()), 0, DevAms::DUMMY, tray, info); + _parse_tray_info(atoi(tray.id.c_str()), 0, DevAms::EXT_SPOOL, tray, info); tray_filaments.emplace(std::make_pair(info.tray_id, info)); } } From c88535ce9eb8388a1ce8b0287f5e6350168089bc Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 12:00:53 +0800 Subject: [PATCH 06/47] feat: add drying enums, structs, getters to DevAms Adds DryCtrlMode, DryStatus, DrySubStatus, DryFanStatus, CannotDryReason enums and DrySettings struct for AMS drying control. --- src/slic3r/GUI/DeviceCore/DevFilaSystem.h | 80 ++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h index 9d5e2f748c..c507b61900 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h @@ -9,6 +9,9 @@ #include #include #include +#include +#include +#include #include #include @@ -17,6 +20,7 @@ namespace Slic3r { class MachineObject; +struct DevFilamentDryingPreset; /** * DevAmsTray - Represents a single filament tray/slot in an AMS unit or virtual tray. @@ -137,6 +141,61 @@ public: static constexpr AmsType N3F = DevAmsType::N3F; static constexpr AmsType N3S = DevAmsType::N3S; +public: + + enum class DryCtrlMode : int + { + Off = 0, + OnTime = 1, + OnHumidity = 2, + }; + + enum class DryStatus : char + { + Off = 0, + Checking = 1, + Drying = 2, + Cooling = 3, + Stopping = 4, + Error = 5, + CannotStopHeatOutofControl = 6, + PrdTesting = 7, + }; + + enum class DrySubStatus + { + Off = 0, + Heating = 1, + Dehumidify = 2, + }; + + enum class DryFanStatus : char + { + Off = 0, + On = 1, + }; + + enum class CannotDryReason : int + { + TaskOccupied = 0, + InsufficientPower = 1, + AmsBusy = 2, + ConsumableAtAmsOutlet = 3, + InitiatingAmsDrying = 4, + NotSupportedIn2dMode = 5, + DryingInProgress = 6, + Upgrading = 7, + InsufficientPowerNeedPluginPower = 8, + FilamentAtAmsOutletManualUnload = 10, + }; + + struct DrySettings + { + std::string dry_filament; + int dry_temp = -1; // -1 means invalid + int dry_hour = -1; // -1 means invalid, hours + }; + public: DevAms(const std::string& ams_id, int extruder_id, AmsType type); DevAms(const std::string& ams_id, int nozzle_id, int type); @@ -168,9 +227,20 @@ public: int GetHumidityLevel() const { return m_humidity_level; } int GetHumidityPercent() const { return m_humidity_percent; } - bool SupportDrying() const { return m_ams_type > AMS_LITE; } + bool SupportDrying() const { return m_ams_type == N3F || m_ams_type == N3S; } int GetLeftDryTime() const { return m_left_dry_time; } + // remote drying control + bool IsSupportRemoteDry(const MachineObject* obj) const; + std::optional GetDryStatus() const { return m_dry_status; }; + std::optional GetDrySubStatus() const { return m_dry_sub_status; } + std::optional GetFan1Status() const { return m_dry_fan1_status; } + std::optional GetFan2Status() const { return m_dry_fan2_status; } + std::optional> GetCannotDryReason() const { return m_dry_cannot_reasons; } + std::optional GetDrySettings() const { return m_dry_settings; }; + + bool AmsIsDrying(); + private: AmsType m_ams_type = AmsType::AMS; std::string m_ams_id; @@ -185,6 +255,14 @@ private: int m_humidity_level = 5; // AmsType::AMS int m_humidity_percent = -1; // N3F N3S, the percentage, -1 means invalid. eg. 100 means 100% int m_left_dry_time = 0; + + // see is_support_remote_dry + std::optional m_dry_status; + std::optional m_dry_sub_status; + std::optional m_dry_fan1_status; + std::optional m_dry_fan2_status; + std::optional> m_dry_cannot_reasons; + std::optional m_dry_settings; }; /** From 1adfb5f242aaa8cec07ef18b7735b95b48f81dff Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 12:13:26 +0800 Subject: [PATCH 07/47] feat: add DevFilamentDryingPreset, ctrl method declarations, tray preset getter --- src/slic3r/GUI/DeviceCore/DevFilaSystem.h | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h index c507b61900..283de146db 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h @@ -104,6 +104,7 @@ public: std::string get_display_filament_type() const; std::string get_filament_type(); + std::optional get_ams_drying_preset() const; // static static wxColour decode_color(const std::string& color); @@ -332,7 +333,9 @@ public: public: // ctrls int CtrlAmsReset() const; - + int CtrlAmsStartDryingHour(int ams_id, std::string filament_type, int tag_temp, int tag_duration_hour, bool rotate_tray, int cooling_temp, bool close_power_conflict = false) const; + int CtrlAmsStopDrying(int ams_id) const; + public: static bool IsBBL_Filament(std::string tag_uid); @@ -367,4 +370,18 @@ public: static void ParseV1_0(const json& print_json, MachineObject* obj, DevFilaSystem* system, bool key_field_only); }; +struct DevFilamentDryingPreset +{ + std::string filament_id; + + std::unordered_set ams_limitations; // only use ams types in the set + std::unordered_map filament_dev_ams_drying_time_on_idle; // hour + std::unordered_map filament_dev_ams_drying_temperature_on_idle; + std::unordered_map filament_dev_ams_drying_time_on_print; // hour + std::unordered_map filament_dev_ams_drying_temperature_on_print; + float filament_dev_drying_cooling_temperature = 0.0f; + float filament_dev_drying_softening_temperature = 0.0f; + float filament_dev_ams_drying_heat_distortion_temperature = 0.0f; +}; + }// namespace Slic3r \ No newline at end of file From 9ee7e9aba40c0f96ff3397b89dcda42578f7c9e6 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 12:51:12 +0800 Subject: [PATCH 08/47] feat: add DevUtilBackend with GetFilamentDryingPreset Reads filament_dev_ams_drying_* config keys from filament presets and returns structured DevFilamentDryingPreset data. --- src/slic3r/GUI/DeviceCore/CMakeLists.txt | 2 + src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp | 76 ++++++++++++++++++++ src/slic3r/GUI/DeviceCore/DevUtilBackend.h | 26 +++++++ 3 files changed, 104 insertions(+) create mode 100644 src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp create mode 100644 src/slic3r/GUI/DeviceCore/DevUtilBackend.h diff --git a/src/slic3r/GUI/DeviceCore/CMakeLists.txt b/src/slic3r/GUI/DeviceCore/CMakeLists.txt index 57fd747228..67e7f1dc8c 100644 --- a/src/slic3r/GUI/DeviceCore/CMakeLists.txt +++ b/src/slic3r/GUI/DeviceCore/CMakeLists.txt @@ -28,6 +28,8 @@ list(APPEND SLIC3R_GUI_SOURCES GUI/DeviceCore/DevFilaSystem.h GUI/DeviceCore/DevFilaSystem.cpp GUI/DeviceCore/DevFilaSystemCtrl.cpp + GUI/DeviceCore/DevUtilBackend.h + GUI/DeviceCore/DevUtilBackend.cpp GUI/DeviceCore/DevFirmware.h GUI/DeviceCore/DevFirmware.cpp GUI/DeviceCore/DevPrintOptions.h diff --git a/src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp b/src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp new file mode 100644 index 0000000000..c3cd5fd850 --- /dev/null +++ b/src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp @@ -0,0 +1,76 @@ +#include "DevUtilBackend.h" + +#include "slic3r/GUI/GUI_App.hpp" + +#include "libslic3r/Preset.hpp" + +#include + +namespace Slic3r +{ + +static std::unordered_map s_ams_type_map = { + {"0", DevAmsType::N3F}, + {"1", DevAmsType::N3S}, +}; + +std::optional DevUtilBackend::GetFilamentDryingPreset(const std::string& fila_id) +{ + if (fila_id.empty() || !GUI::wxGetApp().preset_bundle) { + return std::nullopt; + } + + for (auto iter = GUI::wxGetApp().preset_bundle->filaments.begin(); iter != GUI::wxGetApp().preset_bundle->filaments.end(); ++iter) { + const Preset& filament_preset = *iter; + const auto& config = filament_preset.config; + if (filament_preset.filament_id == fila_id) { + DevFilamentDryingPreset info; + info.filament_id = fila_id; + try { + if (config.has("filament_dev_ams_drying_ams_limitations")) { + std::vector types = config.option("filament_dev_ams_drying_ams_limitations")->values; + for (auto type : types) { + if (s_ams_type_map.count(type) == 0) { + continue; + } + info.ams_limitations.insert(s_ams_type_map[type]); + } + } + + if (config.has("filament_dev_ams_drying_temperature")) { + info.filament_dev_ams_drying_temperature_on_idle[DevAmsType::N3F] = config.option("filament_dev_ams_drying_temperature")->get_at(0); + info.filament_dev_ams_drying_temperature_on_idle[DevAmsType::N3S] = config.option("filament_dev_ams_drying_temperature")->get_at(1); + info.filament_dev_ams_drying_temperature_on_print[DevAmsType::N3F] = config.option("filament_dev_ams_drying_temperature")->get_at(2); + info.filament_dev_ams_drying_temperature_on_print[DevAmsType::N3S] = config.option("filament_dev_ams_drying_temperature")->get_at(3); + } + + if (config.has("filament_dev_ams_drying_time")) { + info.filament_dev_ams_drying_time_on_idle[DevAmsType::N3F] = config.option("filament_dev_ams_drying_time")->get_at(0); + info.filament_dev_ams_drying_time_on_idle[DevAmsType::N3S] = config.option("filament_dev_ams_drying_time")->get_at(1); + info.filament_dev_ams_drying_time_on_print[DevAmsType::N3F] = config.option("filament_dev_ams_drying_time")->get_at(2); + info.filament_dev_ams_drying_time_on_print[DevAmsType::N3S] = config.option("filament_dev_ams_drying_time")->get_at(3); + } + + if (config.has("filament_dev_drying_softening_temperature")) { + info.filament_dev_drying_softening_temperature = config.option("filament_dev_drying_softening_temperature")->get_at(0); + } + + if (config.has("filament_dev_ams_drying_heat_distortion_temperature")) { + info.filament_dev_ams_drying_heat_distortion_temperature = config.option("filament_dev_ams_drying_heat_distortion_temperature")->get_at(0); + } + + if (config.has("filament_dev_drying_cooling_temperature")) { + info.filament_dev_drying_cooling_temperature = config.option("filament_dev_drying_cooling_temperature")->get_at(0); + } + + return info; + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " exception: " << e.what(); + } + } + } + + return std::nullopt; +} + +}; // namespace Slic3r diff --git a/src/slic3r/GUI/DeviceCore/DevUtilBackend.h b/src/slic3r/GUI/DeviceCore/DevUtilBackend.h new file mode 100644 index 0000000000..ccaa12d056 --- /dev/null +++ b/src/slic3r/GUI/DeviceCore/DevUtilBackend.h @@ -0,0 +1,26 @@ +/** + * @file DevUtilBackend.h + * @brief Provides common static utility methods for backend (preset/slicing). + */ + +#pragma once +#include "DevDefs.h" +#include "DevFilaSystem.h" + +#include +#include + +namespace Slic3r +{ + +class DevUtilBackend +{ +public: + DevUtilBackend() = delete; + +public: + // for filament preset + static std::optional GetFilamentDryingPreset(const std::string& fila_id); +}; + +}; // namespace Slic3r From dd5b755f6023a0130f7971b52453409da8a99f7f Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 13:09:32 +0800 Subject: [PATCH 09/47] feat: add drying status JSON parsing and DevAms helper methods Parse dry_status, dry_sub_status, dry_fan statuses, dry_settings, and dry_cannot_reasons from printer JSON. Implement IsSupportRemoteDry, AmsIsDrying, get_ams_drying_preset. Co-Authored-By: Claude --- src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp | 52 +++++++++++++++++++++ src/slic3r/GUI/DeviceManager.hpp | 1 + 2 files changed, 53 insertions(+) diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp index 100e4c30e9..a2787577fd 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp @@ -6,6 +6,7 @@ #include "slic3r/GUI/I18N.hpp" #include "DevUtil.h" +#include "DevUtilBackend.h" using namespace nlohmann; @@ -99,6 +100,12 @@ std::string DevAmsTray::get_filament_type() } +std::optional DevAmsTray::get_ams_drying_preset() const +{ + return DevUtilBackend::GetFilamentDryingPreset(setting_id); +} + + DevAms::DevAms(const std::string& ams_id, int extruder_id, AmsType type) { m_ams_id = ams_id; @@ -189,6 +196,27 @@ DevAmsTray* DevAms::GetTray(const std::string& tray_id) const return nullptr; } +bool DevAms::IsSupportRemoteDry(const MachineObject* obj) const +{ + if (obj && obj->is_support_remote_dry) { + return SupportDrying(); + } + + return false; +} + +bool DevAms::AmsIsDrying() +{ + if (!GetDryStatus().has_value()) { + return false; + } + + return GetDryStatus().value() == DevAms::DryStatus::Checking + || GetDryStatus().value() == DevAms::DryStatus::Drying + || GetDryStatus().value() == DevAms::DryStatus::Error + || GetDryStatus().value() == DevAms::DryStatus::CannotStopHeatOutofControl; +} + DevFilaSystem::~DevFilaSystem() { for (auto it = amsList.begin(); it != amsList.end(); it++) @@ -431,6 +459,30 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS curr_ams->m_left_dry_time = (*it)["dry_time"].get(); } + // Drying status — only parse if printer supports remote drying + if (obj->is_support_remote_dry) { + if (it->contains("info")) { + const std::string& info = (*it)["info"].get(); + curr_ams->m_dry_status = (DevAms::DryStatus)DevUtil::get_flag_bits(info, 4, 4); + curr_ams->m_dry_fan1_status = (DevAms::DryFanStatus)DevUtil::get_flag_bits(info, 18, 2); + curr_ams->m_dry_fan2_status = (DevAms::DryFanStatus)DevUtil::get_flag_bits(info, 20, 2); + curr_ams->m_dry_sub_status = (DevAms::DrySubStatus)DevUtil::get_flag_bits(info, 22, 2); + } + + if (it->contains("dry_setting")) { + const auto& j_dry_settings = (*it)["dry_setting"]; + DevAms::DrySettings dry_settings; + DevJsonValParser::ParseVal(j_dry_settings, "dry_filament", dry_settings.dry_filament); + DevJsonValParser::ParseVal(j_dry_settings, "dry_temperature", dry_settings.dry_temp); + DevJsonValParser::ParseVal(j_dry_settings, "dry_duration", dry_settings.dry_hour); + curr_ams->m_dry_settings = dry_settings; + } + + if (it->contains("dry_sf_reason")) { + curr_ams->m_dry_cannot_reasons = DevJsonValParser::GetVal>((*it), "dry_sf_reason"); + } + } + if (it->contains("humidity")) { try diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 0a31c18502..6e4007a62b 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -254,6 +254,7 @@ public: bool is_tunnel_mqtt = false; //AmsTray vt_tray; // virtual tray + bool is_support_remote_dry = false; long ams_exist_bits = 0; long tray_exist_bits = 0; long tray_is_bbl_bits = 0; From 22d9765348ea17e140c40ebeb439f39af9824940 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 13:19:11 +0800 Subject: [PATCH 10/47] feat: add CtrlAmsStartDryingHour and CtrlAmsStopDrying commands Publish 'ams_filament_drying' JSON commands via MQTT for starting and stopping AMS filament drying. Co-Authored-By: Claude --- .../GUI/DeviceCore/DevFilaSystemCtrl.cpp | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp b/src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp index f81d6a2835..acdc81dd8c 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp @@ -16,4 +16,44 @@ int DevFilaSystem::CtrlAmsReset() const return m_owner->publish_json(jj_command); } +int DevFilaSystem::CtrlAmsStartDryingHour(int ams_id, + std::string filament_type, + int tag_temp, + int tag_duration_hour, + bool rotate_tray, + int cooling_temp, + bool close_power_conflict) const +{ + json jj_command; + jj_command["print"]["command"] = "ams_filament_drying"; + jj_command["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++); + jj_command["print"]["ams_id"] = ams_id; + jj_command["print"]["mode"] = static_cast(DevAms::DryCtrlMode::OnTime); + jj_command["print"]["filament"] = filament_type; + jj_command["print"]["temp"] = tag_temp; + jj_command["print"]["duration"] = tag_duration_hour; + jj_command["print"]["humidity"] = 0; + jj_command["print"]["rotate_tray"] = rotate_tray; + jj_command["print"]["cooling_temp"] = cooling_temp; + jj_command["print"]["close_power_conflict"] = close_power_conflict; + return m_owner->publish_json(jj_command); +} + +int DevFilaSystem::CtrlAmsStopDrying(int ams_id) const +{ + json jj_command; + jj_command["print"]["command"] = "ams_filament_drying"; + jj_command["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++); + jj_command["print"]["ams_id"] = ams_id; + jj_command["print"]["mode"] = static_cast(DevAms::DryCtrlMode::Off); + jj_command["print"]["filament"] = ""; + jj_command["print"]["temp"] = 0; + jj_command["print"]["duration"] = 0; + jj_command["print"]["humidity"] = 0; + jj_command["print"]["rotate_tray"] = false; + jj_command["print"]["cooling_temp"] = 0; + jj_command["print"]["close_power_conflict"] = false; + return m_owner->publish_json(jj_command); +} + } \ No newline at end of file From ec913eaf302d80b4270602de76b6f1a691b1608c Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 13:27:39 +0800 Subject: [PATCH 11/47] feat: add is_support_remote_dry flag parsing from firmware Parses fun2 bit 5 from printer firmware to enable remote drying support. --- src/slic3r/GUI/DeviceManager.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 9d7afd13b9..2562d73b22 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -5031,6 +5031,7 @@ void MachineObject::parse_new_info(json print) // fun2 may have infinite length, use get_flag_bits_no_border if (!fun2.empty()) { is_support_print_with_emmc = get_flag_bits_no_border(fun2, 0) == 1; + is_support_remote_dry = (get_flag_bits_no_border(fun2, 5) == 1); } /*aux*/ From b2298855b02e01c0d9c868990af900e3161fd888 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 13:35:16 +0800 Subject: [PATCH 12/47] assets: add AMS drying control images from BambuStudio 14 images: humidity level icons, drying state images per AMS type, heating icon, guide illustration, tray status icons. --- resources/images/dev_ams_dry_ctr_disable.svg | 5 +++++ resources/images/dev_ams_dry_ctr_enable.svg | 4 ++++ .../dev_ams_dry_ctr_filament_in_chamber.png | Bin 0 -> 105573 bytes .../images/dev_ams_dry_ctr_heating_icon.svg | 3 +++ .../images/dev_ams_dry_ctr_n3f_cooling.png | Bin 0 -> 21003 bytes .../dev_ams_dry_ctr_n3f_dehumidifying.png | Bin 0 -> 22353 bytes resources/images/dev_ams_dry_ctr_n3f_error.png | Bin 0 -> 17987 bytes .../images/dev_ams_dry_ctr_n3f_heating.png | Bin 0 -> 20210 bytes .../images/dev_ams_dry_ctr_n3s_cooling.png | Bin 0 -> 10325 bytes .../dev_ams_dry_ctr_n3s_dehumidifying.png | Bin 0 -> 10546 bytes resources/images/dev_ams_dry_ctr_n3s_error.png | Bin 0 -> 7305 bytes .../images/dev_ams_dry_ctr_n3s_heating.png | Bin 0 -> 10872 bytes 12 files changed, 12 insertions(+) create mode 100644 resources/images/dev_ams_dry_ctr_disable.svg create mode 100644 resources/images/dev_ams_dry_ctr_enable.svg create mode 100644 resources/images/dev_ams_dry_ctr_filament_in_chamber.png create mode 100644 resources/images/dev_ams_dry_ctr_heating_icon.svg create mode 100644 resources/images/dev_ams_dry_ctr_n3f_cooling.png create mode 100644 resources/images/dev_ams_dry_ctr_n3f_dehumidifying.png create mode 100644 resources/images/dev_ams_dry_ctr_n3f_error.png create mode 100644 resources/images/dev_ams_dry_ctr_n3f_heating.png create mode 100644 resources/images/dev_ams_dry_ctr_n3s_cooling.png create mode 100644 resources/images/dev_ams_dry_ctr_n3s_dehumidifying.png create mode 100644 resources/images/dev_ams_dry_ctr_n3s_error.png create mode 100644 resources/images/dev_ams_dry_ctr_n3s_heating.png diff --git a/resources/images/dev_ams_dry_ctr_disable.svg b/resources/images/dev_ams_dry_ctr_disable.svg new file mode 100644 index 0000000000..f2212204e9 --- /dev/null +++ b/resources/images/dev_ams_dry_ctr_disable.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/images/dev_ams_dry_ctr_enable.svg b/resources/images/dev_ams_dry_ctr_enable.svg new file mode 100644 index 0000000000..0c8d4ca146 --- /dev/null +++ b/resources/images/dev_ams_dry_ctr_enable.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/images/dev_ams_dry_ctr_filament_in_chamber.png b/resources/images/dev_ams_dry_ctr_filament_in_chamber.png new file mode 100644 index 0000000000000000000000000000000000000000..1b36643c42cd12c4fe71f574df2a490c4229fab2 GIT binary patch literal 105573 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 {gfEi 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 B*+&2X literal 0 HcmV?d00001 diff --git a/resources/images/dev_ams_dry_ctr_heating_icon.svg b/resources/images/dev_ams_dry_ctr_heating_icon.svg new file mode 100644 index 0000000000..9084670fb7 --- /dev/null +++ b/resources/images/dev_ams_dry_ctr_heating_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/images/dev_ams_dry_ctr_n3f_cooling.png b/resources/images/dev_ams_dry_ctr_n3f_cooling.png new file mode 100644 index 0000000000000000000000000000000000000000..6a43425da5d2c5e828c8a5cc72f851fb4d4bdc9b GIT binary patch literal 21003 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 literal 0 HcmV?d00001 diff --git a/resources/images/dev_ams_dry_ctr_n3f_dehumidifying.png b/resources/images/dev_ams_dry_ctr_n3f_dehumidifying.png new file mode 100644 index 0000000000000000000000000000000000000000..92064da370318fc95a1f4523f9f5ee046e334a43 GIT binary patch literal 22353 zcma%CRa+cgvz@`67n0zX;KAM92^QR)K?Zkca0{N`t|7R)yF&;t=-}?|9KL^WF81C} z_r>n+>guXmwN^)}D9K==5upJ908BYqN!9oD@qL7%AidAdsXO%V3#y~6jtc;Q{^5TE z2uRN)cs~hrQI!z~R8Eo{ydNM~iYbZ#05x&w&nAcffab28q?o!V@T3E^*+RpOw)3{- z%J0hO#wMGVPCZN(<#*(EbbdZ+u5olK0`78`-E|IBOt)Z_o@NCO&r#`dQ44Ctt2)!7G!B@8Tm#wx#KLc zXnQ*;6^*z0@~g^s=S}4cOFEUP@-`xRa2rp~)SG*YzCTa!;|(Px6sT8pv&Z6f=> zO_D@?ki{J7!TV*}!CkQp?g*Iv`_p+};RVfOFsaIrkee@dw+yIz`4OLk5Ya(|*EwA` z^LkzKkt7~+knCZ$$l0|Nw)lYX1`ihS>34^uVJhO^esKe&#&#(!Z3|x6lz1Co9Jv9ya^un&jF{lwOR>ng(KMkZ$9l6`jh>S%z`xtwj z1IjU$!tz!|{(2sslPhT$PbDfcvNLA%dg+A$^o_F%Z?AmSs9x-E`*Obm=dWucpYXTDF%u-7;GggqwIX=U-i zTae3>Gt!-pw$B?81oMrUrge5b!E7q8H*$n+?r(4Qm2iU1m3ZFtEr~Er2_PIv3n1~c z9e!UAcsf5;R#nB~H%edfyyxJ5m9e1mW$GJlZjR(;>Qyv>YkmjeI!bipEl5p&-1#{i z0Uy~_jJq4778BABi%8nyP`Fi~;n;i71;*k-j=+xP3sbSGGxxw$_%2en(5=4W<$vo^sTfNdeP)yOG~6>%8GXlL z(nB?bzIGGt$Jl_aBHa$^>-l=Dv5zYhx2}L5*nK&PsIs!M{bH?!mj5nC&ckQw%S^!CCu7!WvqOHA?%VC*wnr0 zTPSΠ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 literal 0 HcmV?d00001 diff --git a/resources/images/dev_ams_dry_ctr_n3s_cooling.png b/resources/images/dev_ams_dry_ctr_n3s_cooling.png new file mode 100644 index 0000000000000000000000000000000000000000..f663c57c5fa01f340eb6fe6191c0e9feb02cec98 GIT binary patch literal 10325 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 IOBx0KAH8yGZvX%Q literal 0 HcmV?d00001 diff --git a/resources/images/dev_ams_dry_ctr_n3s_dehumidifying.png b/resources/images/dev_ams_dry_ctr_n3s_dehumidifying.png new file mode 100644 index 0000000000000000000000000000000000000000..5926a6cb5421f9600822db6438ee31013c9eac19 GIT binary patch literal 10546 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 B@E8C9 literal 0 HcmV?d00001 diff --git a/resources/images/dev_ams_dry_ctr_n3s_error.png b/resources/images/dev_ams_dry_ctr_n3s_error.png new file mode 100644 index 0000000000000000000000000000000000000000..0b3df1c3fe1af439195f2fcadf660b3497c0a036 GIT binary patch literal 7305 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 literal 0 HcmV?d00001 diff --git a/resources/images/dev_ams_dry_ctr_n3s_heating.png b/resources/images/dev_ams_dry_ctr_n3s_heating.png new file mode 100644 index 0000000000000000000000000000000000000000..3613a3aefc251adb2b04c3ab40cffbf988f77f80 GIT binary patch literal 10872 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 Date: Tue, 7 Jul 2026 14:16:04 +0800 Subject: [PATCH 13/47] feat: add AMSDryControl dialog for AMS filament drying Three-page wizard: main status/control page, guide page with filament tray status, and progress page. Supports N3F and N3S AMS types. Co-Authored-By: Claude --- src/slic3r/CMakeLists.txt | 2 + src/slic3r/GUI/AMSDryControl.cpp | 1864 ++++++++++++++++++++++++++++++ src/slic3r/GUI/AMSDryControl.hpp | 253 ++++ 3 files changed, 2119 insertions(+) create mode 100644 src/slic3r/GUI/AMSDryControl.cpp create mode 100644 src/slic3r/GUI/AMSDryControl.hpp diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 969dcb693a..463d98315e 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -28,6 +28,8 @@ set(SLIC3R_GUI_SOURCES GUI/AMSMaterialsSetting.hpp GUI/AMSSetting.cpp GUI/AMSSetting.hpp + GUI/AMSDryControl.cpp + GUI/AMSDryControl.hpp GUI/AmsWidgets.cpp GUI/AmsWidgets.hpp GUI/Auxiliary.cpp diff --git a/src/slic3r/GUI/AMSDryControl.cpp b/src/slic3r/GUI/AMSDryControl.cpp new file mode 100644 index 0000000000..f88e46f65c --- /dev/null +++ b/src/slic3r/GUI/AMSDryControl.cpp @@ -0,0 +1,1864 @@ +#include "AMSDryControl.hpp" +#include "slic3r/GUI/DeviceCore/DevFilaSystem.h" +#include "GUI_App.hpp" +#include "I18N.hpp" + +#include "slic3r/GUI/DeviceCore/DevExtruderSystem.h" + +#include "slic3r/GUI/DeviceCore/DevManager.h" + +#include "slic3r/GUI/MsgDialog.hpp" + +#include "slic3r/GUI/Widgets/AnimaController.hpp" +#include "slic3r/GUI/Widgets/Label.hpp" +#include "slic3r/GUI/Widgets/ComboBox.hpp" +#include "slic3r/GUI/Widgets/ProgressBar.hpp" +#include "slic3r/GUI/DeviceCore/DevUtilBackend.h" + +#include "libslic3r/PresetBundle.hpp" +#include "libslic3r/Preset.hpp" + +#include + +namespace Slic3r { namespace GUI { + +const int AMS_DRY_STATUS_IMAGE_SIZE = 96; + +static std::string get_humidity_level_img_path(int humidity_percent) +{ + const int num_levels = 5; + int hum_level; + + if (humidity_percent <= 100 / num_levels) { + hum_level = 5; + } else if (humidity_percent <= 100 / num_levels * 2) { + hum_level = 4; + } else if (humidity_percent <= 100 / num_levels * 3) { + hum_level = 3; + } else if (humidity_percent <= 100 / num_levels * 4) { + hum_level = 2; + } else { + hum_level = 1; + } + + if (wxGetApp().dark_mode()) { + return "hum_level" + std::to_string(hum_level) + "_no_num_light"; + } else { + return "hum_level" + std::to_string(hum_level) + "_no_num_light"; + } + +} + +static std::string get_dry_status_img_path(DevAmsType type, DevAms::DryStatus status, DevAms::DrySubStatus sub_status) +{ + std::string img_name = "dev_ams_dry_ctr_"; + switch (type) { + case DevAmsType::N3S: + img_name += "n3s"; + break; + case DevAmsType::N3F: + img_name += "n3f"; + break; + default: + return ""; + } + + img_name += "_"; + switch (status) { + case DevAms::DryStatus::Error: + img_name += "error"; + return img_name; + } + + switch (sub_status) { + case DevAms::DrySubStatus::Heating: + img_name += "heating"; + return img_name; + case DevAms::DrySubStatus::Dehumidify: + img_name += "dehumidifying"; + return img_name; + } + + return ""; +} + +FilamentItemPanel::FilamentItemPanel(wxWindow* parent, const wxString& text, const std::string& icon_name, wxWindowID id) + : wxPanel(parent, id) + , m_icon_name(icon_name) +{ + SetBackgroundColour(wxColour("#F7F7F7")); // Light gray background + SetMinSize(wxSize(FromDIP(64), FromDIP(106))); // Width: 64, Height: 106 + SetSize(wxSize(FromDIP(64), FromDIP(106))); // Fixed size + + // Create sizer for vertical layout + wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL); + + // Top section with text - moved to be closer to center + wxBoxSizer* top_sizer = new wxBoxSizer(wxVERTICAL); + top_sizer->AddStretchSpacer(5); + + m_text_label = new Label(this, text); + m_text_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour(*wxBLACK))); + m_text_label->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F7F7F7"))); + m_text_label->SetFont(Label::Body_12); + m_text_label->Wrap(FromDIP(40)); + top_sizer->Add(m_text_label, 0, wxALIGN_CENTER_HORIZONTAL); + + top_sizer->AddStretchSpacer(1); // Increased bottom spacer to push text down + sizer->Add(top_sizer, 1, wxEXPAND); + + // Bottom section with icon - vertically centered in bottom half + wxBoxSizer* bottom_sizer = new wxBoxSizer(wxVERTICAL); + bottom_sizer->AddStretchSpacer(1); + + m_icon_bitmap = new wxStaticBitmap(this, wxID_ANY, wxNullBitmap); + m_icon_bitmap->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F7F7F7"))); + m_icon_bitmap->SetMinSize(wxSize(FromDIP(24), FromDIP(24))); + m_icon_bitmap->SetMaxSize(wxSize(FromDIP(24), FromDIP(24))); + bottom_sizer->Add(m_icon_bitmap, 0, wxALIGN_CENTER_HORIZONTAL); + + bottom_sizer->AddStretchSpacer(1); + sizer->Add(bottom_sizer, 1, wxEXPAND); + + SetSizer(sizer); + + // Bind events + Bind(wxEVT_PAINT, &FilamentItemPanel::OnPaint, this); + Bind(wxEVT_SIZE, &FilamentItemPanel::OnSize, this); + + SetIcon(icon_name); + wxGetApp().UpdateDarkUI(this); +} + +void FilamentItemPanel::SetText(const wxString& text) +{ + m_text_label->SetLabel(text); + Layout(); +} + +void FilamentItemPanel::SetIcon(const std::string& icon_name) +{ + m_icon_name = icon_name; + if (!icon_name.empty()) { + try { + m_icon = ScalableBitmap(this, icon_name, 20); + m_icon.msw_rescale(); + m_icon_bitmap->SetBitmap(m_icon.bmp()); + } catch (Exception&) { + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": failed to load filament icon"; + m_icon_bitmap->SetBitmap(wxNullBitmap); + } + } else { + m_icon_bitmap->SetBitmap(wxNullBitmap); + } + m_icon_bitmap->Refresh(); +} + +void FilamentItemPanel::msw_rescale() +{ + if (m_icon_bitmap && !m_icon_name.empty()) { + m_icon.msw_rescale(); + m_icon_bitmap->SetBitmap(m_icon.bmp()); + m_icon_bitmap->Refresh(); + } + Layout(); +} + +void FilamentItemPanel::OnPaint(wxPaintEvent& event) +{ + wxPaintDC dc(this); + wxSize size = GetSize(); + + // bool is_dark_mode = wxGetApp().dark_mode(); + wxColour backgroundColor = StateColor::darkModeColorFor(wxColour("#F7F7F7")); + wxColour borderColor = StateColor::darkModeColorFor(wxColour("#DBDBDB")); + + // Draw white background rectangle with rounded corners inside the thick vertical lines + dc.SetBrush(wxBrush(backgroundColor)); + dc.SetPen(wxPen(backgroundColor)); + dc.DrawRectangle(FromDIP(6), FromDIP(12), size.GetWidth() - FromDIP(12), size.GetHeight() - 2 * FromDIP(12)); + + // Draw much thicker vertical rounded rectangles on left and right (3 times thicker) + dc.SetBrush(wxBrush(wxColour(borderColor))); + dc.SetPen(wxPen(wxColour(borderColor))); + dc.DrawRoundedRectangle(FromDIP(0), FromDIP(0), FromDIP(6), size.GetHeight(), FromDIP(6)); // Left rounded rectangle + dc.DrawRoundedRectangle(size.GetWidth() - FromDIP(6), FromDIP(0), FromDIP(6), size.GetHeight(), FromDIP(6)); // Right rounded rectangle + + // Draw thin horizontal rounded rectangles on top and bottom (moved closer to center by half the distance) + dc.SetBrush(wxBrush(wxColour(borderColor))); + dc.SetPen(wxPen(wxColour(borderColor))); + dc.DrawRoundedRectangle(FromDIP(6), FromDIP(12), size.GetWidth() - FromDIP(12), FromDIP(3), FromDIP(1)); // Top rounded rectangle + dc.DrawRoundedRectangle(FromDIP(6), size.GetHeight() - FromDIP(13), size.GetWidth() - FromDIP(12), FromDIP(3), FromDIP(1)); // Bottom rounded rectangle +} + +void FilamentItemPanel::OnSize(wxSizeEvent& event) +{ + Refresh(); + event.Skip(); +} + +// class AMSFilamentPanel + +AMSFilamentPanel::AMSFilamentPanel(wxWindow* parent, const wxString& ams_name, wxWindowID id) + : wxPanel(parent, id) +{ + SetBackgroundColour(wxColour("#DBDBDB")); + + wxBoxSizer* main_sizer = new wxBoxSizer(wxVERTICAL); + + // Filament items section + m_filament_container = new wxPanel(this); + m_filament_container->SetBackgroundColour(wxColour("#F7F7F7")); + m_filament_sizer = new wxBoxSizer(wxHORIZONTAL); + m_filament_container->SetSizer(m_filament_sizer); + + // AMS name section + m_ams_name_label = new Label(this, ams_name); + m_ams_name_label->SetForegroundColour(wxColour("#858585")); + m_ams_name_label->SetFont(Label::Body_14); + m_ams_name_label->SetBackgroundColour(wxColour("#DBDBDB")); + + main_sizer->Add(m_filament_container, 1, wxEXPAND | wxALL, 0); + main_sizer->Add(m_ams_name_label, 0, wxALIGN_LEFT | wxALL, FromDIP(5)); + + SetSizer(main_sizer); + wxGetApp().UpdateDarkUI(this); +} + +void AMSFilamentPanel::AddFilamentItem(FilamentItemPanel* panel) +{ + if (panel) { + panel->Reparent(m_filament_container); + m_filament_sizer->Add(panel, 0, wxALL, FromDIP(5)); + m_filament_items.push_back(panel); + Layout(); + } +} + +void AMSFilamentPanel::AddFilamentItem(const wxString& text, const std::string& icon_name) +{ + FilamentItemPanel* item = new FilamentItemPanel(m_filament_container, text, icon_name); + m_filament_items.push_back(item); + m_filament_sizer->Add(item, 0, wxALL, FromDIP(5)); + Layout(); +} + +void AMSFilamentPanel::SetAmsName(const wxString& ams_name) +{ + m_ams_name_label->SetLabel(ams_name); + Layout(); +} + +void AMSFilamentPanel::Clear() +{ + m_filament_sizer->Clear(true); + m_filament_items.clear(); +} + +void AMSFilamentPanel::msw_rescale() +{ + for (auto item : m_filament_items) { + item->msw_rescale(); + } + Layout(); +} + + +AMSDryCtrWin::AMSDryCtrWin(wxWindow *parent) + :DPIDialog(parent, wxID_ANY, _L("AMS Dryness Control"), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX) +{ + create(); +} + +AMSDryCtrWin::~AMSDryCtrWin() +{ + if (m_progress_timer) { + delete m_progress_timer; + } +} + + +wxScrolledWindow* AMSDryCtrWin::create_preview_scrolled_window(wxWindow* parent) +{ + wxScrolledWindow* panel = new wxScrolledWindow(parent, wxID_ANY); + panel->SetScrollRate(10, 0); + panel->SetSize(AMS_ITEMS_PANEL_SIZE); + panel->SetMinSize(AMS_ITEMS_PANEL_SIZE); + panel->SetBackgroundColour(AMS_CONTROL_DEF_BLOCK_BK_COLOUR); + + return panel; +} + +wxBoxSizer* AMSDryCtrWin::create_humidity_status_section(wxPanel* parent) +{ + wxBoxSizer* image_sizer = new wxBoxSizer(wxVERTICAL); + + m_humidity_img = new wxStaticBitmap(parent, wxID_ANY, wxNullBitmap); + image_sizer->Add(m_humidity_img, 1, wxALIGN_CENTER_HORIZONTAL, 0); + + m_humidity_img->SetBitmap(wxNullBitmap); + + // Create a horizontal sizer for description and icon + wxBoxSizer* desc_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_image_description_icon = new wxStaticBitmap(parent, wxID_ANY, wxNullBitmap); + m_image_description_icon->SetBackgroundColour(*wxWHITE); + m_image_description_icon->SetMinSize(wxSize(FromDIP(20), FromDIP(20))); + m_image_description_icon->SetMaxSize(wxSize(FromDIP(20), FromDIP(20))); + m_image_description_icon->Show(false); + desc_sizer->Add(m_image_description_icon, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5)); + + m_image_description = new Label(parent, _L("Idle")); + m_image_description->SetFont(Label::Head_14); + m_image_description->SetForegroundColour(*wxBLACK); + m_image_description->SetBackgroundColour(*wxWHITE); + desc_sizer->Add(m_image_description, 0, wxALIGN_CENTER_VERTICAL); + + image_sizer->Add(desc_sizer, 0, wxALIGN_CENTER | wxALL, FromDIP(5)); + + return image_sizer; +} + +wxBoxSizer* AMSDryCtrWin::create_description_item(wxPanel* parent, const wxString& title, Label*& dataLabel) +{ + wxBoxSizer* item_sizer = new wxBoxSizer(wxVERTICAL); + + Label* titleLabel = new Label(parent, title); + titleLabel->SetForegroundColour(*wxBLACK); + titleLabel->SetFont(Label::Body_16); + + dataLabel = new Label(parent, wxT("--")); + dataLabel->SetForegroundColour(*wxBLACK); + dataLabel->SetFont(Label::Body_14); + + item_sizer->AddStretchSpacer(); + item_sizer->Add(titleLabel, 0, wxALIGN_CENTER | wxALL, FromDIP(2)); + item_sizer->Add(dataLabel, 0, wxALIGN_CENTER | wxALL, FromDIP(2)); + item_sizer->AddStretchSpacer(); + + return item_sizer; +} + +wxBoxSizer* AMSDryCtrWin::create_status_descriptions_section(wxPanel* parent) +{ + wxBoxSizer* desc_sizer = new wxBoxSizer(wxHORIZONTAL); + + wxBoxSizer* hum_desc_sizer = create_description_item(parent, _L("Humidity"), m_humidity_data_label); + desc_sizer->Add(hum_desc_sizer, 1, wxEXPAND, FromDIP(1)); + + wxStaticLine* vert_separator = new wxStaticLine(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL); + desc_sizer->Add(vert_separator, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(5)); + + wxBoxSizer* temp_desc_sizer = create_description_item(parent, _L("Temperature"), m_temperature_data_label); + desc_sizer->Add(temp_desc_sizer, 1, wxEXPAND, FromDIP(1)); + + m_time_descrition_container = new wxBoxSizer(wxHORIZONTAL); + wxStaticLine* vert_separator2 = new wxStaticLine(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL); + m_time_descrition_container->Add(vert_separator2, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(5)); + + wxBoxSizer* time_desc_sizer = create_description_item(parent, _L("Left Time"), m_time_data_label); + m_time_descrition_container->Add(time_desc_sizer, 1, wxEXPAND, FromDIP(1)); + + desc_sizer->Add(m_time_descrition_container, 1, wxEXPAND, 0); + // m_time_descrition_container->Show(false); + + return desc_sizer; +} + +wxBoxSizer* AMSDryCtrWin::create_left_panel(wxPanel* parent) +{ + wxBoxSizer* left_sizer = new wxBoxSizer(wxVERTICAL); + + wxBoxSizer* image_section = create_humidity_status_section(parent); + left_sizer->Add(image_section, 1, wxEXPAND | wxALL, FromDIP(5)); + + wxBoxSizer* descriptions_section = create_status_descriptions_section(parent); + left_sizer->Add(descriptions_section, 0, wxEXPAND | wxALL, FromDIP(20)); + + return left_sizer; +} + +Button* AMSDryCtrWin::create_button(wxPanel* parent, const wxString& title, + const wxColour& background_color, const wxColour& border_color, const wxColour& text_color) +{ + Button* button = new Button(parent, title); + + // Create state colors for background + StateColor bg_color( + std::pair(AMS_CONTROL_DISABLE_COLOUR, StateColor::Disabled), + std::pair(background_color.ChangeLightness(80), StateColor::Pressed), + std::pair(background_color.ChangeLightness(120), StateColor::Hovered), + std::pair(background_color, StateColor::Normal) + ); + + // Create state colors for border + StateColor bd_color( + std::pair(AMS_CONTROL_WHITE_COLOUR, StateColor::Disabled), + std::pair(border_color, StateColor::Enabled) + ); + + button->SetBackgroundColor(bg_color); + button->SetBorderColor(bd_color); + button->SetTextColor(text_color); + button->SetFont(Label::Body_14); + + // Auto-size button based on text content with padding + wxSize best_size = button->GetBestSize(); + int padding_width = FromDIP(4); + int padding_height = FromDIP(2); + wxSize final_size(best_size.GetWidth() + padding_width, best_size.GetHeight() + padding_height); + button->SetMinSize(final_size); + + return button; +} + +wxBoxSizer* AMSDryCtrWin::create_normal_state_panel(wxPanel* parent) +{ + wxBoxSizer* normal_state_sizer = new wxBoxSizer(wxVERTICAL); + + Label* description_label = new Label(parent, _L("Filament Drying Settings")); + description_label->SetForegroundColour(*wxBLACK); + description_label->SetFont(Label::Head_14); + normal_state_sizer->Add(description_label, 0, wxALL, FromDIP(5)); + + // Part 2: ComboBox for material selection + wxBoxSizer* combo_sizer = new wxBoxSizer(wxHORIZONTAL); + m_trays_combo = new ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(-1, FromDIP(26)), 0, nullptr, wxCB_READONLY); + m_trays_combo->SetFont(Label::Body_14); + + combo_sizer->Add(m_trays_combo, 1, wxEXPAND); + normal_state_sizer->Add(combo_sizer, 0, wxEXPAND | wxALL, FromDIP(5)); + + m_trays_combo->Bind(wxEVT_COMBOBOX, &AMSDryCtrWin::OnFilamentSelectionChanged, this); + + // part 3 time and temperature + // Temperature part + wxBoxSizer* temp_sizer = new wxBoxSizer(wxHORIZONTAL); + m_temperature_input = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(FromDIP(80), -1)); + m_temperature_input->SetMaxLength(3); // Limit to 3 digits + + m_temperature_input->Bind(wxEVT_CHAR, [this](wxKeyEvent& event) { + int keycode = event.GetKeyCode(); + if (keycode >= '0' && keycode <= '9') { + event.Skip(); + } else if (keycode == WXK_BACK || keycode == WXK_DELETE || keycode == WXK_LEFT || keycode == WXK_RIGHT) { + event.Skip(); + } + }); + + m_temperature_input->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { + m_next_button->Disable(); + m_start_button->Disable(); + }); + + m_temperature_input->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + m_temperature_input->SetForegroundColour(StateColor::darkModeColorFor(*wxBLACK)); + + Label* temp_unit_label = new Label(parent, wxString::FromUTF8("℃")); + temp_unit_label->SetForegroundColour(*wxBLACK); + temp_sizer->Add(m_temperature_input, 1, wxRIGHT, FromDIP(1)); + temp_sizer->Add(temp_unit_label, 0, wxALIGN_CENTER_VERTICAL); + + // Time part + wxBoxSizer* time_sizer = new wxBoxSizer(wxHORIZONTAL); + m_time_input = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(FromDIP(100), -1)); + m_time_input->SetMaxLength(3); // Limit to 3 digits + + m_time_input->Bind(wxEVT_CHAR, [this](wxKeyEvent& event) { + int keycode = event.GetKeyCode(); + if (keycode >= '0' && keycode <= '9') { + event.Skip(); + } else if (keycode == WXK_BACK || keycode == WXK_DELETE || keycode == WXK_LEFT || keycode == WXK_RIGHT) { + event.Skip(); + } + }); + + m_time_input->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { + m_next_button->Disable(); + m_start_button->Disable(); + }); + + m_time_input->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + m_time_input->SetForegroundColour(StateColor::darkModeColorFor(*wxBLACK)); + + Label* time_unit_label = new Label(parent, "H"); + time_unit_label->SetForegroundColour(*wxBLACK); + time_sizer->Add(m_time_input, 1, wxRIGHT, FromDIP(1)); + time_sizer->Add(time_unit_label, 0, wxALIGN_CENTER_VERTICAL); + + wxBoxSizer* input_sizer = new wxBoxSizer(wxHORIZONTAL); + input_sizer->Add(temp_sizer, 1, wxRIGHT, FromDIP(10)); + input_sizer->Add(time_sizer, 1, 0); + normal_state_sizer->Add(input_sizer, 0, wxEXPAND | wxALL, FromDIP(5)); + + // Part 4: Abnormal description/message area + m_normal_description = new Label(parent, ""); + m_normal_description->SetForegroundColour(wxColour("#F09A17")); + m_normal_description->SetFont(Label::Body_12); + normal_state_sizer->Add(m_normal_description, 0, wxALL, FromDIP(5)); + + // Part 5: Start button + m_next_button = create_button( + parent, + _L("Start"), + AMS_CONTROL_BRAND_COLOUR, // Background color - green + AMS_CONTROL_BRAND_COLOUR, // Border color - green + wxColour("#FFFFFE") // Text color - white + ); + + m_next_button->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { + m_main_simplebook->SetSelection(1); + }); + + normal_state_sizer->Add(m_next_button, 0, wxALL, FromDIP(5)); + m_next_button->Disable(); + + m_stop_button = create_button( + parent, + _L("Stop"), + wxColour(255, 0, 0), // Background color - red + wxColour(255, 0, 0), // Border color - red + wxColour("#FFFFFE") // Text color - white + ); + + m_stop_button->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { + auto fila_system = get_fila_system(); + if (!fila_system) { + BOOST_LOG_TRIVIAL(info) << "AMSDryCtrWin::start_sending_drying_command: Invalid FilaSystem Pointer"; + return; + } + + fila_system->CtrlAmsStopDrying(std::stoi(m_ams_info.m_ams_id)); + // Temporarily show stopping state, then restore after 2 seconds + if (m_stop_button) { + m_stop_button->SetLabel(_L("Stopping")); + update_button_size(m_stop_button); // Adjust button size for longer text + m_stop_button->Disable(); + m_stop_button->Layout(); + m_stop_button->GetParent()->Layout(); // Relayout parent container + m_stop_button->Refresh(); + + m_stop_button_restore_deadline.reset(); + m_stop_button_restore_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + } + }); + + m_stop_button->Show(false); + normal_state_sizer->Add(m_stop_button, 0, wxALL, FromDIP(5)); + + return normal_state_sizer; +} + +wxBoxSizer* AMSDryCtrWin::create_cannot_dry_panel(wxPanel* parent) +{ + wxBoxSizer* abnormal_sizer = new wxBoxSizer(wxVERTICAL); + + Label* description_label = new Label(parent, _L("Unable to dry temporarily due to ...")); + description_label->SetForegroundColour(*wxBLACK); + description_label->SetFont(Label::Head_16); + description_label->Wrap(FromDIP(300)); + abnormal_sizer->Add(description_label, 0, wxALIGN_CENTER_VERTICAL, FromDIP(10)); + + // Add a description label for the abnormal state + m_cannot_dry_description_label = new Label(parent, ("")); + m_cannot_dry_description_label->SetForegroundColour(*wxBLACK); + m_cannot_dry_description_label->SetFont(Label::Body_14); + m_cannot_dry_description_label->Wrap(FromDIP(250)); // Wrap text to fit within panel + + abnormal_sizer->Add(m_cannot_dry_description_label, 0, wxALIGN_CENTER_VERTICAL, FromDIP(10)); + + // Unload button shown only when ConsumableAtAmsOutlet reason is active + m_unload_button = create_button( + parent, + _L("Unload"), + AMS_CONTROL_BRAND_COLOUR, // Background color - light gray + AMS_CONTROL_BRAND_COLOUR, // Border color - gray + *wxWHITE // Text color - white + ); + + m_unload_button->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { + auto fila_system = get_fila_system(); + if (!fila_system) { + BOOST_LOG_TRIVIAL(info) << "AMSDryCtrWin::unload_button: Invalid FilaSystem Pointer"; + return; + } + + MachineObject* obj = fila_system->GetOwner(); + if (!obj) { + BOOST_LOG_TRIVIAL(info) << "AMSDryCtrWin::unload_button: Invalid MachineObject Pointer"; + return; + } + + obj->command_ams_change_filament(false, m_ams_info.m_ams_id, "255"); + + m_unload_button->Disable(); + m_unload_button_restore_deadline.reset(); + m_unload_button_restore_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + }); + + m_unload_button->Show(false); + abnormal_sizer->Add(m_unload_button, 0, wxALL, FromDIP(5)); + + return abnormal_sizer; +} + +wxBoxSizer* AMSDryCtrWin::create_drying_error_panel(wxPanel* parent) +{ + wxBoxSizer* err_sizer = new wxBoxSizer(wxVERTICAL); + Label* description_label = new Label(parent, _L("Drying Error")); + description_label->SetForegroundColour(*wxRED); + description_label->SetFont(Label::Body_14); // Wrap text to fit within panel + err_sizer->Add(description_label, 0, wxALIGN_CENTER_VERTICAL, FromDIP(10)); + + Label* additional_description_label = new Label(parent, _L("Please check the Assistant for troubleshooting")); + additional_description_label->SetForegroundColour(*wxBLACK); + additional_description_label->SetFont(Label::Body_14); // Wrap text to fit within panel + err_sizer->Add(additional_description_label, 0, wxALIGN_CENTER_VERTICAL, FromDIP(10)); + + return err_sizer; +} + +wxBoxSizer* AMSDryCtrWin::create_right_panel(wxPanel* parent) +{ + wxBoxSizer* right_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_normal_state_sizer = create_normal_state_panel(parent); + m_cannot_dry_sizer = create_cannot_dry_panel(parent); + m_dry_error_sizer = create_drying_error_panel(parent); + + right_sizer->Add(m_normal_state_sizer, 1, wxEXPAND); + right_sizer->Add(m_cannot_dry_sizer, 1, wxEXPAND); + + m_cannot_dry_sizer->Show(false); + m_dry_error_sizer->Show(false); + + return right_sizer; +} + +wxBoxSizer* AMSDryCtrWin::create_main_content_section(wxPanel* parent) +{ + wxBoxSizer* content_sizer = new wxBoxSizer(wxHORIZONTAL); + + wxBoxSizer* left_panel = create_left_panel(parent); + content_sizer->Add(left_panel, 2, wxALIGN_CENTER_VERTICAL | wxALL, FromDIP(5)); + left_panel->SetMinSize(wxSize(FromDIP(250), -1)); + + wxBoxSizer* right_panel = create_right_panel(parent); + content_sizer->Add(right_panel, 1, wxALIGN_CENTER_VERTICAL | wxALL, FromDIP(5)); + right_panel->SetMinSize(wxSize(FromDIP(250), -1)); + + return content_sizer; +} + +wxBoxSizer* AMSDryCtrWin::create_main_page_sizer(wxPanel* parent) +{ + wxBoxSizer* main_sizer = new wxBoxSizer(wxVERTICAL); + + wxBoxSizer* content_section = create_main_content_section(parent); + main_sizer->Add(content_section, 1, wxEXPAND | wxALL, FromDIP(5)); + + return main_sizer; +} + +wxBoxSizer* AMSDryCtrWin::create_guide_info_filament(wxPanel* parent) +{ + wxBoxSizer* filament_section = new wxBoxSizer(wxHORIZONTAL); + + m_ams_filament_panel = new AMSFilamentPanel(parent, ""); + + filament_section->Add(m_ams_filament_panel, 0, wxEXPAND | wxALL | wxALIGN_CENTER_HORIZONTAL, FromDIP(5)); + + return filament_section; +} + +wxBoxSizer* AMSDryCtrWin::create_guide_info_section(wxPanel* parent) +{ + wxBoxSizer* info_section = new wxBoxSizer(wxVERTICAL); + + // Part 1: Title + m_guide_title_label = new Label(parent, _L("Please remove and store the filament (as shown).")); + m_guide_title_label->SetForegroundColour(*wxBLACK); + m_guide_title_label->SetFont(Label::Head_18); + info_section->Add(m_guide_title_label, 0, wxEXPAND | wxALL, FromDIP(5)); + + // Part 2: Description + m_guide_description_label = new Label(parent, _L("The AMS can rotate the filament which is properly stored, providing better drying results.")); + m_guide_description_label->SetForegroundColour(*wxBLACK); + m_guide_description_label->SetFont(Label::Body_14); + m_guide_description_label->Wrap(FromDIP(300)); // Wrap text to fit within panel + info_section->Add(m_guide_description_label, 0, wxEXPAND | wxALL, FromDIP(5)); + + // Part 3: filament panel + wxBoxSizer* fila_section = create_guide_info_filament(parent); + info_section->Add(fila_section, 0, wxEXPAND | wxALL, FromDIP(5)); + + // Part 4: Circular toggle with description + wxBoxSizer* toggle_section = new wxBoxSizer(wxHORIZONTAL); + + m_rotate_spool_toggle = new wxCheckBox(parent, wxID_ANY, ""); + m_rotate_spool_toggle->SetValue(false); + + m_rotate_spool_toggle->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent& event) { + bool is_checked = event.IsChecked(); + // Add toggle behavior logic here + }); + + toggle_section->Add(m_rotate_spool_toggle, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(10)); + + // Toggle description + Label* toggle_description = new Label(parent, _L("Rotate spool when drying"), LB_AUTO_WRAP); + toggle_description->SetForegroundColour(*wxBLACK); + toggle_description->SetFont(Label::Body_12); + toggle_section->Add(toggle_description, 1, wxALIGN_CENTER_VERTICAL | wxEXPAND, 0); + + info_section->Add(toggle_section, 0, wxEXPAND | wxALL, FromDIP(5)); + + return info_section; +} + +wxBoxSizer* AMSDryCtrWin::create_guide_right_section(wxPanel* parent) +{ + wxBoxSizer* right_section = new wxBoxSizer(wxVERTICAL); + + // Upper part: Image section + m_image_placeholder = new wxStaticBitmap(parent, wxID_ANY, wxNullBitmap); + m_guide_image = ScalableBitmap(parent, "dev_ams_dry_ctr_filament_in_chamber", 256); + m_image_placeholder->SetBitmap(m_guide_image.bmp()); + + wxBoxSizer* image_container = new wxBoxSizer(wxHORIZONTAL); + image_container->AddStretchSpacer(1); + image_container->Add(m_image_placeholder, 0, wxALL, FromDIP(5)); + + right_section->Add(image_container, 0, wxEXPAND | wxALL, FromDIP(5)); + + wxBoxSizer* buttons_container = new wxBoxSizer(wxHORIZONTAL); + buttons_container->AddStretchSpacer(1); + + m_back_button = create_button( + parent, + wxString::FromUTF8(_CTX_utf8(L_CONTEXT("Back", "amsdrying"), "amsdrying")), + wxColour("#F8F8F8"), // Background color - light gray + wxColour("#D0D0D0"), // Border color - gray + *wxBLACK // Text color - black + ); + + m_back_button->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { + m_main_simplebook->SetSelection(0); + }); + + buttons_container->Add(m_back_button, 0, wxALL, FromDIP(5)); + + m_start_button = create_button( + parent, + _L("Start"), + AMS_CONTROL_BRAND_COLOUR, // Background color - green + AMS_CONTROL_BRAND_COLOUR, // Border color - green + wxColour("#FFFFFE") // Text color - white + ); + + m_start_button->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { + m_main_simplebook->SetSelection(2); + m_progress_gauge->SetValue(0); + + m_progress_timer->Start(70); + m_progress_value = 0; + start_sending_drying_command(); + }); + + buttons_container->Add(m_start_button, 0, wxALL, FromDIP(5)); + + right_section->Add(buttons_container, 0, wxEXPAND | wxALL, FromDIP(5)); + + return right_section; +} + +wxBoxSizer* AMSDryCtrWin::create_guide_page_sizer(wxPanel* parent) +{ + wxBoxSizer* guide_sizer = new wxBoxSizer(wxHORIZONTAL); + + wxBoxSizer* info_section = create_guide_info_section(parent); + guide_sizer->Add(info_section, 1, wxEXPAND | wxALL, FromDIP(10)); + + wxBoxSizer* right_section = create_guide_right_section(parent); + guide_sizer->Add(right_section, 0, wxEXPAND | wxALL, FromDIP(10)); + + return guide_sizer; +} + +void AMSDryCtrWin::start_sending_drying_command() +{ + auto fila_system = get_fila_system(); + if (!fila_system) { + BOOST_LOG_TRIVIAL(info) << "AMSDryCtrWin::start_sending_drying_command: Invalid FilaSystem Pointer"; + return; + } + + if (m_temperature_input->GetValue().IsEmpty() || m_time_input->GetValue().IsEmpty()) { + // Show error message to user + BOOST_LOG_TRIVIAL(info) << "AMSDryCtrWin::start_sending_drying_command: Time or temperature is empty"; + return; + } + + long temperature, time; + if (!m_temperature_input->GetValue().ToLong(&temperature) || + !m_time_input->GetValue().ToLong(&time)) { + BOOST_LOG_TRIVIAL(info) << "AMSDryCtrWin::start_sending_drying_command: Failed to convert temperature or time"; + return; + } + + int tray_index; + tray_index = m_trays_combo->GetSelection(); + if (m_tray_ids.empty() || tray_index < 0 || tray_index >= m_tray_ids.size()) { + BOOST_LOG_TRIVIAL(warning) << "AMSDryCtrWin::start_sending_drying_command: Invalid tray_index " << tray_index + << ", m_tray_ids.size=" << m_tray_ids.size(); + return; + } + + int cooling_temp = 50; + std::optional preset = DevUtilBackend::GetFilamentDryingPreset(m_tray_ids[tray_index].filament_id); + if (preset.has_value()) { + cooling_temp = static_cast(preset.value().filament_dev_drying_softening_temperature); + } + + fila_system->CtrlAmsStartDryingHour(std::stoi(m_ams_info.m_ams_id), m_tray_ids[tray_index].filament_type, + temperature, time, m_rotate_spool_toggle->GetValue(), cooling_temp, false); + + m_dry_setting.m_filament_names[m_ams_info.m_ams_id] = m_tray_ids[tray_index].filament_name; + m_dry_setting.m_filament_type[m_ams_info.m_ams_id] = m_tray_ids[tray_index].filament_type; + m_dry_setting.m_dry_temp[m_ams_info.m_ams_id] = temperature; + m_dry_setting.m_dry_time[m_ams_info.m_ams_id] = time; +} + +void AMSDryCtrWin::OnProgressTimer(wxTimerEvent& event) +{ + m_progress_value += 1; + + if (m_progress_value <= 100) + m_progress_gauge->SetValue(m_progress_value); + + if (m_progress_value == 1) { // First tick, reset message index + m_progress_message_index = 0; + if (!m_progress_text.empty()) { + m_progress_title->SetLabel(m_progress_text[0]); + } + } + else if (m_progress_value % 15 == 0) { // Approximately every second + m_progress_message_index++; + if (m_progress_message_index < m_progress_text.size()) { + m_progress_title->SetLabel(m_progress_text[m_progress_message_index]); + m_progress_title->Refresh(); + } + } + + if (m_progress_value >= 100 && !is_dry_ctr_idle()) { + m_progress_value = 0; + m_progress_timer->Stop(); + m_main_simplebook->SetSelection(0); + } +} + +void AMSDryCtrWin::restore_stop_button_if_deadline_passed() +{ + if (m_stop_button_restore_deadline.has_value()) { + if (std::chrono::steady_clock::now() >= m_stop_button_restore_deadline.value()) { + if (m_stop_button) { + m_stop_button->SetLabel(_L("Stop")); + update_button_size(m_stop_button); // Adjust button size back to original + m_stop_button->Enable(); + m_stop_button->Layout(); + m_stop_button->GetParent()->Layout(); + m_stop_button->Refresh(); + } + m_stop_button_restore_deadline.reset(); + } + } +} + +void AMSDryCtrWin::restore_unload_button_if_deadline_passed() +{ + if (m_unload_button_restore_deadline.has_value()) { + if (std::chrono::steady_clock::now() >= m_unload_button_restore_deadline.value()) { + if (m_unload_button) { + m_unload_button->Enable(); + } + m_unload_button_restore_deadline.reset(); + } + } +} + +void AMSDryCtrWin::update_button_size(Button* button) +{ + if (!button) return; + + // Reset MinSize first to avoid accumulation of size + button->SetMinSize(wxSize(-1, -1)); + + // Recalculate button size based on current text and DPI settings + wxSize best_size = button->GetBestSize(); + int padding_width = FromDIP(4); + int padding_height = FromDIP(2); + wxSize final_size(best_size.GetWidth() + padding_width, best_size.GetHeight() + padding_height); + button->SetMinSize(final_size); +} + +void AMSDryCtrWin::OnShow(wxShowEvent& event) +{ + if (event.IsShown()) { + wxGetApp().UpdateDlgDarkUI(this); + msw_rescale(); + } + event.Skip(); +} + +void AMSDryCtrWin::OnClose(wxCloseEvent& event) +{ + if (m_progress_timer && m_progress_timer->IsRunning()) { + m_progress_timer->Stop(); + } + + if (m_progress_gauge) { + m_progress_gauge->SetValue(0); + } + + m_progress_value = 0; + m_progress_message_index = 0; + + if (m_main_simplebook) { + m_main_simplebook->SetSelection(0); + } + + if (m_progress_title && !m_progress_text.empty()) { + m_progress_title->SetLabel(m_progress_text[0]); + } + + // Clean up and restore stop button state + if (m_stop_button) { + m_stop_button->SetLabel(_L("Stop")); + update_button_size(m_stop_button); // Adjust button size back to original + m_stop_button->Enable(); + m_stop_button->Layout(); + m_stop_button->GetParent()->Layout(); + m_stop_button->Refresh(); + } + m_stop_button_restore_deadline.reset(); + + // Clean up unload button state + if (m_unload_button) { + m_unload_button->Enable(); + } + m_unload_button_restore_deadline.reset(); + + event.Skip(); +} + +wxBoxSizer* AMSDryCtrWin::create_progress_page_sizer(wxPanel* parent) +{ + wxBoxSizer* progress_sizer = new wxBoxSizer(wxVERTICAL); + + m_progress_title = new Label(parent, m_progress_text[0]); + m_progress_title->SetForegroundColour(*wxBLACK); + m_progress_title->SetFont(Label::Body_16); + + progress_sizer->Add(0, 0, 1, wxEXPAND, 0); // Spacer + progress_sizer->Add(m_progress_title, 0, wxALIGN_CENTER | wxALL, FromDIP(30)); + + m_progress_gauge = new ProgressBar(parent, wxID_ANY, 100, wxDefaultPosition, wxSize(FromDIP(300), FromDIP(8))); + m_progress_gauge->SetValue(0); + m_progress_gauge->SetHeight(FromDIP(8)); + + progress_sizer->Add(m_progress_gauge, 0, wxALIGN_CENTER | wxALL, FromDIP(10)); + progress_sizer->Add(0, 0, 1, wxEXPAND, 0); + + return progress_sizer; +} + +void AMSDryCtrWin::create() +{ + // set title icon + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + this->SetDoubleBuffered(true); + std::string icon_path = (boost::format("%1%/images/BambuStudioTitle.ico") % resources_dir()).str(); + SetIcon(wxIcon(encode_path(icon_path.c_str()), wxBITMAP_TYPE_ICO)); + + SetSize(wxSize(FromDIP(700), FromDIP(500))); + SetMinSize(wxSize(FromDIP(700), FromDIP(500))); + SetMaxSize(wxSize(FromDIP(700), FromDIP(500))); + + m_main_simplebook = new wxSimplebook(this, wxID_ANY, wxDefaultPosition, wxDefaultSize); + + // Create main page + m_original_page = new wxPanel(m_main_simplebook, wxID_ANY); + m_original_page->SetBackgroundColour(*wxWHITE); + wxBoxSizer* main_sizer = create_main_page_sizer(m_original_page); + m_original_page->SetSizer(main_sizer); + + m_main_simplebook->AddPage(m_original_page, "Main Page"); + + m_guide_page = new wxPanel(m_main_simplebook, wxID_ANY); + m_guide_page->SetBackgroundColour(*wxWHITE); + wxBoxSizer* guide_sizer = create_guide_page_sizer(m_guide_page); + m_guide_page->SetSizer(guide_sizer); + m_main_simplebook->AddPage(m_guide_page, "Guide Page"); + + // Create progress page + m_progress_page = new wxPanel(m_main_simplebook, wxID_ANY); + m_progress_page->SetBackgroundColour(*wxWHITE); + wxBoxSizer* progress_sizer = create_progress_page_sizer(m_progress_page); + m_progress_page->SetSizer(progress_sizer); + m_main_simplebook->AddPage(m_progress_page, "Progress Page"); + + m_progress_timer = new wxTimer(this, wxID_ANY); + Bind(wxEVT_TIMER, &AMSDryCtrWin::OnProgressTimer, this, m_progress_timer->GetId()); + + Bind(wxEVT_CLOSE_WINDOW, &AMSDryCtrWin::OnClose, this); + + wxBoxSizer* top_level_sizer = new wxBoxSizer(wxVERTICAL); + top_level_sizer->Add(m_main_simplebook, 1, wxEXPAND | wxALL, FromDIP(10)); + + Bind(wxEVT_SHOW, &AMSDryCtrWin::OnShow, this); + + SetSizer(top_level_sizer); + Layout(); + Refresh(); + wxGetApp().UpdateDlgDarkUI(this); +} + +void AMSDryCtrWin::on_dpi_changed(const wxRect &suggested_rect) +{ + msw_rescale(); +} + +void AMSDryCtrWin::msw_rescale() +{ + if (!IsShown()) { + return; + } + + if (m_ams_filament_panel) {m_ams_filament_panel->msw_rescale();} + if (m_trays_combo) {m_trays_combo->Rescale(); m_trays_combo->Layout(); m_trays_combo->SetFont(Label::Body_14), m_trays_combo->Refresh();} + if (m_humidity_img && m_humidity_image.bmp().IsOk()) {m_humidity_image.msw_rescale(); m_humidity_img->SetBitmap(m_humidity_image.bmp());} + if (m_image_placeholder && m_guide_image.bmp().IsOk()) {m_guide_image.msw_rescale();m_image_placeholder->SetBitmap(m_guide_image.bmp());} + if (m_image_description_icon && m_description_icon_bitmap.bmp().IsOk()) {m_description_icon_bitmap.msw_rescale(); m_image_description_icon->SetBitmap(m_description_icon_bitmap.bmp());} + + // Rescale and update button sizes based on text content + if (m_next_button) { + m_next_button->Rescale(); + update_button_size(m_next_button); + m_next_button->Layout(); + m_next_button->Refresh(); + } + if (m_stop_button) { + m_stop_button->Rescale(); + update_button_size(m_stop_button); + m_stop_button->Layout(); + m_stop_button->Refresh(); + } + if (m_start_button) { + m_start_button->Rescale(); + update_button_size(m_start_button); + m_start_button->Layout(); + m_start_button->Refresh(); + } + if (m_back_button) { + m_back_button->Rescale(); + update_button_size(m_back_button); + m_back_button->Layout(); + m_back_button->Refresh(); + } + if (m_unload_button) { + m_unload_button->Rescale(); + update_button_size(m_unload_button); + m_unload_button->Layout(); + m_unload_button->Refresh(); + } + + if (m_guide_page) {m_guide_page->Layout();} + if (m_original_page) {m_original_page->Layout();} + + Fit(); + Layout(); + Refresh(); +} + +void AMSDryCtrWin::set_ams_id(const std::string& ams_id) +{ + m_ams_info.m_ams_id = ams_id; + m_is_ams_changed = true; +} + +void AMSDryCtrWin::update_img_description(DevAms::DryStatus status, DevAms::DrySubStatus sub_status) +{ + if (status == DevAms::DryStatus::Off || status == DevAms::DryStatus::Cooling) { + m_image_description->SetLabel(_L("Idle")); + m_image_description_icon->Show(false); + return; + } + + // Determine label text for non-idle states + wxString label_text; + if (status == DevAms::DryStatus::Error) { + label_text = _L("Drying"); + } else if (sub_status == DevAms::DrySubStatus::Heating) { + label_text = _L("Drying-Heating"); + } else if (sub_status == DevAms::DrySubStatus::Dehumidify) { + label_text = _L("Drying-Dehumidifying"); + } else { + m_image_description_icon->Show(false); + return; + } + + m_image_description->SetLabel(label_text); + + try { + m_description_icon_bitmap = ScalableBitmap(this, "dev_ams_dry_ctr_heating_icon", 20); + m_description_icon_bitmap.msw_rescale(); + m_image_description_icon->SetBitmap(m_description_icon_bitmap.bmp()); + m_image_description_icon->Show(true); + } catch (Exception&) { + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " Error loading drying icon"; + m_image_description_icon->Show(false); + } +} + +int AMSDryCtrWin::update_image(DevAmsType model, DevAms::DryStatus status, DevAms::DrySubStatus sub_status, int humidity_percent) +{ + if (model == m_ams_info.m_model && status == m_ams_info.m_dry_status + && sub_status == m_ams_info.m_dry_sub_status && humidity_percent == m_ams_info.m_humidity_percent) { + return 0; + } + + std::string img_path; + + if (status == DevAms::DryStatus::Off && sub_status == DevAms::DrySubStatus::Off) { + img_path = get_humidity_level_img_path(humidity_percent); + } else { + img_path = get_dry_status_img_path(model, status, sub_status); + } + + if (img_path.empty()) { + return 0; + } + + m_humidity_image = ScalableBitmap(this, img_path, AMS_DRY_STATUS_IMAGE_SIZE); + m_humidity_img->SetBitmap(m_humidity_image.bmp()); + + update_img_description(status, sub_status); + + return 1; +} + +bool AMSDryCtrWin::check_values_changed(DevAms* dev_ams) +{ + bool changed = false; + + if (m_ams_info.m_model != dev_ams->GetAmsType()) { + m_ams_info.m_model = dev_ams->GetAmsType(); + changed = true; + } + + if (dev_ams->GetDryStatus().has_value() && m_ams_info.m_dry_status != dev_ams->GetDryStatus()) { + m_ams_info.m_dry_status = dev_ams->GetDryStatus().value(); + changed = true; + } + + if (dev_ams->GetDrySubStatus().has_value() && m_ams_info.m_dry_sub_status != dev_ams->GetDrySubStatus()) { + m_ams_info.m_dry_sub_status = dev_ams->GetDrySubStatus().value(); + changed = true; + } + + if (m_ams_info.m_humidity_percent != dev_ams->GetHumidityPercent()) { + m_ams_info.m_humidity_percent = dev_ams->GetHumidityPercent(); + changed = true; + } + + return changed; +} + +void AMSDryCtrWin::update_normal_description(DevAms* dev_ams) +{ + if (m_tray_ids.empty() || m_trays_combo->GetSelection() >= m_tray_ids.size()) { return; } + + FilamentBaseInfo info = m_tray_ids[m_trays_combo->GetSelection()]; + wxString warning_text; + bool can_enable_button = true; + long temp_val = 0, time_val = 0; + m_temperature_input->GetValue().ToLong(&temp_val); + m_time_input->GetValue().ToLong(&time_val); + std::optional preset = DevUtilBackend::GetFilamentDryingPreset(info.filament_id); + auto total_dry = preset.has_value() ? preset.value().ams_limitations : std::unordered_set(); + + struct AmsTempLimit { DevAmsType type; int min_temp; int max_temp; const char* name; }; + static const AmsTempLimit ams_limits[] = { + { DevAmsType::N3F, 45, 65, "AMS2" }, + { DevAmsType::N3S, 45, 85, "AMS-S" } + }; + + for (const auto& lim : ams_limits) { + if (dev_ams->GetAmsType() == lim.type) { + if (temp_val > lim.max_temp) { + wxString msg = wxString(lim.name) + _L(" maximum drying temperature is ") + wxString::Format(wxT("%d"), lim.max_temp) + wxString::FromUTF8("°C."); + warning_text += msg + "\n"; + can_enable_button = false; + } else if (temp_val < lim.min_temp) { + wxString msg = wxString(lim.name) + _L(" minimum drying temperature is ") + wxString::Format(wxT("%d"), lim.min_temp) + wxString::FromUTF8("°C."); + warning_text += msg + "\n"; + can_enable_button = false; + } + if (total_dry.find(lim.type) == total_dry.end()) { + warning_text += _L("This filament may not be completely dried.") + "\n\n"; + } + break; + } + } + + if (m_printer_status.m_is_printing && temp_val > m_ams_info.m_recommand_dry_temp) { + warning_text += _L("This AMS is currently printing. To ensure print quality, the drying temperature cannot exceed the recommended drying temperature.") + "\n"; + can_enable_button = false; + } else if (preset.has_value()) { + // Only check heat distortion temperature when AMS has filament inserted; + // empty AMS only needs to respect the device hardware temperature limit. + bool has_filament_inserted = false; + for (const auto& tray_pair : dev_ams->GetTrays()) { + if (tray_pair.second && tray_pair.second->is_exists) { + has_filament_inserted = true; + break; + } + } + if (has_filament_inserted) { + auto limit_temperature = preset.value().filament_dev_ams_drying_heat_distortion_temperature; + if (temp_val > limit_temperature) { + warning_text += _L("The temperature shall not exceed the filament's heat distortion temperature") + "(" + + wxString::Format(wxT("%d"), static_cast(limit_temperature)) + wxString::FromUTF8("°C)\n"); + can_enable_button = false; + } + } + } + + if (time_val < 1) { + warning_text += _L("Minimum time value cannot be less than 1.") + "\n"; + can_enable_button = false; + } else if (time_val > 24) { + warning_text += _L("Maximum time value cannot be greater than 24.") + "\n"; + can_enable_button = false; + } + + m_next_button->Enable(can_enable_button); + m_normal_description->SetLabel(warning_text); + m_normal_description->Wrap(FromDIP(250)); + m_normal_description->GetParent()->Layout(); +} + +void AMSDryCtrWin::update_normal_state(DevAms* dev_ams) +{ + if (is_dry_ctr_idle(dev_ams)) { + m_next_button->Show(true); + m_normal_description->Show(true); + m_trays_combo->Enable(); + m_temperature_input->SetEditable(true); + m_time_input->SetEditable(true); + + m_stop_button->Hide(); + m_dry_error_sizer->Show(false); + } else if (is_dry_ctr_err(dev_ams)) { + m_dry_error_sizer->Show(true); + m_next_button->Hide(); + m_normal_description->Hide(); + + m_trays_combo->Hide(); + m_temperature_input->Hide(); + m_time_input->Hide(); + m_stop_button->Show(true); + } else { + m_next_button->Hide(); + m_normal_description->Hide(); + + m_trays_combo->Disable(); + m_temperature_input->SetEditable(false); + m_time_input->SetEditable(false); + m_stop_button->Show(true); + m_dry_error_sizer->Show(false); + } + + if (m_temperature_input->GetValue().IsEmpty() || m_time_input->GetValue().IsEmpty()) { + m_next_button->Disable(); + m_start_button->Disable(); + } else { + m_next_button->Enable(); + } + + update_normal_description(dev_ams); +} + +void AMSDryCtrWin::OnFilamentSelectionChanged(wxCommandEvent& event) +{ + int selectionIndex = event.GetSelection(); + wxString selectedFilament = event.GetString(); + + auto fila_system = get_fila_system(); + if (!fila_system) { + BOOST_LOG_TRIVIAL(info) << "AMSDryCtrWin::OnFilamentSelectionChanged: Invalid FilaSystem Pointer"; + return; + } + + DevAms* dev_ams = fila_system->GetAmsById(m_ams_info.m_ams_id); + if (!dev_ams) { + BOOST_LOG_TRIVIAL(info) << "AMSDryCtrWin::OnFilamentSelectionChanged: Invalid AMS id"; + return; + } + + std::optional preset = DevUtilBackend::GetFilamentDryingPreset(m_tray_ids[selectionIndex].filament_id); + if (preset.has_value()) { + DevFilamentDryingPreset info = preset.value(); + if (m_printer_status.m_is_printing) { + m_temperature_input->SetValue(std::to_string(static_cast(info.filament_dev_ams_drying_temperature_on_print[dev_ams->GetAmsType()]))); + m_time_input->SetValue(std::to_string(static_cast(info.filament_dev_ams_drying_time_on_print[dev_ams->GetAmsType()]))); + } else { + m_temperature_input->SetValue(std::to_string(static_cast(info.filament_dev_ams_drying_temperature_on_idle[dev_ams->GetAmsType()]))); + m_time_input->SetValue(std::to_string(static_cast(info.filament_dev_ams_drying_time_on_idle[dev_ams->GetAmsType()]))); + } + } + + update_filament_guide_info(dev_ams); +} + +wxString get_cannot_reason_text(DevAms::CannotDryReason reason) +{ + wxString cannot_reason_text; + switch (reason) + { + case DevAms::CannotDryReason::InsufficientPower: + cannot_reason_text = "*" + _L("Insufficient power") + "\n"; + cannot_reason_text += _L(" Too many AMS drying simultaneously. Please plug in the power or stop other drying processes before starting.") + "\n"; + break; + case DevAms::CannotDryReason::AmsBusy: + cannot_reason_text = "*" + _L("AMS is busy") + "\n"; + cannot_reason_text += _L(" AMS is calibrating | reading RFID | loading/unloading material, please wait.") + "\n"; + break; + case DevAms::CannotDryReason::ConsumableAtAmsOutlet: + cannot_reason_text = "*" + _L("Filament in AMS outlet") + "\n"; + cannot_reason_text += _L(" The high drying temperature may cause AMS blockage, please unload first."); + break; + case DevAms::CannotDryReason::InitiatingAmsDrying: + cannot_reason_text = "*" + _L("Initiating AMS drying") + "\n"; + break; + case DevAms::CannotDryReason::NotSupportedIn2dMode: + cannot_reason_text = "*" + _L("Not supported in 2D mode") + "\n"; + break; + case DevAms::CannotDryReason::DryingInProgress: + cannot_reason_text = "*" + _L("Task in progress") + "\n"; + cannot_reason_text += _L(" The AMS might be in use during Task.") + "\n"; + break; + case DevAms::CannotDryReason::Upgrading: + cannot_reason_text = "*" + _L("Upgrading") + "\n"; + cannot_reason_text += _L(" Firmware update in progress, please wait...") + "\n"; + break; + case DevAms::CannotDryReason::InsufficientPowerNeedPluginPower: + cannot_reason_text = "*" + _L("Insufficient power") + "\n"; + cannot_reason_text += _L(" Please plug in the power and then use the drying function.") + "\n"; + break; + case DevAms::CannotDryReason::FilamentAtAmsOutletManualUnload: + cannot_reason_text = "*" + _L("Filament in AMS outlet") + "\n"; + cannot_reason_text += _L(" The high drying temperature may cause AMS blockage. Please unload the filament manually before proceeding.") + "\n"; + break; + default: + cannot_reason_text = "*" + _L("System is busy") + "\n"; + cannot_reason_text += _L(" Initiating other drying processes, please wait a few seconds...") + "\n"; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": unknown cannot dry reason"; + break; + } + return cannot_reason_text; +} + +wxString organize_cannot_reasons_text(std::vector& reasons) +{ + wxString cannot_reasons_text; + if (std::find(reasons.begin(), reasons.end(), DevAms::CannotDryReason::DryingInProgress) != reasons.end()) { + cannot_reasons_text += get_cannot_reason_text(DevAms::CannotDryReason::DryingInProgress); + } else if (std::find(reasons.begin(), reasons.end(), DevAms::CannotDryReason::AmsBusy) != reasons.end()) { + cannot_reasons_text += get_cannot_reason_text(DevAms::CannotDryReason::AmsBusy); + } else if (std::find(reasons.begin(), reasons.end(), DevAms::CannotDryReason::ConsumableAtAmsOutlet) != reasons.end()) { + cannot_reasons_text += get_cannot_reason_text(DevAms::CannotDryReason::ConsumableAtAmsOutlet); + } else if (std::find(reasons.begin(), reasons.end(), DevAms::CannotDryReason::FilamentAtAmsOutletManualUnload) != reasons.end()) { + cannot_reasons_text += get_cannot_reason_text(DevAms::CannotDryReason::FilamentAtAmsOutletManualUnload); + } + + if (std::find(reasons.begin(), reasons.end(), DevAms::CannotDryReason::InsufficientPower) != reasons.end()) { + cannot_reasons_text += get_cannot_reason_text(DevAms::CannotDryReason::InsufficientPower); + } + + for (auto reason : reasons) { + if (reason == DevAms::CannotDryReason::InsufficientPowerNeedPluginPower) { + cannot_reasons_text += get_cannot_reason_text(DevAms::CannotDryReason::InsufficientPowerNeedPluginPower); + } else if (reason == DevAms::CannotDryReason::NotSupportedIn2dMode) { + cannot_reasons_text += get_cannot_reason_text(DevAms::CannotDryReason::NotSupportedIn2dMode); + } else if (reason == DevAms::CannotDryReason::InitiatingAmsDrying) { + cannot_reasons_text += get_cannot_reason_text(DevAms::CannotDryReason::InitiatingAmsDrying); + } else if (reason == DevAms::CannotDryReason::Upgrading) { + cannot_reasons_text += get_cannot_reason_text(DevAms::CannotDryReason::Upgrading); + } + } + + if (cannot_reasons_text.empty()) { + cannot_reasons_text += "*" + _L("System is busy") + "\n"; + cannot_reasons_text += _L(" Initiating other drying processes, please wait a few seconds...") + "\n"; + } + + return cannot_reasons_text; +} + +int AMSDryCtrWin::update_state(DevAms* dev_ams) +{ + std::vector cannot_reasons = dev_ams->GetCannotDryReason().has_value()? + dev_ams->GetCannotDryReason().value(): std::vector(); + + if (cannot_reasons.size() == 0 || + (cannot_reasons.size() == 1 && cannot_reasons[0] == DevAms::CannotDryReason::DryingInProgress)) { + m_normal_state_sizer->Show(true); + m_cannot_dry_sizer->Show(false); + m_unload_button->Show(false); + update_normal_state(dev_ams); + } else if (cannot_reasons.size() > 0) { + m_normal_state_sizer->Show(false); + m_cannot_dry_sizer->Show(true); + + m_cannot_dry_description_label->SetLabel(organize_cannot_reasons_text(cannot_reasons)); + m_cannot_dry_description_label->Wrap(FromDIP(300)); + + // Show unload button when ConsumableAtAmsOutlet reason is present + bool has_consumable_at_outlet = std::find(cannot_reasons.begin(), cannot_reasons.end(), + DevAms::CannotDryReason::ConsumableAtAmsOutlet) != cannot_reasons.end(); + m_unload_button->Show(has_consumable_at_outlet); + } + + restore_stop_button_if_deadline_passed(); + restore_unload_button_if_deadline_passed(); + + return 1; +} + +int AMSDryCtrWin::update_ams_change(DevAms* dev_ams) +{ + if (!is_ams_changed(dev_ams)) { + return 0; + } + + m_ams_info.m_ams_id = dev_ams->GetAmsId(); + if (dev_ams->GetAmsType() == DevAmsType::N3F) { + m_temperature_input->SetHint("45-65" + wxString::FromUTF8("°C")); + } else if (dev_ams->GetAmsType() == DevAmsType::N3S) { + m_temperature_input->SetHint("45-85" + wxString::FromUTF8("°C")); + } + + m_time_input->SetHint("1-24 h"); + + return 0; +} + +int AMSDryCtrWin::update_dryness_status(DevAms* dev_ams) +{ + int updated = 0; + + if (m_ams_info.m_humidity_percent != dev_ams->GetHumidityPercent()) { + updated += 1; + m_ams_info.m_humidity_percent = dev_ams->GetHumidityPercent(); + m_humidity_data_label->SetLabel(std::to_string(m_ams_info.m_humidity_percent) + "%"); + } + + if (m_ams_info.m_temperature != dev_ams->GetCurrentTemperature()) { + updated += 1; + m_ams_info.m_temperature = dev_ams->GetCurrentTemperature(); + m_temperature_data_label->SetLabel(std::to_string(m_ams_info.m_temperature) + wxString::FromUTF8("°C")); + } + + if (is_dry_ctr_idle(dev_ams)) { + m_time_descrition_container->Show(false); + m_original_page->Layout(); + } else { + if (is_dry_status_changed(dev_ams)) { + m_time_descrition_container->Show(true); + m_original_page->Layout(); + } + + if (m_ams_info.m_left_dry_time != dev_ams->GetLeftDryTime()) { + updated += 1; + m_ams_info.m_left_dry_time = dev_ams->GetLeftDryTime(); + m_time_data_label->SetLabel(wxString::Format("%02d : %02d", + m_ams_info.m_left_dry_time / 60, m_ams_info.m_left_dry_time % 60)); + } + } + + return updated; +} + +bool AMSDryCtrWin::is_tray_changed(DevAms* dev_ams) +{ + return false; +} + +void AMSDryCtrWin::update_filament_guide_info(DevAms* dev_ams) +{ + if (!IsShown()) { + return; + } + + m_guide_page->Freeze(); + m_ams_filament_panel->Clear(); + m_ams_filament_panel->SetAmsName(dev_ams->GetDisplayName()); + + // Get the temperature input value + long input_temp = 0; + bool valid_temp = !m_temperature_input->GetValue().IsEmpty() && + m_temperature_input->GetValue().ToLong(&input_temp); + bool can_start = true; + + int slot_count = 0, empty_count = 0; + for (auto& tray_pair : dev_ams->GetTrays()) { + if (!tray_pair.second) { + continue; + } + ++slot_count; + if (!tray_pair.second->is_exists) { + m_ams_filament_panel->AddFilamentItem("/", "dev_ams_dry_ctr_enable"); + ++empty_count; + continue; + } + auto preset_opt = tray_pair.second->get_ams_drying_preset(); + wxString filament_type = tray_pair.second->get_display_filament_type(); + DevFilamentDryingPreset preset; + if (filament_type.IsEmpty()) { + auto fallback_preset = DevUtilBackend::GetFilamentDryingPreset("GFA00"); + preset = fallback_preset.value(); + filament_type = "?"; + } else if (preset_opt.has_value()) { + preset = preset_opt.value(); + } else { + auto fallback_preset = DevUtilBackend::GetFilamentDryingPreset("GFA00"); + preset = fallback_preset.value(); + } + std::string icon_path = "dev_ams_dry_ctr_enable"; + int distortion_temp = static_cast(preset.filament_dev_ams_drying_heat_distortion_temperature); + if (valid_temp && distortion_temp < input_temp) { + icon_path = "dev_ams_dry_ctr_disable"; + can_start = false; + } + m_ams_filament_panel->AddFilamentItem(filament_type, icon_path); + } + if (slot_count == 0) { + can_start = false; + } + + if (can_start) { + m_guide_title_label->SetLabel(_L("For better drying results, remove the filament and allow it to rotate.")); + m_guide_description_label->SetLabel(_L("The AMS will automatically rotate the stored filament slots to enhance the drying performance.") + + "\n" + _L("Alternatively, you can dry the filament without removing it.") + + "\n" + "*" + _L("Unknown filaments will be treated as PLA.")); + m_guide_description_label->Wrap(FromDIP(300)); + } else { + m_guide_title_label->SetLabel(_L("Please store the filament marked with an exclamation mark.")); + m_guide_description_label->SetLabel(_L("Filament left in the feeder during drying may soften because the drying temperature exceeds the softening point of materials like PLA and TPU.") + + "\n" + "*" + _L("Unknown filaments will be treated as PLA.")); + m_guide_description_label->Wrap(FromDIP(300)); + } + + m_start_button->Enable(can_start); + m_guide_page->Layout(); + m_guide_page->Thaw(); +} + +int AMSDryCtrWin::update_filament_list(DevAms* dev_ams, MachineObject* obj) +{ + const bool ams_changed = is_ams_changed(dev_ams); + bool rebuilt_filament_list = false; + + // Build `m_tray_ids` and the combobox items from the preset bundle. + // This list is also used as a lookup table in non-idle state (type -> name), + // so it must be available even when the combobox is disabled. + auto rebuild_filament_list = [&]() -> bool { + m_trays_combo->Clear(); + m_tray_ids.clear(); + + PresetBundle *preset_bundle = wxGetApp().preset_bundle; + if (!preset_bundle || !obj) { + return false; + } + + std::set filament_id_set; + auto & filaments = preset_bundle->filaments; + + // Get nozzle diameter using the same method as AMSMaterialsSetting::Popup + std::ostringstream stream; + int extruder_id = obj->GetFilaSystem()->GetExtruderIdByAmsId(m_ams_info.m_ams_id); + if (!obj->GetExtderSystem()->GetExtderById(extruder_id)) { + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " get extruder id failed"; + extruder_id = 0; + } + stream << std::fixed << std::setprecision(1) << obj->GetExtderSystem()->GetNozzleDiameter(extruder_id); + std::string nozzle_diameter_str = stream.str(); + std::set printer_names = preset_bundle->get_printer_names_by_printer_type_and_nozzle( + DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str); + + for (auto filament_it = filaments.begin(); filament_it != filaments.end(); ++filament_it) { + Preset& preset = *filament_it; + // Filter by system preset: root preset and (system preset or user preset is supported) + if (filaments.get_preset_base(*filament_it) != &preset || (!filament_it->is_system && !obj->is_support_user_preset)) { + continue; + } + + ConfigOption * printer_opt = filament_it->config.option("compatible_printers"); + ConfigOptionStrings *printer_strs = dynamic_cast(printer_opt); + if (!printer_strs) continue; + + for (auto printer_str : printer_strs->values) { + if (printer_names.find(printer_str) != printer_names.end()) { + if (filament_id_set.find(filament_it->filament_id) != filament_id_set.end()) { + continue; + } + + filament_id_set.insert(filament_it->filament_id); + auto filament_alias = filament_it->alias; + if (!filament_alias.empty()) { + auto opt_info = preset_bundle->get_filament_by_filament_id(filament_it->filament_id); + if (opt_info.has_value()) { + auto real_info = opt_info.value(); + real_info.filament_name = filament_alias; + m_tray_ids.push_back(std::move(real_info)); + m_trays_combo->Append(wxString::FromUTF8(filament_alias)); + } + } + } + } + } + + if (m_tray_ids.empty()) { + BOOST_LOG_TRIVIAL(warning) << "AMSDryCtrWin::update_filament_list: No valid filaments found"; + return false; + } + + return true; + }; + + if (ams_changed || m_tray_ids.empty() || m_trays_combo->GetCount() == 0) { + // Disable start button during rebuild + if (m_start_button) { + m_start_button->Disable(); + } + + rebuilt_filament_list = rebuild_filament_list(); + } + + if (!is_dry_ctr_idle(dev_ams)) { + const std::string& ams_id = dev_ams->GetAmsId(); + const auto settings_opt = dev_ams->GetDrySettings(); + + // Set the combobox display label by the filament "type" string. + // Priority: last saved selection for this AMS (if the type matches) -> fallback to current `m_tray_ids`. + auto set_filament_label_by_type = [&](const std::string& filament_type) { + if (filament_type.empty()) { + return; + } + + auto it_name = m_dry_setting.m_filament_names.find(ams_id); + auto it_type = m_dry_setting.m_filament_type.find(ams_id); + if (it_name != m_dry_setting.m_filament_names.end() && it_type != m_dry_setting.m_filament_type.end() + && it_type->second == filament_type) { + m_trays_combo->SetLabel(wxString::FromUTF8(it_name->second)); + return; + } + + const auto it = std::find_if(m_tray_ids.begin(), m_tray_ids.end(), [&](const FilamentBaseInfo& tray) { + return tray.filament_type == filament_type; + }); + if (it != m_tray_ids.end()) { + m_trays_combo->SetLabel(wxString::FromUTF8(it->filament_name)); + } + }; + + if (settings_opt.has_value()) { + const auto& settings = settings_opt.value(); + m_temperature_input->SetValue(std::to_string(settings.dry_temp)); + m_time_input->SetValue(std::to_string(settings.dry_hour)); + set_filament_label_by_type(settings.dry_filament); + } else { + auto it_name = m_dry_setting.m_filament_names.find(ams_id); + if (it_name != m_dry_setting.m_filament_names.end()) { + m_trays_combo->SetLabel(wxString::FromUTF8(it_name->second)); + m_temperature_input->SetValue(std::to_string(m_dry_setting.m_dry_temp[ams_id])); + m_time_input->SetValue(std::to_string(m_dry_setting.m_dry_time[ams_id])); + } + } + + return 0; + } + + // Idle state: avoid re-selecting / firing selection change unless the list was rebuilt. + if (!rebuilt_filament_list && !ams_changed && is_dry_ctr_idle()) { + return 0; + } + + if (m_tray_ids.empty()) { + return 0; + } + + // Select recommended drying temperature and default filament + float min_dry_temp = std::numeric_limits::max(); + std::string default_filament_id = "GFA00"; + bool has_ready = false; + const auto fallback_preset = DevUtilBackend::GetFilamentDryingPreset("GFA00"); + for (const auto& tray_pair : dev_ams->GetTrays()) { + if (!tray_pair.second || !tray_pair.second->is_tray_info_ready()) continue; + has_ready = true; + wxString filament_type = tray_pair.second->get_display_filament_type(); + Slic3r::DevFilamentDryingPreset preset; + if (filament_type.IsEmpty()) { + // no filament type, is PLA + if (!fallback_preset) continue; + preset = fallback_preset.value(); + } else { + auto preset_opt = tray_pair.second->get_ams_drying_preset(); + if (preset_opt.has_value()) { + preset = preset_opt.value(); + } else { + // no preset,is PLA + if (!fallback_preset) continue; + preset = fallback_preset.value(); + } + } + float cur_temp = m_printer_status.m_is_printing + ? std::min({preset.filament_dev_ams_drying_temperature_on_print[dev_ams->GetAmsType()], + preset.filament_dev_drying_softening_temperature, preset.filament_dev_ams_drying_heat_distortion_temperature}) + : preset.filament_dev_ams_drying_temperature_on_idle[dev_ams->GetAmsType()]; + if (cur_temp < min_dry_temp) { + min_dry_temp = cur_temp; + default_filament_id = preset.filament_id; + } + } + // if no tray is ready, use PLA as default + if (!has_ready) { + if (fallback_preset.has_value()) { + Slic3r::DevFilamentDryingPreset preset = fallback_preset.value(); + float cur_temp = m_printer_status.m_is_printing + ? std::min({preset.filament_dev_ams_drying_temperature_on_print[dev_ams->GetAmsType()], + preset.filament_dev_drying_softening_temperature, preset.filament_dev_ams_drying_heat_distortion_temperature}) + : preset.filament_dev_ams_drying_temperature_on_idle[dev_ams->GetAmsType()]; + min_dry_temp = cur_temp; + default_filament_id = preset.filament_id; + } + } + m_ams_info.m_recommand_dry_temp = min_dry_temp; + + // Set default selection + unsigned int default_index = 0; + for (unsigned int i = 0; i < m_tray_ids.size(); i++) { + if (m_tray_ids[i].filament_id == default_filament_id) { + default_index = i; + break; + } + } + m_trays_combo->SetSelection(default_index); + wxCommandEvent evt(wxEVT_COMBOBOX, m_trays_combo->GetId()); + evt.SetInt(default_index); + OnFilamentSelectionChanged(evt); + return 1; +} + +std::shared_ptr AMSDryCtrWin::get_fila_system() const +{ + std::shared_ptr fila_system = m_fila_system.lock(); + if (!fila_system) { + const_cast(this)->Close(); + } + return fila_system; +} + +void AMSDryCtrWin::update_printer_state(MachineObject* obj) +{ + m_printer_status.m_is_printing = obj->is_in_printing() + && obj->GetExtderSystem()->GetCurrentAmsId() == m_ams_info.m_ams_id; +} + +void AMSDryCtrWin::update(std::shared_ptr fila_system, MachineObject* obj) +{ + if (!fila_system || !obj) { + return; + } + + update_printer_state(obj); + m_fila_system = fila_system; + + DevAms* dev_ams = fila_system->GetAmsById(m_ams_info.m_ams_id); + if (!dev_ams) { + BOOST_LOG_TRIVIAL(info) << "AMSDryCtrWin::update: Invalid AMS id"; + m_ams_info.m_ams_id = ""; + Close(); + return; + } + + if (!dev_ams->IsSupportRemoteDry(fila_system->GetOwner())) { + BOOST_LOG_TRIVIAL(info) << "AMSDryCtrWin::update: Selected AMS does not support remote drying"; + Close(); + return; + } + + + update_ams_change(dev_ams); + + update_image(dev_ams->GetAmsType(), + dev_ams->GetDryStatus().has_value()? dev_ams->GetDryStatus().value(): DevAms::DryStatus::Off, + dev_ams->GetDrySubStatus().has_value()? dev_ams->GetDrySubStatus().value(): DevAms::DrySubStatus::Off, + dev_ams->GetHumidityPercent()); + + update_state(dev_ams); + update_dryness_status(dev_ams); + + update_filament_list(dev_ams, obj); + update_filament_guide_info(dev_ams); + + m_is_ams_changed = false; + + check_values_changed(dev_ams); + + Layout(); + Refresh(); +} + +bool AMSDryCtrWin::is_ams_changed(DevAms* dev_ams) +{ + return m_ams_info.m_ams_id != dev_ams->GetAmsId() || m_is_ams_changed; +} + +bool AMSDryCtrWin::is_dry_status_changed(DevAms* dev_ams) +{ + if (!dev_ams->GetDryStatus().has_value() || !dev_ams->GetDrySubStatus().has_value()) { + return true; + } + + return dev_ams->GetDryStatus().value() != m_ams_info.m_dry_status + || dev_ams->GetDrySubStatus().value() != m_ams_info.m_dry_sub_status; +} + +bool AMSDryCtrWin::is_dry_ctr_idle(DevAms* dev_ams) +{ + if (!dev_ams->GetDryStatus().has_value() || !dev_ams->GetDrySubStatus().has_value()) { + return true; + } + + return dev_ams->GetDryStatus().value() == DevAms::DryStatus::Off + || dev_ams->GetDryStatus().value() == DevAms::DryStatus::Cooling; +} + +bool AMSDryCtrWin::is_dry_ctr_idle() +{ + return m_ams_info.m_dry_status == DevAms::DryStatus::Off || m_ams_info.m_dry_status == DevAms::DryStatus::Cooling; +} + +bool AMSDryCtrWin::is_dry_ctr_err(DevAms* dev_ams) +{ + if (!dev_ams->GetDryStatus().has_value() || !dev_ams->GetDrySubStatus().has_value()) { + return false; + } + + return dev_ams->GetDryStatus().value() == DevAms::DryStatus::Error; +} + +} // GUI +} // Slic3r diff --git a/src/slic3r/GUI/AMSDryControl.hpp b/src/slic3r/GUI/AMSDryControl.hpp new file mode 100644 index 0000000000..43e177eeb1 --- /dev/null +++ b/src/slic3r/GUI/AMSDryControl.hpp @@ -0,0 +1,253 @@ +#pragma once +#include "GUI_ObjectLayers.hpp" +#include "slic3r/GUI/Widgets/AMSItem.hpp" +#include "slic3r/GUI/Widgets/Label.hpp" +#include "slic3r/GUI/Widgets/PopupWindow.hpp" + +#include "slic3r/GUI/wxExtensions.hpp" +#include "slic3r/GUI/DeviceCore/DevFilaSystem.h" +#include "slic3r/GUI/I18N.hpp" + +#include +#include + + +//Previous defintions +class wxGrid; + +namespace Slic3r { + +namespace GUI { + + +enum class DryCtrState { + IDLE, + DRY_WHEN_PRINT, + UNKNOWN +}; + +enum class DryCtrDev { + N3S, + N3F, + UNKNOWN +}; + +struct DryingPreset { + DryCtrState state; + DryCtrDev dev; + int dry_temp; + int dry_time; +}; + +class FilamentItemPanel : public wxPanel +{ +public: + FilamentItemPanel(wxWindow* parent, const wxString& text, const std::string& icon_name = "", + wxWindowID id = wxID_ANY); + + void SetText(const wxString& text); + void SetIcon(const std::string& icon_name); + void msw_rescale(); + +private: + void OnPaint(wxPaintEvent& event); + void OnSize(wxSizeEvent& event); + + Label* m_text_label; + wxStaticBitmap* m_icon_bitmap; + int m_target_size; + std::string m_icon_name; + ScalableBitmap m_icon; +}; + +class AMSFilamentPanel : public wxPanel +{ + wxBoxSizer* m_filament_sizer; + Label* m_ams_name_label; + int m_border_radius; + wxPanel* m_filament_container{nullptr}; + std::vector m_filament_items; + +public: + AMSFilamentPanel(wxWindow* parent, const wxString& ams_name, wxWindowID id = wxID_ANY); + + void AddFilamentItem(const wxString& text, const std::string& icon_name); + void AddFilamentItem(FilamentItemPanel* panel); + void SetAmsName(const wxString& ams_name); + void Clear(); + void msw_rescale(); +private: + void OnPaint(wxPaintEvent& event); +}; + + +class AMSDryCtrWin : public DPIDialog +{ +public: + AMSDryCtrWin(wxWindow *parent); + ~AMSDryCtrWin(); + + void msw_rescale(); + void update(std::shared_ptr fila_system, MachineObject* obj); + void set_ams_id(const std::string& ams_id); + +protected: + void on_dpi_changed(const wxRect &suggested_rect) override; + +private: + wxSimplebook* m_main_simplebook{nullptr}; + wxPanel* m_original_page{nullptr}; + + wxWindow* m_amswin{nullptr}; + wxBoxSizer* m_sizer_ams_items{nullptr}; + wxScrolledWindow* m_panel_prv_left {nullptr}; + wxScrolledWindow* m_panel_prv_right{nullptr}; + wxBoxSizer* m_sizer_prv_left{nullptr}; + wxBoxSizer* m_sizer_prv_right{nullptr}; + + // left panel related members + ScalableBitmap m_humidity_image; + wxStaticBitmap* m_humidity_img{nullptr}; + Label* m_image_description{nullptr}; + wxStaticBitmap* m_image_description_icon{nullptr}; + ScalableBitmap m_description_icon_bitmap; + + Label* m_humidity_data_label = nullptr; + Label* m_temperature_data_label = nullptr; + Label* m_time_data_label = nullptr; + wxBoxSizer* m_time_descrition_container = nullptr; + + // right panel related members + wxBoxSizer* m_normal_state_sizer{nullptr}; + wxBoxSizer* m_cannot_dry_sizer{nullptr}; + wxBoxSizer* m_dry_error_sizer{nullptr}; + Label* m_cannot_dry_description_label = nullptr; + + // right panel normal state + ComboBox* m_trays_combo; + std::vector m_tray_ids; + wxTextCtrl* m_temperature_input; + wxTextCtrl* m_time_input; + Label* m_normal_description; + Button* m_start_button{nullptr}; + Button* m_next_button{nullptr}; + Button* m_stop_button{nullptr}; + Button* m_back_button{nullptr}; + Button* m_unload_button{nullptr}; + + // guide page description + Label* m_guide_title_label{nullptr}; + Label* m_guide_description_label{nullptr}; + + wxCheckBox* m_rotate_spool_toggle{nullptr}; + + wxPanel* m_progress_page; + ProgressBar* m_progress_gauge; + wxTimer* m_progress_timer; + + std::optional m_stop_button_restore_deadline; + std::optional m_unload_button_restore_deadline; + + Label* m_progress_title; + int m_progress_value; + int m_progress_message_index; + std::vector m_progress_text = { + _L("Starting: Checking adapter connection"), + _L("Starting: Checking filament status"), + _L("Starting: Checking drying presets"), + _L("Starting: Checking filament location"), + _L("Starting: Checking air intake"), + _L("Starting: Checking air vent") + }; + + // Guide page + wxPanel* m_guide_page{nullptr}; + AMSFilamentPanel* m_ams_filament_panel{nullptr}; + std::map m_filament_items; + wxStaticBitmap* m_image_placeholder{nullptr}; + ScalableBitmap m_guide_image; + + + bool m_is_ams_changed = false; + std::weak_ptr m_fila_system; + struct { + std::string m_ams_id; + DevAmsType m_model = DevAmsType::EXT_SPOOL; + DevAms::DryStatus m_dry_status = DevAms::DryStatus::Off; + DevAms::DrySubStatus m_dry_sub_status = DevAms::DrySubStatus::Off; + int m_humidity_percent; + int m_temperature; + int m_left_dry_time; + float m_recommand_dry_temp; + } m_ams_info; + + struct { + std::unordered_map m_filament_names; + std::unordered_map m_filament_type; + std::unordered_map m_dry_temp; + std::unordered_map m_dry_time; + } m_dry_setting; + + struct { + bool m_is_printing = false; + } m_printer_status; + +private: + void create(); + wxBoxSizer* create_guide_page_sizer(wxPanel* parent); + wxBoxSizer* create_main_content_section(wxPanel* parent); + wxBoxSizer* create_guide_info_filament(wxPanel* parent); + wxBoxSizer* create_guide_info_section(wxPanel* parent); + wxBoxSizer* create_guide_right_section(wxPanel* parent); + wxBoxSizer* create_main_page_sizer(wxPanel* parent); + wxBoxSizer* create_left_panel(wxPanel* parent); + wxBoxSizer* create_humidity_status_section(wxPanel* parent); + wxBoxSizer* create_description_item(wxPanel* parent, const wxString& title, Label*& dataLabel); + wxBoxSizer* create_status_descriptions_section(wxPanel* parent); + + wxBoxSizer* create_right_panel(wxPanel* parent); + wxBoxSizer* create_normal_state_panel(wxPanel* parent); + wxBoxSizer* create_cannot_dry_panel(wxPanel* parent); + wxBoxSizer* create_drying_error_panel(wxPanel* parent); + Button* create_button(wxPanel* parent, const wxString& title, + const wxColour& background_color, const wxColour& border_color, const wxColour& text_color); + + wxBoxSizer* create_progress_page_sizer(wxPanel* parent); + void OnProgressTimer(wxTimerEvent& event); + void OnClose(wxCloseEvent& event); + void OnShow(wxShowEvent& event); + + void OnFilamentSelectionChanged(wxCommandEvent& event); + + + wxScrolledWindow* create_preview_scrolled_window(wxWindow* parent); + + bool check_values_changed(DevAms* dev_ams); + int update_image(DevAmsType type, DevAms::DryStatus status, DevAms::DrySubStatus sub_status, int humidity_percent); + void update_img_description(DevAms::DryStatus status, DevAms::DrySubStatus sub_status); + void update_normal_description(DevAms* dev_ams); + int update_state(DevAms* dev_ams); + int update_dryness_status(DevAms* dev_ams); + int update_ams_change(DevAms* dev_ams); + int update_filament_list(DevAms* dev_ams, MachineObject* obj); + void update_filament_guide_info(DevAms* dev_ams); + void update_normal_state(DevAms* dev_ams); + void update_printer_state(MachineObject* obj); + + std::shared_ptr get_fila_system() const; + void start_sending_drying_command(); + void restore_stop_button_if_deadline_passed(); + void restore_unload_button_if_deadline_passed(); + void update_button_size(Button* button); + + bool is_dry_status_changed(DevAms* dev_ams); + bool is_dry_ctr_idle(DevAms* dev_ams); + bool is_ams_changed(DevAms* dev_ams); + bool is_dry_ctr_idle(); + bool is_tray_changed(DevAms* dev_ams); + bool is_dry_ctr_err(DevAms* dev_ams); + +}; + +} // GUI +} // Slic3r From 08b08e09fc55ee3c4a8921707d88827691368d1e Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 14:23:05 +0800 Subject: [PATCH 14/47] fix: use OrcaSlicerTitle.ico instead of BambuStudioTitle.ico --- src/slic3r/GUI/AMSDryControl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slic3r/GUI/AMSDryControl.cpp b/src/slic3r/GUI/AMSDryControl.cpp index f88e46f65c..f8ced3ea39 100644 --- a/src/slic3r/GUI/AMSDryControl.cpp +++ b/src/slic3r/GUI/AMSDryControl.cpp @@ -977,7 +977,7 @@ void AMSDryCtrWin::create() // set title icon SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); this->SetDoubleBuffered(true); - std::string icon_path = (boost::format("%1%/images/BambuStudioTitle.ico") % resources_dir()).str(); + std::string icon_path = (boost::format("%1%/images/OrcaSlicerTitle.ico") % resources_dir()).str(); SetIcon(wxIcon(encode_path(icon_path.c_str()), wxBITMAP_TYPE_ICO)); SetSize(wxSize(FromDIP(700), FromDIP(500))); From b2827cb9fa21dff9904525d53ce4ed7926c1b5d1 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 14:36:52 +0800 Subject: [PATCH 15/47] feat: wire AMS humidity click to open AMSDryControl dialog For N3F and N3S AMS types, clicking the humidity indicator now opens the full drying control dialog instead of the popup. --- src/slic3r/GUI/Widgets/AMSControl.cpp | 36 +++++++++++++++++++++++++++ src/slic3r/GUI/Widgets/AMSControl.hpp | 2 ++ 2 files changed, 38 insertions(+) diff --git a/src/slic3r/GUI/Widgets/AMSControl.cpp b/src/slic3r/GUI/Widgets/AMSControl.cpp index a3913dc4e4..ea62d18d92 100644 --- a/src/slic3r/GUI/Widgets/AMSControl.cpp +++ b/src/slic3r/GUI/Widgets/AMSControl.cpp @@ -6,6 +6,7 @@ #include "slic3r/GUI/MsgDialog.hpp" #include "slic3r/GUI/DeviceTab/uiAmsHumidityPopup.h" +#include "slic3r/GUI/AMSDryControl.hpp" #include "slic3r/GUI/DeviceCore/DevManager.h" #include "slic3r/GUI/DeviceCore/DevFilaSystem.h" @@ -254,6 +255,24 @@ AMSControl::AMSControl(wxWindow *parent, wxWindowID id, const wxPoint &pos, cons if (humidity_value > 0 && humidity_value <= 5) { m_Humidity_tip_popup.set_humidity_level(humidity_value); } m_Humidity_tip_popup.Popup(); } + else if (info->ams_type == AMSModel::N3F_AMS || info->ams_type == AMSModel::N3S_AMS) + { + // Open full drying control dialog for N3F/N3S AMS + if (!m_dry_ctr_win) { + m_dry_ctr_win = new AMSDryCtrWin(this); + } + m_dry_ctr_win->set_ams_id(info->ams_id); + + Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); + if (dev) { + MachineObject *obj = dev->get_selected_machine(); + if (obj && obj->GetFilaSystem()) { + std::shared_ptr fila_ptr(obj->GetFilaSystem(), [](DevFilaSystem*){}); + m_dry_ctr_win->update(fila_ptr, obj); + } + } + m_dry_ctr_win->ShowModal(); + } else { m_percent_humidity_dry_popup->Update(info); @@ -493,6 +512,10 @@ void AMSControl::msw_rescale() m_percent_humidity_dry_popup->msw_rescale(); } + if (m_dry_ctr_win){ + m_dry_ctr_win->msw_rescale(); + } + m_Humidity_tip_popup.msw_rescale(); Layout(); @@ -970,6 +993,19 @@ void AMSControl::UpdateAms(const std::string &series_name, } } + /*update AMS dry control dialog*/ + if (m_dry_ctr_win && m_dry_ctr_win->IsShown()) + { + Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); + if (dev) { + MachineObject *obj = dev->get_selected_machine(); + if (obj && obj->GetFilaSystem()) { + std::shared_ptr fila_ptr(obj->GetFilaSystem(), [](DevFilaSystem*){}); + m_dry_ctr_win->update(fila_ptr, obj); + } + } + } + /*update ams extruder*/ if (m_extruder->updateNozzleNum(m_total_ext_count, series_name)) { diff --git a/src/slic3r/GUI/Widgets/AMSControl.hpp b/src/slic3r/GUI/Widgets/AMSControl.hpp index 31c6b86feb..93d9acee67 100644 --- a/src/slic3r/GUI/Widgets/AMSControl.hpp +++ b/src/slic3r/GUI/Widgets/AMSControl.hpp @@ -21,6 +21,7 @@ namespace Slic3r { namespace GUI { //Previous definitions class uiAmsPercentHumidityDryPopup; +class AMSDryCtrWin; class AMSControl : public wxSimplebook { @@ -118,6 +119,7 @@ protected: AmsHumidityTipPopup m_Humidity_tip_popup; uiAmsPercentHumidityDryPopup* m_percent_humidity_dry_popup; + AMSDryCtrWin* m_dry_ctr_win{nullptr}; std::string m_last_ams_id = ""; std::string m_last_tray_id = ""; From 044a113d54946c55e39b6d67c940e61402733948 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 14:47:33 +0800 Subject: [PATCH 16/47] feat: update error dialog stop-drying to use CtrlAmsStopDrying Keeps command_ams_drying_stop() fallback for backward compatibility. --- src/slic3r/GUI/DeviceErrorDialog.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/slic3r/GUI/DeviceErrorDialog.cpp b/src/slic3r/GUI/DeviceErrorDialog.cpp index 353250361f..41d54e0ecd 100644 --- a/src/slic3r/GUI/DeviceErrorDialog.cpp +++ b/src/slic3r/GUI/DeviceErrorDialog.cpp @@ -5,6 +5,7 @@ #include "GUI_App.hpp" #include "MainFrame.hpp" #include "ReleaseNote.hpp" +#include "DeviceCore/DevFilaSystem.h" namespace Slic3r { namespace GUI @@ -449,6 +450,11 @@ void DeviceErrorDialog::on_button_click(ActionButton btn_id) break; } case DeviceErrorDialog::STOP_DRYING: { + // Use the canonical CtrlAmsStopDrying path + if (m_obj && m_obj->GetFilaSystem()) { + m_obj->GetFilaSystem()->CtrlAmsStopDrying(0); + } + // Fallback to the old command for backward compatibility m_obj->command_ams_drying_stop(); break; } From 2524669139a1324e7e51567c81ff673b2a09c121 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 14:47:52 +0800 Subject: [PATCH 17/47] Revert "feat: update error dialog stop-drying to use CtrlAmsStopDrying" This reverts commit 044a113d54946c55e39b6d67c940e61402733948. --- src/slic3r/GUI/DeviceErrorDialog.cpp | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/slic3r/GUI/DeviceErrorDialog.cpp b/src/slic3r/GUI/DeviceErrorDialog.cpp index 41d54e0ecd..353250361f 100644 --- a/src/slic3r/GUI/DeviceErrorDialog.cpp +++ b/src/slic3r/GUI/DeviceErrorDialog.cpp @@ -5,7 +5,6 @@ #include "GUI_App.hpp" #include "MainFrame.hpp" #include "ReleaseNote.hpp" -#include "DeviceCore/DevFilaSystem.h" namespace Slic3r { namespace GUI @@ -450,11 +449,6 @@ void DeviceErrorDialog::on_button_click(ActionButton btn_id) break; } case DeviceErrorDialog::STOP_DRYING: { - // Use the canonical CtrlAmsStopDrying path - if (m_obj && m_obj->GetFilaSystem()) { - m_obj->GetFilaSystem()->CtrlAmsStopDrying(0); - } - // Fallback to the old command for backward compatibility m_obj->command_ams_drying_stop(); break; } From 668654da5f57c9876c6f82ec5a74206121b7f527 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 14:58:51 +0800 Subject: [PATCH 18/47] fix: use dark humidity icons in dark mode --- src/slic3r/GUI/AMSDryControl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slic3r/GUI/AMSDryControl.cpp b/src/slic3r/GUI/AMSDryControl.cpp index f8ced3ea39..7e727abbdf 100644 --- a/src/slic3r/GUI/AMSDryControl.cpp +++ b/src/slic3r/GUI/AMSDryControl.cpp @@ -42,7 +42,7 @@ static std::string get_humidity_level_img_path(int humidity_percent) } if (wxGetApp().dark_mode()) { - return "hum_level" + std::to_string(hum_level) + "_no_num_light"; + return "hum_level" + std::to_string(hum_level) + "_no_num_dark"; } else { return "hum_level" + std::to_string(hum_level) + "_no_num_light"; } From 23799e02100f69f37fbc97ef78ffd7d0c4bd74e6 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 15:20:26 +0800 Subject: [PATCH 19/47] chore: remove superpowers planning docs --- .../2026-07-07-ams-drying-control-plan.md | 1046 ----------------- .../2026-07-07-ams-drying-control-design.md | 325 ----- 2 files changed, 1371 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-07-ams-drying-control-plan.md delete mode 100644 docs/superpowers/specs/2026-07-07-ams-drying-control-design.md diff --git a/docs/superpowers/plans/2026-07-07-ams-drying-control-plan.md b/docs/superpowers/plans/2026-07-07-ams-drying-control-plan.md deleted file mode 100644 index 6cd673c5a6..0000000000 --- a/docs/superpowers/plans/2026-07-07-ams-drying-control-plan.md +++ /dev/null @@ -1,1046 +0,0 @@ -# AMS Drying Control — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Port the AMS filament drying control feature from BambuStudio into OrcaSlicer, enabling users to start, monitor, and stop filament drying via the AMS unit from the slicer UI. - -**Architecture:** Add drying status enums/structs to the `DevAms` data model, extend the JSON parser to populate them, add `CtrlAmsStartDryingHour`/`CtrlAmsStopDrying` commands to `DevFilaSystem`, create a `DevUtilBackend` utility for filament drying preset lookup, and build the `AMSDryCtrWin` dialog. Wire the dialog to open when clicking the AMS humidity indicator for N3F/N3S AMS types. - -**Tech Stack:** C++17, wxWidgets, nlohmann/json, Boost - -## Global Constraints - -- **N3F/N3S only:** Standard AMS and AMS Lite do not have heating hardware; the dialog is not shown for them -- **Backward compatibility:** Existing `command_ams_drying_stop()` is preserved; new commands are additive -- **Cross-platform:** wxWidgets UI must work on Windows, macOS, Linux -- **Dark mode:** Dialog must support dark mode via `wxGetApp().dark_mode()` and `StateColor::darkModeColorFor()` -- **Profile compatibility:** Existing profiles with `filament_dev_ams_drying_*` keys (Qidi) continue to work; the feature reads the same keys -- **No printer-agent changes needed:** All commands go through existing `publish_json()` → `NetworkAgent` path -- **Reference:** `D:\projects\BambuStudio` — match its behavior as much as possible; when in doubt, check the BambuStudio source - ---- - -### Task 1: Global `DevAmsType` enum in `DevDefs.h` - -**Files:** -- Modify: `src/slic3r/GUI/DeviceCore/DevDefs.h` -- Modify: `src/slic3r/GUI/DeviceCore/DevFilaSystem.h` (replace local `AmsType` enum with typedef) -- Modify: `src/slic3r/GUI/DeviceCore/DevMapping.cpp` (rename `DevAms::DUMMY` → `DevAms::EXT_SPOOL`) - -**Interfaces:** -- Produces: Global `enum DevAmsType { EXT_SPOOL=0, AMS=1, AMS_LITE=2, N3F=3, N3S=4 }` in `DevDefs.h` -- Produces: `using AmsType = DevAmsType;` inside `DevAms` class (replaces old local enum) -- Note: Old `DevAms::DUMMY` becomes `DevAms::EXT_SPOOL`; all existing references update accordingly - -- [ ] **Step 1: Add global `DevAmsType` enum to `DevDefs.h`** - -In `src/slic3r/GUI/DeviceCore/DevDefs.h`, add before the closing `namespace Slic3r` or after existing enums: - -```cpp -enum DevAmsType : int -{ - EXT_SPOOL = 0, // EXT - AMS = 1, // AMS1 - AMS_LITE = 2, // AMS-Lite - N3F = 3, // N3F, AMS 2PRO - N3S = 4, // N3S, AMS HT -}; -``` - -- [ ] **Step 2: Replace `DevAms::AmsType` local enum with typedef** - -In `src/slic3r/GUI/DeviceCore/DevFilaSystem.h`, find the `DevAms` class and replace the local enum: - -```cpp -// Before (delete this): - enum AmsType : int - { - DUMMY = 0, - AMS = 1, // AMS - AMS_LITE = 2, // AMS-Lite - N3F = 3, // N3F - N3S = 4, // N3S - }; - -// After (replace with): - using AmsType = DevAmsType; -``` - -- [ ] **Step 3: Rename `DevAms::DUMMY` → `DevAms::EXT_SPOOL` in `DevMapping.cpp`** - -In `src/slic3r/GUI/DeviceCore/DevMapping.cpp` line 189: - -```cpp -// Before: -_parse_tray_info(atoi(tray.id.c_str()), 0, DevAms::DUMMY, tray, info); -// After: -_parse_tray_info(atoi(tray.id.c_str()), 0, DevAms::EXT_SPOOL, tray, info); -``` - -- [ ] **Step 4: Rename `DUMMY` → `EXT_SPOOL` in `DevFilaSystem.cpp`** - -In `src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp` line 114: - -```cpp -// Before: -assert(DUMMY < type && m_ams_type <= N3S); -// After: -assert(EXT_SPOOL < type && m_ams_type <= N3S); -``` - -- [ ] **Step 5: Verify the build compiles** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -Expected: build succeeds, no `DUMMY` or `AmsType` related compile errors. - -- [ ] **Step 6: Commit** - -```bash -git add src/slic3r/GUI/DeviceCore/DevDefs.h src/slic3r/GUI/DeviceCore/DevFilaSystem.h src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp src/slic3r/GUI/DeviceCore/DevMapping.cpp -git commit -m "refactor: promote DevAmsType to global enum, rename DUMMY to EXT_SPOOL - -Matches BambuStudio's DevAmsType naming convention." -``` - ---- - -### Task 2: Drying enums and structs in `DevAms` - -**Files:** -- Modify: `src/slic3r/GUI/DeviceCore/DevFilaSystem.h` - -**Interfaces:** -- Produces: `DevAms::DryCtrlMode`, `DevAms::DryStatus`, `DevAms::DrySubStatus`, `DevAms::DryFanStatus`, `DevAms::CannotDryReason` enums -- Produces: `DevAms::DrySettings` struct -- Produces: Getters: `GetDryStatus()`, `GetDrySubStatus()`, `GetFan1Status()`, `GetFan2Status()`, `GetCannotDryReason()`, `GetDrySettings()` -- Produces: `IsSupportRemoteDry(const MachineObject*)`, `AmsIsDrying()` -- Produces: `SupportDrying()` — already exists as `m_ams_type > AMS_LITE`; update to check N3F/N3S -- Produces: Members: `m_dry_status`, `m_dry_sub_status`, `m_dry_fan1_status`, `m_dry_fan2_status`, `m_dry_cannot_reasons`, `m_dry_settings` - -**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceCore\DevFilaSystem.h` lines 119–262 - -- [ ] **Step 1: Add enums inside `DevAms` class** - -In `src/slic3r/GUI/DeviceCore/DevFilaSystem.h`, inside the `DevAms` class (after the `using AmsType = DevAmsType;` line, before the `public:` constructor section), add: - -```cpp -public: - - enum class DryCtrlMode : int - { - Off = 0, - OnTime = 1, - OnHumidity = 2, - }; - - enum class DryStatus : char - { - Off = 0, - Checking = 1, - Drying = 2, - Cooling = 3, - Stopping = 4, - Error = 5, - CannotStopHeatOutofControl = 6, - PrdTesting = 7, - }; - - enum class DrySubStatus - { - Off = 0, - Heating = 1, - Dehumidify = 2, - }; - - enum class DryFanStatus : char - { - Off = 0, - On = 1, - }; - - enum class CannotDryReason : int - { - TaskOccupied = 0, - InsufficientPower = 1, - AmsBusy = 2, - ConsumableAtAmsOutlet = 3, - InitiatingAmsDrying = 4, - NotSupportedIn2dMode = 5, - DryingInProgress = 6, - Upgrading = 7, - InsufficientPowerNeedPluginPower = 8, - FilamentAtAmsOutletManualUnload = 10, - }; - - struct DrySettings - { - std::string dry_filament; - int dry_temp = -1; // -1 means invalid - int dry_hour = -1; // -1 means invalid, hours - }; -``` - -- [ ] **Step 2: Add new getters to `DevAms`** - -After the existing `GetLeftDryTime()` getter, add: - -```cpp - // remote drying control - bool IsSupportRemoteDry(const MachineObject* obj) const; - std::optional GetDryStatus() const { return m_dry_status; }; - std::optional GetDrySubStatus() const { return m_dry_sub_status; } - std::optional GetFan1Status() const { return m_dry_fan1_status; } - std::optional GetFan2Status() const { return m_dry_fan2_status; } - std::optional> GetCannotDryReason() const { return m_dry_cannot_reasons; } - std::optional GetDrySettings() const { return m_dry_settings; }; - - bool AmsIsDrying(); -``` - -- [ ] **Step 3: Add new private members to `DevAms`** - -In the private section of `DevAms`, after `m_left_dry_time`, add: - -```cpp - // see is_support_remote_dry - std::optional m_dry_status; - std::optional m_dry_sub_status; - std::optional m_dry_fan1_status; - std::optional m_dry_fan2_status; - std::optional> m_dry_cannot_reasons; - std::optional m_dry_settings; -``` - -Note: In BambuStudio these members are `public:` (prefixed with `public:` before `m_dry_status`). Keep them private and use getters. The friends (`DevFilaSystemParser`) can access them. - -Also remove the duplicate `GetAmsType()` which currently returns `m_ams_type` — since `m_ams_type` is now `DevAmsType`, the getter still works. - -- [ ] **Step 4: Add forward declaration for `DevFilamentDryingPreset`** - -At the top of the file, after the existing forward declarations: - -```cpp -struct DevFilamentDryingPreset; -``` - -- [ ] **Step 5: Verify the header compiles** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -Expected: build succeeds. If there are compilation errors related to `GetAmsType()` return type, check that `AmsType` typedef resolves correctly. - -- [ ] **Step 6: Commit** - -```bash -git add src/slic3r/GUI/DeviceCore/DevFilaSystem.h -git commit -m "feat: add drying enums, structs, getters to DevAms - -Adds DryCtrlMode, DryStatus, DrySubStatus, DryFanStatus, -CannotDryReason enums and DrySettings struct for AMS drying control." -``` - ---- - -### Task 3: `DevFilamentDryingPreset` struct and `DevFilaSystem` ctrl declarations - -**Files:** -- Modify: `src/slic3r/GUI/DeviceCore/DevFilaSystem.h` - -**Interfaces:** -- Produces: `struct DevFilamentDryingPreset` at namespace scope -- Produces: `DevFilaSystem::CtrlAmsStartDryingHour(int ams_id, std::string filament_type, int tag_temp, int tag_duration_hour, bool rotate_tray, int cooling_temp, bool close_power_conflict = false) const` -- Produces: `DevFilaSystem::CtrlAmsStopDrying(int ams_id) const` -- Produces: `DevAmsTray::get_ams_drying_preset()` method - -**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceCore\DevFilaSystem.h` lines 350–362 - -- [ ] **Step 1: Add `DevFilamentDryingPreset` struct at namespace scope** - -In `src/slic3r/GUI/DeviceCore/DevFilaSystem.h`, add after the `DevFilaSystemParser` class (at the very end of the file, before `}// namespace Slic3r`): - -```cpp -struct DevFilamentDryingPreset -{ - std::string filament_id; - - std::unordered_set ams_limitations; // only use ams types in the set - std::unordered_map filament_dev_ams_drying_time_on_idle; // hour - std::unordered_map filament_dev_ams_drying_temperature_on_idle; - std::unordered_map filament_dev_ams_drying_time_on_print; // hour - std::unordered_map filament_dev_ams_drying_temperature_on_print; - float filament_dev_drying_cooling_temperature = 0.0f; - float filament_dev_drying_softening_temperature = 0.0f; - float filament_dev_ams_drying_heat_distortion_temperature = 0.0f; -}; -``` - -- [ ] **Step 2: Add control method declarations to `DevFilaSystem`** - -In the `DevFilaSystem` class, after the existing `CtrlAmsReset()` declaration: - -```cpp - // crtl - int CtrlAmsStartDryingHour(int ams_id, std::string filament_type, int tag_temp, int tag_duration_hour, bool rotate_tray, int cooling_temp, bool close_power_conflict = false) const; - int CtrlAmsStopDrying(int ams_id) const; -``` - -- [ ] **Step 3: Add `get_ams_drying_preset()` to `DevAmsTray`** - -In the `DevAmsTray` class, add after the `get_filament_type()` method: - -```cpp - std::optional get_ams_drying_preset() const; -``` - -Also add `#include ` and `#include ` to the includes if not already present. -Add `#include ` if not already present. - -- [ ] **Step 4: Verify build** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -- [ ] **Step 5: Commit** - -```bash -git add src/slic3r/GUI/DeviceCore/DevFilaSystem.h -git commit -m "feat: add DevFilamentDryingPreset, ctrl method declarations, tray preset getter" -``` - ---- - -### Task 4: `DevUtilBackend` utility class - -**Files:** -- Create: `src/slic3r/GUI/DeviceCore/DevUtilBackend.h` -- Create: `src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp` -- Modify: `src/slic3r/GUI/DeviceCore/CMakeLists.txt` - -**Interfaces:** -- Produces: `DevUtilBackend::GetFilamentDryingPreset(const std::string& fila_id)` → `std::optional` - -**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceCore\DevUtilBackend.h`, `DevUtilBackend.cpp` - -- [ ] **Step 1: Create `DevUtilBackend.h`** - -Create `src/slic3r/GUI/DeviceCore/DevUtilBackend.h`: - -```cpp -/** - * @file DevUtilBackend.h - * @brief Provides common static utility methods for backend (preset/slicing). - */ - -#pragma once -#include "DevDefs.h" -#include "DevFilaSystem.h" - -#include -#include - -namespace Slic3r -{ - -class DevUtilBackend -{ -public: - DevUtilBackend() = delete; - -public: - // for filament preset - static std::optional GetFilamentDryingPreset(const std::string& fila_id); -}; - -}; // namespace Slic3r -``` - -- [ ] **Step 2: Create `DevUtilBackend.cpp`** - -Create `src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp`: - -```cpp -#include "DevUtilBackend.h" - -#include "slic3r/GUI/GUI_App.hpp" - -#include "libslic3r/Preset.hpp" - -#include - -namespace Slic3r -{ - -static std::unordered_map s_ams_type_map = { - {"0", DevAmsType::N3F}, - {"1", DevAmsType::N3S}, -}; - -std::optional DevUtilBackend::GetFilamentDryingPreset(const std::string& fila_id) -{ - if (fila_id.empty() || !GUI::wxGetApp().preset_bundle) { - return std::nullopt; - } - - for (auto iter = GUI::wxGetApp().preset_bundle->filaments.begin(); iter != GUI::wxGetApp().preset_bundle->filaments.end(); ++iter) { - const Preset& filament_preset = *iter; - const auto& config = filament_preset.config; - if (filament_preset.filament_id == fila_id) { - DevFilamentDryingPreset info; - info.filament_id = fila_id; - try { - if (config.has("filament_dev_ams_drying_ams_limitations")) { - std::vector types = config.option("filament_dev_ams_drying_ams_limitations")->values; - for (auto type : types) { - if (s_ams_type_map.count(type) == 0) { - continue; - } - info.ams_limitations.insert(s_ams_type_map[type]); - } - } - - if (config.has("filament_dev_ams_drying_temperature")) { - info.filament_dev_ams_drying_temperature_on_idle[DevAmsType::N3F] = config.option("filament_dev_ams_drying_temperature")->get_at(0); - info.filament_dev_ams_drying_temperature_on_idle[DevAmsType::N3S] = config.option("filament_dev_ams_drying_temperature")->get_at(1); - info.filament_dev_ams_drying_temperature_on_print[DevAmsType::N3F] = config.option("filament_dev_ams_drying_temperature")->get_at(2); - info.filament_dev_ams_drying_temperature_on_print[DevAmsType::N3S] = config.option("filament_dev_ams_drying_temperature")->get_at(3); - } - - if (config.has("filament_dev_ams_drying_time")) { - info.filament_dev_ams_drying_time_on_idle[DevAmsType::N3F] = config.option("filament_dev_ams_drying_time")->get_at(0); - info.filament_dev_ams_drying_time_on_idle[DevAmsType::N3S] = config.option("filament_dev_ams_drying_time")->get_at(1); - info.filament_dev_ams_drying_time_on_print[DevAmsType::N3F] = config.option("filament_dev_ams_drying_time")->get_at(2); - info.filament_dev_ams_drying_time_on_print[DevAmsType::N3S] = config.option("filament_dev_ams_drying_time")->get_at(3); - } - - if (config.has("filament_dev_drying_softening_temperature")) { - info.filament_dev_drying_softening_temperature = config.option("filament_dev_drying_softening_temperature")->get_at(0); - } - - if (config.has("filament_dev_ams_drying_heat_distortion_temperature")) { - info.filament_dev_ams_drying_heat_distortion_temperature = config.option("filament_dev_ams_drying_heat_distortion_temperature")->get_at(0); - } - - if (config.has("filament_dev_drying_cooling_temperature")) { - info.filament_dev_drying_cooling_temperature = config.option("filament_dev_drying_cooling_temperature")->get_at(0); - } - - return info; - } catch (const std::exception& e) { - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " exception: " << e.what(); - } - } - } - - return std::nullopt; -} - -}; // namespace Slic3r -``` - -- [ ] **Step 3: Register new files in CMakeLists.txt** - -In `src/slic3r/GUI/DeviceCore/CMakeLists.txt`, add the new files. After the existing `DevFilaSystemCtrl.cpp` line: - -```cmake - GUI/DeviceCore/DevUtilBackend.h - GUI/DeviceCore/DevUtilBackend.cpp -``` - -- [ ] **Step 4: Verify build** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -Expected: build succeeds. If `ConfigOptionStrings` or `ConfigOptionFloats` are not found, check the includes — they're in `libslic3r/Preset.hpp` and `libslic3r/PrintConfig.hpp`. - -- [ ] **Step 5: Commit** - -```bash -git add src/slic3r/GUI/DeviceCore/DevUtilBackend.h src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp src/slic3r/GUI/DeviceCore/CMakeLists.txt -git commit -m "feat: add DevUtilBackend with GetFilamentDryingPreset - -Reads filament_dev_ams_drying_* config keys from filament presets -and returns structured DevFilamentDryingPreset data." -``` - ---- - -### Task 5: JSON parsing for drying status fields - -**Files:** -- Modify: `src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp` - -**Interfaces:** -- Consumes: `DevAms::DryStatus`, `DevAms::DrySubStatus`, `DevAms::DryFanStatus`, `DevAms::CannotDryReason`, `DevAms::DrySettings` -- Parses from JSON: `dry_time`, info flag bits (dry_status/fan1/fan2/sub_status), `dry_setting`, `dry_sf_reason` - -**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceCore\DevFilaSystem.cpp` lines 690–712 - -- [ ] **Step 1: Locate the AMS parsing section in `DevFilaSystemParser::ParseV1_0()`** - -First, read `DevFilaSystem.cpp` to find where `m_left_dry_time` is parsed (look for "dry_time") and where the AMS info section is. The parsing function is `DevFilaSystemParser::ParseV1_0`. - -- [ ] **Step 2: Add drying status field parsing** - -After the existing `m_left_dry_time` parsing line (which parses `"dry_time"` from JSON), add the drying status parsing. The exact location will be in the AMS loop where `curr_ams` is populated. - -After `DevJsonValParser::ParseVal(j_ams, "dry_time", curr_ams->m_left_dry_time);` (or the equivalent line), add: - -```cpp - // Drying status — only parse if printer supports remote drying - if (obj->is_support_remote_dry) { - if (j_ams.contains("info")) { - const std::string& info = j_ams["info"].get(); - curr_ams->m_dry_status = (DevAms::DryStatus)DevUtil::get_flag_bits(info, 4, 4); - curr_ams->m_dry_fan1_status = (DevAms::DryFanStatus)DevUtil::get_flag_bits(info, 18, 2); - curr_ams->m_dry_fan2_status = (DevAms::DryFanStatus)DevUtil::get_flag_bits(info, 20, 2); - curr_ams->m_dry_sub_status = (DevAms::DrySubStatus)DevUtil::get_flag_bits(info, 22, 2); - } - - if (j_ams.contains("dry_setting")) { - const auto& j_dry_settings = j_ams["dry_setting"]; - DevAms::DrySettings dry_settings; - DevJsonValParser::ParseVal(j_dry_settings, "dry_filament", dry_settings.dry_filament); - DevJsonValParser::ParseVal(j_dry_settings, "dry_temperature", dry_settings.dry_temp); - DevJsonValParser::ParseVal(j_dry_settings, "dry_duration", dry_settings.dry_hour); - curr_ams->m_dry_settings = dry_settings; - } - - if (j_ams.contains("dry_sf_reason")) { - curr_ams->m_dry_cannot_reasons = DevJsonValParser::GetVal>(j_ams, "dry_sf_reason"); - } - } -``` - -Note: Check if `DevUtil::get_flag_bits` and `DevJsonValParser` exist in OrcaSlicer. If not, these utilities must be added. Check BambuStudio's `DevUtil.h` for `get_flag_bits` and `DevJsonValParser` for the parsing helper. If these are missing, add minimal versions or inline the parsing. - -Check specifically whether `DevUtil.h` in OrcaSlicer has `get_flag_bits`. The `DevUtil.h` exists in the CMakeLists. Check its contents — it should have a static `get_flag_bits(std::string, int, int)` method. If not present, port it from BambuStudio. - -- [ ] **Step 3: Implement `IsSupportRemoteDry()` and `AmsIsDrying()`** - -In the same file, add the implementations after the existing `DevAms` methods: - -```cpp -bool DevAms::IsSupportRemoteDry(const MachineObject* obj) const -{ - if (obj && obj->is_support_remote_dry) { - return SupportDrying(); - } - return false; -} - -bool DevAms::AmsIsDrying() -{ - if (!GetDryStatus().has_value()) { - return false; - } - - return GetDryStatus().value() == DevAms::DryStatus::Checking - || GetDryStatus().value() == DevAms::DryStatus::Drying - || GetDryStatus().value() == DevAms::DryStatus::Error - || GetDryStatus().value() == DevAms::DryStatus::CannotStopHeatOutofControl; -} -``` - -- [ ] **Step 4: Implement `DevAmsTray::get_ams_drying_preset()`** - -```cpp -std::optional DevAmsTray::get_ams_drying_preset() const -{ - return DevUtilBackend::GetFilamentDryingPreset(setting_id); -} -``` - -- [ ] **Step 5: Ensure `#include "DevUtilBackend.h"` is present in `DevFilaSystem.cpp`** - -Add at the top if not present. - -- [ ] **Step 6: Verify build** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -- [ ] **Step 7: Commit** - -```bash -git add src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp -git commit -m "feat: add drying status JSON parsing and DevAms helper methods - -Parse dry_status, dry_sub_status, dry_fan statuses, -dry_settings, and dry_cannot_reasons from printer JSON. -Implement IsSupportRemoteDry, AmsIsDrying, get_ams_drying_preset." -``` - ---- - -### Task 6: `is_support_remote_dry` flag on `MachineObject` - -**Files:** -- Modify: `src/slic3r/GUI/DeviceManager.hpp` (add field) -- Modify: `src/slic3r/GUI/DeviceManager.cpp` (parse the flag) - -**Interfaces:** -- Produces: `MachineObject::is_support_remote_dry` (bool) - -**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceManager.hpp` line 566, `DeviceManager.cpp` line 4403 - -- [ ] **Step 1: Add `is_support_remote_dry` field to `MachineObject`** - -In `src/slic3r/GUI/DeviceManager.hpp`, find the section with other `is_support_*` flags (near `is_support_ams_humidity`) and add: - -```cpp - bool is_support_remote_dry = false; -``` - -- [ ] **Step 2: Parse `is_support_remote_dry` from firmware flags** - -In `src/slic3r/GUI/DeviceManager.cpp`, find the `parse_version_func()` or `parse_new_info()` method where other `is_support_*` flags are set. Look for the `is_support_ams_humidity` parsing pattern. - -Add parsing for `is_support_remote_dry` from the `fun2` field. Check the BambuStudio reference: it uses `get_flag_bits_no_border(fun2, 5) == 1` for bit 5. The exact location depends on how OrcaSlicer parses firmware capability bits. - -If a `fun2` parsing block exists, add: -```cpp - is_support_remote_dry = (get_flag_bits_no_border(fun2, 5) == 1); -``` - -If `fun2` parsing or `get_flag_bits_no_border` don't exist yet in OrcaSlicer, check how similar flags like `is_support_ams_humidity` are parsed and follow the same pattern. If the needed helper methods are missing, port them from BambuStudio's `DeviceManager.cpp`. - -- [ ] **Step 3: Verify build** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/slic3r/GUI/DeviceManager.hpp src/slic3r/GUI/DeviceManager.cpp -git commit -m "feat: add is_support_remote_dry flag to MachineObject - -Parsed from firmware fun2 bit 5. Controls whether drying -status fields are parsed and drying commands are available." -``` - ---- - -### Task 7: Start/Stop drying commands in `DevFilaSystemCtrl.cpp` - -**Files:** -- Modify: `src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp` - -**Interfaces:** -- Consumes: `MachineObject::m_sequence_id`, `MachineObject::publish_json()` -- Produces: `DevFilaSystem::CtrlAmsStartDryingHour(...)`, `DevFilaSystem::CtrlAmsStopDrying(int)` - -**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\DeviceCore\DevFilaSystemCtrl.cpp` - -- [ ] **Step 1: Add `CtrlAmsStartDryingHour` and `CtrlAmsStopDrying`** - -In `src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp`, add after the existing `CtrlAmsReset()` method: - -```cpp -int DevFilaSystem::CtrlAmsStartDryingHour(int ams_id, - std::string filament_type, - int tag_temp, - int tag_duration_hour, - bool rotate_tray, - int cooling_temp, - bool close_power_conflict) const -{ - json jj_command; - jj_command["print"]["command"] = "ams_filament_drying"; - jj_command["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++); - jj_command["print"]["ams_id"] = ams_id; - jj_command["print"]["mode"] = DevAms::DryCtrlMode::OnTime; - jj_command["print"]["filament"] = filament_type; - jj_command["print"]["temp"] = tag_temp; - jj_command["print"]["duration"] = tag_duration_hour; - jj_command["print"]["humidity"] = 0; - jj_command["print"]["rotate_tray"] = rotate_tray; - jj_command["print"]["cooling_temp"] = cooling_temp; - jj_command["print"]["close_power_conflict"] = close_power_conflict; - return m_owner->publish_json(jj_command); -} - -int DevFilaSystem::CtrlAmsStopDrying(int ams_id) const -{ - json jj_command; - jj_command["print"]["command"] = "ams_filament_drying"; - jj_command["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++); - jj_command["print"]["ams_id"] = ams_id; - jj_command["print"]["mode"] = DevAms::DryCtrlMode::Off; - jj_command["print"]["filament"] = ""; - jj_command["print"]["temp"] = 0; - jj_command["print"]["duration"] = 0; - jj_command["print"]["humidity"] = 0; - jj_command["print"]["rotate_tray"] = false; - jj_command["print"]["cooling_temp"] = 0; - jj_command["print"]["close_power_conflict"] = false; - return m_owner->publish_json(jj_command); -} -``` - -Note: The `DevAms::DryCtrlMode` enum class uses `DevAms::DryCtrlMode::OnTime` in C++. When serialized to JSON (`jj_command["print"]["mode"] = DevAms::DryCtrlMode::OnTime`), nlohmann/json will convert the enum class to its underlying int (1). This matches the BambuStudio behavior. If the implicit conversion doesn't compile, add a `static_cast()` wrapper. - -- [ ] **Step 2: Verify build** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp -git commit -m "feat: add CtrlAmsStartDryingHour and CtrlAmsStopDrying commands - -Publish 'ams_filament_drying' JSON commands via MQTT for -starting and stopping AMS filament drying." -``` - ---- - -### Task 8: Copy image assets from BambuStudio - -**Files:** -- Copy 14 image files from `D:\projects\BambuStudio\resources\images\` to `resources\images\` - -**Assets to copy:** - -```powershell -Copy-Item "D:\projects\BambuStudio\resources\images\hum_level1_no_num_light.svg" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\hum_level2_no_num_light.svg" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\hum_level3_no_num_light.svg" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\hum_level4_no_num_light.svg" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\hum_level5_no_num_light.svg" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3f_heating.png" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3f_dehumidifying.png" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3f_error.png" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3f_cooling.png" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3s_heating.png" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3s_dehumidifying.png" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3s_error.png" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_n3s_cooling.png" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_heating_icon.svg" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_filament_in_chamber.png" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_enable.svg" -Destination "resources\images\" -Copy-Item "D:\projects\BambuStudio\resources\images\dev_ams_dry_ctr_disable.svg" -Destination "resources\images\" -``` - -- [ ] **Step 1: Copy each asset** - -Run each Copy-Item command. If any file is missing from the source, note it — the dialog will degrade gracefully with null bitmaps. - -- [ ] **Step 2: Verify files exist** - -```powershell -Get-ChildItem resources\images\dev_ams_dry_ctr_*, resources\images\hum_level* | Select Name -``` - -Expected: all 17 files listed. - -- [ ] **Step 3: Commit** - -```bash -git add resources/images/dev_ams_dry_ctr_* resources/images/hum_level* -git commit -m "assets: add AMS drying control images from BambuStudio - -14 images: humidity level icons, drying state images per AMS type, -heating icon, guide illustration, tray status icons." -``` - ---- - -### Task 9: `AMSDryControl` UI dialog - -**Files:** -- Create: `src/slic3r/GUI/AMSDryControl.hpp` -- Create: `src/slic3r/GUI/AMSDryControl.cpp` - -**Interfaces:** -- Produces: `AMSDryCtrWin` dialog class extending `DPIDialog` -- Produces: `FilamentItemPanel`, `AMSFilamentPanel` helper widget classes -- Consumes: `DevFilaSystem`, `MachineObject`, `DevAms`, `DevUtilBackend::GetFilamentDryingPreset()` - -**Reference:** `D:\projects\BambuStudio\src\slic3r\GUI\AMSDryControl.hpp` and `AMSDryControl.cpp` - -- [ ] **Step 1: Create `AMSDryControl.hpp`** - -Create `src/slic3r/GUI/AMSDryControl.hpp`. Port the full header from BambuStudio `AMSDryControl.hpp`, adapting includes for OrcaSlicer paths: - -Key adaptations: -- Change `#include "GUI_ObjectLayers.hpp"` to appropriate OrcaSlicer equivalent (check if it exists; if not, it may not be needed or can be replaced with `wx/wx.h`) -- Keep includes for `slic3r/GUI/Widgets/AMSItem.hpp`, `Widgets/Label.hpp`, `Widgets/PopupWindow.hpp`, `wxExtensions.hpp`, `DeviceCore/DevFilaSystem.h` -- Keep the full class declarations for `FilamentItemPanel`, `AMSFilamentPanel`, `AMSDryCtrWin` - -The complete header from `D:\projects\BambuStudio\src\slic3r\GUI\AMSDryControl.hpp` (lines 1–252) should be ported with path adjustments. Due to the file's length (~250 lines), copy the full content from the reference and adapt. - -- [ ] **Step 2: Create `AMSDryControl.cpp`** - -Create `src/slic3r/GUI/AMSDryControl.cpp`. Port from `D:\projects\BambuStudio\src\slic3r\GUI\AMSDryControl.cpp` (lines 1–1860). Key adaptations: - -Replace these BambuStudio includes: -```cpp -#include "AMSDryControl.hpp" -#include "DeviceCore/DevFilaSystem.h" -#include "GUI_App.hpp" -#include "I18N.hpp" -#include "slic3r/GUI/DeviceCore/DevExtruderSystem.h" -#include "slic3r/GUI/DeviceCore/DevUpgrade.h" -#include "slic3r/GUI/DeviceCore/DevManager.h" -#include "slic3r/GUI/MsgDialog.hpp" -#include "slic3r/GUI/Widgets/AnimaController.hpp" -#include "slic3r/GUI/Widgets/Label.hpp" -#include "slic3r/GUI/Widgets/ComboBox.hpp" -#include "slic3r/GUI/Widgets/ProgressBar.hpp" -#include "DeviceCore/DevUtilBackend.h" -``` - -Adapt includes for OrcaSlicer paths. Check which of these already exist: -- `GUI_App.hpp` → existing -- `I18N.hpp` → existing -- `DeviceCore/DevFilaSystem.h` → existing -- `Widgets/Label.hpp`, `Widgets/ComboBox.hpp`, `Widgets/ProgressBar.hpp` → check existence in OrcaSlicer - -Since this file is ~1860 lines, port it in full from the BambuStudio reference, adapting only the include paths and namespace references as needed for OrcaSlicer. The implementation logic should be identical to BambuStudio. - -Key parts to verify during port: -- `AMS_ITEMS_PANEL_SIZE`, `AMS_CONTROL_DEF_BLOCK_BK_COLOUR`, `AMS_CONTROL_BRAND_COLOUR`, `AMS_CONTROL_DISABLE_COLOUR`, `AMS_CONTROL_WHITE_COLOUR` — these constants are defined in `AMSItem.hpp`. Verify they exist in OrcaSlicer's version. -- `_L()` and `_CTX_utf8()` — I18N macros; verify they work the same in OrcaSlicer -- `StateColor` — widget state color class; verify it exists in OrcaSlicer -- `ScalableBitmap` — verify it exists in OrcaSlicer -- `ComboBox` — OrcaSlicer custom combobox; verify path -- `ProgressBar` — OrcaSlicer custom progress bar; verify path -- `FromDIP()` — DPI scaling helper; should exist in OrcaSlicer -- `encode_path()` — path encoding utility; should exist -- `resources_dir()` — resources directory getter; verify it exists -- `bool is_support_user_preset` on `MachineObject` — verify this field exists - -- [ ] **Step 3: Register `AMSDryControl` in CMakeLists** - -In `src/slic3r/CMakeLists.txt`, add after the `AMSSetting` entries (around line 30): - -```cmake - GUI/AMSDryControl.cpp - GUI/AMSDryControl.hpp -``` - -- [ ] **Step 4: Verify build** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -Fix any compilation errors from missing includes or type mismatches between OrcaSlicer and BambuStudio APIs. - -- [ ] **Step 5: Commit** - -```bash -git add src/slic3r/GUI/AMSDryControl.hpp src/slic3r/GUI/AMSDryControl.cpp -git commit -m "feat: add AMSDryControl dialog for AMS filament drying - -Three-page wizard: main status/control page, guide page with -filament tray status, and progress page. Supports N3F and N3S AMS types." -``` - ---- - -### Task 10: Wire humidity click to open `AMSDryCtrWin` - -**Files:** -- Modify: `src/slic3r/GUI/Widgets/AMSControl.hpp` -- Modify: `src/slic3r/GUI/Widgets/AMSControl.cpp` - -**Interfaces:** -- Consumes: `AMSDryCtrWin` dialog class -- Modifies: `EVT_AMS_SHOW_HUMIDITY_TIPS` handler — for N3F/N3S AMS types, opens `AMSDryCtrWin` instead of the popup - -- [ ] **Step 1: Add member and include to `AMSControl.hpp`** - -In `src/slic3r/GUI/Widgets/AMSControl.hpp`: - -Add forward declaration: -```cpp -class AMSDryCtrWin; -``` - -Add member variable near the existing `m_percent_humidity_dry_popup`: -```cpp - AMSDryCtrWin* m_dry_ctr_win{nullptr}; -``` - -- [ ] **Step 2: Add include to `AMSControl.cpp`** - -```cpp -#include "slic3r/GUI/AMSDryControl.hpp" -``` - -- [ ] **Step 3: Modify the `EVT_AMS_SHOW_HUMIDITY_TIPS` handler** - -In the handler at around line 243, modify the `else` branch (for non-GENERIC_AMS types) to check if the AMS type is N3F/N3S and open the `AMSDryCtrWin` instead of the popup: - -```cpp - Bind(EVT_AMS_SHOW_HUMIDITY_TIPS, [this](wxCommandEvent& evt) { - uiAmsHumidityInfo *info = (uiAmsHumidityInfo *) evt.GetClientData(); - if (info) - { - if (info->ams_type == AMSModel::GENERIC_AMS) - { - wxPoint img_pos = ClientToScreen(wxPoint(0, 0)); - wxPoint popup_pos(img_pos.x - m_Humidity_tip_popup.GetSize().GetWidth() + FromDIP(150), img_pos.y - FromDIP(80)); - m_Humidity_tip_popup.Position(popup_pos, wxSize(0, 0)); - - int humidity_value = info->humidity_display_idx; - if (humidity_value > 0 && humidity_value <= 5) { m_Humidity_tip_popup.set_humidity_level(humidity_value); } - m_Humidity_tip_popup.Popup(); - } - else if (info->ams_type == AMSModel::N3F_AMS || info->ams_type == AMSModel::N3S_AMS) - { - // Open full drying control dialog for N3F/N3S AMS - if (!m_dry_ctr_win) { - m_dry_ctr_win = new AMSDryCtrWin(this); - } - m_dry_ctr_win->set_ams_id(info->ams_id); - // Get fila system and machine object via the parent chain - // The dialog will be updated in parse_object() - m_dry_ctr_win->ShowModal(); - } - else - { - m_percent_humidity_dry_popup->Update(info); - - wxPoint img_pos = ClientToScreen(wxPoint(0, 0)); - wxPoint popup_pos(img_pos.x - m_percent_humidity_dry_popup->GetSize().GetWidth() + FromDIP(150), img_pos.y - FromDIP(80)); - m_percent_humidity_dry_popup->Move(popup_pos); - m_percent_humidity_dry_popup->ShowModal(); - } - } - - delete info; - }); -``` - -Note: The `AMSDryCtrWin` needs `DevFilaSystem` and `MachineObject` references for its `update()` method. The dialog's `update()` should be called before `ShowModal()`. The `MachineObject*` can be obtained from the parent widget chain or stored as a member of `AMSControl`. Check how `AMSControl` currently accesses `MachineObject` (likely through `m_obj` or similar). Pass it to the dialog's `update()` call. - -- [ ] **Step 4: Add periodic update for the dialog in `parse_object()`** - -In the `parse_object()` method (around line 954), find the existing humidity popup update block and extend it to also update the dry control dialog: - -```cpp - /*update AMS dry control dialog*/ - if (m_dry_ctr_win && m_dry_ctr_win->IsShown()) - { - // Get the MachineObject and DevFilaSystem from the current device - // m_dry_ctr_win->update(obj->GetFilaSystem_ptr(), obj); - } -``` - -Note: The exact code depends on how `AMSControl` accesses `MachineObject*`. Check existing patterns — it likely has access through `m_obj` or a similar member. The update will need both the `DevFilaSystem` (as `shared_ptr`) and `MachineObject*`. - -- [ ] **Step 5: Verify build** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/slic3r/GUI/Widgets/AMSControl.hpp src/slic3r/GUI/Widgets/AMSControl.cpp -git commit -m "feat: wire AMS humidity click to open AMSDryControl dialog - -For N3F and N3S AMS types, clicking the humidity indicator -now opens the full drying control dialog instead of the popup." -``` - ---- - -### Task 11: Update error dialog stop-drying call - -**Files:** -- Modify: `src/slic3r/GUI/DeviceErrorDialog.cpp` - -**Interfaces:** -- Consumes: `DevFilaSystem::CtrlAmsStopDrying(int)` - -- [ ] **Step 1: Update `STOP_DRYING` case in `DeviceErrorDialog.cpp`** - -In `src/slic3r/GUI/DeviceErrorDialog.cpp` around line 452, update the stop drying action: - -```cpp - case DeviceErrorDialog::STOP_DRYING: { - // Use the canonical CtrlAmsStopDrying path - if (m_obj && m_obj->GetFilaSystem()) { - // Get the AMS ID — check how the dialog knows which AMS is involved - // For now, stop all AMS units or use the first one - m_obj->GetFilaSystem()->CtrlAmsStopDrying(0); - } - // Fallback to the old command for backward compatibility - m_obj->command_ams_drying_stop(); - break; - } -``` - -Note: The exact AMS ID to pass depends on the error context. Check how BambuStudio handles this — it may use a specific AMS ID from the error data, or stop all drying. The fallback to `command_ams_drying_stop()` ensures backward compatibility if `CtrlAmsStopDrying` fails or the fila system isn't available. - -- [ ] **Step 2: Verify build** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/slic3r/GUI/DeviceErrorDialog.cpp -git commit -m "feat: update error dialog stop-drying to use CtrlAmsStopDrying - -Keeps command_ams_drying_stop() fallback for backward compatibility." -``` - ---- - -### Task 12: Build, verify, and final integration test - -**Files:** All modified files - -- [ ] **Step 1: Full clean build** - -```powershell -cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -Expected: Zero errors, zero warnings related to the new code. - -- [ ] **Step 2: Verify no regressions in existing AMS features** - -- Confirm the app launches -- Confirm AMS panel renders correctly for printers without N3F/N3S -- Confirm humidity popup still works for standard AMS and AMS Lite -- Confirm existing `command_ams_drying_stop()` still functions - -- [ ] **Step 3: Manual UI smoke test (requires N3F/N3S printer)** - -If a printer with N3F or N3S AMS is available: -1. Connect to the printer -2. Click AMS humidity indicator → verify `AMSDryCtrWin` opens -3. Verify humidity/temperature readings match printer data -4. Verify filament combobox is populated -5. Verify temp/time auto-fill when selecting a filament -6. Verify validation warnings for invalid temperatures -7. Verify the dialog closes cleanly - -- [ ] **Step 4: Commit any final fixes** - -```bash -git add -A -git commit -m "fix: final integration fixes for AMS drying control" -``` diff --git a/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md b/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md deleted file mode 100644 index cecbf7852f..0000000000 --- a/docs/superpowers/specs/2026-07-07-ams-drying-control-design.md +++ /dev/null @@ -1,325 +0,0 @@ -# AMS Drying Control — Design Spec - -**Date:** 2026-07-07 -**Source:** Port from BambuStudio `AMSDryControl` feature -**Reference:** `D:\projects\BambuStudio` — match its behavior as much as possible; when in doubt, check the BambuStudio source -**Branch:** `dev/ams-heat` - -## Overview - -Port the AMS filament drying control feature from BambuStudio into OrcaSlicer. This allows users to start, monitor, and stop AMS-based filament drying directly from the slicer UI. Currently OrcaSlicer can monitor drying state and stop drying, but cannot initiate it. This feature adds the missing start-drying command, the full drying control dialog, and the drying preset lookup infrastructure. - -**Target AMS models:** N3F (AMS 2 Pro, max 65°C) and N3S (AMS HT, max 85°C) — the only AMS units with heating hardware. - -## Architecture - -The feature spans three layers, matching the existing OrcaSlicer `DeviceCore/` pattern: - -``` -┌─────────────────────────────────────────────────────┐ -│ UI Layer │ -│ AMSDryControl.hpp/.cpp (new dialog) │ -│ AMSControl.cpp (hook: click → dialog) │ -│ DeviceErrorDialog.cpp (stop drying on error) │ -├─────────────────────────────────────────────────────┤ -│ Command/Control Layer │ -│ DevFilaSystemCtrl.cpp (start/stop drying) │ -│ DevUtilBackend.h/.cpp (preset lookup) │ -├─────────────────────────────────────────────────────┤ -│ Data Model Layer │ -│ DevFilaSystem.h/.cpp (enums, structs, parse) │ -│ DevDefs.h (DevAmsType enum) │ -└─────────────────────────────────────────────────────┘ -│ Communication Layer (already exists) │ -│ MachineObject::publish_json() → MQTT → printer │ -└─────────────────────────────────────────────────────┘ -``` - -### New files (4 source + headers) - -| File | Purpose | -|------|---------| -| `src/slic3r/GUI/AMSDryControl.hpp` | AMS drying control dialog class declarations | -| `src/slic3r/GUI/AMSDryControl.cpp` | Dialog implementation (~1800 lines) | -| `src/slic3r/GUI/DeviceCore/DevFilaSystemCtrl.cpp` | `CtrlAmsStartDryingHour()` and `CtrlAmsStopDrying()` | -| `src/slic3r/GUI/DeviceCore/DevUtilBackend.h` | Static utility class declaring `GetFilamentDryingPreset()` | -| `src/slic3r/GUI/DeviceCore/DevUtilBackend.cpp` | Reads filament drying presets from printer config | - -### Modified files - -| File | Changes | -|------|---------| -| `DevFilaSystem.h` | Add drying enums/structs to `DevAms`; add `DevFilamentDryingPreset` struct; add ctrl method declarations to `DevFilaSystem`; add `get_ams_drying_preset()` to `DevAmsTray` | -| `DevFilaSystem.cpp` | Parse dry status fields in `DevFilaSystemParser`; implement `IsSupportRemoteDry()`, `AmsIsDrying()`, `get_ams_drying_preset()` | -| `DevDefs.h` | Add `DevAmsType` as a global enum | -| `AMSControl.cpp` | Wire humidity indicator click to open `AMSDryCtrWin` for N3F/N3S AMS | -| `DeviceErrorDialog.cpp` | Update stop-drying to use `DevFilaSystem::CtrlAmsStopDrying()` | -| `DeviceCore/CMakeLists.txt` | Register new source files | - -### New image assets (14) - -| Asset | Purpose | -|-------|---------| -| `hum_level1_no_num_light.svg` – `hum_level5_no_num_light.svg` | 5 humidity level icons | -| `dev_ams_dry_ctr_n3f_heating.png`, `_dehumidifying.png`, `_error.png`, `_cooling.png` | 4 N3F drying state images | -| `dev_ams_dry_ctr_n3s_heating.png`, `_dehumidifying.png`, `_error.png`, `_cooling.png` | 4 N3S drying state images | -| `dev_ams_dry_ctr_heating_icon.svg` | Animated heating indicator | -| `dev_ams_dry_ctr_filament_in_chamber.png` | Guide page illustration | -| `dev_ams_dry_ctr_enable.svg`, `dev_ams_dry_ctr_disable.svg` | Filament tray status icons | - -Additionally, OrcaSlicer already has `ams_drying.svg` and `ams_is_drying.svg`. - -## Data Model - -### `DevDefs.h` — Global `DevAmsType` enum - -Promoted from `DevAms::AmsType` (currently local to the class) to a global enum in `DevDefs.h` so `DevFilamentDryingPreset` and `DevUtilBackend` can reference it without depending on `DevAms`. `DevAms::AmsType` is updated to be a typedef for `DevAmsType`, and all existing references to `DevAms::AmsType` remain valid (it's the same type): - -```cpp -// DevDefs.h — global enum matching BambuStudio's DevAmsType naming -enum DevAmsType : int { - EXT_SPOOL = 0, - AMS = 1, - AMS_LITE = 2, - N3F = 3, // AMS 2 Pro - N3S = 4, // AMS HT -}; - -// DevFilaSystem.h — DevAms class updated: -// Replace "enum AmsType { DUMMY=0, AMS=1, AMS_LITE=2, N3F=3, N3S=4 }" -// with "using AmsType = DevAmsType;" -// Existing references to DevAms::DUMMY become DevAms::EXT_SPOOL (same value, different name). -``` - -### `DevAms` class additions (in `DevFilaSystem.h`) - -**New enums:** - -| Enum | Values | -|------|--------| -| `DryCtrlMode` | `Off=0, OnTime=1, OnHumidity=2` | -| `DryStatus` | `Off=0, Checking=1, Drying=2, Cooling=3, Stopping=4, Error=5, CannotStopHeatOutofControl=6, PrdTesting=7` | -| `DrySubStatus` | `Off=0, Heating=1, Dehumidify=2` | -| `DryFanStatus` | `Off=0, On=1` | -| `CannotDryReason` | `TaskOccupied=0, InsufficientPower=1, AmsBusy=2, ConsumableAtAmsOutlet=3, InitiatingAmsDrying=4, NotSupportedIn2dMode=5, DryingInProgress=6, Upgrading=7, InsufficientPowerNeedPluginPower=8, FilamentAtAmsOutletManualUnload=10` | - -**New struct:** - -```cpp -struct DrySettings { - std::string dry_filament; - int dry_temp = -1; // -1 means invalid - int dry_hour = -1; // hours -}; -``` - -**New getters on `DevAms`:** - -- `std::optional GetDryStatus() const` -- `std::optional GetDrySubStatus() const` -- `std::optional GetFan1Status() const` -- `std::optional GetFan2Status() const` -- `std::optional> GetCannotDryReason() const` -- `std::optional GetDrySettings() const` -- `bool IsSupportRemoteDry(const MachineObject* obj) const` -- `bool AmsIsDrying()` - -**New private members:** `m_dry_status`, `m_dry_sub_status`, `m_dry_fan1_status`, `m_dry_fan2_status`, `m_dry_cannot_reasons`, `m_dry_settings` — all `std::optional<>`. - -### `DevFilamentDryingPreset` struct (namespace scope in `DevFilaSystem.h`) - -```cpp -struct DevFilamentDryingPreset { - std::string filament_id; - std::unordered_set ams_limitations; - std::unordered_map filament_dev_ams_drying_time_on_idle; // hours - std::unordered_map filament_dev_ams_drying_temperature_on_idle; - std::unordered_map filament_dev_ams_drying_time_on_print; // hours - std::unordered_map filament_dev_ams_drying_temperature_on_print; - float filament_dev_drying_cooling_temperature; - float filament_dev_drying_softening_temperature; - float filament_dev_ams_drying_heat_distortion_temperature; -}; -``` - -Populated from filament preset config keys (`filament_dev_ams_drying_temperature`, `filament_dev_ams_drying_time`, `filament_dev_drying_softening_temperature`, etc.). - -### `DevAmsTray` addition - -```cpp -std::optional get_ams_drying_preset() const; -``` - -Delegates to `DevUtilBackend::GetFilamentDryingPreset(setting_id)`. - -### JSON parsing - -`DevFilaSystemParser::ParseV1_0()` extended to parse the following fields from the printer's AMS status JSON: - -| JSON field | DevAms member | -|------------|---------------| -| `dry_status` | `m_dry_status` (`DryStatus` enum) | -| `dry_sub_status` | `m_dry_sub_status` (`DrySubStatus` enum) | -| `dry_fan1_status` | `m_dry_fan1_status` | -| `dry_fan2_status` | `m_dry_fan2_status` | -| `dry_cannot_reasons` | `m_dry_cannot_reasons` (vector of `CannotDryReason`) | -| `dry_settings` | `m_dry_settings` (`DrySettings` struct) | - -## Commands - -### `DevFilaSystem::CtrlAmsStartDryingHour()` - -Publishes JSON command `"ams_filament_drying"` via `MachineObject::publish_json()`: - -```json -{ - "print": { - "command": "ams_filament_drying", - "sequence_id": "", - "ams_id": , - "mode": 1, - "filament": "", - "temp": , - "duration": , - "humidity": 0, - "rotate_tray": , - "cooling_temp": , - "close_power_conflict": false - } -} -``` - -### `DevFilaSystem::CtrlAmsStopDrying()` - -Same command with `mode: 0` (Off). - -### Existing `MachineObject::command_ams_drying_stop()` - -Kept for backward compatibility. The error dialog will be updated to call `CtrlAmsStopDrying()` instead, which is the preferred path. - -## Backend Utility - -### `DevUtilBackend` (new, `DeviceCore/DevUtilBackend.h/.cpp`) - -Static utility class. Only method needed for this feature: - -```cpp -static std::optional GetFilamentDryingPreset(const std::string& fila_id); -``` - -Iterates `wxGetApp().preset_bundle->filaments` to find the matching filament_id, then reads config keys into a `DevFilamentDryingPreset`. Uses a static map `{"0" → N3F, "1" → N3S}` for the `ams_limitations` string-to-enum conversion. - -## UI Dialog - -### `AMSDryCtrWin` (extends `DPIDialog`) - -Three pages in a `wxSimplebook`: - -#### Page 0: Main Page - -Split left/right layout: - -**Left panel — Status display:** -- Large humidity/drying state image: humidity level icon when idle; heating/dehumidifying/error animation when active -- Status label with animated heating icon ("Idle", "Drying-Heating", "Drying-Dehumidifying") -- Three stat labels: Humidity (%), Temperature (°C), Left Time (HH:MM). Left Time hidden when idle. - -**Right panel — Controls.** Three sub-panels shown/hidden based on `DryStatus` and `CannotDryReason`: - -| Sub-panel | When shown | Contents | -|-----------|------------|----------| -| Normal state | `DryStatus` is Off or Cooling | Filament type combobox → Temperature input (numeric, validated to AMS limits) → Time input (hours, 1–24) → Validation warning text → **Start** button | -| Cannot dry | `CannotDryReason` is non-empty (excluding only `DryingInProgress`) | Formatted reason text + **Unload** button (only for `ConsumableAtAmsOutlet` reason) | -| Drying error | `DryStatus` is Error | Error message + **Stop** button | -| Drying active | `DryStatus` is Checking/Drying/Stopping | Read-only temp/time display + **Stop** button | - -**Temperature validation rules:** -- Hardware limits: N3F 45–65°C, N3S 45–85°C -- Heat distortion check: temp must not exceed `filament_dev_ams_drying_heat_distortion_temperature` when filament is present -- Printing mode: temp must not exceed recommended drying temp when this AMS is actively feeding a print -- Time: 1–24 hours - -#### Page 1: Guide Page - -Shown after clicking Start on Page 0, before sending the command: -- Instructional text about removing heat-sensitive filament -- `AMSFilamentPanel` showing each tray's filament type with ✅/⚠ icons based on whether drying temp exceeds that filament's softening point -- "Rotate spool when drying" checkbox -- **Back** and **Start** buttons - -#### Page 2: Progress Page - -Shown while waiting for printer to confirm drying has started: -- Animated progress bar (cycles 0–99% until printer state changes) -- Cycling status messages -- Auto-transitions back to Page 0 when printer reports drying active - -### Helper classes (in `AMSDryControl.hpp`) - -- **`FilamentItemPanel`** — Renders a single filament item with icon and text, with custom rounded-rectangle border painting -- **`AMSFilamentPanel`** — Container for multiple `FilamentItemPanel` instances, labeled with AMS name -- **`DryingPreset` struct, `DryCtrState` enum, `DryCtrDev` enum** — Local types for tracking preset state - -### Dialog lifecycle - -- Created on first click of the AMS humidity indicator -- Updated on each `AMSControl::parse_object()` cycle via `update(fila_system, obj)` -- Auto-closes if: AMS is removed from the system, AMS no longer supports remote drying, or AMS ID becomes invalid -- Closed manually via close button or dialog destruction -- On close: stops progress timer, resets to main page, restores button states - -## Integration - -### Entry point: AMS humidity click → dialog - -In `AMSControl.cpp`, the `EVT_AMS_SHOW_HUMIDITY_TIPS` handler currently shows either `AmsHumidityTipPopup` or `uiAmsPercentHumidityDryPopup`. For N3F and N3S AMS types only, replace this with opening `AMSDryCtrWin`: - -1. Lazily create `AMSDryCtrWin*` member on `AMSControl` (like `m_percent_humidity_dry_popup` today) -2. Call `set_ams_id()` and `update(fila_system, obj)` before showing -3. Show the dialog modally via `ShowModal()` (matching BambuStudio behavior) - -### Periodic updates - -`AMSControl::parse_object()` updates AMS state on a timer. Extend it to: -1. If `m_dry_ctr_win` is shown, call `update()` to refresh readings -2. Update the dialog's AMS ID if the user switches AMS view - -### Error dialog - -`DeviceErrorDialog.cpp` line 452 calls `m_obj->command_ams_drying_stop()`. Update to use `obj->GetFilaSystem()->CtrlAmsStopDrying(ams_id)` for the correct AMS ID. - -### CMakeLists - -- `src/slic3r/GUI/DeviceCore/CMakeLists.txt`: add `DevFilaSystemCtrl.cpp`, `DevUtilBackend.h`, `DevUtilBackend.cpp` -- GUI sources list: add `AMSDryControl.hpp`, `AMSDryControl.cpp` - -## Testing - -### Manual verification - -1. **Prerequisite:** Connect to a printer with N3F or N3S AMS hardware -2. Click AMS humidity indicator → verify dialog opens with correct humidity/temperature readings -3. Select a filament from the combobox → verify temp/time auto-fill from preset -4. Enter valid temp/time → verify Start button enables -5. Enter invalid temp (e.g., 100°C for N3F) → verify warning appears and button disables -6. Click Start → verify guide page shows filament tray status -7. Click Start on guide page → verify progress page appears, command is sent -8. Verify printer begins drying → dialog shows active drying state with countdown -9. Click Stop → verify drying stops -10. Test "cannot dry" states (e.g., during print, filament at outlet) - -### Code-level checks - -- Verify `CtrlAmsStartDryingHour` publishes correct JSON structure -- Verify `CtrlAmsStopDrying` publishes stop command -- Verify `GetFilamentDryingPreset` returns correct values for known filament IDs -- Verify dark mode rendering of all dialog pages - -## Constraints - -- **N3F/N3S only:** Standard AMS and AMS Lite do not have heating hardware; the dialog is not shown for them -- **Backward compatibility:** Existing `command_ams_drying_stop()` is preserved; new commands are additive -- **Cross-platform:** wxWidgets UI must work on Windows, macOS, Linux -- **Dark mode:** Dialog must support dark mode via `wxGetApp().dark_mode()` and `StateColor::darkModeColorFor()` -- **Profile compatibility:** Existing profiles with `filament_dev_ams_drying_*` keys (Qidi) continue to work; the feature reads the same keys -- **No printer-agent changes needed:** All commands go through existing `publish_json()` → `NetworkAgent` path, which already handles MQTT for connected printers From 0e20f607122144ce18175655ea99ec5cf331f82c Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 15:25:32 +0800 Subject: [PATCH 20/47] fix: adapt SVG colors to OrcaSlicer palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dev_ams_dry_ctr_enable.svg: Bambu green #00AE42 → Orca teal #009688 - dev_ams_dry_ctr_heating_icon.svg: red #E14747 → amber #F09A17 (matches ams_is_drying.svg) --- resources/images/dev_ams_dry_ctr_enable.svg | 2 +- resources/images/dev_ams_dry_ctr_heating_icon.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/images/dev_ams_dry_ctr_enable.svg b/resources/images/dev_ams_dry_ctr_enable.svg index 0c8d4ca146..31eb482d17 100644 --- a/resources/images/dev_ams_dry_ctr_enable.svg +++ b/resources/images/dev_ams_dry_ctr_enable.svg @@ -1,4 +1,4 @@ - + diff --git a/resources/images/dev_ams_dry_ctr_heating_icon.svg b/resources/images/dev_ams_dry_ctr_heating_icon.svg index 9084670fb7..614b0c9a92 100644 --- a/resources/images/dev_ams_dry_ctr_heating_icon.svg +++ b/resources/images/dev_ams_dry_ctr_heating_icon.svg @@ -1,3 +1,3 @@ - + From f713c7ae4937d9f85f6d5d8fbf05e07b257f56c1 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 16:34:35 +0800 Subject: [PATCH 21/47] Make sure codes uses same structure/naming/position as BambuStudio for easier code comparasion & porting in the future --- src/libslic3r/Preset.cpp | 20 ++++++ src/libslic3r/Preset.hpp | 3 + src/slic3r/GUI/AMSDryControl.cpp | 2 +- src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp | 69 +++++++++--------- src/slic3r/GUI/DeviceCore/DevFilaSystem.h | 7 +- src/slic3r/GUI/DeviceManager.hpp | 2 +- src/slic3r/GUI/StatusPanel.cpp | 1 + src/slic3r/GUI/Widgets/AMSControl.cpp | 80 +++++++++++---------- src/slic3r/GUI/Widgets/AMSControl.hpp | 5 +- 9 files changed, 111 insertions(+), 78 deletions(-) diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 98a5a4fcb6..6d08ca9f2d 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -3781,6 +3781,26 @@ void PresetCollection::set_custom_preset_alias(Preset &preset) set_printer_hold_alias(preset.alias, preset); } +std::string PresetCollection::get_preset_alias(Preset &preset, bool force) +{ + if (!preset.alias.empty()) + return preset.alias; + else + set_custom_preset_alias(preset); + + if (!preset.alias.empty() || !force) + return preset.alias; + + std::string alias_name; + std::string preset_name = preset.name; + size_t end_pos = preset_name.find_first_of("@"); + if (end_pos != std::string::npos) { + alias_name = preset_name.substr(0, end_pos); + boost::trim_right(alias_name); + } + return alias_name; +} + void PresetCollection::set_printer_hold_alias(const std::string &alias, Preset &preset, bool remove) { auto compatible_printers = dynamic_cast(preset.config.option("compatible_printers")); diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp index bbbdc6c5ff..cd3c244fd9 100644 --- a/src/libslic3r/Preset.hpp +++ b/src/libslic3r/Preset.hpp @@ -803,6 +803,9 @@ public: std::string path_from_name(const std::string &new_name, bool detach = false) const; std::string path_for_preset(const Preset & preset) const; + // Get the alias of a preset, setting it if it's empty + std::string get_preset_alias(Preset &preset, bool force = false); + size_t num_default_presets() { return m_num_default_presets; } protected: diff --git a/src/slic3r/GUI/AMSDryControl.cpp b/src/slic3r/GUI/AMSDryControl.cpp index 7e727abbdf..98b35b5809 100644 --- a/src/slic3r/GUI/AMSDryControl.cpp +++ b/src/slic3r/GUI/AMSDryControl.cpp @@ -1615,7 +1615,7 @@ int AMSDryCtrWin::update_filament_list(DevAms* dev_ams, MachineObject* obj) } filament_id_set.insert(filament_it->filament_id); - auto filament_alias = filament_it->alias; + auto filament_alias = filaments.get_preset_alias(*filament_it, true); if (!filament_alias.empty()) { auto opt_info = preset_bundle->get_filament_by_filament_id(filament_it->filament_id); if (opt_info.has_value()) { diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp index a2787577fd..c54b8b8964 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp @@ -99,8 +99,7 @@ std::string DevAmsTray::get_filament_type() return m_fila_type; } - -std::optional DevAmsTray::get_ams_drying_preset() const +std::optional DevAmsTray::get_ams_drying_preset() const { return DevUtilBackend::GetFilamentDryingPreset(setting_id); } @@ -454,32 +453,17 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS ; } - if (it->contains("dry_time") && (*it)["dry_time"].is_number()) + + if (it->contains("temp")) { - curr_ams->m_left_dry_time = (*it)["dry_time"].get(); - } - - // Drying status — only parse if printer supports remote drying - if (obj->is_support_remote_dry) { - if (it->contains("info")) { - const std::string& info = (*it)["info"].get(); - curr_ams->m_dry_status = (DevAms::DryStatus)DevUtil::get_flag_bits(info, 4, 4); - curr_ams->m_dry_fan1_status = (DevAms::DryFanStatus)DevUtil::get_flag_bits(info, 18, 2); - curr_ams->m_dry_fan2_status = (DevAms::DryFanStatus)DevUtil::get_flag_bits(info, 20, 2); - curr_ams->m_dry_sub_status = (DevAms::DrySubStatus)DevUtil::get_flag_bits(info, 22, 2); + std::string temp = (*it)["temp"].get(); + try + { + curr_ams->m_current_temperature = DevUtil::string_to_float(temp); } - - if (it->contains("dry_setting")) { - const auto& j_dry_settings = (*it)["dry_setting"]; - DevAms::DrySettings dry_settings; - DevJsonValParser::ParseVal(j_dry_settings, "dry_filament", dry_settings.dry_filament); - DevJsonValParser::ParseVal(j_dry_settings, "dry_temperature", dry_settings.dry_temp); - DevJsonValParser::ParseVal(j_dry_settings, "dry_duration", dry_settings.dry_hour); - curr_ams->m_dry_settings = dry_settings; - } - - if (it->contains("dry_sf_reason")) { - curr_ams->m_dry_cannot_reasons = DevJsonValParser::GetVal>((*it), "dry_sf_reason"); + catch (...) + { + curr_ams->m_current_temperature = INVALID_AMS_TEMPERATURE; } } @@ -509,17 +493,32 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS } } - - if (it->contains("temp")) + if (it->contains("dry_time") && (*it)["dry_time"].is_number()) { - std::string temp = (*it)["temp"].get(); - try - { - curr_ams->m_current_temperature = DevUtil::string_to_float(temp); + curr_ams->m_left_dry_time = (*it)["dry_time"].get(); + } + + // Drying status — only parse if printer supports remote drying + if (obj->is_support_remote_dry) { + if (it->contains("info")) { + const std::string& info = (*it)["info"].get(); + curr_ams->m_dry_status = (DevAms::DryStatus)DevUtil::get_flag_bits(info, 4, 4); + curr_ams->m_dry_fan1_status = (DevAms::DryFanStatus)DevUtil::get_flag_bits(info, 18, 2); + curr_ams->m_dry_fan2_status = (DevAms::DryFanStatus)DevUtil::get_flag_bits(info, 20, 2); + curr_ams->m_dry_sub_status = (DevAms::DrySubStatus)DevUtil::get_flag_bits(info, 22, 2); } - catch (...) - { - curr_ams->m_current_temperature = INVALID_AMS_TEMPERATURE; + + if (it->contains("dry_setting")) { + const auto& j_dry_settings = (*it)["dry_setting"]; + DevAms::DrySettings dry_settings; + DevJsonValParser::ParseVal(j_dry_settings, "dry_filament", dry_settings.dry_filament); + DevJsonValParser::ParseVal(j_dry_settings, "dry_temperature", dry_settings.dry_temp); + DevJsonValParser::ParseVal(j_dry_settings, "dry_duration", dry_settings.dry_hour); + curr_ams->m_dry_settings = dry_settings; + } + + if (it->contains("dry_sf_reason")) { + curr_ams->m_dry_cannot_reasons = DevJsonValParser::GetVal>((*it), "dry_sf_reason"); } } diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h index 283de146db..23f8d23b50 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h @@ -104,10 +104,11 @@ public: std::string get_display_filament_type() const; std::string get_filament_type(); - std::optional get_ams_drying_preset() const; // static static wxColour decode_color(const std::string& color); + + std::optional get_ams_drying_preset() const; }; /** @@ -228,7 +229,7 @@ public: int GetHumidityLevel() const { return m_humidity_level; } int GetHumidityPercent() const { return m_humidity_percent; } - bool SupportDrying() const { return m_ams_type == N3F || m_ams_type == N3S; } + bool SupportDrying() const { return m_ams_type == DevAmsType::N3F || m_ams_type == DevAmsType::N3S; } int GetLeftDryTime() const { return m_left_dry_time; } // remote drying control @@ -333,6 +334,8 @@ public: public: // ctrls int CtrlAmsReset() const; + + // crtl int CtrlAmsStartDryingHour(int ams_id, std::string filament_type, int tag_temp, int tag_duration_hour, bool rotate_tray, int cooling_temp, bool close_power_conflict = false) const; int CtrlAmsStopDrying(int ams_id) const; diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 6e4007a62b..a0d8f97ea6 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -254,7 +254,6 @@ public: bool is_tunnel_mqtt = false; //AmsTray vt_tray; // virtual tray - bool is_support_remote_dry = false; long ams_exist_bits = 0; long tray_exist_bits = 0; long tray_is_bbl_bits = 0; @@ -621,6 +620,7 @@ public: // fun2 bool is_support_print_with_emmc{false}; + bool is_support_remote_dry = false; bool installed_upgrade_kit{false}; int bed_temperature_limit = -1; diff --git a/src/slic3r/GUI/StatusPanel.cpp b/src/slic3r/GUI/StatusPanel.cpp index 5cbf3e2a83..6b1cb8ebd2 100644 --- a/src/slic3r/GUI/StatusPanel.cpp +++ b/src/slic3r/GUI/StatusPanel.cpp @@ -3334,6 +3334,7 @@ void StatusPanel::update_ams(MachineObject *obj) // must select a current can m_ams_control->UpdateAms(obj->get_printer_series_str(), obj->printer_type, ams_info, ext_info, *obj->GetExtderSystem(), obj->get_dev_id(), false); + m_ams_control->UpdateAmsDryControl(obj); last_tray_exist_bits = obj->tray_exist_bits; last_ams_exist_bits = obj->ams_exist_bits; diff --git a/src/slic3r/GUI/Widgets/AMSControl.cpp b/src/slic3r/GUI/Widgets/AMSControl.cpp index ea62d18d92..24e35cda12 100644 --- a/src/slic3r/GUI/Widgets/AMSControl.cpp +++ b/src/slic3r/GUI/Widgets/AMSControl.cpp @@ -6,7 +6,6 @@ #include "slic3r/GUI/MsgDialog.hpp" #include "slic3r/GUI/DeviceTab/uiAmsHumidityPopup.h" -#include "slic3r/GUI/AMSDryControl.hpp" #include "slic3r/GUI/DeviceCore/DevManager.h" #include "slic3r/GUI/DeviceCore/DevFilaSystem.h" @@ -30,6 +29,7 @@ AMSControl::AMSControl(wxWindow *parent, wxWindowID id, const wxPoint &pos, cons , m_Humidity_tip_popup(AmsHumidityTipPopup(this)) , m_percent_humidity_dry_popup(new uiAmsPercentHumidityDryPopup(this)) , m_ams_introduce_popup(AmsIntroducePopup(this)) + , m_ams_dry_ctr_win(new AMSDryCtrWin(this)) { Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); if (dev) { @@ -245,6 +245,12 @@ AMSControl::AMSControl(wxWindow *parent, wxWindowID id, const wxPoint &pos, cons uiAmsHumidityInfo *info = (uiAmsHumidityInfo *) evt.GetClientData(); if (info) { + Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); + MachineObject *obj = nullptr; + if (dev) { + obj = dev->get_selected_machine(); + } + if (info->ams_type == AMSModel::GENERIC_AMS) { wxPoint img_pos = ClientToScreen(wxPoint(0, 0)); @@ -254,27 +260,14 @@ AMSControl::AMSControl(wxWindow *parent, wxWindowID id, const wxPoint &pos, cons int humidity_value = info->humidity_display_idx; if (humidity_value > 0 && humidity_value <= 5) { m_Humidity_tip_popup.set_humidity_level(humidity_value); } m_Humidity_tip_popup.Popup(); - } - else if (info->ams_type == AMSModel::N3F_AMS || info->ams_type == AMSModel::N3S_AMS) - { - // Open full drying control dialog for N3F/N3S AMS - if (!m_dry_ctr_win) { - m_dry_ctr_win = new AMSDryCtrWin(this); - } - m_dry_ctr_win->set_ams_id(info->ams_id); + } else if (obj && obj->is_support_remote_dry && (info->ams_type == AMSModel::N3F_AMS || info->ams_type == AMSModel::N3S_AMS)){ + m_ams_dry_ctr_win->set_ams_id(info->ams_id); - Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); - if (dev) { - MachineObject *obj = dev->get_selected_machine(); - if (obj && obj->GetFilaSystem()) { - std::shared_ptr fila_ptr(obj->GetFilaSystem(), [](DevFilaSystem*){}); - m_dry_ctr_win->update(fila_ptr, obj); - } - } - m_dry_ctr_win->ShowModal(); - } - else - { + wxPoint img_pos = ClientToScreen(wxPoint(0, 0)); + wxPoint popup_pos(img_pos.x - m_ams_dry_ctr_win->GetSize().GetWidth() + FromDIP(150), img_pos.y - FromDIP(80)); + m_ams_dry_ctr_win->Move(popup_pos); + m_ams_dry_ctr_win->ShowModal(); + } else { m_percent_humidity_dry_popup->Update(info); wxPoint img_pos = ClientToScreen(wxPoint(0, 0)); @@ -294,7 +287,12 @@ void AMSControl::on_retry() post_event(wxCommandEvent(EVT_AMS_RETRY)); } -AMSControl::~AMSControl() {} +AMSControl::~AMSControl() +{ + if (m_ams_dry_ctr_win) { + delete m_ams_dry_ctr_win; + } +} std::string AMSControl::GetCurentAms() { return m_current_ams; @@ -512,8 +510,8 @@ void AMSControl::msw_rescale() m_percent_humidity_dry_popup->msw_rescale(); } - if (m_dry_ctr_win){ - m_dry_ctr_win->msw_rescale(); + if (m_ams_dry_ctr_win) { + m_ams_dry_ctr_win->msw_rescale(); } m_Humidity_tip_popup.msw_rescale(); @@ -839,6 +837,27 @@ void AMSControl::show_vams_kn_value(bool show) //m_vams_lib->show_kn_value(show); } +void AMSControl::UpdateAmsDryControl(MachineObject* obj) +{ + if (!m_ams_dry_ctr_win->IsShown()) { + return; + } + + if (!obj || !obj->GetFilaSystem()) { + m_ams_dry_ctr_win->Close(); + return; + } + + std::weak_ptr weak_fila_system = obj->GetFilaSystem(); + + if (auto locaked_fila_system = weak_fila_system.lock()) { + m_ams_dry_ctr_win->update(locaked_fila_system, obj); + } else { + m_ams_dry_ctr_win->Close(); + return; + } +} + std::vector AMSControl::GenerateSimulateData() { auto caninfo0_0 = Caninfo{ "0", (""), *wxRED, AMSCanType::AMS_CAN_TYPE_VIRTUAL }; auto caninfo0_1 = Caninfo{ "1", (""), *wxGREEN, AMSCanType::AMS_CAN_TYPE_VIRTUAL }; @@ -993,19 +1012,6 @@ void AMSControl::UpdateAms(const std::string &series_name, } } - /*update AMS dry control dialog*/ - if (m_dry_ctr_win && m_dry_ctr_win->IsShown()) - { - Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); - if (dev) { - MachineObject *obj = dev->get_selected_machine(); - if (obj && obj->GetFilaSystem()) { - std::shared_ptr fila_ptr(obj->GetFilaSystem(), [](DevFilaSystem*){}); - m_dry_ctr_win->update(fila_ptr, obj); - } - } - } - /*update ams extruder*/ if (m_extruder->updateNozzleNum(m_total_ext_count, series_name)) { diff --git a/src/slic3r/GUI/Widgets/AMSControl.hpp b/src/slic3r/GUI/Widgets/AMSControl.hpp index 93d9acee67..ff727fd77c 100644 --- a/src/slic3r/GUI/Widgets/AMSControl.hpp +++ b/src/slic3r/GUI/Widgets/AMSControl.hpp @@ -15,13 +15,13 @@ #include #include "slic3r/GUI/DeviceCore/DevExtruderSystem.h" +#include "slic3r/GUI/AMSDryControl.hpp" namespace Slic3r { namespace GUI { //Previous definitions class uiAmsPercentHumidityDryPopup; -class AMSDryCtrWin; class AMSControl : public wxSimplebook { @@ -119,7 +119,7 @@ protected: AmsHumidityTipPopup m_Humidity_tip_popup; uiAmsPercentHumidityDryPopup* m_percent_humidity_dry_popup; - AMSDryCtrWin* m_dry_ctr_win{nullptr}; + AMSDryCtrWin* m_ams_dry_ctr_win; std::string m_last_ams_id = ""; std::string m_last_tray_id = ""; @@ -158,6 +158,7 @@ public: void CreateAmsDoubleNozzle(const std::string &series_name, const std::string& printer_type); void CreateAmsSingleNozzle(const std::string &series_name, const std::string &printer_type); void ClearAms(); + void UpdateAmsDryControl(MachineObject* obj); void UpdateAms(const std::string &series_name, const std::string &printer_type, std::vector ams_info, From 922af972c70c534d630e01d9f9958dd5995f6e8e Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 17:29:10 +0800 Subject: [PATCH 22/47] refactor: convert m_fila_system from raw pointer to shared_ptr Matches BambuStudio commit c8f70c6ca. DevFilaSystem* m_fila_system becomes std::shared_ptr, enabling proper shared ownership for the AMS drying control feature without no-op deleter hacks. - DeviceManager.hpp: m_fila_system and GetFilaSystem() use shared_ptr - DeviceManager.cpp: std::make_shared allocation, remove manual delete, add .get() to ParseV1_0 calls - MoonrakerPrinterAgent.cpp: add .get() to GetFilaSystem() in ParseV1_0 --- src/slic3r/GUI/DeviceManager.cpp | 6 ++---- src/slic3r/GUI/DeviceManager.hpp | 4 ++-- src/slic3r/Utils/MoonrakerPrinterAgent.cpp | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 2562d73b22..f8d7c970b2 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -566,7 +566,7 @@ MachineObject::MachineObject(DeviceManager* manager, NetworkAgent* agent, std::s m_extder_system = new DevExtderSystem(this); m_extension_tool = DevExtensionTool::Create(this); m_nozzle_system = new DevNozzleSystem(this); - m_fila_system = new DevFilaSystem(this); + m_fila_system = std::make_shared(this); m_hms_system = new DevHMS(this); m_config = new DevConfig(this); @@ -612,8 +612,6 @@ MachineObject::~MachineObject() delete m_ctrl; m_ctrl = nullptr; - delete m_fila_system; - m_fila_system = nullptr; delete m_hms_system; m_hms_system = nullptr; @@ -3723,7 +3721,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ update_printer_preset_name(); update_filament_list(); if (jj.contains("ams")) { - DevFilaSystemParser::ParseV1_0(jj, this, m_fila_system, key_field_only); + DevFilaSystemParser::ParseV1_0(jj, this, m_fila_system.get(), key_field_only); } /* vitrual tray*/ diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index a0d8f97ea6..fae8d60415 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -114,7 +114,7 @@ private: std::shared_ptr m_extension_tool; DevExtderSystem* m_extder_system; DevNozzleSystem* m_nozzle_system; - DevFilaSystem* m_fila_system; + std::shared_ptr m_fila_system; DevFan* m_fan; DevBed * m_bed; DevStorage* m_storage; @@ -329,7 +329,7 @@ public: DevNozzleSystem* GetNozzleSystem() const { return m_nozzle_system;} - DevFilaSystem* GetFilaSystem() const { return m_fila_system;} + std::shared_ptr GetFilaSystem() const { return m_fila_system;} bool HasAms() const; DevLamp* GetLamp() const { return m_lamp; } diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index f1d892134b..b0ea653dd3 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -536,7 +536,7 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, print_json["ams"] = ams_json; // Call the parser to populate DevFilaSystem - DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem(), false); + DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false); BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload: Parsed " << trays.size() << " trays"; // Set printer_type so update_sync_status() can match it against the preset's printer type. From f58a1f350a077715e3590db1cecd7d95ff4b1c4d Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 17:45:08 +0800 Subject: [PATCH 23/47] feat: add drying-while-printing warning in SelectMachine dialog Port from BambuStudio commit c8f70c6ca. Shows a warning when AMS is actively drying during print job preparation, alerting users that the drying temperature will be lowered during printing. --- src/slic3r/GUI/SelectMachine.cpp | 29 +++++++++++++++++++++++++++++ src/slic3r/GUI/SelectMachine.hpp | 6 ++++++ 2 files changed, 35 insertions(+) diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 09314d3b49..3330f527ca 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -506,6 +506,14 @@ SelectMachineDialog::SelectMachineDialog(Plater *plater) //m_change_filament_times_sizer->Add(m_img_change_filament_times, 0, wxTOP, FromDIP(2)); m_change_filament_times_sizer->Add(m_txt_change_filament_times, 0, wxTOP, 0); + m_warn_when_drying_sizer = new wxBoxSizer(wxHORIZONTAL); + m_txt_warn_when_drying = new Label(m_scroll_area, wxEmptyString); + m_txt_warn_when_drying->SetFont(::Label::Body_13); + m_txt_warn_when_drying->SetForegroundColour(wxColour("#F09A17")); + m_txt_warn_when_drying->SetBackgroundColour(*wxWHITE); + m_txt_warn_when_drying->SetLabel(_L("To ensure print quality, the drying temperature will be lowered during printing.")); + m_warn_when_drying_sizer->Add(m_txt_warn_when_drying, 0, wxTOP, FromDIP(2)); + /*Advanced Options*/ wxBoxSizer* sizer_split_options = new wxBoxSizer(wxHORIZONTAL); auto m_split_options_line = new wxPanel(m_scroll_area, wxID_ANY); @@ -716,6 +724,8 @@ SelectMachineDialog::SelectMachineDialog(Plater *plater) m_scroll_sizer->Add(m_change_filament_times_sizer, 0,wxLEFT|wxRIGHT, FromDIP(15)); // m_scroll_sizer->Add(m_link_edit_nozzle, 0, wxLEFT|wxRIGHT, FromDIP(15)); m_scroll_sizer->Add(suggestion_sizer, 0, wxLEFT|wxRIGHT|wxEXPAND, FromDIP(15)); + m_scroll_sizer->Add(0, 0, 0, wxTOP, FromDIP(10)); + m_scroll_sizer->Add(m_warn_when_drying_sizer, 0, wxLEFT|wxRIGHT, FromDIP(15)); m_scroll_sizer->Add(sizer_split_options, 1, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(15)); m_scroll_sizer->Add(0, 0, 0, wxTOP, FromDIP(10)); m_scroll_sizer->Add(m_options_other, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(15)); @@ -1443,6 +1453,18 @@ int SelectMachineDialog::convert_filament_map_nozzle_id_to_task_nozzle_id(int no } } +bool SelectMachineDialog::is_ams_drying(MachineObject* obj) +{ + const auto& ams_list = obj->GetFilaSystem()->GetAmsList(); + for (auto ams = ams_list.begin(); ams != ams_list.end(); ams++) { + if (ams->second->AmsIsDrying()) { + return true; + } + } + + return false; +} + void SelectMachineDialog::prepare(int print_plate_idx) { m_print_plate_idx = print_plate_idx; @@ -3285,6 +3307,12 @@ void SelectMachineDialog::update_show_status(MachineObject* obj_) m_check_ext_change_assist->Enable(false); } + if (is_ams_drying(obj_)) { + m_warn_when_drying_sizer->Show(true); + } else { + m_warn_when_drying_sizer->Show(false); + } + /*reading done*/ if (wxGetApp().app_config) { if (obj_->upgrade_force_upgrade) { @@ -3774,6 +3802,7 @@ void SelectMachineDialog::set_default() m_mapping_sugs_sizer->Show(false); m_change_filament_times_sizer->Show(false); m_txt_change_filament_times->Show(false); + m_warn_when_drying_sizer->Show(false); // rset status bar m_status_bar->reset(); diff --git a/src/slic3r/GUI/SelectMachine.hpp b/src/slic3r/GUI/SelectMachine.hpp index ab88240ec0..c6c8ed458b 100644 --- a/src/slic3r/GUI/SelectMachine.hpp +++ b/src/slic3r/GUI/SelectMachine.hpp @@ -28,6 +28,7 @@ #include "boost/bimap/bimap.hpp" #include "AmsMappingPopup.hpp" +#include "GUI_ObjectLayers.hpp" #include "ReleaseNote.hpp" #include "GUI_Utils.hpp" #include "wxExtensions.hpp" @@ -354,6 +355,7 @@ protected: wxBoxSizer* m_sizer_autorefill{ nullptr }; wxBoxSizer* m_mapping_sugs_sizer{ nullptr }; wxBoxSizer* m_change_filament_times_sizer{ nullptr }; + wxBoxSizer* m_warn_when_drying_sizer{ nullptr }; Button* m_button_ensure{ nullptr }; wxStaticBitmap * m_rename_button{nullptr}; wxStaticBitmap* m_staticbitmap{ nullptr }; @@ -387,6 +389,8 @@ protected: CheckBox* m_check_ext_change_assist{ nullptr }; Label* m_label_ext_change_assist{ nullptr }; + Label* m_txt_warn_when_drying{ nullptr }; + PrinterInfoBox* m_printer_box { nullptr}; PrinterMsgPanel * m_text_printer_msg{nullptr}; Label* m_text_printer_msg_tips{ nullptr }; @@ -511,6 +515,8 @@ public: bool is_nozzle_type_match(DevExtderSystem data, wxString& error_message) const; int convert_filament_map_nozzle_id_to_task_nozzle_id(int nozzle_id) const; + bool is_ams_drying(MachineObject* obj); + PrintFromType get_print_type() {return m_print_type;}; wxString format_steel_name(NozzleType type); PrintDialogStatus get_status() { return m_print_status; } From 1b93f35565a33e86830f81c47dedf540013b0761 Mon Sep 17 00:00:00 2001 From: "changyu.chen" Date: Wed, 14 Jan 2026 10:17:23 +0800 Subject: [PATCH 24/47] FIX: print warning when ams drying jira: [STUDIO-16505] Change-Id: I47b98643a7d941c594eb4d90c08f48a7880c947e (cherry picked from commit bdc833d49440c73a56cf10b723fb02d33d1c3a98) --- src/slic3r/GUI/SelectMachine.cpp | 31 ++++++++++++++++++++++++++++++- src/slic3r/GUI/SelectMachine.hpp | 1 + 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 3330f527ca..5efbe7a620 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -1465,6 +1465,35 @@ bool SelectMachineDialog::is_ams_drying(MachineObject* obj) return false; } +bool SelectMachineDialog::is_selected_ams_drying(MachineObject* obj) +{ + if (!obj) return false; + + // If a UI material is selected, only when that material is mapped to an AMS + // and that AMS is currently drying. + for (const auto &kv : m_materialList) { + Material *mat = kv.second; + if (!mat || !mat->item) continue; + if (!mat->item->m_selected) continue; + + // find mapping entry for this material id + for (const FilamentInfo &f : m_ams_mapping_result) { + if (f.id != mat->id) continue; + + if (f.ams_id.empty()) return false; + + auto fila_system = obj->GetFilaSystem(); + if (!fila_system) return false; + DevAms* dev_ams = fila_system->GetAmsById(f.ams_id); + return (dev_ams && dev_ams->AmsIsDrying()); + } + + return false; + } + + return false; +} + void SelectMachineDialog::prepare(int print_plate_idx) { m_print_plate_idx = print_plate_idx; @@ -3307,7 +3336,7 @@ void SelectMachineDialog::update_show_status(MachineObject* obj_) m_check_ext_change_assist->Enable(false); } - if (is_ams_drying(obj_)) { + if (is_selected_ams_drying(obj_)) { m_warn_when_drying_sizer->Show(true); } else { m_warn_when_drying_sizer->Show(false); diff --git a/src/slic3r/GUI/SelectMachine.hpp b/src/slic3r/GUI/SelectMachine.hpp index c6c8ed458b..deb40237ab 100644 --- a/src/slic3r/GUI/SelectMachine.hpp +++ b/src/slic3r/GUI/SelectMachine.hpp @@ -516,6 +516,7 @@ public: int convert_filament_map_nozzle_id_to_task_nozzle_id(int nozzle_id) const; bool is_ams_drying(MachineObject* obj); + bool is_selected_ams_drying(MachineObject* obj); PrintFromType get_print_type() {return m_print_type;}; wxString format_steel_name(NozzleType type); From c2f5a87e243aa433d3b9d143d50dead1644f93d4 Mon Sep 17 00:00:00 2001 From: "changyu.chen" Date: Fri, 16 Jan 2026 18:43:59 +0800 Subject: [PATCH 25/47] FIX: print warning when ams is drying jira: [STUDIO-16558] Change-Id: I6313c4c02dce6853d48fd4b175a161bf116865a9 (cherry picked from commit 49609394cd92b7043f4f65debf585cbc37f382bd) --- src/slic3r/GUI/SelectMachine.cpp | 54 +++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 5efbe7a620..d5ea29046b 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -3336,10 +3336,14 @@ void SelectMachineDialog::update_show_status(MachineObject* obj_) m_check_ext_change_assist->Enable(false); } - if (is_selected_ams_drying(obj_)) { - m_warn_when_drying_sizer->Show(true); - } else { - m_warn_when_drying_sizer->Show(false); + { + const bool show_warn_when_drying = is_selected_ams_drying(obj_); + const bool is_currently_shown = (m_txt_warn_when_drying != nullptr) ? m_txt_warn_when_drying->IsShown() : false; + if (is_currently_shown != show_warn_when_drying) { + m_warn_when_drying_sizer->Show(show_warn_when_drying); + Layout(); + Fit(); + } } /*reading done*/ @@ -3941,6 +3945,10 @@ void SelectMachineDialog::reset_and_sync_ams_list() m_filaments_map = wxGetApp().plater()->get_partplate_list().get_curr_plate()->get_real_filament_maps(project_config); } + bool selected_any = false; + MaterialItem *first_enabled = nullptr; + int first_enabled_id = -1; + for (auto i = 0; i < extruders.size(); i++) { auto extruder = extruders[i] - 1; auto colour = wxGetApp().preset_bundle->project_config.opt_string("filament_colour", (unsigned int) extruder); @@ -3970,7 +3978,20 @@ void SelectMachineDialog::reset_and_sync_ams_list() item = new MaterialItem(m_filament_panel, colour_rgb, _L(display_materials[extruder])); m_sizer_ams_mapping->Add(item, 0, wxALL, FromDIP(5)); } + + if (!item) continue; + item->SetToolTip(m_ams_tooltip); + + if (!selected_any && extruder == m_current_filament_id && item->m_enable) { + item->on_selected(); + selected_any = true; + } + if (!first_enabled && item->m_enable) { + first_enabled = item; + first_enabled_id = extruder; + } + item->Bind(wxEVT_LEFT_UP, [this, item, materials, extruder](wxMouseEvent &e) {}); item->Bind(wxEVT_LEFT_DOWN, [this, item, materials, extruder](wxMouseEvent &e) { if (!item->m_enable) {return;} @@ -4049,6 +4070,11 @@ void SelectMachineDialog::reset_and_sync_ams_list() } } + if (!selected_any && first_enabled) { + m_current_filament_id = first_enabled_id; + first_enabled->on_selected(); + } + if (use_double_extruder) { m_filament_left_panel->Show(); @@ -4459,6 +4485,10 @@ void SelectMachineDialog::set_default_from_sdcard() m_materialList.clear(); m_filaments.clear(); + bool selected_any = false; + MaterialItem *first_enabled = nullptr; + int first_enabled_id = -1; + for (auto i = 0; i < m_required_data_plate_data_list[m_print_plate_idx]->slice_filaments_info.size(); i++) { FilamentInfo fo = m_required_data_plate_data_list[m_print_plate_idx]->slice_filaments_info[i]; @@ -4481,6 +4511,17 @@ void SelectMachineDialog::set_default_from_sdcard() m_sizer_ams_mapping->Add(item, 0, wxALL, FromDIP(5)); } + if (!item) continue; + + if (!selected_any && fo.id == m_current_filament_id && item->m_enable) { + item->on_selected(); + selected_any = true; + } + if (!first_enabled && item->m_enable) { + first_enabled = item; + first_enabled_id = fo.id; + } + item->Bind(wxEVT_LEFT_UP, [this, item, materials](wxMouseEvent& e) {}); item->Bind(wxEVT_LEFT_DOWN, [this, obj_, item, materials, diameters_count, fo](wxMouseEvent& e) { if (!item->m_enable) {return;} @@ -4557,6 +4598,11 @@ void SelectMachineDialog::set_default_from_sdcard() wxSize screenSize = wxGetDisplaySize(); auto dialogSize = this->GetSize(); + if (!selected_any && first_enabled) { + m_current_filament_id = first_enabled_id; + first_enabled->on_selected(); + } + reset_ams_material(); // basic info From ae5d9c26fb47963947a6679d6e262359ccead46e Mon Sep 17 00:00:00 2001 From: "weizhen.xie" Date: Mon, 24 Nov 2025 11:36:49 +0800 Subject: [PATCH 26/47] ENH: Add filament ams/chamber parameters Jira: None Change-Id: I4c0a2d5b7e1c503fc9af9eb656fc6325e99af1de (cherry picked from commit ce88e7794cb8786fae85603617757628a963e807) --- src/libslic3r/Preset.cpp | 6 +++++- src/libslic3r/PrintConfig.cpp | 24 ++++++++++++++++++++++++ src/libslic3r/PrintConfig.hpp | 9 +++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 6d08ca9f2d..dbdd48a0df 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1348,7 +1348,11 @@ static std::vector s_Preset_filament_options {/*"filament_colour", "filament_long_retractions_when_cut","filament_retraction_distances_when_cut", "idle_temperature", //BBS filament change length while the extruder color "filament_change_length","filament_flush_volumetric_speed","filament_flush_temp", "filament_cooling_before_tower", - "long_retractions_when_ec", "retraction_distances_when_ec" + "long_retractions_when_ec", "retraction_distances_when_ec", + //ams chamber + "filament_dev_ams_drying_ams_limitations", "filament_dev_ams_drying_temperature", "filament_dev_ams_drying_time", "filament_dev_ams_drying_heat_distortion_temperature", + "filament_dev_chamber_drying_bed_temperature", "filament_dev_chamber_drying_time", + "filament_dev_drying_softening_temperature", "filament_dev_drying_cooling_temperature" }; static std::vector s_Preset_machine_limits_options { diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index dda24e4e5b..c053b3f8fa 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -7307,6 +7307,30 @@ void PrintConfigDef::init_fff_params() def->min = 0; def->set_default_value(new ConfigOptionPercent(85)); + def = this->add("filament_dev_ams_drying_ams_limitations", coStrings); + def->set_default_value(new ConfigOptionStrings{""}); + + def = this->add("filament_dev_ams_drying_temperature", coFloats); + def->set_default_value(new ConfigOptionFloats{0}); + + def = this->add("filament_dev_ams_drying_time", coFloats); + def->set_default_value(new ConfigOptionFloats{0}); + + def = this->add("filament_dev_ams_drying_heat_distortion_temperature", coFloats); + def->set_default_value(new ConfigOptionFloats{0}); + + def = this->add("filament_dev_chamber_drying_bed_temperature", coFloats); + def->set_default_value(new ConfigOptionFloats{0}); + + def = this->add("filament_dev_chamber_drying_time", coFloats); + def->set_default_value(new ConfigOptionFloats{0}); + + def = this->add("filament_dev_drying_softening_temperature", coFloats); + def->set_default_value(new ConfigOptionFloats{0}); + + def = this->add("filament_dev_drying_cooling_temperature", coFloats); + def->set_default_value(new ConfigOptionFloats{0}); + // Declare retract values for filament profile, overriding the printer's extruder profile. for (auto& opt_key : filament_extruder_override_keys) { const std::string filament_prefix = "filament_"; diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 3b8b809c78..2948f24417 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1490,6 +1490,15 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionStrings, small_area_infill_flow_compensation_model)) ((ConfigOptionBool, has_scarf_joint_seam)) + //ams chamber + ((ConfigOptionStrings, filament_dev_ams_drying_ams_limitations)) + ((ConfigOptionFloats, filament_dev_ams_drying_temperature)) + ((ConfigOptionFloats, filament_dev_ams_drying_time)) + ((ConfigOptionFloats, filament_dev_ams_drying_heat_distortion_temperature)) + ((ConfigOptionFloats, filament_dev_chamber_drying_bed_temperature)) + ((ConfigOptionFloats, filament_dev_chamber_drying_time)) + ((ConfigOptionFloats, filament_dev_drying_softening_temperature)) + ((ConfigOptionFloats, filament_dev_drying_cooling_temperature)) ) // This object is mapped to Perl as Slic3r::Config::Print. From 4227a192cd35e4e18db2ea3e590d1dab625b9d17 Mon Sep 17 00:00:00 2001 From: shsst Date: Wed, 10 Dec 2025 21:07:39 +0800 Subject: [PATCH 27/47] ENH:[Process/Filament] MOD: filament_ams parameters Profile Edited by lianhu.xiong Change-Id: I2f540d0afd6ba368100bc88433cdf2024a1c7d3c (cherry picked from commit 316d8461476d65a85de8dba41a861877c358dba5) --- .../BBL/filament/Bambu ASA-Aero @base.json | 4 +-- .../BBL/filament/Bambu ASA-CF @base.json | 3 ++ .../BBL/filament/Bambu PAHT-CF @base.json | 3 ++ .../BBL/filament/Bambu PET-CF @base.json | 21 +++++++++++++ .../Bambu Support For PA PET @base.json | 18 +++++++++++ .../filament/Bambu Support For PLA @base.json | 15 +++++++-- .../Bambu Support For PLA-PETG @base.json | 18 +++++++++-- .../BBL/filament/Bambu Support G @base.json | 21 +++++++++++-- .../filament/Bambu Support for ABS @base.json | 9 ++++-- .../BBL/filament/Generic PA @base.json | 15 +++++++++ .../BBL/filament/Generic PCTG @base.json | 28 +++++++++++++++++ .../BBL/filament/Generic PPS @base.json | 23 +++++++++++++- .../Polymaker/Fiberon PET-CF @base.json | 27 ++++++++++++++-- .../BBL/filament/fdm_filament_abs.json | 21 +++++++++++++ .../BBL/filament/fdm_filament_asa.json | 21 +++++++++++++ .../BBL/filament/fdm_filament_bvoh.json | 28 +++++++++++++++++ .../BBL/filament/fdm_filament_eva.json | 28 +++++++++++++++++ .../BBL/filament/fdm_filament_hips.json | 24 ++++++++++++-- .../BBL/filament/fdm_filament_pa.json | 24 ++++++++++++++ .../BBL/filament/fdm_filament_pc.json | 21 +++++++++++++ .../BBL/filament/fdm_filament_pctg.json | 31 +++++++++++++++++++ .../BBL/filament/fdm_filament_pe.json | 25 +++++++++++++++ .../BBL/filament/fdm_filament_pet.json | 28 +++++++++++++++++ .../BBL/filament/fdm_filament_pha.json | 25 +++++++++++++++ .../BBL/filament/fdm_filament_pla.json | 31 +++++++++++++++++-- .../BBL/filament/fdm_filament_pp.json | 28 +++++++++++++++++ .../BBL/filament/fdm_filament_ppa.json | 28 +++++++++++++++++ .../BBL/filament/fdm_filament_pps.json | 25 +++++++++++++++ .../BBL/filament/fdm_filament_pva.json | 21 +++++++++++++ .../BBL/filament/fdm_filament_tpu.json | 24 ++++++++++++++ 30 files changed, 614 insertions(+), 24 deletions(-) diff --git a/resources/profiles/BBL/filament/Bambu ASA-Aero @base.json b/resources/profiles/BBL/filament/Bambu ASA-Aero @base.json index 2dcf633a26..1a36118d7f 100644 --- a/resources/profiles/BBL/filament/Bambu ASA-Aero @base.json +++ b/resources/profiles/BBL/filament/Bambu ASA-Aero @base.json @@ -42,8 +42,8 @@ "filament_z_hop_types": [ "Normal Lift" ], - "filament_scarf_seam_type": [ - "none" + "filament_dev_drying_cooling_temperature": [ + "70" ], "impact_strength_z": [ "3.4" diff --git a/resources/profiles/BBL/filament/Bambu ASA-CF @base.json b/resources/profiles/BBL/filament/Bambu ASA-CF @base.json index 9c425dba90..69624032bb 100644 --- a/resources/profiles/BBL/filament/Bambu ASA-CF @base.json +++ b/resources/profiles/BBL/filament/Bambu ASA-CF @base.json @@ -32,6 +32,9 @@ "filament_vendor": [ "Bambu Lab" ], + "filament_dev_drying_cooling_temperature": [ + "95" + ], "hot_plate_temp": [ "100" ], diff --git a/resources/profiles/BBL/filament/Bambu PAHT-CF @base.json b/resources/profiles/BBL/filament/Bambu PAHT-CF @base.json index 54122f3bbf..28b1391f84 100644 --- a/resources/profiles/BBL/filament/Bambu PAHT-CF @base.json +++ b/resources/profiles/BBL/filament/Bambu PAHT-CF @base.json @@ -33,6 +33,9 @@ "full_fan_speed_layer": [ "2" ], + "filament_dev_chamber_drying_time": [ + "16" + ], "impact_strength_z": [ "13.3" ], diff --git a/resources/profiles/BBL/filament/Bambu PET-CF @base.json b/resources/profiles/BBL/filament/Bambu PET-CF @base.json index b982cd1361..ff8b40070f 100644 --- a/resources/profiles/BBL/filament/Bambu PET-CF @base.json +++ b/resources/profiles/BBL/filament/Bambu PET-CF @base.json @@ -45,6 +45,27 @@ "filament_adhesiveness_category": [ "800" ], + "filament_dev_chamber_drying_bed_temperature": [ + "90" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "165" + ], + "filament_dev_drying_cooling_temperature": [ + "150" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "80", + "65", + "80" + ], + "filament_dev_drying_softening_temperature": [ + "150" + ], "hot_plate_temp": [ "100" ], diff --git a/resources/profiles/BBL/filament/Bambu Support For PA PET @base.json b/resources/profiles/BBL/filament/Bambu Support For PA PET @base.json index a622bff105..d199cdb0a0 100644 --- a/resources/profiles/BBL/filament/Bambu Support For PA PET @base.json +++ b/resources/profiles/BBL/filament/Bambu Support For PA PET @base.json @@ -24,6 +24,24 @@ "filament_adhesiveness_category": [ "703" ], + "filament_dev_chamber_drying_time": [ + "16" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "90" + ], + "filament_dev_drying_cooling_temperature": [ + "75" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "80", + "65", + "80" + ], + "filament_dev_drying_softening_temperature": [ + "85" + ], "nozzle_temperature": [ "280" ], diff --git a/resources/profiles/BBL/filament/Bambu Support For PLA @base.json b/resources/profiles/BBL/filament/Bambu Support For PLA @base.json index 10e4983ebf..9bfdef5bf9 100644 --- a/resources/profiles/BBL/filament/Bambu Support For PLA @base.json +++ b/resources/profiles/BBL/filament/Bambu Support For PLA @base.json @@ -24,11 +24,20 @@ "filament_vendor": [ "Bambu Lab" ], - "filament_scarf_seam_type": [ - "none" - ], "filament_adhesiveness_category": [ "702" + ], + "filament_dev_ams_drying_temperature": [ + "60", + "60", + "50", + "50" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "50" + ], + "filament_dev_drying_softening_temperature": [ + "55" ], "slow_down_layer_time": [ "8" diff --git a/resources/profiles/BBL/filament/Bambu Support For PLA-PETG @base.json b/resources/profiles/BBL/filament/Bambu Support For PLA-PETG @base.json index 0f1b87e83f..38443f5be3 100644 --- a/resources/profiles/BBL/filament/Bambu Support For PLA-PETG @base.json +++ b/resources/profiles/BBL/filament/Bambu Support For PLA-PETG @base.json @@ -27,14 +27,26 @@ "filament_max_volumetric_speed": [ "6" ], - "filament_scarf_seam_type": [ - "none" - ], "filament_vendor": [ "Bambu Lab" ], "filament_adhesiveness_category": [ "705" + ], + "filament_dev_chamber_drying_bed_temperature": [ + "80" + ], + "filament_dev_ams_drying_temperature": [ + "60", + "60", + "50", + "50" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "50" + ], + "filament_dev_drying_softening_temperature": [ + "55" ], "hot_plate_temp": [ "60" diff --git a/resources/profiles/BBL/filament/Bambu Support G @base.json b/resources/profiles/BBL/filament/Bambu Support G @base.json index c903f92d07..9cb01972b8 100644 --- a/resources/profiles/BBL/filament/Bambu Support G @base.json +++ b/resources/profiles/BBL/filament/Bambu Support G @base.json @@ -21,11 +21,26 @@ "filament_vendor": [ "Bambu Lab" ], - "filament_scarf_seam_type": [ - "none" - ], "filament_adhesiveness_category": [ "701" + ], + "filament_dev_chamber_drying_time": [ + "16" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "90" + ], + "filament_dev_drying_cooling_temperature": [ + "75" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "80", + "65", + "80" + ], + "filament_dev_drying_softening_temperature": [ + "85" ], "nozzle_temperature": [ "280" diff --git a/resources/profiles/BBL/filament/Bambu Support for ABS @base.json b/resources/profiles/BBL/filament/Bambu Support for ABS @base.json index 35e13e67a0..bc8867db2a 100644 --- a/resources/profiles/BBL/filament/Bambu Support for ABS @base.json +++ b/resources/profiles/BBL/filament/Bambu Support for ABS @base.json @@ -27,11 +27,14 @@ "filament_vendor": [ "Bambu Lab" ], - "filament_scarf_seam_type": [ - "none" - ], "filament_adhesiveness_category": [ "706" + ], + "filament_dev_ams_drying_time": [ + "12", + "4", + "12", + "4" ], "nozzle_temperature_range_high": [ "270" diff --git a/resources/profiles/BBL/filament/Generic PA @base.json b/resources/profiles/BBL/filament/Generic PA @base.json index 5947f5a859..fe819487b0 100644 --- a/resources/profiles/BBL/filament/Generic PA @base.json +++ b/resources/profiles/BBL/filament/Generic PA @base.json @@ -21,6 +21,21 @@ "filament_max_volumetric_speed": [ "12" ], + "filament_dev_ams_drying_temperature": [ + "65", + "80", + "65", + "80" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "90" + ], + "filament_dev_drying_cooling_temperature": [ + "65" + ], + "filament_dev_drying_softening_temperature": [ + "85" + ], "nozzle_temperature": [ "260" ], diff --git a/resources/profiles/BBL/filament/Generic PCTG @base.json b/resources/profiles/BBL/filament/Generic PCTG @base.json index d0ec60848f..e8b7c1ce0a 100644 --- a/resources/profiles/BBL/filament/Generic PCTG @base.json +++ b/resources/profiles/BBL/filament/Generic PCTG @base.json @@ -38,6 +38,34 @@ "filament_max_volumetric_speed": [ "6" ], + "filament_dev_chamber_drying_bed_temperature": [ + "80" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1", + "0" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "75" + ], + "filament_dev_drying_cooling_temperature": [ + "55" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "65", + "55", + "55" + ], + "filament_dev_drying_softening_temperature": [ + "60" + ], "hot_plate_temp": [ "70" ], diff --git a/resources/profiles/BBL/filament/Generic PPS @base.json b/resources/profiles/BBL/filament/Generic PPS @base.json index e8da776dfc..882fea94e0 100644 --- a/resources/profiles/BBL/filament/Generic PPS @base.json +++ b/resources/profiles/BBL/filament/Generic PPS @base.json @@ -4,5 +4,26 @@ "inherits": "fdm_filament_pps", "from": "system", "filament_id": "GFT97", - "instantiation": "false" + "instantiation": "false", + "filament_dev_chamber_drying_bed_temperature": [ + "90" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "90" + ], + "filament_dev_drying_cooling_temperature": [ + "80" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "80", + "65", + "80" + ], + "filament_dev_drying_softening_temperature": [ + "85" + ] } diff --git a/resources/profiles/BBL/filament/Polymaker/Fiberon PET-CF @base.json b/resources/profiles/BBL/filament/Polymaker/Fiberon PET-CF @base.json index 921ce55dd6..365ebb2fd7 100644 --- a/resources/profiles/BBL/filament/Polymaker/Fiberon PET-CF @base.json +++ b/resources/profiles/BBL/filament/Polymaker/Fiberon PET-CF @base.json @@ -41,6 +41,30 @@ "filament_vendor": [ "Polymaker" ], + "filament_adhesiveness_category": [ + "800" + ], + "filament_dev_chamber_drying_bed_temperature": [ + "90" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "165" + ], + "filament_dev_drying_cooling_temperature": [ + "150" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "80", + "65", + "80" + ], + "filament_dev_drying_softening_temperature": [ + "150" + ], "hot_plate_temp": [ "70" ], @@ -83,9 +107,6 @@ "textured_plate_temp_initial_layer": [ "70" ], - "filament_adhesiveness_category": [ - "800" - ], "filament_start_gcode": [ "; filament start gcode\n{if (bed_temperature[current_extruder] >80)||(bed_temperature_initial_layer[current_extruder] >80)}M106 P3 S255\n{elsif (bed_temperature[current_extruder] >60)||(bed_temperature_initial_layer[current_extruder] >60)}M106 P3 S180\n{endif}\n\n{if activate_air_filtration[current_extruder] && support_air_filtration}\nM106 P3 S{during_print_exhaust_fan_speed_num[current_extruder]} \n{endif}" ] diff --git a/resources/profiles/BBL/filament/fdm_filament_abs.json b/resources/profiles/BBL/filament/fdm_filament_abs.json index d05b005db9..4eb47ce0bd 100644 --- a/resources/profiles/BBL/filament/fdm_filament_abs.json +++ b/resources/profiles/BBL/filament/fdm_filament_abs.json @@ -43,6 +43,27 @@ "filament_adhesiveness_category": [ "200" ], + "filament_dev_ams_drying_time": [ + "12", + "8.0", + "12", + "8.0" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "90" + ], + "filament_dev_drying_cooling_temperature": [ + "75" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "80", + "65", + "75" + ], + "filament_dev_drying_softening_temperature": [ + "80" + ], "hot_plate_temp": [ "90" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_asa.json b/resources/profiles/BBL/filament/fdm_filament_asa.json index de053b5e14..b5ae3ad8b5 100644 --- a/resources/profiles/BBL/filament/fdm_filament_asa.json +++ b/resources/profiles/BBL/filament/fdm_filament_asa.json @@ -43,6 +43,27 @@ "filament_adhesiveness_category": [ "200" ], + "filament_dev_ams_drying_time": [ + "12", + "8.0", + "12", + "8.0" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "100" + ], + "filament_dev_drying_cooling_temperature": [ + "85" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "80", + "65", + "80" + ], + "filament_dev_drying_softening_temperature": [ + "85" + ], "hot_plate_temp": [ "90" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_bvoh.json b/resources/profiles/BBL/filament/fdm_filament_bvoh.json index 1a766a5a71..ee15cc5e30 100644 --- a/resources/profiles/BBL/filament/fdm_filament_bvoh.json +++ b/resources/profiles/BBL/filament/fdm_filament_bvoh.json @@ -43,6 +43,34 @@ "filament_type": [ "BVOH" ], + "filament_dev_chamber_drying_bed_temperature": [ + "80" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1", + "0" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "65" + ], + "filament_dev_drying_cooling_temperature": [ + "40" + ], + "filament_dev_ams_drying_temperature": [ + "60", + "60", + "45", + "45" + ], + "filament_dev_drying_softening_temperature": [ + "50" + ], "hot_plate_temp": [ "55" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_eva.json b/resources/profiles/BBL/filament/fdm_filament_eva.json index 283606c21e..a3f9ab0011 100644 --- a/resources/profiles/BBL/filament/fdm_filament_eva.json +++ b/resources/profiles/BBL/filament/fdm_filament_eva.json @@ -7,6 +7,34 @@ "filament_type": [ "EVA" ], + "filament_dev_chamber_drying_bed_temperature": [ + "70" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1", + "0" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "45" + ], + "filament_dev_drying_cooling_temperature": [ + "40" + ], + "filament_dev_ams_drying_temperature": [ + "45", + "45", + "45", + "45" + ], + "filament_dev_drying_softening_temperature": [ + "50" + ], "supertack_plate_temp": [ "0" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_hips.json b/resources/profiles/BBL/filament/fdm_filament_hips.json index 25c0acbb6f..7016a866f6 100644 --- a/resources/profiles/BBL/filament/fdm_filament_hips.json +++ b/resources/profiles/BBL/filament/fdm_filament_hips.json @@ -4,9 +4,6 @@ "inherits": "fdm_filament_common", "from": "system", "instantiation": "false", - "additional_cooling_fan_speed": [ - "0" - ], "cool_plate_temp": [ "0" ], @@ -42,6 +39,27 @@ ], "filament_adhesiveness_category": [ "798" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "90" + ], + "filament_dev_drying_cooling_temperature": [ + "75" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "80", + "65", + "75" + ], + "filament_dev_drying_softening_temperature": [ + "80" ], "hot_plate_temp": [ "90" diff --git a/resources/profiles/BBL/filament/fdm_filament_pa.json b/resources/profiles/BBL/filament/fdm_filament_pa.json index 629ef70885..61aeed66ce 100644 --- a/resources/profiles/BBL/filament/fdm_filament_pa.json +++ b/resources/profiles/BBL/filament/fdm_filament_pa.json @@ -43,6 +43,30 @@ "filament_adhesiveness_category": [ "400" ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_chamber_drying_time": [ + "18" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "165" + ], + "filament_dev_drying_cooling_temperature": [ + "150" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "85", + "65", + "85" + ], + "filament_dev_drying_softening_temperature": [ + "150" + ], "hot_plate_temp": [ "100" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_pc.json b/resources/profiles/BBL/filament/fdm_filament_pc.json index 11dcf5ea46..a73a56b440 100644 --- a/resources/profiles/BBL/filament/fdm_filament_pc.json +++ b/resources/profiles/BBL/filament/fdm_filament_pc.json @@ -40,6 +40,27 @@ "filament_adhesiveness_category": [ "500" ], + "filament_dev_ams_drying_time": [ + "12", + "8.0", + "12", + "8.0" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "105" + ], + "filament_dev_drying_cooling_temperature": [ + "90" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "80", + "65", + "80" + ], + "filament_dev_drying_softening_temperature": [ + "90" + ], "hot_plate_temp": [ "110" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_pctg.json b/resources/profiles/BBL/filament/fdm_filament_pctg.json index f735c26f48..01810a2ddc 100644 --- a/resources/profiles/BBL/filament/fdm_filament_pctg.json +++ b/resources/profiles/BBL/filament/fdm_filament_pctg.json @@ -28,6 +28,37 @@ "filament_type": [ "PCTG" ], + "filament_dev_chamber_drying_bed_temperature": [ + "80" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1", + "0" + ], + "filament_dev_chamber_drying_time": [ + "12" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "75" + ], + "filament_dev_drying_cooling_temperature": [ + "55" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "65", + "55", + "55" + ], + "filament_dev_drying_softening_temperature": [ + "60" + ], "hot_plate_temp": [ "80" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_pe.json b/resources/profiles/BBL/filament/fdm_filament_pe.json index 51549b2d8d..9235c3301e 100644 --- a/resources/profiles/BBL/filament/fdm_filament_pe.json +++ b/resources/profiles/BBL/filament/fdm_filament_pe.json @@ -40,6 +40,31 @@ "filament_type": [ "PE" ], + "filament_dev_chamber_drying_bed_temperature": [ + "70" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1", + "0" + ], + "filament_dev_drying_cooling_temperature": [ + "50" + ], + "filament_dev_ams_drying_temperature": [ + "45", + "45", + "45", + "45" + ], + "filament_dev_drying_softening_temperature": [ + "50" + ], "hot_plate_temp": [ "55" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_pet.json b/resources/profiles/BBL/filament/fdm_filament_pet.json index 8fde5f2faa..23a8f73fe4 100644 --- a/resources/profiles/BBL/filament/fdm_filament_pet.json +++ b/resources/profiles/BBL/filament/fdm_filament_pet.json @@ -31,6 +31,34 @@ "filament_adhesiveness_category": [ "300" ], + "filament_dev_chamber_drying_bed_temperature": [ + "80" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1", + "0" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "75" + ], + "filament_dev_drying_cooling_temperature": [ + "55" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "65", + "55", + "55" + ], + "filament_dev_drying_softening_temperature": [ + "60" + ], "hot_plate_temp": [ "80" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_pha.json b/resources/profiles/BBL/filament/fdm_filament_pha.json index b9ac18587d..2bd348e2de 100644 --- a/resources/profiles/BBL/filament/fdm_filament_pha.json +++ b/resources/profiles/BBL/filament/fdm_filament_pha.json @@ -40,6 +40,31 @@ "filament_type": [ "PHA" ], + "filament_dev_chamber_drying_bed_temperature": [ + "70" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1", + "0" + ], + "filament_dev_drying_cooling_temperature": [ + "45" + ], + "filament_dev_ams_drying_temperature": [ + "45", + "45", + "45", + "45" + ], + "filament_dev_drying_softening_temperature": [ + "50" + ], "hot_plate_temp": [ "55" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_pla.json b/resources/profiles/BBL/filament/fdm_filament_pla.json index 3c6713b86d..f1e1c9a244 100644 --- a/resources/profiles/BBL/filament/fdm_filament_pla.json +++ b/resources/profiles/BBL/filament/fdm_filament_pla.json @@ -37,14 +37,39 @@ "filament_max_volumetric_speed": [ "12" ], - "filament_scarf_seam_type": [ - "none" - ], "filament_scarf_gap": [ "15%" ], "filament_adhesiveness_category": [ "100" + ], + "filament_dev_chamber_drying_bed_temperature": [ + "70" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1", + "0" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "45" + ], + "filament_dev_drying_cooling_temperature": [ + "45" + ], + "filament_dev_ams_drying_temperature": [ + "45", + "45", + "45", + "45" + ], + "filament_dev_drying_softening_temperature": [ + "50" ], "hot_plate_temp": [ "55" diff --git a/resources/profiles/BBL/filament/fdm_filament_pp.json b/resources/profiles/BBL/filament/fdm_filament_pp.json index dd390e0a82..2d8c3772ba 100644 --- a/resources/profiles/BBL/filament/fdm_filament_pp.json +++ b/resources/profiles/BBL/filament/fdm_filament_pp.json @@ -43,6 +43,34 @@ "filament_adhesiveness_category": [ "902" ], + "filament_dev_chamber_drying_bed_temperature": [ + "70" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [ + "1", + "0" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "60" + ], + "filament_dev_drying_cooling_temperature": [ + "50" + ], + "filament_dev_ams_drying_temperature": [ + "60", + "60", + "50", + "50" + ], + "filament_dev_drying_softening_temperature": [ + "55" + ], "hot_plate_temp": [ "55" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_ppa.json b/resources/profiles/BBL/filament/fdm_filament_ppa.json index bb06a73c76..3bba318bc5 100644 --- a/resources/profiles/BBL/filament/fdm_filament_ppa.json +++ b/resources/profiles/BBL/filament/fdm_filament_ppa.json @@ -46,6 +46,34 @@ "filament_vendor": [ "Bambu Lab" ], + "filament_dev_chamber_drying_bed_temperature": [ + "100" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [], + "filament_dev_chamber_drying_time": [ + "18" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "165" + ], + "filament_dev_drying_cooling_temperature": [ + "150" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "85", + "65", + "85" + ], + "filament_dev_drying_softening_temperature": [ + "150" + ], "hot_plate_temp": [ "100" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_pps.json b/resources/profiles/BBL/filament/fdm_filament_pps.json index a19a5aca2b..d1c6a6aed2 100644 --- a/resources/profiles/BBL/filament/fdm_filament_pps.json +++ b/resources/profiles/BBL/filament/fdm_filament_pps.json @@ -43,6 +43,31 @@ "filament_adhesiveness_category": [ "801" ], + "filament_dev_chamber_drying_bed_temperature": [ + "100" + ], + "filament_dev_ams_drying_time": [ + "12", + "12", + "12", + "12" + ], + "filament_dev_ams_drying_ams_limitations": [], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "165" + ], + "filament_dev_drying_cooling_temperature": [ + "150" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "85", + "65", + "85" + ], + "filament_dev_drying_softening_temperature": [ + "150" + ], "hot_plate_temp": [ "110" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_pva.json b/resources/profiles/BBL/filament/fdm_filament_pva.json index 98ca7e63b9..55d83517ce 100644 --- a/resources/profiles/BBL/filament/fdm_filament_pva.json +++ b/resources/profiles/BBL/filament/fdm_filament_pva.json @@ -49,6 +49,27 @@ "filament_adhesiveness_category": [ "704" ], + "filament_dev_ams_drying_time": [ + "12", + "18", + "12", + "18" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "75" + ], + "filament_dev_drying_cooling_temperature": [ + "40" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "85", + "65", + "70" + ], + "filament_dev_drying_softening_temperature": [ + "75" + ], "hot_plate_temp": [ "55" ], diff --git a/resources/profiles/BBL/filament/fdm_filament_tpu.json b/resources/profiles/BBL/filament/fdm_filament_tpu.json index f6ab1d3edf..d2ef5db84e 100644 --- a/resources/profiles/BBL/filament/fdm_filament_tpu.json +++ b/resources/profiles/BBL/filament/fdm_filament_tpu.json @@ -46,6 +46,30 @@ "filament_adhesiveness_category": [ "600" ], + "filament_dev_ams_drying_time": [ + "12", + "18", + "12", + "18" + ], + "filament_dev_chamber_drying_time": [ + "16" + ], + "filament_dev_ams_drying_heat_distortion_temperature": [ + "45" + ], + "filament_dev_drying_cooling_temperature": [ + "40" + ], + "filament_dev_ams_drying_temperature": [ + "65", + "75", + "45", + "45" + ], + "filament_dev_drying_softening_temperature": [ + "50" + ], "hot_plate_temp": [ "35" ], From 7f67946dbf4eb74283e22ad8bb4e29607bce5412 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 22:02:10 +0800 Subject: [PATCH 28/47] Fix incorrect length modification when loading device-related parameters (cherry picked from commit bambulab/BambuStudio@ebf57372da54e7515d3a469b0dbe468ff16e9aed) Co-Authored-By: weizhen.xie --- src/libslic3r/Preset.cpp | 2 ++ src/libslic3r/PrintConfig.cpp | 11 +++++++++++ src/libslic3r/PrintConfig.hpp | 2 ++ 3 files changed, 15 insertions(+) diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index dbdd48a0df..077814130d 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -470,6 +470,8 @@ void Preset::normalize(DynamicPrintConfig &config) continue; if (filament_options_with_variant.find(key) != filament_options_with_variant.end()) continue; + if (filament_dev_options.find(key) != filament_dev_options.end()) + continue; auto *opt = config.option(key, false); /*assert(opt != nullptr); assert(opt->is_vector());*/ diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index c053b3f8fa..7c116dce3e 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -8538,6 +8538,17 @@ std::set printer_options_with_variant_2 = { std::set empty_options; +std::set filament_dev_options = { + "filament_dev_ams_drying_ams_limitations", + "filament_dev_ams_drying_temperature", + "filament_dev_ams_drying_time", + "filament_dev_ams_drying_heat_distortion_temperature", + "filament_dev_chamber_drying_bed_temperature", + "filament_dev_chamber_drying_time", + "filament_dev_drying_softening_temperature", + "filament_dev_drying_cooling_temperature" +}; + DynamicPrintConfig DynamicPrintConfig::full_print_config() { return DynamicPrintConfig((const PrintRegionConfig&)FullPrintConfig::defaults()); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 2948f24417..6ea1f9db1c 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -698,6 +698,8 @@ extern std::set printer_options_with_variant_1; extern std::set printer_options_with_variant_2; extern std::set empty_options; +extern std::set filament_dev_options; + extern void compute_filament_override_value(const std::string& opt_key, const ConfigOption *opt_old_machine, const ConfigOption *opt_new_machine, const ConfigOption *opt_new_filament, const DynamicPrintConfig& new_full_config, t_config_option_keys& diff_keys, DynamicPrintConfig& filament_overrides, std::vector& f_maps); From 696a94a1a6c76a4bec04072b735b8f8af83726f1 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 22:29:28 +0800 Subject: [PATCH 29/47] Update color --- src/slic3r/GUI/AMSDryControl.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/slic3r/GUI/AMSDryControl.cpp b/src/slic3r/GUI/AMSDryControl.cpp index 98b35b5809..9efe7faa87 100644 --- a/src/slic3r/GUI/AMSDryControl.cpp +++ b/src/slic3r/GUI/AMSDryControl.cpp @@ -86,7 +86,7 @@ FilamentItemPanel::FilamentItemPanel(wxWindow* parent, const wxString& text, con : wxPanel(parent, id) , m_icon_name(icon_name) { - SetBackgroundColour(wxColour("#F7F7F7")); // Light gray background + SetBackgroundColour(wxColour("#F0F0F1")); // Light gray background SetMinSize(wxSize(FromDIP(64), FromDIP(106))); // Width: 64, Height: 106 SetSize(wxSize(FromDIP(64), FromDIP(106))); // Fixed size @@ -99,7 +99,7 @@ FilamentItemPanel::FilamentItemPanel(wxWindow* parent, const wxString& text, con m_text_label = new Label(this, text); m_text_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour(*wxBLACK))); - m_text_label->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F7F7F7"))); + m_text_label->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F0F0F1"))); m_text_label->SetFont(Label::Body_12); m_text_label->Wrap(FromDIP(40)); top_sizer->Add(m_text_label, 0, wxALIGN_CENTER_HORIZONTAL); @@ -112,7 +112,7 @@ FilamentItemPanel::FilamentItemPanel(wxWindow* parent, const wxString& text, con bottom_sizer->AddStretchSpacer(1); m_icon_bitmap = new wxStaticBitmap(this, wxID_ANY, wxNullBitmap); - m_icon_bitmap->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F7F7F7"))); + m_icon_bitmap->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F0F0F1"))); m_icon_bitmap->SetMinSize(wxSize(FromDIP(24), FromDIP(24))); m_icon_bitmap->SetMaxSize(wxSize(FromDIP(24), FromDIP(24))); bottom_sizer->Add(m_icon_bitmap, 0, wxALIGN_CENTER_HORIZONTAL); @@ -170,7 +170,7 @@ void FilamentItemPanel::OnPaint(wxPaintEvent& event) wxSize size = GetSize(); // bool is_dark_mode = wxGetApp().dark_mode(); - wxColour backgroundColor = StateColor::darkModeColorFor(wxColour("#F7F7F7")); + wxColour backgroundColor = StateColor::darkModeColorFor(wxColour("#F0F0F1")); wxColour borderColor = StateColor::darkModeColorFor(wxColour("#DBDBDB")); // Draw white background rectangle with rounded corners inside the thick vertical lines @@ -208,7 +208,7 @@ AMSFilamentPanel::AMSFilamentPanel(wxWindow* parent, const wxString& ams_name, w // Filament items section m_filament_container = new wxPanel(this); - m_filament_container->SetBackgroundColour(wxColour("#F7F7F7")); + m_filament_container->SetBackgroundColour(wxColour("#F0F0F1")); m_filament_sizer = new wxBoxSizer(wxHORIZONTAL); m_filament_container->SetSizer(m_filament_sizer); From 40fd22530870ed65828231c669d76d8c189f3f4f Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Tue, 7 Jul 2026 23:16:04 +0800 Subject: [PATCH 30/47] Make sure new strings are translated --- localization/i18n/list.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index d968cd5322..d63686b1da 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -274,4 +274,6 @@ src/slic3r/GUI/DownloaderFileGet.cpp src/slic3r/GUI/FileArchiveDialog.cpp src/slic3r/GUI/PrinterCloudAuthDialog.cpp src/slic3r/GUI/PrinterWebViewHandler.cpp +src/slic3r/GUI/AMSDryControl.cpp +src/slic3r/GUI/AMSDryControl.hpp src/libslic3r/PresetBundle.cpp From 84fa8d31f5322198b8024b96eb9c6fadbeb705b5 Mon Sep 17 00:00:00 2001 From: Bingo2023 <61052575+Bingo2023@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:51:19 +0200 Subject: [PATCH 31/47] Update Maschine G-Code for X2D [Update Bambu Lab X2D 0.4 nozzle.json] Update Maschine G-Code according to latest Bambu Studio Version X2D filament_change gcode: 2026/07/01 X2D layer_change gcode: 2026/07/01 X2D start gcode: 2026/06/05 X2D timelapse gcode: 2026/06/03 --- .../profiles/BBL/machine/Bambu Lab X2D 0.4 nozzle.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/profiles/BBL/machine/Bambu Lab X2D 0.4 nozzle.json b/resources/profiles/BBL/machine/Bambu Lab X2D 0.4 nozzle.json index ed731be291..0c79b7fed0 100644 --- a/resources/profiles/BBL/machine/Bambu Lab X2D 0.4 nozzle.json +++ b/resources/profiles/BBL/machine/Bambu Lab X2D 0.4 nozzle.json @@ -5,10 +5,10 @@ "from": "system", "setting_id": "GM045", "instantiation": "true", - "change_filament_gcode": "======== X2D filament_change gcode ==========\n;===== 2026/05/15 =====\n\nM620 S[next_filament_id]A B H[next_hotend]\n;M204 S9000\n{if toolchange_count > 1 && (z_hop_types[current_filament_id] == 0 || z_hop_types[current_filament_id] == 3)}\nG17\nG2 Z{z_after_toolchange + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift\n{endif}\n\n;nozzle_change_gcode\n\nG1 Z{max_layer_z + 3.0} F1200\n\nM400\nM106 P1 S0\n\n{if toolchange_count == 2}\n; get travel path for change filament\n;M620.1 X[travel_point_1_x] Y[travel_point_1_y] F21000 P0\n;M620.1 X[travel_point_2_x] Y[travel_point_2_y] F21000 P1\n;M620.1 X[travel_point_3_x] Y[travel_point_3_y] F21000 P2\n{endif}\n\n{if ((filament_type[current_filament_id] == \"PLA\") || (filament_type[current_filament_id] == \"PLA-CF\") || (filament_type[current_filament_id] == \"PETG\")) && (nozzle_diameter_at_nozzle_id[current_nozzle_id] == 0.2)}\nM620.10 A0 F74.8347 L[flush_length] H{nozzle_diameter_at_nozzle_id[current_nozzle_id]} T{flush_temperatures[current_filament_id]} P[old_filament_temp] S1\n{else}\nM620.10 A0 F{flush_volumetric_speeds[current_filament_id]/2.4053*60} L[flush_length] H{nozzle_diameter_at_nozzle_id[current_nozzle_id]} T{flush_temperatures[current_filament_id]} P[old_filament_temp] S1\n{endif}\n\n{if ((filament_type[next_filament_id] == \"PLA\") || (filament_type[next_filament_id] == \"PLA-CF\") || (filament_type[next_filament_id] == \"PETG\")) && (nozzle_diameter_at_nozzle_id[next_nozzle_id] == 0.2)}\nM620.10 A1 F74.8347 L[flush_length] H{nozzle_diameter_at_nozzle_id[next_nozzle_id]} T{flush_temperatures[next_filament_id]} P[new_filament_temp] S1\n{else}\nM620.10 A1 F{flush_volumetric_speeds[next_filament_id]/2.4053*60} L[flush_length] H{nozzle_diameter_at_nozzle_id[next_nozzle_id]} T{flush_temperatures[next_filament_id]} P[new_filament_temp] S1\n{endif}\n\nM620.15 C{new_filament_temp - filament_cooling_before_tower[next_filament_id]}\n\n{if long_retraction_when_cut}\nM620.11 P1 L0 I[current_filament_id] B[current_hotend] E-{retraction_distance_when_cut} F{max((flush_volumetric_speeds[current_filament_id]/2.4053*60), 200)}\n{else}\nM620.11 P0 L0 I[current_filament_id] B[current_hotend] E0\n{endif}\n\n{if long_retraction_when_ec}\nM620.11 K1 I[current_filament_id] B[current_hotend] R{retraction_distance_when_ec} F{max((flush_volumetric_speeds[current_filament_id]/2.4053*60), 200)}\n{else}\nM620.11 K0 I[current_filament_id] B[current_hotend] R0\n{endif}\n\nM620.22 I[next_filament_id] P1 ; enable remote extruder runout auto purge.\n\nT[next_filament_id] H[next_hotend]\n\n;deretract\n{if filament_type[next_filament_id] == \"TPU\"}\n{else}\n{if filament_type[next_filament_id] == \"PA\"}\n;VG1 E1 F{max(new_filament_e_feedrate, 200)}\n;VG1 E1 F{max(new_filament_e_feedrate/2, 100)}\n{else}\n;VG1 E4 F{max(new_filament_e_feedrate, 200)}\n;VG1 E4 F{max(new_filament_e_feedrate/2, 100)}\n{endif}\n{endif}\n\n; VFLUSH_START\n{if flush_length>41.5}\n;VG1 E41.5 F{min(old_filament_e_feedrate,new_filament_e_feedrate)}\n;VG1 E{flush_length-41.5} F{new_filament_e_feedrate}\n{else}\n;VG1 E{flush_length} F{min(old_filament_e_feedrate,new_filament_e_feedrate)}\n{endif}\nSYNC T{ceil(flush_length / 125) * 5}\n; VFLUSH_END\n\nM1002 set_filament_type:{filament_type[next_filament_id]}\n\nM400\nM83\n{if next_filament_id < 255}\nM620.10 R{retract_length_toolchange[filament_map[next_filament_id]-1]}\nM628 S0\n;VM109 S[new_filament_temp]\nM629\nM400\n\n;prime_tower_interface\n{if is_prime_tower_interface && filament_tower_interface_purge_volume !=0}\nG150.1\nM620.13 W0 L{filament_tower_interface_purge_volume} T{filament_tower_interface_print_temp} R0.0\n{endif}\n;prime_tower_interface\n\nM983.3 F{filament_max_volumetric_speed[next_filament_id]/2.4} A0.4 R{retract_length_toolchange[filament_map[next_filament_id]-1]}\n\nM400\n\nG1 Z{max_layer_z + 3.0} F3000\n\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\n\n\nM621 S[next_filament_id]A B\n\nM622.1 S0 ;for prev version, default skip\nM1002 judge_flag powerloss_resume_flag\nM622 J1\nM983.3 F{filament_max_volumetric_speed[next_filament_id]/2.4} A0.4 R{retract_length_toolchange[filament_map[next_filament_id]-1]}\nM400\nG1 Z{max_layer_z + 3.0} F3000\nM1002 set_flag powerloss_resume_flag=0\nM623\n\nM620.6 I[next_filament_id] H[next_hotend] W1 ;enable ams air printing detect\n\n{if (filament_type[next_filament_id] == \"TPU\")}\nM1015.3 S1 H{nozzle_diameter_at_nozzle_id[next_nozzle_id]};enable tpu clog detect\n{else}\nM1015.3 S0;disable tpu clog detect\n{endif}\n\n{if (filament_type[next_filament_id] == \"PLA\") || (filament_type[next_filament_id] == \"PETG\")\n || (filament_type[next_filament_id] == \"PLA-CF\") || (filament_type[next_filament_id] == \"PETG-CF\")}\nM1015.4 S1 K1 H{nozzle_diameter_at_nozzle_id[next_nozzle_id]} ;enable E air printing detect\n{else}\nM1015.4 S0 K0 H{nozzle_diameter_at_nozzle_id[next_nozzle_id]} ;disable E air printing detect\n{endif}\n\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[travel_acceleration]\n{endif}\n\nG1 Y256 F18000\n\n\n{if (overall_chamber_temperature < 40)}\n{if (layer_num + 1 <= close_additional_fan_first_x_layers[next_filament_id])}\n M106 P2 S{first_x_layer_fan_speed[next_filament_id]*255.0/100.0 };set first x_layer fan\n\tM106 P10 S{first_x_layer_fan_speed[next_filament_id]*255.0/100.0 };set first x_layer fan\n{elsif (layer_num + 1 < additional_fan_full_speed_layer[next_filament_id] && additional_fan_full_speed_layer[next_filament_id] > close_additional_fan_first_x_layers[next_filament_id])}\n M106 P2 S{(first_x_layer_fan_speed[next_filament_id] + (additional_cooling_fan_speed[next_filament_id] - first_x_layer_fan_speed[next_filament_id]) * (layer_num + 1 - close_additional_fan_first_x_layers[next_filament_id]) / max(additional_fan_full_speed_layer[next_filament_id] - close_additional_fan_first_x_layers[next_filament_id], 1)) * 255.0/100.0}\n\tM106 P10 S{(first_x_layer_fan_speed[next_filament_id] + (additional_cooling_fan_speed[next_filament_id] - first_x_layer_fan_speed[next_filament_id]) * (layer_num + 1 - close_additional_fan_first_x_layers[next_filament_id]) / max(additional_fan_full_speed_layer[next_filament_id] - close_additional_fan_first_x_layers[next_filament_id], 1)) * 255.0/100.0}\n{else}\n {if (min_vitrification_temperature <= 50)}\n {if (nozzle_diameter_at_nozzle_id[current_nozzle_id] == 0.2)}\n M142 P1 R30 S35 U{max_additional_fan/100.0} V1.0 O40; set PLA/TPU ND0.2 chamber autocooling\n {else}\n M142 P1 R30 S40 U{max_additional_fan/100.0} V1.0 O45; set PLA/TPU ND0.4 chamber autocooling\n {endif}\n {else}\n {if (nozzle_diameter_at_nozzle_id[current_nozzle_id] == 0.2)}\n M142 P1 R35 S45 U{max_additional_fan/100.0} V0.5 O50; set PETG ND0.2 chamber autocooling\n {else}\n M142 P1 R35 S50 U{max_additional_fan/100.0} V0.5 O55; set PETG ND0.4 chamber autocooling\n {endif}\n {endif}\n{endif}\n{endif}\n;not set fan changing filament", - "layer_change_gcode": ";======== X2D layer_change gcode ==========\n;===== 2026/05/15 =====\n\n{if (layer_num + 1 == 1)}\n{if (overall_chamber_temperature >= 40)}\n ;not reset filter fan in first layer\n ;not reset fan\n{endif}\n{endif}\n\n{if (layer_num + 1 <= close_additional_fan_first_x_layers[current_filament_id])}\n{if (overall_chamber_temperature < 40)}\n M106 P2 S{first_x_layer_fan_speed[current_filament_id]*255.0/100.0}\n\tM106 P10 S{first_x_layer_fan_speed[current_filament_id]*255.0/100.0}\n{endif}\n;not reset fan\n{elsif (layer_num + 1 < additional_fan_full_speed_layer[current_filament_id] && additional_fan_full_speed_layer[current_filament_id] > close_additional_fan_first_x_layers[current_filament_id])}\n{if (overall_chamber_temperature < 40)}\n M106 P2 S{(first_x_layer_fan_speed[current_filament_id] + (additional_cooling_fan_speed[current_filament_id] - first_x_layer_fan_speed[current_filament_id]) * (layer_num + 1 - close_additional_fan_first_x_layers[current_filament_id]) / max(additional_fan_full_speed_layer[current_filament_id] - close_additional_fan_first_x_layers[current_filament_id], 1)) * 255.0/100.0}\n\tM106 P10 S{(first_x_layer_fan_speed[current_filament_id] + (additional_cooling_fan_speed[current_filament_id] - first_x_layer_fan_speed[current_filament_id]) * (layer_num + 1 - close_additional_fan_first_x_layers[current_filament_id]) / max(additional_fan_full_speed_layer[current_filament_id] - close_additional_fan_first_x_layers[current_filament_id], 1)) * 255.0/100.0}\n{endif}\n;not reset fan\n{elsif (layer_num + 1 == max(close_additional_fan_first_x_layers[current_filament_id] + 1, additional_fan_full_speed_layer[current_filament_id]))}\n{if (overall_chamber_temperature < 40)}\n ;updata chamber autocooling in Xth layer\n {if (min_vitrification_temperature <= 50)}\n {if (nozzle_diameter_at_nozzle_id[current_nozzle_id] == 0.2)}\n M142 P1 R30 S35 U{max_additional_fan/100.0} V1.0 O40; set PLA/TPU ND0.2 chamber autocooling\n {else}\n M142 P1 R30 S40 U{max_additional_fan/100.0} V1.0 O45; set PLA/TPU ND0.4 chamber autocooling\n {endif}\n {else}\n {if (nozzle_diameter_at_nozzle_id[current_nozzle_id] == 0.2)}\n M142 P1 R35 S45 U{max_additional_fan/100.0} V0.5 O50; set PETG ND0.2 chamber autocooling\n {else}\n M142 P1 R35 S50 U{max_additional_fan/100.0} V0.5 O55; set PETG ND0.4 chamber autocooling\n {endif}\n {endif}\n{else}\n ;not reset filter fan in Xth layer\n{endif}\n;not reset fan\n{endif}\n\n\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change\n", + "change_filament_gcode": "======== X2D filament_change gcode ==========\n;===== 2026/07/01 =====\n\nM620 S[next_filament_id]A B H[next_hotend]\n;M204 S9000\n{if toolchange_count > 1 && (z_hop_types[current_filament_id] == 0 || z_hop_types[current_filament_id] == 3)}\nG17\nG2 Z{z_after_toolchange + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift\n{endif}\n\n;nozzle_change_gcode\n\nG1 Z{max_layer_z + 3.0} F1200\n\nM400\nM106 P1 S0\n\n{if toolchange_count == 2}\n; get travel path for change filament\n;M620.1 X[travel_point_1_x] Y[travel_point_1_y] F21000 P0\n;M620.1 X[travel_point_2_x] Y[travel_point_2_y] F21000 P1\n;M620.1 X[travel_point_3_x] Y[travel_point_3_y] F21000 P2\n{endif}\n\n{if ((filament_type[current_filament_id] == \"PLA\") || (filament_type[current_filament_id] == \"PLA-CF\") || (filament_type[current_filament_id] == \"PETG\")) && (nozzle_diameter_at_nozzle_id[current_nozzle_id] == 0.2)}\nM620.10 A0 F74.8347 L[flush_length] H{nozzle_diameter_at_nozzle_id[current_nozzle_id]} T{flush_temperatures[current_filament_id]} P[old_filament_temp] S1\n{else}\nM620.10 A0 F{flush_volumetric_speeds[current_filament_id]/2.4053*60} L[flush_length] H{nozzle_diameter_at_nozzle_id[current_nozzle_id]} T{flush_temperatures[current_filament_id]} P[old_filament_temp] S1\n{endif}\n\n{if ((filament_type[next_filament_id] == \"PLA\") || (filament_type[next_filament_id] == \"PLA-CF\") || (filament_type[next_filament_id] == \"PETG\")) && (nozzle_diameter_at_nozzle_id[next_nozzle_id] == 0.2)}\nM620.10 A1 F74.8347 L[flush_length] H{nozzle_diameter_at_nozzle_id[next_nozzle_id]} T{flush_temperatures[next_filament_id]} P[new_filament_temp] S1\n{else}\nM620.10 A1 F{flush_volumetric_speeds[next_filament_id]/2.4053*60} L[flush_length] H{nozzle_diameter_at_nozzle_id[next_nozzle_id]} T{flush_temperatures[next_filament_id]} P[new_filament_temp] S1\n{endif}\n\nM620.15 C{new_filament_temp - filament_cooling_before_tower[next_filament_id]}\n\n{if long_retraction_when_cut}\nM620.11 P1 L0 I[current_filament_id] B[current_hotend] E-{retraction_distance_when_cut} F{max((flush_volumetric_speeds[current_filament_id]/2.4053*60), 200)}\n{else}\nM620.11 P0 L0 I[current_filament_id] B[current_hotend] E0\n{endif}\n\n{if long_retraction_when_ec}\nM620.11 K1 I[current_filament_id] B[current_hotend] R{retraction_distance_when_ec} F{max((flush_volumetric_speeds[current_filament_id]/2.4053*60), 200)}\n{else}\nM620.11 K0 I[current_filament_id] B[current_hotend] R0\n{endif}\n\nM620.22 I[next_filament_id] P1 ; enable remote extruder runout auto purge.\n\nT[next_filament_id] H[next_hotend]\n\n;deretract\n{if filament_type[next_filament_id] == \"TPU\"}\n{else}\n{if filament_type[next_filament_id] == \"PA\"}\n;VG1 E1 F{max(new_filament_e_feedrate, 200)}\n;VG1 E1 F{max(new_filament_e_feedrate/2, 100)}\n{else}\n;VG1 E4 F{max(new_filament_e_feedrate, 200)}\n;VG1 E4 F{max(new_filament_e_feedrate/2, 100)}\n{endif}\n{endif}\n\n; VFLUSH_START\n{if flush_length>41.5}\n;VG1 E41.5 F{min(old_filament_e_feedrate,new_filament_e_feedrate)}\n;VG1 E{flush_length-41.5} F{new_filament_e_feedrate}\n{else}\n;VG1 E{flush_length} F{min(old_filament_e_feedrate,new_filament_e_feedrate)}\n{endif}\nSYNC T{ceil(flush_length / 125) * 5}\n; VFLUSH_END\n\nM1002 set_filament_type:{filament_type[next_filament_id]}\n\nM400\nM83\n{if next_filament_id < 255}\nM620.10 R{retract_length_toolchange[filament_map[next_filament_id]-1]}\nM628 S0\n;VM109 S[new_filament_temp]\nM629\nM400\n\n;prime_tower_interface\n{if is_prime_tower_interface && filament_tower_interface_purge_volume !=0}\nG150.1\nM620.13 W0 L{filament_tower_interface_purge_volume} T{filament_tower_interface_print_temp} R0.0\n{endif}\n;prime_tower_interface\n\nM983.3 F{filament_max_volumetric_speed[next_filament_id]/2.4} A0.4 R{retract_length_toolchange[filament_map[next_filament_id]-1]}\n\nM400\n\nG1 Z{max_layer_z + 3.0} F3000\n\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\n\n\nM621 S[next_filament_id]A B\n\nM622.1 S0 ;for prev version, default skip\nM1002 judge_flag powerloss_resume_flag\nM622 J1\nM983.3 F{filament_max_volumetric_speed[next_filament_id]/2.4} A0.4 R{retract_length_toolchange[filament_map[next_filament_id]-1]}\nM400\nG1 Z{max_layer_z + 3.0} F3000\nM1002 set_flag powerloss_resume_flag=0\nM623\n\nM620.6 I[next_filament_id] H[next_hotend] W1 ;enable ams air printing detect\n\n{if (filament_type[next_filament_id] == \"TPU\")}\nM1015.3 S1 H{nozzle_diameter_at_nozzle_id[next_nozzle_id]};enable tpu clog detect\n{else}\nM1015.3 S0;disable tpu clog detect\n{endif}\n\n{if (filament_type[next_filament_id] == \"PLA\") || (filament_type[next_filament_id] == \"PETG\")\n || (filament_type[next_filament_id] == \"PLA-CF\") || (filament_type[next_filament_id] == \"PETG-CF\")}\nM1015.4 S1 K1 H{nozzle_diameter_at_nozzle_id[next_nozzle_id]} ;enable E air printing detect\n{else}\nM1015.4 S0 K0 H{nozzle_diameter_at_nozzle_id[next_nozzle_id]} ;disable E air printing detect\n{endif}\n\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[travel_acceleration]\n{endif}\n\nG1 Y256 F18000\n\n\n{if (overall_chamber_temperature < 40)}\n{if (layer_num + 1 <= close_additional_fan_first_x_layers[next_filament_id])}\n M106 P2 S{first_x_layer_fan_speed[next_filament_id]*255.0/100.0 };set first x_layer fan\n\tM106 P10 S{first_x_layer_fan_speed[next_filament_id]*255.0/100.0 };set first x_layer fan\n{elsif (layer_num + 1 < additional_fan_full_speed_layer[next_filament_id] && additional_fan_full_speed_layer[next_filament_id] > close_additional_fan_first_x_layers[next_filament_id])}\n M106 P2 S{(first_x_layer_fan_speed[next_filament_id] + (additional_cooling_fan_speed[next_filament_id] - first_x_layer_fan_speed[next_filament_id]) * (layer_num + 1 - close_additional_fan_first_x_layers[next_filament_id]) / max(additional_fan_full_speed_layer[next_filament_id] - close_additional_fan_first_x_layers[next_filament_id], 1)) * 255.0/100.0}\n\tM106 P10 S{(first_x_layer_fan_speed[next_filament_id] + (additional_cooling_fan_speed[next_filament_id] - first_x_layer_fan_speed[next_filament_id]) * (layer_num + 1 - close_additional_fan_first_x_layers[next_filament_id]) / max(additional_fan_full_speed_layer[next_filament_id] - close_additional_fan_first_x_layers[next_filament_id], 1)) * 255.0/100.0}\n{else}\n {if (min_vitrification_temperature <= 50)}\n {if (nozzle_diameter_at_nozzle_id[current_nozzle_id] == 0.2)}\n M142 P1 R30 S40 U{max_additional_fan/100.0} V1.0 O45; set PLA/TPU ND0.2 chamber autocooling\n {else}\n M142 P1 R30 S40 U{max_additional_fan/100.0} V1.0 O45; set PLA/TPU ND0.4 chamber autocooling\n {endif}\n {else}\n {if (!is_all_bbl_filament)}\n M142 P1 R40 S45 U{max_additional_fan/100.0} V0.5 O50; set third-party PETG chamber \n {else}\n {if (nozzle_diameter_at_nozzle_id[current_nozzle_id] == 0.2)}\n M142 P1 R45 S50 U{max_additional_fan/100.0} V0.5 O50; set PETG ND0.2 chamber autocooling\n {else}\n M142 P1 R50 S55 U{max_additional_fan/100.0} V0.5 O55; set PETG ND0.4 chamber autocooling\n {endif}\n {endif}\n {endif}\n{endif}\n{endif}\n;not set fan changing filament", + "layer_change_gcode": ";======== X2D layer_change gcode ==========\n;===== 2026/07/01 =====\n\n{if (layer_num + 1 == 1)}\n{if (overall_chamber_temperature >= 40)}\n ;not reset filter fan in first layer\n ;not reset fan\n{endif}\n{endif}\n\n{if (layer_num + 1 <= close_additional_fan_first_x_layers[current_filament_id])}\n{if (overall_chamber_temperature < 40)}\n M106 P2 S{first_x_layer_fan_speed[current_filament_id]*255.0/100.0}\n\tM106 P10 S{first_x_layer_fan_speed[current_filament_id]*255.0/100.0}\n{endif}\n;not reset fan\n{elsif (layer_num + 1 < additional_fan_full_speed_layer[current_filament_id] && additional_fan_full_speed_layer[current_filament_id] > close_additional_fan_first_x_layers[current_filament_id])}\n{if (overall_chamber_temperature < 40)}\n M106 P2 S{(first_x_layer_fan_speed[current_filament_id] + (additional_cooling_fan_speed[current_filament_id] - first_x_layer_fan_speed[current_filament_id]) * (layer_num + 1 - close_additional_fan_first_x_layers[current_filament_id]) / max(additional_fan_full_speed_layer[current_filament_id] - close_additional_fan_first_x_layers[current_filament_id], 1)) * 255.0/100.0}\n\tM106 P10 S{(first_x_layer_fan_speed[current_filament_id] + (additional_cooling_fan_speed[current_filament_id] - first_x_layer_fan_speed[current_filament_id]) * (layer_num + 1 - close_additional_fan_first_x_layers[current_filament_id]) / max(additional_fan_full_speed_layer[current_filament_id] - close_additional_fan_first_x_layers[current_filament_id], 1)) * 255.0/100.0}\n{endif}\n;not reset fan\n{elsif (layer_num + 1 == max(close_additional_fan_first_x_layers[current_filament_id] + 1, additional_fan_full_speed_layer[current_filament_id]))}\n{if (overall_chamber_temperature < 40)}\n ;updata chamber autocooling in Xth layer\n {if (min_vitrification_temperature <= 50)}\n {if (nozzle_diameter_at_nozzle_id[current_nozzle_id] == 0.2)}\n M142 P1 R30 S40 U{max_additional_fan/100.0} V1.0 O45; set PLA/TPU ND0.2 chamber autocooling\n {else}\n M142 P1 R30 S40 U{max_additional_fan/100.0} V1.0 O45; set PLA/TPU ND0.4 chamber autocooling\n {endif}\n {else}\n {if (!is_all_bbl_filament)}\n M142 P1 R40 S45 U{max_additional_fan/100.0} V0.5 O50; set third-party PETG chamber \n {else}\n {if (nozzle_diameter_at_nozzle_id[current_nozzle_id] == 0.2)}\n M142 P1 R45 S50 U{max_additional_fan/100.0} V0.5 O50; set PETG ND0.2 chamber autocooling\n {else}\n M142 P1 R50 S55 U{max_additional_fan/100.0} V0.5 O55; set PETG ND0.4 chamber autocooling\n {endif}\n {endif}\n {endif}\n{else}\n ;not reset filter fan in Xth layer\n{endif}\n;not reset fan\n{endif}\n\n\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change\n", "machine_end_gcode": ";======== X2D end gcode ==========\n;===== 2026/05/18 =====\n\nM400 ; wait for buffer to clear\nG92 E0 ; zero the extruder\nM211 Z1\n\nG90\nG1 Z{max_layer_z + 0.4} F900 ; lower z a little\nM1002 judge_flag timelapse_record_flag\nM622 J1\n G150.3\n M400 ; wait all motion done\n M991 S0 P-1 ;end smooth timelapse at safe pos\n M400 S5 ;wait for last picture to be taken\nM623 ;end of \"timelapse_record_flag\"\n\nG90\nG1 Z{max_layer_z + 10} F900 ; lower z a little\n\nM140 S0 ; turn off bed\nM141 S0 ; turn off chamber heating\nM106 S0 ; turn off fan\nM106 P2 S0 ; turn off remote part cooling fan\nM106 P3 S0 ; turn off chamber cooling fan\nM106 P10 S0 ; turn off remote part1 cooling fan\n\n; pull back filament to AMS\nM620 S65279 B\n; M620.11 P1 L0 I65279 E-3\nT65279\nG150.1 F8000\nM621 S65279 B\n\nM620 S65535 B\n; M620.11 P1 L0 I65535 E-4\nT65535\nG150.1 F8000\nM621 S65535 B\n\nG150.3\n\nM104 S0 T0; turn off hotend\nM104 S0 T1; turn off hotend\n\nM400 ; wait all motion done\nM17 S\nM17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom\n{if (80.0 - max_layer_z/2) > 0}\n {if (max_layer_z + 80.0 - max_layer_z/2) < 256}\n G1 Z{max_layer_z + 80.0 - max_layer_z/2} F600\n G1 Z{max_layer_z + 78.0 - max_layer_z/2}\n {else}\n G1 Z256 F600\n G1 Z256\n {endif}\n{else}\n {if (max_layer_z + 4.0) < 256}\n G1 Z{max_layer_z + 4.0} F600\n G1 Z{max_layer_z + 2.0}\n {else}\n G1 Z256 F600\n G1 Z256\n {endif}\n{endif}\nM400 P100\nM17 R ; restore z current\n\nM220 S100 ; Reset feedrate magnitude\nM201.2 K1.0 ; Reset acc magnitude\nM73.2 R1.0 ;Reset left time magnitude\nM1002 set_gcode_claim_speed_level : 0\n\nM1015.3 S0 ;disable clog detect\nM1015.4 S0 K0 ;disable air printing detect\n\n;=====printer finish air purification=========\nM622.1 S0\nM1002 judge_flag print_finish_air_filt_flag\n\nM622 J1\nM1002 gcode_claim_action : 66\nM145 P1\nM106 P10 S255\nM400 S180\nM106 P10 S0\nM623\n\nM622 J2\nM1002 gcode_claim_action : 66\nM145 P0\nM106 P3 S255\nM400 S180\nM106 P3 S0\nM623\n;=====printer finish air purification=========\n\n;=====printer finish sound=========\nM17\nM400 S1\nM1006 S1\nM1006 A53 B10 L50 C53 D10 M50 E53 F10 N50 \nM1006 A57 B10 L50 C57 D10 M50 E57 F10 N50 \nM1006 A0 B15 L0 C0 D15 M0 E0 F15 N0 \nM1006 A53 B10 L50 C53 D10 M50 E53 F10 N50 \nM1006 A57 B10 L50 C57 D10 M50 E57 F10 N50 \nM1006 A0 B15 L0 C0 D15 M0 E0 F15 N0 \nM1006 A48 B10 L50 C48 D10 M50 E48 F10 N50 \nM1006 A0 B15 L0 C0 D15 M0 E0 F15 N0 \nM1006 A60 B10 L50 C60 D10 M50 E60 F10 N50 \nM1006 W\n;=====printer finish sound=========\nM400\nM18\n\n", - "machine_start_gcode": ";M1002 set_flag extrude_cali_flag=1\n;M1002 set_flag g29_before_print_flag=1\n;M1002 set_flag auto_cali_toolhead_offset_flag=1\n;M1002 set_flag build_plate_detect_flag=1\n\n;======== X2D start gcode==========\n;===== 2026/05/18 =====\n\n M140 S[bed_temperature_initial_layer_single] ; heat heatbed first\n M993 A0 B0 C0 ; nozzle cam detection not allowed.\n M400\n ;M73 P99\n\n;=====printer start sound ===================\nM17\nM400 S1\nM1006 S1\nM1006 A53 B9 L50 C53 D9 M50 E53 F9 N50\nM1006 A56 B9 L50 C56 D9 M50 E56 F9 N50\nM1006 A61 B9 L50 C61 D9 M50 E61 F9 N50\nM1006 A53 B9 L50 C53 D9 M50 E53 F9 N50\nM1006 A56 B9 L50 C56 D9 M50 E56 F9 N50\nM1006 A61 B18 L50 C61 D18 M50 E61 F18 N50\nM1006 W\n;=====printer start sound ===================\n\n M1012.1 T1100\n M620 M ;enable remap\n M622.1 S0\n G383.4\n\n;===== avoid end stop =================\n G91\n G380 S2 Z22 F1200\n G380 S2 Z-12 F1200\n G90\n;===== avoid end stop =================\n\n;===== reset machine status =================\n M204 S10000\n M630 S0 P1\n G90\n M17 D ; reset motor current to default\n M960 S5 P1 ; turn on logo lamp\n M220 S100 ;Reset Feedrate\n M1002 set_gcode_claim_speed_level: 5\n M221 S100 ;Reset Flowrate\n M73.2 R1.0 ;Reset left time magnitude\n G29.1 Z{+0.0} ; clear z-trim value first\n M983.1 M1\n M982.2 S1 ; turn on cog noise reduction\n;===== reset machine status =================\n\n;==== set airduct mode ====\n{if (overall_chamber_temperature >= 40)}\nM145 P1 ; set airduct mode to heating mode for heating\nM106 P2 S0 ; turn off auxiliary fan\nM106 P10 S255 ; turn on filter fan\n{else}\nM145 P0 ; set airduct mode to cooling mode for cooling\nM106 P2 S255 ; turn on auxiliary fan for cooling\nM106 P10 S255 ; turn on auxiliary fan for cooling\nM106 P3 S127 ; turn on chamber fan for cooling\n;M140 S0 ; stop heatbed from heating\nM1002 gcode_claim_action : 29\nM191 S0 ; wait for chamber temp\nM106 P2 S102 ; turn on auxiliary fan\nM106 P10 S102 ; turn on chamber fan\nM142 P6 R30 S40 U0.6 V0.8 ; set PLA/TPU/PETG exhaust chamber autocooling\n{endif}\n;==== set airduct mode ====\n\n;===== start to heat heatbed & hotend==========\n M1002 gcode_claim_action : 2\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n\n ;===== set chamber temperature ==========\n {if (overall_chamber_temperature >= 40)}\n M145 P1 ; set airduct mode to heating mode\n M141 S[overall_chamber_temperature] ; Let Chamber begin to heat\n {endif}\n;===== set chamber temperature ==========\n\n G29.2 S0 ; avoid invalid abl data\n\n;===== first homing start =====\n M1002 gcode_claim_action : 13\n G28 X T300 R\n G150.1 F8000 ; wipe mouth to avoid filament stick to heatbed\n G150.3\n M972 S24 P0\n M1002 gcode_claim_action : 74 ; Heatbed surface foreign object detection\n M972 S26 P0 C0\n G90\n M83\n G1 Y128 F30000\n G1 X128\n G28 Z P0 T400\n M400\n;===== first homign end =====\n\n;===== detection start =====\n M1002 gcode_claim_action : 11\n\n M104 S0 T0\n M104 S0 T1\n M562 P1 E0 B1\n M562 P2 E0 B1\n M18 E\n M400 P200\n M1028 S1\n M972 S19 P0 ;heatbed detection\n M972 S31 P0 ;toolhead camera dirt detection\n M1002 gcode_claim_action : 73 ; Build plate alignment detection\n M972 S34 P0 ;print plate deviation detection\n M1028 S0\n M562 P1 E1 B1\n M562 P2 E1 B1\n M17 D\n\n ;M400\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]} T{filament_map[initial_no_support_filament_id] % 2} ; rise temp in advance\n\n {if max_print_z >= 145}\n G151 P{filament_map[initial_no_support_filament_id] % 2} M ; plug the heat nozzle\n M1002 gcode_claim_action : 75 ; Detect obstacles at the botton of the heated bed\n G3811 Z{max_print_z} ; Detect obstacles at the bottom of the heated bed\n {endif}\n;===== detection end =====\n\n;===== prepare print temperature and material ==========\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]-40} A ; rise temp in advance\n M400\n M211 X0 Y0 Z0 ;turn off soft endstop\n M975 S1 ; turn on input shaping\n\n G29.2 S0 ; avoid invalid abl data\n G150.3\n{if ((filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG\")) && (nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2)}\nM620.10 A0 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{else}\nM620.10 A0 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{endif}\n\n M620.11 P0 L0 I[initial_no_support_filament_id] B[initial_no_support_hotend] E0\n M620.11 K0 I[initial_no_support_filament_id] B[initial_no_support_hotend] R0\n\n M620 S[initial_no_support_filament_id]A H[initial_no_support_hotend] B ; switch material if AMS exist\n M620.22 I[initial_no_support_filament_id] P1 ; enable remote extruder runout auto purge.\n M1002 gcode_claim_action : 4\n M1002 set_filament_type:UNKNOWN\n M400\n T[initial_no_support_filament_id] H[initial_no_support_hotend]\n M400\n M628 S0\n M629\n M400\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M621 S[initial_no_support_filament_id]A B\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M400\n M106 P1 S0\n M400\n G29.2 S1\n;===== prepare print temperature and material ==========\n\n;===== auto extrude cali start =========================\n M975 S1\n M1002 judge_flag extrude_cali_flag\n M622 J0\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M623\n\n M622 J1\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\n M623\n\n M622 J2\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\n M623\n;===== auto extrude cali end =========================\n\n {if hold_chamber_temp_for_flat_print}\n G150.3\n M1002 gcode_claim_action : 58\n M104 S{first_layer_temperature[initial_no_support_filament_id]}\n {if bed_temperature_initial_layer_single > 89}\n {if overall_chamber_temperature < 40}\n M1030 S1200\n SYNC R0 T1200\n {else}\n M1030 S600\n SYNC R0 T600\n {endif}\n {else}\n M1030 S300\n SYNC R0 T300\n {endif}\n M1030 C\n {endif}\n\n {if filament_type[initial_filament_id] == \"TPU\" || filament_type[initial_filament_id] == \"PVA\"}\n {else}\n M83\n G1 E-3 F1800\n M400 P500\n {endif}\n G150.2\n G150.1 F8000\n G150.2\n G150.1 F8000\n\n G91\n G1 Y-16 F12000 ; move away from the trash bin\n G90\n M400\n\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]-80} A\n\n;===== wipe right nozzle start =====\n M1002 gcode_claim_action : 14\n G150 T{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M400\n;===== wipe left nozzle end =====\n\n{if filament_type[initial_filament_id] == \"PC\"}\n M109 S170 A\n{else}\n M109 S140 A\n{endif}\n M106 S0 ; turn off fan , too noisy\n G91\n G1 Z5 F1200\n G90\n M400\n G150.1\n\n{if (overall_chamber_temperature >= 40)}\nM1002 gcode_claim_action : 49\nM191 S[overall_chamber_temperature] ; wait for chamber temp\n{endif}\n\n;===== z ofst cali start =====\n M190 S[bed_temperature_initial_layer_single]; ensure bed temp\n G383 O0 M1 T140\n M400\n;===== z ofst cali end =====\nG90\nM83\nG0 Y200 F18000\n\n;===== bed leveling ==================================\n M1002 gcode_claim_action : 54\n M190 S[bed_temperature_initial_layer_single]; ensure bed temp\n M109 S140 A\n M106 S0 ; turn off fan , too noisy\n M1002 judge_flag g29_before_print_flag\n M622 J1\n M1002 gcode_claim_action : 1\n {if hold_chamber_temp_for_flat_print}\n G29 H R\n {else}\n G29 A1 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]} R\n {endif}\n M400\n M623\n\n M622 J2\n M1002 gcode_claim_action : 1\n {if hold_chamber_temp_for_flat_print}\n G29 H R\n {else}\n G29 A2 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]} R\n {endif}\n M400\n M623\n\n M622 J0\n G28 R\n M623\n G29.2 S1\n;===== bed leveling end ================================\n\n; cali eddy z pos\n;G383.13 T1 C1\n\nM104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]} A\n;===== mech mode sweep start =====\n M1002 gcode_claim_action : 3\n G90\n G1 X128 Y128 F20000\n G1 Z5 F1200\n M400 P200\n M970.3 Q1 A5 K0 O1\n M974 Q1 S2 P0\n M970.3 Q0 A7 K0 O1\n M970.2 Q0 W73 K1 Z0.01\n M974 Q0 S2 P0\n M975 S1\n M400\n;===== mech mode sweep end =====\n\nM104 S{nozzle_temperature_initial_layer[initial_filament_id]} A\nG150.3\n\n;===== xy ofst cali start =====\nM1002 judge_flag auto_cali_toolhead_offset_flag\n\nM622 J0\n M1012.5 N1 R1\nM623\n\nM622 J1\n M1002 gcode_claim_action : 39\n M141 S0\n M620.17 T0 S{nozzle_temperature_initial_layer[(first_non_support_filaments[0] != -1 ? first_non_support_filaments[0] : first_filaments[0])]} L{(first_non_support_filaments[0] != -1 ? first_non_support_filaments[0] : first_filaments[0])}\n M620.17 T1 S{nozzle_temperature_initial_layer[(first_non_support_filaments[1] != -1 ? first_non_support_filaments[1] : first_filaments[1])]} L{(first_non_support_filaments[1] != -1 ? first_non_support_filaments[1] : first_filaments[1])}\n M620 D[initial_no_support_hotend]\n G383 O1 T{nozzle_temperature_initial_layer[initial_no_support_filament_id]} L{initial_no_support_filament_id}\n M141 S[overall_chamber_temperature]\nM623\n\nM622 J2\n M1002 gcode_claim_action : 39\n M141 S0\n M620.17 T0 S{nozzle_temperature_initial_layer[(first_non_support_filaments[0] != -1 ? first_non_support_filaments[0] : first_filaments[0])]} L{(first_non_support_filaments[0] != -1 ? first_non_support_filaments[0] : first_filaments[0])}\n M620.17 T1 S{nozzle_temperature_initial_layer[(first_non_support_filaments[1] != -1 ? first_non_support_filaments[1] : first_filaments[1])]} L{(first_non_support_filaments[1] != -1 ? first_non_support_filaments[1] : first_filaments[1])}\n M620 D[initial_no_support_hotend]\n G383.3 T{nozzle_temperature_initial_layer[initial_no_support_filament_id]} L{initial_no_support_filament_id}\n M141 S[overall_chamber_temperature]\nM623\n;===== xy ofst cali end =====\n\n M104 S{nozzle_temperature_initial_layer[initial_filament_id]} A\n\n G150.3 ; move to garbage can to wait for temp\n\n;===== wait temperature reaching the reference value =======\n M140 S[bed_temperature_initial_layer_single]\n M190 S[bed_temperature_initial_layer_single]\n\n ;========turn off light and fans =============\n M960 S1 P0 ; turn off laser\n M960 S2 P0 ; turn off laser\n M106 S0 ; turn off cooling fan\n\n;===== wait temperature reaching the reference value =======\n\n M1002 gcode_claim_action : 255\n M400\n M975 S1 ; turn on mech mode supression\n M983.4 S0 ; turn off deformation compensation\n\n;============switch again==================\n M211 X0 Y0 Z0 ;turn off soft endstop\n G91\n G1 Z6 F1200\n G90\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M620 S[initial_no_support_filament_id]A H[initial_no_support_hotend] B\n M620.22 I[initial_no_support_filament_id] P1 ; enable remote extruder runout auto purge.\n M400\n T[initial_no_support_filament_id] H[initial_no_support_hotend]\n M400\n M628 S0\n M629\n M400\n M621 S[initial_no_support_filament_id]A B\n;============switch again==================\n\n;===== for Textured PEI Plate , lower the nozzle as the nozzle was touching topmost of the texture when homing ==\n {if bed_temperature_initial_layer_single > 70}\n {if curr_bed_type==\"Textured PEI Plate\"}\n G29.1 Z{-0.003} ; for Textured PEI Plate\n {else}\n G29.1 Z{0.017}\n {endif}\n {else}\n {if curr_bed_type==\"Textured PEI Plate\"}\n G29.1 Z{0.002} ; for Textured PEI Plate\n {else}\n G29.1 Z{0.022}\n {endif}\n {endif}\n\n;===== nozzle load line ===============================\nM1002 gcode_claim_action : 51\n G29.2 S1 ; ensure z comp turn on\n G90\n M83\n M400 P50\n M500 D1\n M400 S3\n M109 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n G0 X100 Y0 F24000\n M400\n ;G130 O0 X100 Y-0.4 Z0.6 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2/2.4053} L40 E20 D5\n G130 O0 X100 Y-0.2 Z0.6 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2/2.4053} L40 E12 D4\nG90\n G90\n M83\n G1 Z1\n M400\n;===== noozle load line end ===========================\nM1002 gcode_claim_action : 0\n G29.99\n\n;M993 A1 B1 C1 ; nozzle cam detection allowed.\n\nM620.6 I[initial_no_support_filament_id] H[initial_no_support_hotend] W1 ;enable ams air printing detect\n\n\n{if (filament_type[initial_no_support_filament_id] == \"TPU\")}\nM1015.3 S1 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]};enable tpu clog detect\n{else}\nM1015.3 S0;disable tpu clog detect\n{endif}\n\n{if (filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PETG\")\n || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG-CF\")}\nM1015.4 S1 K1 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;enable E air printing detect\n{else}\nM1015.4 S0 K0 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;disable E air printing detect\n{endif}\n\n", + "machine_start_gcode": ";M1002 set_flag extrude_cali_flag=1\n;M1002 set_flag g29_before_print_flag=1\n;M1002 set_flag auto_cali_toolhead_offset_flag=1\n;M1002 set_flag build_plate_detect_flag=1\n\n;======== X2D start gcode==========\n;===== 2026/06/05 =====\n\n M140 S[bed_temperature_initial_layer_single] ; heat heatbed first\n M993 A0 B0 C0 ; nozzle cam detection not allowed.\n M400\n ;M73 P99\n\n;=====printer start sound ===================\nM17\nM400 S1\nM1006 S1\nM1006 A53 B9 L50 C53 D9 M50 E53 F9 N50\nM1006 A56 B9 L50 C56 D9 M50 E56 F9 N50\nM1006 A61 B9 L50 C61 D9 M50 E61 F9 N50\nM1006 A53 B9 L50 C53 D9 M50 E53 F9 N50\nM1006 A56 B9 L50 C56 D9 M50 E56 F9 N50\nM1006 A61 B18 L50 C61 D18 M50 E61 F18 N50\nM1006 W\n;=====printer start sound ===================\n\n M1012.1 T1100\n M620 M ;enable remap\n M622.1 S0\n G383.4\n\n;===== avoid end stop =================\n G91\n G380 S2 Z22 F1200\n G380 S2 Z-12 F1200\n G90\n;===== avoid end stop =================\n\n;===== reset machine status =================\n M204 S10000\n M630 S0 P1\n G90\n M17 D ; reset motor current to default\n M960 S5 P1 ; turn on logo lamp\n M220 S100 ;Reset Feedrate\n M1002 set_gcode_claim_speed_level: 5\n M221 S100 ;Reset Flowrate\n M73.2 R1.0 ;Reset left time magnitude\n G29.1 Z{+0.0} ; clear z-trim value first\n M983.1 M1\n M982.2 S1 ; turn on cog noise reduction\n;===== reset machine status =================\n\n;==== set airduct mode ====\n{if (overall_chamber_temperature >= 40)}\nM145 P1 ; set airduct mode to heating mode for heating\nM106 P2 S0 ; turn off auxiliary fan\nM106 P10 S255 ; turn on filter fan\n{else}\nM145 P0 ; set airduct mode to cooling mode for cooling\nM106 P2 S255 ; turn on auxiliary fan for cooling\nM106 P10 S255 ; turn on auxiliary fan for cooling\nM106 P3 S127 ; turn on chamber fan for cooling\n;M140 S0 ; stop heatbed from heating\nM1002 gcode_claim_action : 29\nM191 S0 ; wait for chamber temp\nM106 P2 S102 ; turn on auxiliary fan\nM106 P10 S102 ; turn on chamber fan\nM142 P6 R30 S40 U0.6 V0.8 ; set PLA/TPU/PETG exhaust chamber autocooling\n{endif}\n;==== set airduct mode ====\n\n;===== start to heat heatbed & hotend==========\n M1002 gcode_claim_action : 2\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n\n ;===== set chamber temperature ==========\n {if (overall_chamber_temperature >= 40)}\n M145 P1 ; set airduct mode to heating mode\n M141 S[overall_chamber_temperature] ; Let Chamber begin to heat\n {endif}\n;===== set chamber temperature ==========\n\n G29.2 S0 ; avoid invalid abl data\n\n;===== first homing start =====\n M1002 gcode_claim_action : 13\n G28 X T300 R\n G150.1 F8000 ; wipe mouth to avoid filament stick to heatbed\n G150.3\n M972 S24 P0\n M1002 gcode_claim_action : 74 ; Heatbed surface foreign object detection\n M972 S26 P0 C0\n G90\n M83\n G1 Y128 F30000\n G1 X128\n G28 Z P0 T400\n M400\n;===== first homign end =====\n\n;===== detection start =====\n M1002 gcode_claim_action : 11\n\n M104 S0 T0\n M104 S0 T1\n M562 P1 E0 B1\n M562 P2 E0 B1\n M18 E\n M400 P200\n M1028 S1\n M972 S19 P0 ;heatbed detection\n M972 S31 P0 ;toolhead camera dirt detection\n M1002 gcode_claim_action : 73 ; Build plate alignment detection\n M972 S34 P0 ;print plate deviation detection\n M1028 S0\n M562 P1 E1 B1\n M562 P2 E1 B1\n M17 D\n\n ;M400\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]} T{filament_map[initial_no_support_filament_id] % 2} ; rise temp in advance\n\n {if max_print_z >= 145}\n G151 P{filament_map[initial_no_support_filament_id] % 2} M ; plug the heat nozzle\n M1002 gcode_claim_action : 75 ; Detect obstacles at the botton of the heated bed\n G3811 Z{max_print_z} ; Detect obstacles at the bottom of the heated bed\n {endif}\n;===== detection end =====\n\n;===== prepare print temperature and material ==========\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]-40} A ; rise temp in advance\n M400\n M211 X0 Y0 Z0 ;turn off soft endstop\n M975 S1 ; turn on input shaping\n\n G29.2 S0 ; avoid invalid abl data\n G150.3\n{if ((filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG\")) && (nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2)}\nM620.10 A0 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{else}\nM620.10 A0 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{endif}\n\n M620.11 P0 L0 I[initial_no_support_filament_id] B[initial_no_support_hotend] E0\n M620.11 K0 I[initial_no_support_filament_id] B[initial_no_support_hotend] R0\n\n M620 S[initial_no_support_filament_id]A H[initial_no_support_hotend] B ; switch material if AMS exist\n M620.22 I[initial_no_support_filament_id] P1 ; enable remote extruder runout auto purge.\n M1002 gcode_claim_action : 4\n M1002 set_filament_type:UNKNOWN\n M400\n T[initial_no_support_filament_id] H[initial_no_support_hotend]\n M400\n M628 S0\n M629\n M400\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M621 S[initial_no_support_filament_id]A B\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M400\n M106 P1 S0\n M400\n G29.2 S1\n;===== prepare print temperature and material ==========\n\n;===== auto extrude cali start =========================\n M975 S1\n M1002 judge_flag extrude_cali_flag\n M622 J0\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M623\n\n M622 J1\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\n M623\n\n M622 J2\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\n M623\n;===== auto extrude cali end =========================\n\n {if hold_chamber_temp_for_flat_print}\n G150.3\n M1002 gcode_claim_action : 58\n M104 S{first_layer_temperature[initial_no_support_filament_id]}\n {if bed_temperature_initial_layer_single > 89}\n {if overall_chamber_temperature < 40}\n M1030 S1200\n SYNC R0 T1200\n {else}\n M1030 S600\n SYNC R0 T600\n {endif}\n {else}\n M1030 S300\n SYNC R0 T300\n {endif}\n M1030 C\n {endif}\n\n {if filament_type[initial_filament_id] == \"TPU\" || filament_type[initial_filament_id] == \"PVA\"}\n {else}\n M83\n G1 E-3 F1800\n M400 P500\n {endif}\n G150.2\n G150.1 F8000\n G150.2\n G150.1 F8000\n\n G91\n G1 Y-16 F12000 ; move away from the trash bin\n G90\n M400\n\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]-80} A\n\n;===== wipe right nozzle start =====\n M1002 gcode_claim_action : 14\n G150 T{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M400\n;===== wipe left nozzle end =====\n\n{if filament_type[initial_filament_id] == \"PC\"}\n M109 S170 A\n{else}\n M109 S140 A\n{endif}\n M106 S0 ; turn off fan , too noisy\n G91\n G1 Z5 F1200\n G90\n M400\n G150.1\n\n{if (overall_chamber_temperature >= 40)}\nM1002 gcode_claim_action : 49\nM191 S[overall_chamber_temperature] ; wait for chamber temp\n{endif}\n\n;===== z ofst cali start =====\n M190 S[bed_temperature_initial_layer_single]; ensure bed temp\n G383 O0 M1 T140\n M400\n;===== z ofst cali end =====\nG90\nM83\nG0 Y200 F18000\n\n;===== bed leveling ==================================\n M1002 gcode_claim_action : 54\n M190 S[bed_temperature_initial_layer_single]; ensure bed temp\n M109 S140 A\n M106 S0 ; turn off fan , too noisy\n M1002 judge_flag g29_before_print_flag\n M622 J1\n M1002 gcode_claim_action : 1\n {if hold_chamber_temp_for_flat_print}\n G29 H R\n {else}\n G29 A1 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]} R\n {endif}\n M400\n M623\n\n M622 J2\n M1002 gcode_claim_action : 1\n {if hold_chamber_temp_for_flat_print}\n G29 H R\n {else}\n G29 A2 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]} R\n {endif}\n M400\n M623\n\n M622 J0\n G28 R\n M623\n G29.2 S1\n;===== bed leveling end ================================\n\n; cali eddy z pos\n;G383.13 T1 C1\n\nM104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]} A\n;===== mech mode sweep start =====\n M1002 gcode_claim_action : 3\n G90\n G1 X128 Y128 F20000\n G1 Z5 F1200\n M400 P200\n M970.3 Q1 A5 K0 O1\n M974 Q1 S2 P0\n M970.3 Q0 A7 K0 O1\n M970.2 Q0 W73 K1 Z0.01\n M974 Q0 S2 P0\n M975 S1\n M400\n;===== mech mode sweep end =====\n\nM104 S{nozzle_temperature_initial_layer[initial_filament_id]} A\nG150.3\n\n;===== xy ofst cali start =====\nM1002 judge_flag auto_cali_toolhead_offset_flag\n\nM622 J0\n M1012.5 N1 R1\nM623\n\nM622 J1\n M1002 gcode_claim_action : 39\n M141 S0\n M620.17 T0 S{nozzle_temperature_initial_layer[(first_non_support_filaments[0] != -1 ? first_non_support_filaments[0] : (first_filaments[0] != -1 ? first_filaments[0] : 0))]} L{(first_non_support_filaments[0] != -1 ? first_non_support_filaments[0] : (first_filaments[0] != -1 ? first_filaments[0] : 0))}\n M620.17 T1 S{nozzle_temperature_initial_layer[(first_non_support_filaments[1] != -1 ? first_non_support_filaments[1] : (first_filaments[1] != -1 ? first_filaments[1] : 0))]} L{(first_non_support_filaments[1] != -1 ? first_non_support_filaments[1] : (first_filaments[1] != -1 ? first_filaments[1] : 0))}\n M620 D[initial_no_support_hotend]\n G383 O1 T{nozzle_temperature_initial_layer[initial_no_support_filament_id]} L{initial_no_support_filament_id}\n M141 S[overall_chamber_temperature]\nM623\n\nM622 J2\n M1002 gcode_claim_action : 39\n M141 S0\n M620.17 T0 S{nozzle_temperature_initial_layer[(first_non_support_filaments[0] != -1 ? first_non_support_filaments[0] : (first_filaments[0] != -1 ? first_filaments[0] : 0))]} L{(first_non_support_filaments[0] != -1 ? first_non_support_filaments[0] : (first_filaments[0] != -1 ? first_filaments[0] : 0))}\n M620.17 T1 S{nozzle_temperature_initial_layer[(first_non_support_filaments[1] != -1 ? first_non_support_filaments[1] : (first_filaments[1] != -1 ? first_filaments[1] : 0))]} L{(first_non_support_filaments[1] != -1 ? first_non_support_filaments[1] : (first_filaments[1] != -1 ? first_filaments[1] : 0))}\n M620 D[initial_no_support_hotend]\n G383.3 T{nozzle_temperature_initial_layer[initial_no_support_filament_id]} L{initial_no_support_filament_id}\n M141 S[overall_chamber_temperature]\nM623\n;===== xy ofst cali end =====\n\n M104 S{nozzle_temperature_initial_layer[initial_filament_id]} A\n\n G150.3 ; move to garbage can to wait for temp\n\n;===== wait temperature reaching the reference value =======\n M140 S[bed_temperature_initial_layer_single]\n M190 S[bed_temperature_initial_layer_single]\n\n ;========turn off light and fans =============\n M960 S1 P0 ; turn off laser\n M960 S2 P0 ; turn off laser\n M106 S0 ; turn off cooling fan\n\n;===== wait temperature reaching the reference value =======\n\n M1002 gcode_claim_action : 255\n M400\n M975 S1 ; turn on mech mode supression\n M983.4 S0 ; turn off deformation compensation\n\n;============switch again==================\n M211 X0 Y0 Z0 ;turn off soft endstop\n G91\n G1 Z6 F1200\n G90\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M620 S[initial_no_support_filament_id]A H[initial_no_support_hotend] B\n M620.22 I[initial_no_support_filament_id] P1 ; enable remote extruder runout auto purge.\n M400\n T[initial_no_support_filament_id] H[initial_no_support_hotend]\n M400\n M628 S0\n M629\n M400\n M621 S[initial_no_support_filament_id]A B\n;============switch again==================\n\n;===== for Textured PEI Plate , lower the nozzle as the nozzle was touching topmost of the texture when homing ==\n {if bed_temperature_initial_layer_single > 70}\n {if curr_bed_type==\"Textured PEI Plate\"}\n G29.1 Z{-0.003} ; for Textured PEI Plate\n {else}\n G29.1 Z{0.017}\n {endif}\n {else}\n {if curr_bed_type==\"Textured PEI Plate\"}\n G29.1 Z{0.002} ; for Textured PEI Plate\n {else}\n G29.1 Z{0.022}\n {endif}\n {endif}\n\n;===== nozzle load line ===============================\nM1002 gcode_claim_action : 51\n G29.2 S1 ; ensure z comp turn on\n G90\n M83\n M400 P50\n M500 D1\n M400 S3\n M109 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n G0 X100 Y0 F24000\n M400\n ;G130 O0 X100 Y-0.4 Z0.6 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2/2.4053} L40 E20 D5\n G130 O0 X100 Y-0.2 Z0.6 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2/2.4053} L40 E12 D4\nG90\n G90\n M83\n G1 Z1\n M400\n;===== noozle load line end ===========================\nM1002 gcode_claim_action : 0\n G29.99\n\n;M993 A1 B1 C1 ; nozzle cam detection allowed.\n\nM620.6 I[initial_no_support_filament_id] H[initial_no_support_hotend] W1 ;enable ams air printing detect\n\n\n{if (filament_type[initial_no_support_filament_id] == \"TPU\")}\nM1015.3 S1 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]};enable tpu clog detect\n{else}\nM1015.3 S0;disable tpu clog detect\n{endif}\n\n{if (filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PETG\")\n || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG-CF\")}\nM1015.4 S1 K1 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;enable E air printing detect\n{else}\nM1015.4 S0 K0 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;disable E air printing detect\n{endif}\n\n", "time_lapse_gcode": ";======== X2D timelapse gcode ========\n;======== 2026/06/03 ========\n; SKIPPABLE_START\n; SKIPTYPE: timelapse\nM622.1 S1 ; for prev firware, default turned on\n\nM1002 judge_flag timelapse_record_flag\n\nM622 J1\n {if !spiral_mode && !(has_timelapse_safe_pos) }\n {if most_used_physical_extruder_id!= curr_physical_extruder_id || timelapse_type == 1}\n M83\n G1 Z{max_layer_z + 0.4} F1200\n M400\n {endif}\n {endif}\n\n {if timelapse_inline_photo}\n M971 S11 C10 O0\n M1004 S5 P1 ; external shutter\n {elsif has_timelapse_safe_pos && !spiral_mode}\n M9711 M{timelapse_type} E{most_used_physical_extruder_id} U{timelapse_pos_x} V{timelapse_pos_y} Z{layer_z + (farthest_point_timelapse_enabled ? 0.0 : 0.4)} S11 C10 O0 T3000\n {else}\n {if spiral_mode}\n M971 S11 C10 O0\n M1004 S5 P1 ; external shutter\n {else}\n M9711 M{timelapse_type} E{most_used_physical_extruder_id} Z{layer_z + (farthest_point_timelapse_enabled ? 0.0 : 0.4)} S11 C10 O0 T3000\n {endif}\n {endif}\n\n {if !spiral_mode && !(has_timelapse_safe_pos) }\n {if most_used_physical_extruder_id!= curr_physical_extruder_id || timelapse_type == 1}\n G90\n G1 Z{max_layer_z + 3.0} F1200\n G91\n G0 Y-20 F18000\n G90\n M83\n {endif}\n {endif}\nM623\n; SKIPPABLE_END\n", "nozzle_diameter": [ "0.4", From d2b50081564c79db789bf3acef8168c32b0b03f9 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 15 Jul 2026 21:39:55 +0800 Subject: [PATCH 32/47] fix: test_slicing_pipeline_hook --- .../fff_print/test_slicing_pipeline_hook.cpp | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp index 66f7813503..341bd73465 100644 --- a/tests/fff_print/test_slicing_pipeline_hook.cpp +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -1,5 +1,6 @@ #include #include "libslic3r/PrintConfig.hpp" +#include "test_helpers.hpp" using namespace Slic3r; TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_pipeline]") { @@ -24,7 +25,6 @@ TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pi CHECK(calls == 0); } -#include "test_data.hpp" #include #include using namespace Slic3r::Test; @@ -38,7 +38,7 @@ TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slic Slic3r::Print print; Slic3r::Model model; Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config(); config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); print.process(); Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); @@ -102,7 +102,7 @@ TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset) Slic3r::Print::set_slicing_pipeline_hook_fn([](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){}); else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); std::string g = Slic3r::Test::gcode(print); Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); return g; @@ -126,7 +126,7 @@ TEST_CASE("Empty option: registered hook is gated off and never fires", "[slicin Slic3r::Print print; Slic3r::Model model; auto config = Slic3r::DynamicPrintConfig::full_print_config(); // option left EMPTY -> inactive regardless of the registered hook. - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); print.process(); Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); CHECK(calls == 0); @@ -150,7 +150,7 @@ TEST_CASE("Duplicate objects share a slice: Slice hook fires exactly once", "[sl config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate // init_print builds one arranged, on-bed cube object (o1). - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); Slic3r::ModelObject* o1 = model.objects.front(); // Model::add_object(const ModelObject&) force-sets object extruder=1 on the clone; give o1 // the same so the two objects' configs match (is_print_object_the_same compares config). @@ -198,7 +198,7 @@ TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing } }); else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); print.process(); double a = 0; for (auto* l : print.objects().front()->layers()) for (auto* r : l->regions()) for (auto& s : r->fill_surfaces.surfaces) a += s.expolygon.area(); Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); @@ -210,7 +210,7 @@ TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pipeline]") { Slic3r::Print print; Slic3r::Model model; auto config = Slic3r::DynamicPrintConfig::full_print_config(); - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); print.process(); REQUIRE(print.objects().front()->is_step_done(posSlice)); config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); @@ -271,7 +271,7 @@ TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox } }); else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); print.process(); double area = 0; coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false; @@ -320,7 +320,7 @@ TEST_CASE("Identity round-trip through slices.set() is byte-identical", "[slicin r->slices.set(std::move(in)); // write back unchanged: identity transform } }); - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); std::string g = Slic3r::Test::gcode(print); Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); return g; @@ -376,7 +376,7 @@ TEST_CASE("raw_slices captures post-hook geometry so a perimeter re-run keeps th Slic3r::Print print; Slic3r::Model model; auto config = Slic3r::DynamicPrintConfig::full_print_config(); config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); print.process(); const double w_mutated = outer_slices_width(print); // inset applied at the Slice hook @@ -410,7 +410,7 @@ TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", r->fill_surfaces.set(std::move(out)); } }); - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); print.process(); size_t n = 0; for (auto* l : print.objects().front()->layers()) @@ -451,7 +451,7 @@ TEST_CASE("refreshing lslices after a slice mutation makes islands track the geo l->make_slices(); } }); - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); print.process(); coord_t min_x = 0, max_x = 0; bool seeded = false; for (auto* l : print.objects().front()->layers()) @@ -533,7 +533,7 @@ TEST_CASE("Fuzzing slice contours at the Slice boundary cascades with bounded di } }); else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); - init_print({TestMesh::cube_20x20x20}, print, model, config); + init_print({cube(20)}, print, model, config); print.process(); Measure m { 0.0, 0, outer_slices_width(print) }; for (auto* l : print.objects().front()->layers()) From a34310cbdaf5d42bccda6c6c96d87e2c2d08744b Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 15 Jul 2026 23:16:33 +0800 Subject: [PATCH 33/47] Assign setting_ids to the profiles added for slice validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eight presets added in 0545750c1b never got ids: the six iQ processes had none, and K1 SE 0.8 / V-Core 4 0.8 carried ids copied from the presets they were duplicated from — K1 SE 0.8 still shared K1C 0.8's id. Regenerated with scripts/assign_vendor_setting_ids.py; none of the eight have shipped in a release, so no existing id changes meaning. --- .../process/0.40mm Standard @Creality K1 SE 0.8 nozzle.json | 2 +- .../Ratrig/process/0.30mm Big @RatRig V-Core 4 0.8.json | 2 +- .../iQ/process/0.15mm Standard @iQ TiQ2 (0.25 Nozzle).json | 1 + .../iQ/process/0.15mm Standard @iQ TiQ8 (0.25 Nozzle).json | 1 + .../iQ/process/0.30mm Standard @iQ TiQ2 (0.6 Nozzle).json | 1 + .../iQ/process/0.30mm Standard @iQ TiQ8 (0.6 Nozzle).json | 1 + .../iQ/process/0.40mm Standard @iQ TiQ2 (0.8 Nozzle).json | 1 + .../iQ/process/0.40mm Standard @iQ TiQ8 (0.8 Nozzle).json | 1 + 8 files changed, 8 insertions(+), 2 deletions(-) diff --git a/resources/profiles/Creality/process/0.40mm Standard @Creality K1 SE 0.8 nozzle.json b/resources/profiles/Creality/process/0.40mm Standard @Creality K1 SE 0.8 nozzle.json index 46a80e8498..88bc46add2 100644 --- a/resources/profiles/Creality/process/0.40mm Standard @Creality K1 SE 0.8 nozzle.json +++ b/resources/profiles/Creality/process/0.40mm Standard @Creality K1 SE 0.8 nozzle.json @@ -3,7 +3,7 @@ "name": "0.40mm Standard @Creality K1 SE 0.8 nozzle", "inherits": "fdm_process_creality_common", "from": "system", - "setting_id": "caYmVSsFmtESzLNF", + "setting_id": "5mfhrVJYlK4lWDUs", "instantiation": "true", "reduce_crossing_wall": "0", "max_travel_detour_distance": "0", diff --git a/resources/profiles/Ratrig/process/0.30mm Big @RatRig V-Core 4 0.8.json b/resources/profiles/Ratrig/process/0.30mm Big @RatRig V-Core 4 0.8.json index 2eee1dc710..9069167cde 100644 --- a/resources/profiles/Ratrig/process/0.30mm Big @RatRig V-Core 4 0.8.json +++ b/resources/profiles/Ratrig/process/0.30mm Big @RatRig V-Core 4 0.8.json @@ -3,7 +3,7 @@ "name": "0.30mm Big @RatRig V-Core 4 0.8", "inherits": "fdm_process_ratrig_common", "from": "system", - "setting_id": "7FOG8fkUbrLknvuz", + "setting_id": "2tM04aSQ8NcTsmQo", "instantiation": "true", "layer_height": "0.3", "inital_layer_height": "0.35", diff --git a/resources/profiles/iQ/process/0.15mm Standard @iQ TiQ2 (0.25 Nozzle).json b/resources/profiles/iQ/process/0.15mm Standard @iQ TiQ2 (0.25 Nozzle).json index 7bbb5a15f3..1791058107 100644 --- a/resources/profiles/iQ/process/0.15mm Standard @iQ TiQ2 (0.25 Nozzle).json +++ b/resources/profiles/iQ/process/0.15mm Standard @iQ TiQ2 (0.25 Nozzle).json @@ -3,6 +3,7 @@ "name": "0.15mm Standard @iQ TiQ2 (0.25 Nozzle)", "inherits": "fdm_process_tiq_common", "from": "system", + "setting_id": "PNxZXC0peOlGlKwG", "instantiation": "true", "compatible_printers": [ "iQ TiQ2 0.25 Nozzle" diff --git a/resources/profiles/iQ/process/0.15mm Standard @iQ TiQ8 (0.25 Nozzle).json b/resources/profiles/iQ/process/0.15mm Standard @iQ TiQ8 (0.25 Nozzle).json index 0cfcdae16e..b643441055 100644 --- a/resources/profiles/iQ/process/0.15mm Standard @iQ TiQ8 (0.25 Nozzle).json +++ b/resources/profiles/iQ/process/0.15mm Standard @iQ TiQ8 (0.25 Nozzle).json @@ -3,6 +3,7 @@ "name": "0.15mm Standard @iQ TiQ8 (0.25 Nozzle)", "inherits": "fdm_process_tiq_common", "from": "system", + "setting_id": "K0Ybw0qUqEIL05PY", "instantiation": "true", "compatible_printers": [ "iQ TiQ8 0.25 Nozzle" diff --git a/resources/profiles/iQ/process/0.30mm Standard @iQ TiQ2 (0.6 Nozzle).json b/resources/profiles/iQ/process/0.30mm Standard @iQ TiQ2 (0.6 Nozzle).json index e44c962ebf..6a71a4ba84 100644 --- a/resources/profiles/iQ/process/0.30mm Standard @iQ TiQ2 (0.6 Nozzle).json +++ b/resources/profiles/iQ/process/0.30mm Standard @iQ TiQ2 (0.6 Nozzle).json @@ -3,6 +3,7 @@ "name": "0.30mm Standard @iQ TiQ2 (0.6 Nozzle)", "inherits": "fdm_process_tiq_common", "from": "system", + "setting_id": "IGN3ZUR73J0veLrE", "instantiation": "true", "compatible_printers": [ "iQ TiQ2 0.6 Nozzle" diff --git a/resources/profiles/iQ/process/0.30mm Standard @iQ TiQ8 (0.6 Nozzle).json b/resources/profiles/iQ/process/0.30mm Standard @iQ TiQ8 (0.6 Nozzle).json index e71c6e80ce..0c8a567846 100644 --- a/resources/profiles/iQ/process/0.30mm Standard @iQ TiQ8 (0.6 Nozzle).json +++ b/resources/profiles/iQ/process/0.30mm Standard @iQ TiQ8 (0.6 Nozzle).json @@ -3,6 +3,7 @@ "name": "0.30mm Standard @iQ TiQ8 (0.6 Nozzle)", "inherits": "fdm_process_tiq_common", "from": "system", + "setting_id": "QEODQzrO9UZEgrtB", "instantiation": "true", "compatible_printers": [ "iQ TiQ8 0.6 Nozzle" diff --git a/resources/profiles/iQ/process/0.40mm Standard @iQ TiQ2 (0.8 Nozzle).json b/resources/profiles/iQ/process/0.40mm Standard @iQ TiQ2 (0.8 Nozzle).json index b487541526..5dba6b07c2 100644 --- a/resources/profiles/iQ/process/0.40mm Standard @iQ TiQ2 (0.8 Nozzle).json +++ b/resources/profiles/iQ/process/0.40mm Standard @iQ TiQ2 (0.8 Nozzle).json @@ -3,6 +3,7 @@ "name": "0.40mm Standard @iQ TiQ2 (0.8 Nozzle)", "inherits": "fdm_process_tiq_common", "from": "system", + "setting_id": "ZoiYkGXOTBh39HTB", "instantiation": "true", "compatible_printers": [ "iQ TiQ2 0.8 Nozzle" diff --git a/resources/profiles/iQ/process/0.40mm Standard @iQ TiQ8 (0.8 Nozzle).json b/resources/profiles/iQ/process/0.40mm Standard @iQ TiQ8 (0.8 Nozzle).json index 7c5bd973e7..5cd189771f 100644 --- a/resources/profiles/iQ/process/0.40mm Standard @iQ TiQ8 (0.8 Nozzle).json +++ b/resources/profiles/iQ/process/0.40mm Standard @iQ TiQ8 (0.8 Nozzle).json @@ -3,6 +3,7 @@ "name": "0.40mm Standard @iQ TiQ8 (0.8 Nozzle)", "inherits": "fdm_process_tiq_common", "from": "system", + "setting_id": "C7TZWdCzQroB8pKL", "instantiation": "true", "compatible_printers": [ "iQ TiQ8 0.8 Nozzle" From 8d8e4c2835e71688c1c3a4f84bbb9790e1f2e703 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Thu, 16 Jul 2026 00:33:14 +0800 Subject: [PATCH 34/47] fix profiles errors spotted with new changes --- resources/profiles/Qidi.json | 2 +- .../Qidi/machine/fdm_qidi_x3_common.json | 30 ------------------- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/resources/profiles/Qidi.json b/resources/profiles/Qidi.json index d3d083c074..9e7a290f96 100644 --- a/resources/profiles/Qidi.json +++ b/resources/profiles/Qidi.json @@ -1,6 +1,6 @@ { "name": "Qidi", - "version": "02.04.00.07", + "version": "02.04.00.08", "force_update": "0", "description": "Qidi configurations", "machine_model_list": [ diff --git a/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json b/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json index ada1be5d24..9796856379 100644 --- a/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json +++ b/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json @@ -10,36 +10,6 @@ "machine_pause_gcode": "M0", "support_chamber_temp_control": "1", "wipe_tower_type": "type1", - "filament_dev_ams_drying_ams_limitations": [ - "1" - ], - "filament_dev_ams_drying_temperature": [ - "40.0", - "40.0", - "40.0", - "40.0" - ], - "filament_dev_ams_drying_time": [ - "8.0", - "8.0", - "8.0", - "8.0" - ], - "filament_dev_drying_softening_temperature": [ - "40.0" - ], - "filament_dev_ams_drying_heat_distortion_temperature": [ - "45.0" - ], - "filament_dev_drying_cooling_temperature": [ - "35.0" - ], - "filament_dev_chamber_drying_bed_temperature": [ - "90.0" - ], - "filament_dev_chamber_drying_time": [ - "12.0" - ], "retraction_length": [ "1" ], From 5e7ad1ad70ded29a3e3d2221d91e1617443b708a Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Thu, 16 Jul 2026 11:02:55 +0800 Subject: [PATCH 35/47] Fix OpenGL framebuffer object support on macOS (#14769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix OpenGL framebuffer object support on macOS this enables the outline on macOS * Revert "Fix OpenGL framebuffer object support on macOS" This reverts commit 9872708954e785a86209613bbab7cc770d636b7a. * Enable ARB framebuffer if OpenGL >= 3.0 (OrcaSlicer/OrcaSlicer#14770) * FIX: plater toolbar image update jira: STUDIO-12457 Change-Id: Ia855b4bd9bec884d52202f5d8f5cfad9efce0f2f (cherry picked from commit 474691813b497654ef701fe7073992db366e81a9) * FIX: thumbinal regen jira: STUDIO-16732 Change-Id: I4cc4e76b36caf1a41793c1f84ee5e88a44ebdd42 (cherry picked from commit 53f33b7db260005e477842d6459b1b28aa8aa0d8) * FIX: force update thumbinal once dirty jira: STUDIO-13041 Change-Id: I770743b53c51351cb3b8a7133140974c75c05364 (cherry picked from commit 96198a12712f21d2b4521b6e3d1caff50bed21ff) * FIX:thumbnail update jira: STUDIO-13041 / STUDIO-13304 Change-Id: I9dec25f12454fc8485195cf5dc3feb421b6347c4 (cherry picked from commit cf778dc90f805d3f46e28f8f1d1d5814d3fb4d51) --------- Co-authored-by: jun.zhang --- resources/shaders/110/imgui.fs | 4 ++-- resources/shaders/140/imgui.fs | 4 ++-- src/slic3r/GUI/GLCanvas3D.cpp | 35 +++++++++++++++++------------- src/slic3r/GUI/GLCanvas3D.hpp | 1 - src/slic3r/GUI/IMToolbar.cpp | 2 -- src/slic3r/GUI/IMToolbar.hpp | 1 - src/slic3r/GUI/ImGuiWrapper.cpp | 4 ++++ src/slic3r/GUI/Jobs/FillBedJob.cpp | 2 ++ src/slic3r/GUI/OpenGLManager.cpp | 7 +++++- src/slic3r/GUI/PartPlate.cpp | 11 ++++++++++ src/slic3r/GUI/Plater.cpp | 32 ++++++++++++++++++++------- src/slic3r/GUI/Plater.hpp | 5 +++++ 12 files changed, 76 insertions(+), 32 deletions(-) diff --git a/resources/shaders/110/imgui.fs b/resources/shaders/110/imgui.fs index 4b0e27ce9d..3af9c82653 100644 --- a/resources/shaders/110/imgui.fs +++ b/resources/shaders/110/imgui.fs @@ -1,11 +1,11 @@ #version 110 -uniform sampler2D Texture; +uniform sampler2D s_texture; varying vec2 Frag_UV; varying vec4 Frag_Color; void main() { - gl_FragColor = Frag_Color * texture2D(Texture, Frag_UV.st); + gl_FragColor = Frag_Color * texture2D(s_texture, Frag_UV.st); } \ No newline at end of file diff --git a/resources/shaders/140/imgui.fs b/resources/shaders/140/imgui.fs index 1666c814eb..daebb189de 100644 --- a/resources/shaders/140/imgui.fs +++ b/resources/shaders/140/imgui.fs @@ -1,6 +1,6 @@ #version 140 -uniform sampler2D Texture; +uniform sampler2D s_texture; in vec2 Frag_UV; in vec4 Frag_Color; @@ -9,5 +9,5 @@ out vec4 out_color; void main() { - out_color = Frag_Color * texture(Texture, Frag_UV.st); + out_color = Frag_Color * texture(s_texture, Frag_UV.st); } \ No newline at end of file diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 2789d0b21b..ceb09426a3 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2338,11 +2338,6 @@ void GLCanvas3D::remove_curr_plate_all() m_dirty = true; } -void GLCanvas3D::update_plate_thumbnails() -{ - _update_imgui_select_plate_toolbar(); -} - void GLCanvas3D::select_all() { if (!m_gizmos.is_allow_select_all()) { @@ -3223,7 +3218,6 @@ void GLCanvas3D::on_idle(wxIdleEvent& evt) // BBS //m_dirty |= wxGetApp().plater()->get_view_toolbar().update_items_state(); m_dirty |= wxGetApp().plater()->get_collapse_toolbar().update_items_state(); - _update_imgui_select_plate_toolbar(); bool mouse3d_controller_applied = wxGetApp().plater()->get_mouse3d_controller().apply(wxGetApp().plater()->get_camera()); m_dirty |= mouse3d_controller_applied; m_dirty |= wxGetApp().plater()->get_notification_manager()->update_notifications(*this); @@ -4833,11 +4827,6 @@ void GLCanvas3D::force_set_focus() { void GLCanvas3D::on_set_focus(wxFocusEvent& evt) { m_tooltip_enabled = false; - if (m_canvas_type == ECanvasType::CanvasPreview) { - // update thumbnails and update plate toolbar - wxGetApp().plater()->update_all_plate_thumbnails(); - _update_imgui_select_plate_toolbar(); - } _refresh_if_shown_on_screen(); m_tooltip_enabled = true; m_is_touchpad_navigation = wxGetApp().app_config->get_bool("camera_navigation_style"); @@ -6920,13 +6909,28 @@ void GLCanvas3D::_update_select_plate_toolbar_stats_item(bool force_selected) { bool GLCanvas3D::_update_imgui_select_plate_toolbar() { bool result = true; - if (!m_sel_plate_toolbar.is_enabled() || m_sel_plate_toolbar.is_render_finish) return false; + if (!m_sel_plate_toolbar.is_enabled()) { + return false; + } + + const auto& p_plater = wxGetApp().plater(); + if (!p_plater) { + return false; + } + + if (!p_plater->is_plate_toolbar_image_dirty()) { + return false; + } + + if (!p_plater->is_gcode_3mf()) { + p_plater->update_all_plate_thumbnails(true); + } _update_select_plate_toolbar_stats_item(); m_sel_plate_toolbar.del_all_item(); - PartPlateList& plate_list = wxGetApp().plater()->get_partplate_list(); + PartPlateList& plate_list = p_plater->get_partplate_list(); for (int i = 0; i < plate_list.get_plate_count(); i++) { IMToolbarItem* item = new IMToolbarItem(); PartPlate* plate = plate_list.get_plate(i); @@ -6939,7 +6943,7 @@ bool GLCanvas3D::_update_imgui_select_plate_toolbar() } m_sel_plate_toolbar.m_items.push_back(item); } - + p_plater->clear_plate_toolbar_image_dirty(); m_sel_plate_toolbar.is_display_scrollbar = false; return result; } @@ -8797,6 +8801,8 @@ void GLCanvas3D::_render_imgui_select_plate_toolbar() return; } + _update_imgui_select_plate_toolbar(); + IMToolbarItem* all_plates_stats_item = m_sel_plate_toolbar.m_all_plates_stats_item; PartPlateList& plate_list = wxGetApp().plater()->get_partplate_list(); @@ -9213,7 +9219,6 @@ void GLCanvas3D::_render_imgui_select_plate_toolbar() m_sel_plate_toolbar.is_display_scrollbar = is_win_hovered; imgui.end(); - m_sel_plate_toolbar.is_render_finish = true; } //BBS: GUI refactor: GLToolbar adjust diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 4c2402c546..55e118fa4c 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -997,7 +997,6 @@ public: void select_curr_plate_all(); void select_object_from_idx(std::vector& object_idxs); void remove_curr_plate_all(); - void update_plate_thumbnails(); void select_all(); void deselect_all(); diff --git a/src/slic3r/GUI/IMToolbar.cpp b/src/slic3r/GUI/IMToolbar.cpp index 733683366f..2a569c960b 100644 --- a/src/slic3r/GUI/IMToolbar.cpp +++ b/src/slic3r/GUI/IMToolbar.cpp @@ -61,8 +61,6 @@ void IMToolbar::del_stats_item() void IMToolbar::set_enabled(bool enable) { m_enabled = enable; - if (!m_enabled) - is_render_finish = false; } bool IMReturnToolbar::init() diff --git a/src/slic3r/GUI/IMToolbar.hpp b/src/slic3r/GUI/IMToolbar.hpp index 24152f20ee..bf5bf70756 100644 --- a/src/slic3r/GUI/IMToolbar.hpp +++ b/src/slic3r/GUI/IMToolbar.hpp @@ -51,7 +51,6 @@ public: float icon_height; bool is_display_scrollbar; bool show_stats_item{ false }; - bool is_render_finish{false}; IMToolbar() { icon_width = DEFAULT_TOOLBAR_BUTTON_WIDTH; icon_height = DEFAULT_TOOLBAR_BUTTON_HEIGHT; diff --git a/src/slic3r/GUI/ImGuiWrapper.cpp b/src/slic3r/GUI/ImGuiWrapper.cpp index c7609be5ef..8974a169d1 100644 --- a/src/slic3r/GUI/ImGuiWrapper.cpp +++ b/src/slic3r/GUI/ImGuiWrapper.cpp @@ -3158,6 +3158,9 @@ void ImGuiWrapper::render_draw_data(ImDrawData *draw_data) shader->set_uniform("Texture", 0); shader->set_uniform("ProjMtx", ortho_projection); + const uint8_t stage = 0; + shader->set_uniform("s_texture", stage); + // Will project scissor/clipping rectangles into framebuffer space const ImVec2 clip_off = draw_data->DisplayPos; // (0,0) unless using multi-viewports const ImVec2 clip_scale = draw_data->FramebufferScale; // (1,1) unless using retina display which are often (2,2) @@ -3222,6 +3225,7 @@ void ImGuiWrapper::render_draw_data(ImDrawData *draw_data) glsafe(::glScissor((int)clip_min.x, (int)(fb_height - clip_max.y), (int)(clip_max.x - clip_min.x), (int)(clip_max.y - clip_min.y))); // Bind texture, Draw + glsafe(::glActiveTexture(GL_TEXTURE0 + stage)); glsafe(::glBindTexture(GL_TEXTURE_2D, (GLuint)(intptr_t)pcmd->GetTexID())); glsafe(::glDrawElements(GL_TRIANGLES, (GLsizei)pcmd->ElemCount, sizeof(ImDrawIdx) == 2 ? GL_UNSIGNED_SHORT : GL_UNSIGNED_INT, (void*)(intptr_t)(pcmd->IdxOffset * sizeof(ImDrawIdx)))); } diff --git a/src/slic3r/GUI/Jobs/FillBedJob.cpp b/src/slic3r/GUI/Jobs/FillBedJob.cpp index 711228c0bc..a9a66cb96f 100644 --- a/src/slic3r/GUI/Jobs/FillBedJob.cpp +++ b/src/slic3r/GUI/Jobs/FillBedJob.cpp @@ -348,6 +348,8 @@ void FillBedJob::finalize(bool canceled, std::exception_ptr &eptr) } m_plater->update(); } + + m_plater->mark_plate_toolbar_image_dirty(); } }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/OpenGLManager.cpp b/src/slic3r/GUI/OpenGLManager.cpp index 2b5ebcd086..382a9176b9 100644 --- a/src/slic3r/GUI/OpenGLManager.cpp +++ b/src/slic3r/GUI/OpenGLManager.cpp @@ -266,7 +266,12 @@ bool OpenGLManager::init_gl(bool popup_error) else s_compressed_textures_supported = false; - if (GLAD_GL_ARB_framebuffer_object) { + if (s_gl_info.is_version_greater_or_equal_to(3, 0)) { + // ARB framebuffer became a mandatory part of core OpenGL 3.0 + s_framebuffers_type = EFramebufferType::Arb; + BOOST_LOG_TRIVIAL(info) << "Opengl version >= 30, FrameBuffer Type ARB." << std::endl; + } + else if (GLAD_GL_ARB_framebuffer_object) { s_framebuffers_type = EFramebufferType::Arb; BOOST_LOG_TRIVIAL(info) << "Found Framebuffer Type ARB."<< std::endl; } diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 1f0454c778..415e1ea0f5 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -2433,6 +2433,9 @@ void PartPlate::set_pos_and_size(Vec3d& origin, int width, int depth, int height m_depth = depth; m_height = height; + if (with_instance_move && m_plater) + m_plater->mark_plate_toolbar_image_dirty(); + return; } @@ -2780,6 +2783,8 @@ int PartPlate::add_instance(int obj_id, int instance_id, bool move_position, Bou } BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": plate %1% , m_ready_for_slice changes to %2%") % m_plate_index %m_ready_for_slice; + if (m_plater) + m_plater->mark_plate_toolbar_image_dirty(); return 0; } @@ -5344,6 +5349,9 @@ int PartPlateList::notify_instance_removed(int obj_id, int instance_id) unprintable_plate.update_object_index(obj_id, m_model->objects.size()); } + if (m_plater) + m_plater->mark_plate_toolbar_image_dirty(); + return 0; } @@ -6265,6 +6273,9 @@ int PartPlateList::rebuild_plates_after_arrangement(bool recycle_plates, bool ex } #endif + if (m_plater) + m_plater->mark_plate_toolbar_image_dirty(); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(":after rebuild, plates count %1%") % m_plate_list.size(); return ret; } diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 45c3098d36..ffa4bbc5b3 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -277,6 +277,21 @@ void Plater::show_illegal_characters_warning(wxWindow* parent) show_error(parent, _L("Invalid name, the following characters are not allowed:") + " <>:/\\|?*\""); } +void Plater::mark_plate_toolbar_image_dirty() +{ + m_b_plate_toolbar_image_dirty = true; +} + +bool Plater::is_plate_toolbar_image_dirty() const +{ + return m_b_plate_toolbar_image_dirty; +} + +void Plater::clear_plate_toolbar_image_dirty() +{ + m_b_plate_toolbar_image_dirty = false; +} + static std::map bed_type_thumbnails = { {BedType::btPC, "bed_cool" }, {BedType::btEP, "bed_engineering" }, @@ -6326,10 +6341,8 @@ void Plater::priv::update(unsigned int flags) //BBS assemble view this->assemble_view->reload_scene(false, flags); - if (current_panel && is_preview_shown()) { - q->force_update_all_plate_thumbnails(); - //update_fff_scene_only_shells(true); - } + // todo: better to mark thumbnail dirty here + q->mark_plate_toolbar_image_dirty(); if (force_background_processing_restart) this->restart_background_process(update_status); @@ -7884,6 +7897,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } q->schedule_background_process(true); + q->mark_plate_toolbar_image_dirty(); return obj_idxs; } @@ -8263,6 +8277,8 @@ void Plater::priv::object_list_changed() main_frame->update_slice_print_status(MainFrame::eEventObjectUpdate, can_slice); wxGetApp().params_panel()->notify_object_config_changed(); + + q->mark_plate_toolbar_image_dirty(); } void Plater::priv::select_curr_plate_all() @@ -9166,6 +9182,8 @@ void Plater::priv::update_fff_scene() view3D->reload_scene(true); //BBS: add assemble view related logic assemble_view->reload_scene(true); + + q->mark_plate_toolbar_image_dirty(); } //BBS: add print project related logic @@ -10074,9 +10092,6 @@ void Plater::priv::set_current_panel(wxPanel* panel, bool no_slice) preview->get_canvas3d()->enable_select_plate_toolbar(true); } } - else { - preview->get_canvas3d()->enable_select_plate_toolbar(false); - } if (current_panel == panel) { @@ -10935,6 +10950,7 @@ void Plater::priv::on_process_completed(SlicingProcessCompletedEvent &evt) BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(":finished, reload print soon"); m_is_slicing = false; this->preview->reload_print(false); + q->mark_plate_toolbar_image_dirty(); /* BBS if in publishing progress */ if (m_is_publishing) { if (m_publish_dlg && !m_publish_dlg->was_cancelled()) { @@ -14651,6 +14667,7 @@ void Plater::invalid_all_plate_thumbnails() plate->thumbnail_data.reset(); plate->no_light_thumbnail_data.reset(); } + mark_plate_toolbar_image_dirty(); } void Plater::force_update_all_plate_thumbnails() @@ -14661,7 +14678,6 @@ void Plater::force_update_all_plate_thumbnails() invalid_all_plate_thumbnails(); update_all_plate_thumbnails(true); } - get_preview_canvas3D()->update_plate_thumbnails(); } // BBS: backup diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 1730bd2705..fdba3496e0 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -936,6 +936,10 @@ public: bool is_loading_project() const { return m_loading_project; } + void mark_plate_toolbar_image_dirty(); + bool is_plate_toolbar_image_dirty() const; + void clear_plate_toolbar_image_dirty(); + private: struct priv; std::unique_ptr p; @@ -956,6 +960,7 @@ private: std::string m_preview_only_filename; int m_valid_plates_count { 0 }; int m_check_status = 0; // 0 not check, 1 check success, 2 check failed + bool m_b_plate_toolbar_image_dirty{ true }; void suppress_snapshots(); void allow_snapshots(); From 525a16177c643bb509ca3d7734fe9e5953002913 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Thu, 16 Jul 2026 12:06:45 +0800 Subject: [PATCH 36/47] fix: bundle python runtime for macos plugin tests --- tests/slic3rutils/CMakeLists.txt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 429d3582df..ead5846c11 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -29,6 +29,19 @@ if (WIN32) COMMENT "Copying Python runtime for slic3rutils plugin host API tests" VERBATIM ) +elseif (APPLE) + target_link_options(${_TEST_NAME}_tests PRIVATE + "LINKER:-rpath,@executable_path/python/lib") + + add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E rm -rf + "$/python" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_PREFIX_PATH}/libpython" + "$/python" + COMMENT "Copying Python runtime for macOS plugin host API tests" + VERBATIM + ) endif() orcaslicer_discover_tests(${_TEST_NAME}_tests) From 49396699e7fa70b04978f5fe7f41080973c1eec0 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Thu, 16 Jul 2026 15:40:13 +0800 Subject: [PATCH 37/47] fix: initialize embedded python from bundled runtime --- tests/slic3rutils/python_test_support.hpp | 37 ++++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/tests/slic3rutils/python_test_support.hpp b/tests/slic3rutils/python_test_support.hpp index 1920b2ce2a..ab66f05887 100644 --- a/tests/slic3rutils/python_test_support.hpp +++ b/tests/slic3rutils/python_test_support.hpp @@ -2,7 +2,10 @@ // Shared embedded-interpreter bootstrap for slic3rutils tests that need a live Python // interpreter (test_plugin_host_api.cpp, test_slicing_pipeline_bindings.cpp, ...). - +#include +#include +#include +#include #include #include @@ -12,17 +15,29 @@ namespace { void ensure_python_initialized() { - // Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter: - // `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing - // it needs no bundled stdlib/sys.path, and the deterministic assertions are - // independent of the host's Python. PythonInterpreter::initialize() expects the - // bundled Python home laid out next to the app bundle (lib/python3.12/encodings), - // which is not deployed beside the test binary, so using it here would fail to find - // a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime. - if (!Py_IsInitialized()) { - static pybind11::scoped_interpreter interpreter; - (void) interpreter; + if (Py_IsInitialized()) + return; + + static std::unique_ptr interpreter; + + PyConfig config; + PyConfig_InitPythonConfig(&config); + config.parse_argv = 0; + + const auto python_home = boost::dll::program_location().parent_path() / "python"; + + if (boost::filesystem::exists(python_home)) { + const std::string home = python_home.string(); + const PyStatus status = PyConfig_SetBytesString(&config, &config.home, home.c_str()); + + if (PyStatus_Exception(status)) { + const char* message = status.err_msg ? status.err_msg : "Failed to set Python home"; + PyConfig_Clear(&config); + throw std::runtime_error(message); + } } + + interpreter = std::make_unique(&config); } pybind11::module_ import_orca_module() From b779a7bfed86951591bb5209b3d78b5ab79e90bf Mon Sep 17 00:00:00 2001 From: SoftFever Date: Thu, 16 Jul 2026 20:07:41 +0800 Subject: [PATCH 38/47] Create plugin windows off the webview callback stack wx 3.3.2 delivers webview script messages synchronously inside the native callback on GTK and macOS, so script plugins run with the plugins-dialog webview's signal/delegate frame on the stack. Creating and presenting the orca.host.ui window from there crashed on Linux at Raise() -- gtk_window_present while GTK's deferred show was still in flight. Defer the whole window creation to a CallAfter with a pre-bound registry handle (post/close stay FIFO-safe, teardown races become a no-op), and drop Raise() plus the show_modeless_dialog wrapper: Show() already activates and fronts a new window on every platform. --- src/slic3r/GUI/PluginWebDialog.cpp | 8 ------ src/slic3r/GUI/PluginWebDialog.hpp | 1 - src/slic3r/plugin/host/PluginHostUi.cpp | 36 +++++++++++++++++++++---- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/slic3r/GUI/PluginWebDialog.cpp b/src/slic3r/GUI/PluginWebDialog.cpp index b316cb1fe0..974d87f5e3 100644 --- a/src/slic3r/GUI/PluginWebDialog.cpp +++ b/src/slic3r/GUI/PluginWebDialog.cpp @@ -163,14 +163,6 @@ PluginWebDialog* PluginWebDialog::create_modeless_dialog(wxWindow* parent, std::move(on_destroyed)); } -void PluginWebDialog::show_modeless_dialog(PluginWebDialog* dialog) -{ - if (dialog == nullptr) - return; - dialog->Show(); - dialog->Raise(); -} - void PluginWebDialog::post_message(PluginWebDialog* dialog, const nlohmann::json& data) { if (dialog != nullptr && dialog->is_open()) diff --git a/src/slic3r/GUI/PluginWebDialog.hpp b/src/slic3r/GUI/PluginWebDialog.hpp index 1ac025e6f5..24dc945980 100644 --- a/src/slic3r/GUI/PluginWebDialog.hpp +++ b/src/slic3r/GUI/PluginWebDialog.hpp @@ -52,7 +52,6 @@ public: MessageHandler on_message, CloseHandler on_close, CloseHandler on_destroyed); - static void show_modeless_dialog(PluginWebDialog* dialog); static void post_message(PluginWebDialog* dialog, const nlohmann::json& data); static void request_close(PluginWebDialog* dialog); diff --git a/src/slic3r/plugin/host/PluginHostUi.cpp b/src/slic3r/plugin/host/PluginHostUi.cpp index 0c4b82b3d2..af45f26a04 100644 --- a/src/slic3r/plugin/host/PluginHostUi.cpp +++ b/src/slic3r/plugin/host/PluginHostUi.cpp @@ -339,8 +339,35 @@ py::object ui_create_window(const std::string& html, const std::string& title, i const int w = width > 0 ? width : 820; const int h = height > 0 ? height : 600; - const int id = run_on_ui_blocking([&]() -> int { - const int new_id = UiRegistry::instance().reserve_id(); + if (wxTheApp == nullptr) + throw std::runtime_error("OrcaSlicer application is not initialized"); + + // The whole wxWindow/webview creation is deferred to a clean main-loop iteration. + // The WebKit backends deliver script messages synchronously from inside the native + // callback (GTK: HandleWindowEvent in wxgtk_webview_webkit_script_message_received, + // wx src/gtk/webview_webkit2.cpp; macOS: ProcessWindowEvent in the + // WKScriptMessageHandler delegate, src/osx/webview_webkit.mm), so a script-triggered + // create_window otherwise runs with the plugins-dialog webview's signal/delegate + // frame still on the stack — creating and presenting a second webview window from + // there crashed on Linux (observed at a since-removed Raise(): gtk_window_present + // right after a Show() whose real map can still be deferred behind the X11 + // frame-extents handshake, wx src/gtk/toplevel.cpp). WebView2 already queues script + // messages (AddPendingEvent), so on Windows this deferral merely matches wx's own + // delivery model. + // + // The id is pre-bound with a null window so the returned handle is live at once: + // is_open() keys on registry presence, post()/close() are CallAfter-marshaled and + // FIFO-ordered after the creation below, and a plugin teardown claims the pending + // id (take_for_plugin), which the creation detects and skips — no orphan window. + const int new_id = UiRegistry::instance().reserve_id(); + UiRegistry::instance().bind(new_id, nullptr, plugin_key); + + GUI::wxGetApp().CallAfter([new_id, plugin_key, html, title, w, h, + msg_adapter = std::move(msg_adapter), + close_holder = std::move(close_holder)]() mutable { + // Torn down (plugin unload / app shutdown) before the window materialized. + if (!UiRegistry::instance().is_open(new_id)) + return; // Plugin's on_close: fired only on a user/JS-initiated close (not forced // teardown), while the dialog is alive. Empty if the plugin passed None. @@ -365,11 +392,10 @@ py::object ui_create_window(const std::string& html, const std::string& title, i wxSize(w, h), std::move(msg_adapter), std::move(on_close), std::move(on_destroyed)); UiRegistry::instance().bind(new_id, dlg, plugin_key); - GUI::PluginWebDialog::show_modeless_dialog(dlg); - return new_id; + dlg->Show(); }); - return py::cast(UiWindowHandle{id}); + return py::cast(UiWindowHandle{new_id}); } void handle_post(int id, py::object data) From 6d1974a66262ecda7f265dc1d04aa69fde744afd Mon Sep 17 00:00:00 2001 From: Mqrius Date: Thu, 16 Jul 2026 14:10:15 +0200 Subject: [PATCH 39/47] Restore flow calibration special order, overridable via fill order (#14786) --- src/libslic3r/Fill/FillBase.cpp | 11 +++++++---- src/libslic3r/Fill/FillPlanePath.cpp | 29 +++++++++++++++++++++++++++- src/libslic3r/Preset.cpp | 1 + src/libslic3r/PrintConfig.cpp | 6 +++++- src/libslic3r/PrintConfig.hpp | 3 +++ src/slic3r/GUI/Plater.cpp | 9 +++++++-- 6 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/libslic3r/Fill/FillBase.cpp b/src/libslic3r/Fill/FillBase.cpp index 29eb6120ed..2606529099 100644 --- a/src/libslic3r/Fill/FillBase.cpp +++ b/src/libslic3r/Fill/FillBase.cpp @@ -162,11 +162,14 @@ void Fill::fill_surface_extrusion(const Surface* surface, const FillParams& para out.push_back(eec = new ExtrusionEntityCollection()); // Only concentric fills are not sorted. eec->no_sort = this->no_sort(); + // ORCA: special flag for flow rate calibration + auto is_flow_calib = params.extrusion_role == erTopSolidInfill && this->print_object_config->has("calib_flowrate_topinfill_special_order") && + this->print_object_config->option("calib_flowrate_topinfill_special_order")->getBool(); // Orca: a forced surface fill order must survive the G-code path planner, which would - // otherwise re-chain and possibly reverse the paths. This also covers the flow rate - // calibration, which forces an outward fill order on its top surfaces. + // otherwise re-chain and possibly reverse the paths. The same applies to the flow rate + // calibration's special toolpath order. const bool keep_fill_order = params.fill_order != SurfaceFillOrder::Default; - if (keep_fill_order) { + if (is_flow_calib || keep_fill_order) { eec->no_sort = true; } size_t idx = eec->entities.size(); @@ -181,7 +184,7 @@ void Fill::fill_surface_extrusion(const Surface* surface, const FillParams& para params.extrusion_role, flow_mm3_per_mm, float(flow_width), params.flow.height()); } - if (!params.can_reverse || keep_fill_order) { + if (!params.can_reverse || is_flow_calib || keep_fill_order) { for (size_t i = idx; i < eec->entities.size(); i++) eec->entities[i]->set_reverse(); } diff --git a/src/libslic3r/Fill/FillPlanePath.cpp b/src/libslic3r/Fill/FillPlanePath.cpp index edfbaef7d6..7ce8e4abb3 100644 --- a/src/libslic3r/Fill/FillPlanePath.cpp +++ b/src/libslic3r/Fill/FillPlanePath.cpp @@ -134,7 +134,34 @@ void FillPlanePath::_fill_surface_single( if (!polylines.empty()) { Polylines chained; if (params.dont_connect() || params.density > 0.5) { - if (params.fill_order != SurfaceFillOrder::Default) { + // ORCA: special flag for flow rate calibration. The chords chained ahead of the + // inside-out center spiral collide with it in opposing directions, raising a + // tactile lip that the calibration reads. Only applies while the fill order is + // Default, so it can be overridden from the calibration objects. + auto is_flow_calib = params.fill_order == SurfaceFillOrder::Default && + params.extrusion_role == erTopSolidInfill && + this->print_object_config->has("calib_flowrate_topinfill_special_order") && + this->print_object_config->option("calib_flowrate_topinfill_special_order")->getBool() && + dynamic_cast(this); + if (is_flow_calib) { + // We want the spiral part to be printed inside-out + // Find the center spiral line first, by looking for the longest one + auto it = std::max_element(polylines.begin(), polylines.end(), + [](const Polyline& a, const Polyline& b) { return a.length() < b.length(); }); + Polyline center_spiral = std::move(*it); + + // Ensure the spiral is printed from inside to out + if (center_spiral.first_point().squaredNorm() > center_spiral.last_point().squaredNorm()) { + center_spiral.reverse(); + } + + // Chain the other polylines + polylines.erase(it); + chained = chain_polylines(std::move(polylines), nullptr); + + // Then add the center spiral back + chained.push_back(std::move(center_spiral)); + } else if (params.fill_order != SurfaceFillOrder::Default) { // Orca: print the fragments in the order they appear along the generated // path, which runs from the center outwards. The Euclidean distance from // the center cannot be used for this: along the Octagram Spiral the radius diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index e3f8f53b31..16ae49bdc1 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1310,6 +1310,7 @@ static std::vector s_Preset_print_options{ "interlocking_depth", "interlocking_boundary_avoidance", "interlocking_beam_width", + "calib_flowrate_topinfill_special_order", // Z Anti-Aliasing (ZAA) "zaa_enabled", "zaa_minimize_perimeter_height", diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index e92562e132..7a71c444f9 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -4647,6 +4647,11 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionInt(2)); + // ORCA: special flag for flow rate calibration + def = this->add("calib_flowrate_topinfill_special_order", coBool); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionBool(false)); + def = this->add("ironing_type", coEnum); def->label = L("Ironing type"); def->category = L("Quality"); @@ -9016,7 +9021,6 @@ void PrintConfigDef::handle_legacy(t_config_option_key &opt_key, std::string &va "internal_bridge_support_thickness", "top_area_threshold", "reduce_wall_solid_infill","filament_load_time","filament_unload_time", "smooth_coefficient", "overhang_totally_speed", "silent_mode", "overhang_speed_classic", "filament_prime_volume", - "calib_flowrate_topinfill_special_order", "anisotropic_surfaces", // superseded by top_surface_fill_order / bottom_surface_fill_order }; diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index f9aab25f2f..7b611dec9f 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1221,6 +1221,9 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionInt, interlocking_beam_layer_count)) ((ConfigOptionInt, interlocking_depth)) ((ConfigOptionInt, interlocking_boundary_avoidance)) + + // Orca: internal use only + ((ConfigOptionBool, calib_flowrate_topinfill_special_order)) // ORCA: special flag for flow rate calibration ) // This object is mapped to Perl as Slic3r::Config::PrintRegion. diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index ffa4bbc5b3..0a1164c3a1 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -13929,8 +13929,13 @@ void adjust_settings_for_flowrate_calib(ModelObjectPtrs& objects, bool linear, i _obj->config.set_key_value("seam_slope_type", new ConfigOptionEnum(SeamScarfType::None)); _obj->config.set_key_value("gap_fill_target", new ConfigOptionEnum(GapFillTarget::gftNowhere)); print_config->set_key_value("max_volumetric_extrusion_rate_slope", new ConfigOptionFloat(0)); - // ORCA: print the top surface spiral from the center outwards, so the tiles are comparable. - _obj->config.set_key_value("top_surface_fill_order", new ConfigOptionEnum(SurfaceFillOrder::Outward)); + // ORCA: request the calibration's special toolpath order (chords first, center spiral + // last and inside-out) so opposing directions collide into the tactile lip the test + // reads. The special order only applies while the fill order is Default, so reset the + // profile's fill order on the calibration objects; changing the setting on the object + // afterwards deliberately overrides the special order. + _obj->config.set_key_value("calib_flowrate_topinfill_special_order", new ConfigOptionBool(true)); + _obj->config.set_key_value("top_surface_fill_order", new ConfigOptionEnum(SurfaceFillOrder::Default)); // extract flowrate from name, filename format: flowrate_xxx std::string obj_name = _obj->name; From 671277b381d1b2c6857334baf16065da41fc77a6 Mon Sep 17 00:00:00 2001 From: Heiko Liebscher Date: Thu, 16 Jul 2026 14:11:09 +0200 Subject: [PATCH 40/47] Update German (de) translation (#14577) --- localization/i18n/de/OrcaSlicer_de.po | 134 ++++++++++++++------------ 1 file changed, 73 insertions(+), 61 deletions(-) diff --git a/localization/i18n/de/OrcaSlicer_de.po b/localization/i18n/de/OrcaSlicer_de.po index 2fcddbf963..b47f37ba60 100644 --- a/localization/i18n/de/OrcaSlicer_de.po +++ b/localization/i18n/de/OrcaSlicer_de.po @@ -8891,10 +8891,10 @@ msgid "Show incompatible/unsupported presets in the printer and filament dropdow msgstr "Zeigt inkompatible/nicht unterstützte Profile in den Dropdown-Listen für Drucker und Filament an. Diese Profile können nicht ausgewählt werden." msgid "Experimental Features" -msgstr "" +msgstr "Experimentelle Funktionen" msgid "Keep painted feature after mesh change" -msgstr "" +msgstr "Bemalte Funktionen nach Mesh-Änderung beibehalten" msgid "" "Attempt to keep painted features (color/seam/support/fuzzy etc.) after changing the object mesh (such as cut/reload from disk/simplify/fix etc.)\n" @@ -9719,10 +9719,10 @@ msgid "Search in preset" msgstr "In Profilen suchen" msgid "Synchronization of different extruder drives or nozzle volume types is not supported." -msgstr "" +msgstr "Die Synchronisierung verschiedener Extrudertypen oder Düsenvolumentypen wird nicht unterstützt." msgid "Synchronize the modification of parameters to the corresponding parameters of another extruder." -msgstr "" +msgstr "Änderungen der Parameter mit den entsprechenden Parametern eines anderen Extruders synchronisieren." msgid "Click to reset all settings to the last saved preset." msgstr "Klicken Sie hier, um alle Einstellungen auf die zuletzt gespeicherten Parameter zurückzusetzen." @@ -10018,13 +10018,13 @@ msgid "Chamber temperature" msgstr "Druckraum Temperatur" msgid "Target chamber temperature, and the minimal chamber temperature at which printing should start" -msgstr "" +msgstr "Ziel-Druckraumtemperatur und die minimale Druckraumtemperatur, bei der der Druck beginnen sollte" msgid "Target" -msgstr "" +msgstr "Ziel" msgid "Minimal" -msgstr "" +msgstr "Minimal" msgid "Print temperature" msgstr "Drucktemperatur" @@ -10047,7 +10047,7 @@ msgstr "Strukturierte kalte Druckplatte" # TODO: Review, changed by lang refactor. PR 14254 msgid "This is the bed temperature when the Textured Cool Plate is installed. A value of 0 means the filament does not support printing on the Textured Cool Plate." -msgstr "Dies ist die Betttemperatur, wenn die kalte Druckplatte installiert ist. Ein Wert von 0 bedeutet, dass das Filament auf der texturierten kalten Druckplatte nicht unterstützt wird." +msgstr "Dies ist die Betttemperatur, wenn die texturierte kalte Druckplatte installiert ist. Ein Wert von 0 bedeutet, dass das Filament auf der texturierten kalten Druckplatte nicht unterstützt wird." # TODO: Review, changed by lang refactor. PR 14254 msgid "This is the bed temperature when the engineering plate is installed. A value of 0 means the filament does not support printing on the Engineering Plate." @@ -10368,21 +10368,21 @@ msgstr "Nicht erneut für dieses Profil warnen" #, c-format, boost-format msgid "%s: %s" -msgstr "" +msgstr "%s: %s" msgid "No modifications need to be copied." -msgstr "" +msgstr "Es müssen keine Änderungen kopiert werden." msgid "Copy paramters" -msgstr "" +msgstr "Parameter kopieren" #, c-format, boost-format msgid "Modify paramters of %s" -msgstr "" +msgstr "Parameter von %s ändern" #, c-format, boost-format msgid "Do you want to modify the following parameters of the %s to that of the %s?" -msgstr "" +msgstr "Möchten Sie die folgenden Parameter von %s auf die von %s ändern?" msgid "Click to reset current value and attach to the global value." msgstr "Klicken Sie hier, um den aktuellen Wert zurückzusetzen und ihn dem globalen Wert zuzuordnen." @@ -10528,10 +10528,10 @@ msgid "Capabilities" msgstr "Fähigkeiten" msgid "Left: " -msgstr "" +msgstr "Links: " msgid "Right: " -msgstr "" +msgstr "Rechts: " msgid "Show all presets (including incompatible)" msgstr "Alle Profile anzeigen (auch inkompatible)" @@ -13887,7 +13887,7 @@ msgid "layer" msgstr "Schicht" msgid "First layer fan speed" -msgstr "" +msgstr "Lüftergeschwindigkeit der ersten Schicht" msgid "" "Sets an exact fan speed for the first layer, overriding all other cooling settings. Useful for protecting 3D-printed toolhead parts (e.g. Voron-style ABS/ASA ducts) from a hot bed. A small amount of airflow cools the ducts down, without using full cooling that may in certain conditions hurt first-layer adhesion.\n" @@ -13896,6 +13896,11 @@ msgid "" "Only available when \"No cooling for the first\" is 0.\n" "Set to -1 to disable it." msgstr "" +"Legt eine genaue Lüftergeschwindigkeit für die erste Schicht fest und überschreibt alle anderen Kühlungseinstellungen. Nützlich zum Schutz von 3D-gedruckten Werkzeugkopfteilen (z.B. Voron-Style ABS/ASA-Düsen) vor einem heißen Bett. Eine kleine Menge Luftstrom kühlt die Düsen ab, ohne die volle Kühlung zu verwenden, die unter bestimmten Bedingungen die Haftung der ersten Schicht beeinträchtigen kann.\n" +"Ab der zweiten Schicht wird die normale Kühlung wieder aufgenommen.\n" +"Wenn auch \"Volle Lüfterdrehzahl ab Schicht\" eingestellt ist, steigt der Lüfter von diesem Wert in der ersten Schicht bis zu Ihrem Zielwert in der gewählten Schicht.\n" +"Nur verfügbar, wenn \"Keine Kühlung für die erste\" 0 ist.\n" +"Auf -1 setzen, um es zu deaktivieren." msgid "Support interface fan speed" msgstr "Stützstruktur-Schnittstelle" @@ -16297,9 +16302,14 @@ msgid "" "\n" "Unlike the \"Target\" chamber temperature, this option does not emit any M141/M191 commands; it only exposes the value to your custom G-code. It should not exceed the \"Target\" chamber temperature." msgstr "" +"Dies ist die Druckraumtemperatur, bei der der Druckvorgang beginnen soll, während der Druckraum weiterhin auf die \"Ziel\"-Druckraumtemperatur erhitzt wird. Zum Beispiel: Setzen Sie das Ziel auf 60 und das Minimum auf 50, um mit dem Drucken zu beginnen, sobald der Druckraum 50°C erreicht hat, ohne auf die vollen 60°C zu warten.\n" +"\n" +"Es wird eine G-Code-Variable namens chamber_minimal_temperature gesetzt, die an Ihr Druckstart-Makro oder ein Wärmespeicher-Makro übergeben werden kann, wie z.B.: PRINT_START (andere Variablen) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" +"\n" +"Im Gegensatz zur \"Ziel\"-Druckraumtemperatur sendet diese Option keine M141/M191-Befehle; sie stellt den Wert nur Ihrem benutzerdefinierten G-Code zur Verfügung. Sie sollte die \"Ziel\"-Druckraumtemperatur nicht überschreiten." msgid "Chamber minimal temperature" -msgstr "" +msgstr "Minimale Druckraumtemperatur" # TODO: Review, changed by lang refactor. PR 14254 msgid "Nozzle temperature after the first layer" @@ -19110,130 +19120,132 @@ msgid "Connection to printers connected via the print host failed." msgstr "Die Verbindung zu den über den Druck-Host verbundenen Druckern ist fehlgeschlagen." msgid "Detect Creality K-series printer" -msgstr "" +msgstr "Creality K-Serie Drucker erkennen" msgid "Click Scan to look for K-series printers on your network." -msgstr "" +msgstr "Klicken Sie auf 'Scannen', um nach K-Serie Druckern in Ihrem Netzwerk zu suchen." msgid "Use Selected" -msgstr "" +msgstr "Ausgewählten verwenden" msgid "Scanning the LAN for K-series printers... this takes a few seconds." -msgstr "" +msgstr "Suche im LAN nach K-Serie Druckern... dies dauert einige Sekunden." msgid "No K-series printers found. Make sure the printer is on the same network and not blocked by Wi-Fi client isolation, then click Scan again." -msgstr "" +msgstr "Keine K-Serie Drucker gefunden. Stellen Sie sicher, dass der Drucker im selben Netzwerk ist und nicht durch Wi-Fi-Client-Isolation blockiert wird, und klicken Sie dann erneut auf 'Scannen'." #, c-format msgid "Found %zu Creality printer(s). Select one and click Use Selected." -msgstr "" +msgstr "Es wurden %zu Creality-Drucker gefunden. Wählen Sie einen aus und klicken Sie auf 'Ausgewählten verwenden'." msgid "Active" -msgstr "" +msgstr "Aktiv" msgid "Printers" -msgstr "" +msgstr "Drucker" msgid "Processes" -msgstr "" +msgstr "Prozesse" msgid "Show/Hide system information" -msgstr "" +msgstr "Systeminformationen anzeigen/verbergen" msgid "Copy system information to clipboard" -msgstr "" +msgstr "Systeminformationen in die Zwischenablage kopieren" msgid "We need information for diagnosing source of the issue. Check wiki page for detailed guide." -msgstr "" +msgstr "Wir benötigen Informationen zur Diagnose der Ursache des Problems. Überprüfen Sie die Wiki-Seite für eine detaillierte Anleitung." msgid "Pack button collects project file and logs of current session onto a zip file." -msgstr "" +msgstr "Die Schaltfläche „Packen“ sammelt die Projektdatei und Protokolle der aktuellen Sitzung in einer ZIP-Datei." msgid "Any additional visual examples like images or screen recordings might be helpful while reporting the issue." -msgstr "" +msgstr "Zusätzliche visuelle Beispiele wie Bilder oder Bildschirmaufnahmen könnten beim Melden des Problems hilfreich sein." msgid "Report issue" -msgstr "" +msgstr "Problem melden" msgid "Pack" -msgstr "" +msgstr "Packen" msgid "Cleans and rebuilds system profiles cache on next launch" -msgstr "" +msgstr "Bereinigt und erstellt den Systemprofil-Cache beim nächsten Start neu" msgid "Clean system profiles cache" -msgstr "" +msgstr "Systemprofil-Cache bereinigen" msgid "Clean" -msgstr "" +msgstr "Bereinigen" msgid "Loaded profiles overview" -msgstr "" +msgstr "Übersicht der geladenen Profile" msgid "This section shows information for loaded profiles" -msgstr "" +msgstr "Dieser Abschnitt zeigt Informationen zu den geladenen Profilen" msgid "Exports detailed overview of loaded profiles in json format" -msgstr "" +msgstr "Exportiert eine detaillierte Übersicht der geladenen Profile im JSON-Format" msgid "Configurations folder" -msgstr "" +msgstr "Konfigurationsordner" msgid "Opens configurations folder" -msgstr "" +msgstr "Öffnet den Konfigurationsordner" msgid "Log level" -msgstr "" +msgstr "Protokollstufe" msgid "Stored logs" -msgstr "" +msgstr "Gespeicherte Protokolle" msgid "Packs all stored logs onto a zip file." -msgstr "" +msgstr "Packt alle gespeicherten Protokolle in eine ZIP-Datei." msgid "Profiles" -msgstr "" +msgstr "Profile" msgid "Select NO to close dialog and review project" -msgstr "" +msgstr "Wählen Sie NEIN, um das Dialogfeld zu schließen und das Projekt zu überprüfen" msgid "No project file on current session. Only logs will be included to package" -msgstr "" +msgstr "Keine Projektdatei in der aktuellen Sitzung. Nur Protokolle werden in das Paket aufgenommen" msgid "Select NO to close dialog and review project." -msgstr "" +msgstr "Wählen Sie NEIN, um das Dialogfeld zu schließen und das Projekt zu überprüfen." msgid "Please make sure any instances of OrcaSlicer are not running" -msgstr "" +msgstr "Bitte stellen Sie sicher, dass keine Instanzen von OrcaSlicer ausgeführt werden." msgid "System folder cannot be deleted because some files are in use by another application. Please close any applications using these files and try again." -msgstr "" +msgstr "Der Systemordner kann nicht gelöscht werden, da einige Dateien von einer anderen Anwendung verwendet werden. Bitte schließen Sie alle Anwendungen, die diese Dateien verwenden, und versuchen Sie es erneut." msgid "Failed to delete system folder..." -msgstr "" +msgstr "Systemordner konnte nicht gelöscht werden..." msgid "Failed to determine executable path." -msgstr "" +msgstr "Konnte den Pfad zur ausführbaren Datei nicht bestimmen." msgid "Failed to launch a new instance." -msgstr "" +msgstr "Konnte keine neue Instanz starten." msgid "log(s)" -msgstr "" +msgstr "Protokoll(e)" msgid "Choose where to save the exported JSON file" -msgstr "" +msgstr "Wählen Sie, wo die exportierte JSON-Datei gespeichert werden soll" msgid "" "Export failed\n" "Please check write permissions or file in use by another application" msgstr "" +"Export fehlgeschlagen\n" +"Bitte überprüfen Sie die Schreibberechtigungen oder ob die Datei von einer anderen Anwendung verwendet wird." msgid "Choose where to save the exported ZIP file" -msgstr "" +msgstr "Wählen Sie, wo die exportierte ZIP-Datei gespeichert werden soll" msgid "File already exists. Overwrite?" -msgstr "" +msgstr "Datei existiert bereits. Überschreiben?" msgid "3DPrinterOS Cloud upload options" msgstr "3DPrinterOS Cloud-Upload-Optionen" @@ -19318,17 +19330,17 @@ msgid "Could not connect to MKS" msgstr "Konnte keine Verbindung zu MKS herstellen" msgid "Connection to Moonraker is working correctly." -msgstr "" +msgstr "Verbindung zu Moonraker funktioniert korrekt." msgid "Could not connect to Moonraker" -msgstr "" +msgstr "Konnte keine Verbindung zu Moonraker herstellen" msgid "The host responded but it doesn't look like Moonraker (missing result.klippy_state)." -msgstr "" +msgstr "Der Host hat geantwortet, sieht aber nicht wie Moonraker aus (fehlender result.klippy_state)." #, c-format, boost-format msgid "Could not parse Moonraker server response: %s" -msgstr "" +msgstr "Konnte die Moonraker-Serverantwort nicht analysieren: %s" msgid "Connection to OctoPrint is working correctly." msgstr "Verbindung zu OctoPrint funktioniert korrekt." From d9f8460a4e5088c851fb64da2fad5ee09646ac1a Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Thu, 16 Jul 2026 20:40:32 +0800 Subject: [PATCH 41/47] fix: merge PythonFileUtils into PluginFsUtils --- resources/web/dialog/PluginsDialog/index.js | 2 +- src/slic3r/CMakeLists.txt | 2 - src/slic3r/GUI/GUI_App.cpp | 2 +- src/slic3r/GUI/PluginsDialog.cpp | 56 +- src/slic3r/GUI/PluginsDialog.hpp | 8 +- src/slic3r/plugin/PluginAuditManager.cpp | 16 +- src/slic3r/plugin/PluginAuditManager.hpp | 19 +- src/slic3r/plugin/PluginFsUtils.cpp | 954 ++++++++++++++++- src/slic3r/plugin/PluginFsUtils.hpp | 43 + src/slic3r/plugin/PluginLoader.cpp | 1 - src/slic3r/plugin/PluginManager.cpp | 22 +- src/slic3r/plugin/PluginManager.hpp | 4 +- src/slic3r/plugin/PythonFileUtils.cpp | 958 ------------------ src/slic3r/plugin/PythonFileUtils.hpp | 61 -- src/slic3r/plugin/PythonInterpreter.cpp | 2 +- ...cingPipelinePluginCapabilityTrampoline.hpp | 4 +- tests/slic3rutils/test_plugin_install.cpp | 2 +- tests/slic3rutils/test_plugin_lifecycle.cpp | 2 +- 18 files changed, 1051 insertions(+), 1107 deletions(-) delete mode 100644 src/slic3r/plugin/PythonFileUtils.cpp delete mode 100644 src/slic3r/plugin/PythonFileUtils.hpp diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index 238de8ec9a..e75841a807 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -1071,7 +1071,7 @@ function RunScriptPlugin(pluginId, capabilityName = "") { if (!plugin || !capability) return; - SendMessage("run_script_plugin", { + SendMessage("run_script_plugin_capability", { plugin_key: pluginId, capability_name: String(capability.name || "") }); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index d0c2360eee..87d2b6b81c 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -594,8 +594,6 @@ set(SLIC3R_GUI_SOURCES GUI/wxExtensions.hpp plugin/PythonInterpreter.cpp plugin/PythonInterpreter.hpp - plugin/PythonFileUtils.cpp - plugin/PythonFileUtils.hpp plugin/PythonPluginBridge.cpp plugin/PythonPluginBridge.hpp plugin/PythonPluginInterface.hpp diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index bb9e9010af..901b653a5a 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -4775,7 +4775,7 @@ void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PRO remove_user_presets(); enable_user_preset_folder(false); Slic3r::PluginManager::instance().unload_cloud_plugins(); - Slic3r::PluginManager::instance().clear_cloud_plugin_catalog(); + Slic3r::PluginManager::instance().clear_cloud_plugin_metadata(); Slic3r::PluginManager::instance().set_cloud_user(""); preset_bundle->load_user_presets(DEFAULT_USER_FOLDER_NAME, ForwardCompatibilitySubstitutionRule::Enable); mainframe->update_side_preset_ui(); diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index b0af572f20..95ff99c822 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -121,8 +121,8 @@ struct PluginDialogItem PluginAvailableActions available_actions; }; -constexpr bool kFetchCloudCatalog = true; -constexpr bool kUseCurrentCloudCatalog = false; +constexpr bool kFetchCloudMeta = true; +constexpr bool kUseCurrentCloudMeta = false; // The type of a package's first loaded capability. A package that is not loaded has no type. PluginCapabilityType primary_capability_type_of(PluginManager& manager, const std::string& plugin_key) @@ -131,7 +131,7 @@ PluginCapabilityType primary_capability_type_of(PluginManager& manager, const st return capabilities.empty() ? PluginCapabilityType::Unknown : capabilities.front()->type(); } -std::vector current_cloud_catalog_snapshot() +std::vector current_cloud_metadata_snapshot() { std::vector cloud_entries; for (const PluginDescriptor& entry : PluginManager::instance().get_plugin_descriptors(/*include_invalid=*/true)) @@ -152,21 +152,18 @@ PluginDescriptor as_cloud_only_descriptor(PluginDescriptor descriptor) return descriptor; } -// rescan_plugins() clears the whole catalog and rediscovers only local packages. When -// callers do not need fresh cloud data, reuse the current cloud rows after the local -// rescan so cloud-only rows and cloud-derived UI state do not disappear. -void refresh_plugin_catalog_blocking(bool fetch_cloud) +void refresh_plugin_metadata_blocking(bool fetch_cloud) { PluginManager& manager = PluginManager::instance(); std::vector not_found, unauthorized; - const std::vector current_cloud_catalog = fetch_cloud ? std::vector{} : - current_cloud_catalog_snapshot(); + const std::vector current_cloud_metadata = fetch_cloud ? std::vector{} : + current_cloud_metadata_snapshot(); manager.rescan_plugins(); if (!fetch_cloud) { - manager.update_cloud_catalog(current_cloud_catalog); + manager.update_cloud_metadata(current_cloud_metadata); return; } @@ -452,7 +449,7 @@ void PluginsDialog::set_open_terminal_dlg_fn() void PluginsDialog::update_plugin_dialog_ui() { - // Called after the shared catalog is already updated, for example from the + // Called after the any metadata is already updated, for example from the // cloud-plugin state callback. Do not fetch here or the callback can re-enter. send_plugins(); resolve_pending_activation(); @@ -486,10 +483,6 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload) const std::string command = payload.value("command", ""); if (command == "request_plugins") { - // The web page finished loading and is asking for the current catalog. Plugin - // discovery already runs at startup and on login, so the shared catalog is up to - // date by the time the dialog opens. Just render it here - no blocking fetch on - // open. The Refresh button (refresh_plugins) is what triggers a fresh discovery. send_plugins(); } else if (command == "refresh_plugins") { refresh_plugins(); @@ -502,8 +495,8 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload) install_plugin_from_file(); } else if (command == "plugin_menu_action") { handle_plugin_menu_action(payload.value("plugin_key", ""), payload.value("action", "")); - } else if (command == "run_script_plugin") { - run_script_plugin(payload.value("plugin_key", ""), payload.value("capability_name", "")); + } else if (command == "run_script_plugin_capability") { + run_script_plugin_capability(payload.value("plugin_key", ""), payload.value("capability_name", "")); } else if (command == "open_terminal") { m_open_terminal_dlg_fn(); } else if (command == "update_plugin") { @@ -574,16 +567,16 @@ std::shared_ptr PluginsDialog::get_capability(const s return PluginManager::instance().get_plugin_capability(plugin_key, capability_name, type, /*only_enabled=*/false); } -void PluginsDialog::refresh_plugin_catalog_async(const wxString& title, const wxString& message, bool fetch_cloud) +void PluginsDialog::refresh_plugin_metadata_async(const wxString& title, const wxString& message, bool fetch_cloud) { - run_with_dialog([fetch_cloud]() { refresh_plugin_catalog_blocking(fetch_cloud); }, [this]() { send_plugins(); }, title, message); + run_with_dialog([fetch_cloud]() { refresh_plugin_metadata_blocking(fetch_cloud); }, [this]() { send_plugins(); }, title, message); } void PluginsDialog::refresh_plugins() { BOOST_LOG_TRIVIAL(info) << "Refreshing plugins from Plugins dialog"; - refresh_plugin_catalog_async(_L("Refreshing"), _L("Refreshing plugins data"), kFetchCloudCatalog); + refresh_plugin_metadata_async(_L("Refreshing"), _L("Refreshing plugins data"), kFetchCloudMeta); } void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled) @@ -595,9 +588,6 @@ void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled) PluginDescriptor row_data; if (!get_descriptor(plugin_key, row_data)) { - // The row no longer maps to a catalog entry (the catalog changed under the UI). - // Toggling is a local action, so just re-render from the current catalog; a cloud - // fetch (or clearing rescan) adds nothing here. send_plugins(); return; } @@ -688,9 +678,6 @@ void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key, Plug PluginDescriptor row_data; if (!get_descriptor(plugin_key, row_data)) { - // The row no longer maps to a catalog entry (the catalog changed under the UI). - // Toggling is a local action, so just re-render from the current catalog; a cloud - // fetch (or clearing rescan) adds nothing here. send_plugins(); return; } @@ -829,7 +816,7 @@ bool PluginsDialog::install_plugin_package(const std::string& package_path) BOOST_LOG_TRIVIAL(info) << "Plugin package installed successfully from " << package_path; const wxString installed_name = from_u8(plugin_descriptor.name.empty() ? package_file.filename().string() : plugin_descriptor.name); show_status(wxString::Format(_L("Installed \"%s\"."), installed_name), "success"); - refresh_plugin_catalog_async(_L("Refreshing"), _L("Refreshing plugins data"), kUseCurrentCloudCatalog); + refresh_plugin_metadata_async(_L("Refreshing"), _L("Refreshing plugins data"), kUseCurrentCloudMeta); return true; } @@ -883,9 +870,8 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const return from_u8(plugin_key); } -void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name) +void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name) { - PluginManager& manager = PluginManager::instance(); std::string error; @@ -1028,11 +1014,11 @@ void PluginsDialog::delete_local_plugin(const PluginDescriptor& plugin) auto state = std::make_shared(); run_with_dialog( - [plugin_key = plugin.plugin_key, refresh_catalog = plugin.is_cloud_plugin(), state]() { + [plugin_key = plugin.plugin_key, should_refresh = plugin.is_cloud_plugin(), state]() { std::string error; const bool succeeded = PluginManager::instance().delete_plugin(plugin_key, error); - if (succeeded && refresh_catalog) - refresh_plugin_catalog_blocking(kFetchCloudCatalog); + if (succeeded && should_refresh) + refresh_plugin_metadata_blocking(kFetchCloudMeta); store_plugin_operation_result(state, succeeded, std::move(error)); }, [this, state, plugin_name]() { @@ -1137,7 +1123,7 @@ void PluginsDialog::reinstall_cloud_plugin(const PluginDescriptor& plugin) return; } - manager.update_cloud_catalog(std::vector{as_cloud_only_descriptor(plugin)}); + manager.update_cloud_metadata(std::vector{as_cloud_only_descriptor(plugin)}); } if (!install_cloud_plugin(plugin_key, plugin.version, from_u8(plugin.name))) { @@ -1195,10 +1181,6 @@ void PluginsDialog::delete_mine_local_and_cloud_plugin(const std::string& plugin return; } - // delete_mine_local_and_cloud_plugin already updated the in-memory catalog - // (finalize_cloud_plugin_removal removes the row and, when a local package existed, - // re-syncs the cloud list itself), so a UI refresh is sufficient here - an extra - // clearing rescan + cloud fetch would be redundant. send_plugins(); show_status(wxString::Format(_L("Deleted \"%s\"."), plugin_name), "success"); } diff --git a/src/slic3r/GUI/PluginsDialog.hpp b/src/slic3r/GUI/PluginsDialog.hpp index 49049d6982..52970443fb 100644 --- a/src/slic3r/GUI/PluginsDialog.hpp +++ b/src/slic3r/GUI/PluginsDialog.hpp @@ -60,7 +60,7 @@ private: bool get_descriptor(const std::string& plugin_key, Slic3r::PluginDescriptor& descriptor) const; std::shared_ptr get_capability(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name) const; - void refresh_plugin_catalog_async(const wxString& title, const wxString& message, bool fetch_cloud); + void refresh_plugin_metadata_async(const wxString& title, const wxString& message, bool fetch_cloud); void refresh_plugins(); void toggle_plugin(const std::string& plugin_key, bool enabled); void toggle_plugin_capability(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name, bool enabled); @@ -69,7 +69,7 @@ private: void install_plugin_from_file(); bool install_plugin_package(const std::string& package_path); bool install_cloud_plugin(const std::string& uuid, const std::string& version, const wxString& name); - void run_script_plugin(const std::string& plugin_key, const std::string& capability_name); + void run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name); // Pushes a one-line result into the web footer status bar (level: "success" | "warn" | "error" | "info"), // used for every plugin/capability operation instead of a modal box so the dialog stays non-disruptive. void show_status(const wxString& message, const char* level); @@ -234,9 +234,9 @@ private: PluginSortKey m_plugin_sort_key = PluginSortKey::None; PluginSortOrder m_plugin_sort_order = PluginSortOrder::Asc; - // Serializes run_script_plugin. With main-thread execution a plugin's orca.host.ui modal + // Serializes run_script_plugin_capability. With main-thread execution a plugin's orca.host.ui modal // (message/show_dialog) or the result message box pumps a nested event loop, which could - // re-dispatch the web "run_script_plugin" command and start a second, overlapping run. + // re-dispatch the web "run_script_plugin_capability" command and start a second, overlapping run. bool m_script_running = false; // Plugin whose asynchronous activation is in flight, awaited by resolve_pending_activation(). diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 40c16a4807..d64e94a968 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -14,11 +14,11 @@ namespace Slic3r { // Path safety // --------------------------------------------------------------------------- -bool is_inside_allowed_root(const std::filesystem::path& candidate, const std::filesystem::path& allowed_root) +bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root) { - namespace fs = std::filesystem; + namespace fs = boost::filesystem; - std::error_code ec; + boost::system::error_code ec; // Canonicalize both paths. weakly_canonical resolves symlinks but does // NOT require the path to exist — it canonicalizes the prefix that exists @@ -78,7 +78,7 @@ bool is_inside_allowed_root(const std::filesystem::path& candidate, const std::f thread_local std::string PluginAuditManager::m_current_plugin_key = ""; thread_local PluginAuditManager::AuditMode PluginAuditManager::m_audit_mode = PluginAuditManager::AuditMode::Loading; -thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; +thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local AuditViolation PluginAuditManager::m_last_violation; @@ -115,7 +115,7 @@ std::string PluginAuditManager::current_plugin() const { return m_current_plugin void PluginAuditManager::clear_current_plugin() { m_current_plugin_key.clear(); } -void PluginAuditManager::add_global_allowed_root(const std::filesystem::path& root) +void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root) { if (root.empty()) return; @@ -125,7 +125,7 @@ void PluginAuditManager::add_global_allowed_root(const std::filesystem::path& ro BOOST_LOG_TRIVIAL(info) << "[AUDIT] Global allowed root: " << root.string(); } -void PluginAuditManager::add_scoped_allowed_root(const std::filesystem::path& root) +void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& root) { if (root.empty()) return; @@ -163,12 +163,12 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const return {true, ""}; } - namespace fs = std::filesystem; + namespace fs = boost::filesystem; fs::path candidate(path_str); // Resolve relative paths against the current working directory if (candidate.is_relative()) { - std::error_code ec; + boost::system::error_code ec; candidate = fs::absolute(candidate, ec); if (ec) candidate = fs::path(path_str); diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 713eeb09cc..659ecdc892 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -2,11 +2,12 @@ #define slic3r_PluginAuditManager_hpp_ #include -#include #include #include #include +#include + namespace Slic3r { struct AuditDecision { @@ -17,14 +18,14 @@ struct AuditDecision { struct AuditViolation { std::string plugin_key; std::string event_name; - std::filesystem::path path; + boost::filesystem::path path; std::string reason; }; // Returns true if candidate resolves to a path inside allowed_root. // Uses weakly_canonical and component-wise comparison to reject traversal attacks. -bool is_inside_allowed_root(const std::filesystem::path& candidate, - const std::filesystem::path& allowed_root); +bool is_inside_allowed_root(const boost::filesystem::path& candidate, + const boost::filesystem::path& allowed_root); class PluginAuditManager { @@ -40,8 +41,8 @@ public: void clear_current_plugin(); // --- allowed-roots registry --- - void add_global_allowed_root(const std::filesystem::path& root); - void add_scoped_allowed_root(const std::filesystem::path& root); + void add_global_allowed_root(const boost::filesystem::path& root); + void add_scoped_allowed_root(const boost::filesystem::path& root); // --- enforcement mode --- enum class AuditMode { @@ -76,12 +77,12 @@ private: static thread_local std::string m_current_plugin_key; static thread_local AuditMode m_audit_mode; - static thread_local std::vector m_scoped_allowed_roots; + static thread_local std::vector m_scoped_allowed_roots; static thread_local bool m_has_last_violation; static thread_local AuditViolation m_last_violation; std::mutex m_mutex; - std::vector m_global_allowed_roots; + std::vector m_global_allowed_roots; }; // RAII guard that sets the current plugin key and restores the previous one. @@ -100,7 +101,7 @@ public: private: std::string m_previous_id; PluginAuditManager::AuditMode m_previous_mode; - std::vector m_previous_scoped_roots; + std::vector m_previous_scoped_roots; }; } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index b9685a4764..7852bce6e4 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -1,16 +1,31 @@ #include "PluginFsUtils.hpp" -#include "PythonFileUtils.hpp" #include "libslic3r/Utils.hpp" +#include "libslic3r/miniz_extension.hpp" #include #include +#include + +#include "PluginAuditManager.hpp" +#include "PythonInterpreter.hpp" + +#include #include #include #include +#include +#include +#include +#include +#include +#include +#include -#include "PluginAuditManager.hpp" +#ifdef WIN32 +#include +#endif namespace Slic3r { @@ -47,8 +62,8 @@ bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root, return false; for (const auto& allowed_dir : allowed_dirs) { - if (is_inside_allowed_root(std::filesystem::path(resolved_root.string()), - std::filesystem::path(allowed_dir))) + if (is_inside_allowed_root(boost::filesystem::path(resolved_root.string()), + boost::filesystem::path(allowed_dir))) return true; } @@ -253,5 +268,936 @@ std::vector discover_plugin_packages(const std::vector(static_cast(p[2])) | + static_cast(static_cast(p[3]) << 8); + if (p[0] == '\x75' && p[1] == '\x70' && len >= 5 && p + 4 + len <= e && p[4] == '\x01') + return std::string(p + 9, p + 4 + len); + p += 4 + len; + } + + return decode_path(fallback.c_str()); +} + +std::string zip_entry_name(mz_zip_archive& archive, const mz_zip_archive_file_stat& stat) +{ + if (stat.m_is_utf8) + return stat.m_filename; + + std::string extra(1024, 0); + const size_t n = mz_zip_reader_get_extra(&archive, stat.m_file_index, extra.data(), extra.size()); + return decode_zip_entry_extra_path(extra.substr(0, n), stat.m_filename); +} + +std::string normalize_zip_entry_name(std::string entry_name) +{ + std::replace(entry_name.begin(), entry_name.end(), '\\', '/'); + while (!entry_name.empty() && entry_name.back() == '/') + entry_name.pop_back(); + return entry_name; +} + +struct ZipReaderGuard +{ + mz_zip_archive archive; + bool opened = false; + + ZipReaderGuard() { mz_zip_zero_struct(&archive); } + + ~ZipReaderGuard() + { + if (opened) + close_zip_reader(&archive); + } +}; + +} // namespace + +bool is_valid_plugin_id(const std::string& id) +{ + if (id.empty()) + return false; + if (id == "." || id == ".." || id[0] == '.' || id.rfind("__", 0) == 0) + return false; + + for (unsigned char ch : id) { + if (std::isalnum(ch) || ch == '_' || ch == '-' || ch == '.') + continue; + return false; + } + + return true; +} + +namespace { + +// RAII helper to free heap-allocated memory from miniz. +struct MzHeapFree { + void* ptr = nullptr; + ~MzHeapFree() { if (ptr) std::free(ptr); } +}; + +// Read a text file from within a zip archive into a string. +// Returns true on success, false if the file is not found or cannot be read. +bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::string& out, std::string& error) +{ + size_t size = 0; + void* data = mz_zip_reader_extract_file_to_heap(&archive, filename, &size, 0); + if (!data) { + error = std::string("Wheel does not contain ") + filename; + return false; + } + MzHeapFree guard{data}; + out.assign(static_cast(data), size); + return true; +} + +// TOML section parsing states. +enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray }; + +// Strip a quoted string value: "foo" → foo, 'foo' → foo. +// Returns the unquoted value or the input unchanged if not quoted. +std::string unquote_toml_string(const std::string& val) +{ + if (val.size() >= 2 && ((val.front() == '"' && val.back() == '"') || (val.front() == '\'' && val.back() == '\''))) + return val.substr(1, val.size() - 2); + return val; +} + +// Split a TOML inline array: ["a", "b"] → {"a", "b"}. +// Handles trailing commas and single-line format. +std::vector parse_toml_inline_array(const std::string& val) +{ + std::vector result; + std::string inner = val; + // Strip outer brackets. + if (!inner.empty() && inner.front() == '[') + inner.erase(0, 1); + if (!inner.empty() && inner.back() == ']') + inner.pop_back(); + + // Simple split by comma, strip quotes and whitespace. + std::istringstream ss(inner); + std::string item; + while (std::getline(ss, item, ',')) { + // Trim whitespace. + size_t s = 0, e = item.size(); + while (s < e && (item[s] == ' ' || item[s] == '\t')) ++s; + while (e > s && (item[e - 1] == ' ' || item[e - 1] == '\t')) --e; + item = item.substr(s, e - s); + if (!item.empty()) + result.push_back(unquote_toml_string(item)); + } + return result; +} + +// Parse PEP 723 TOML subset for dependencies, requires-python, and +// [tool.orcaslicer.plugin] identity fields. +// +// requires-python = ">=3.12" +// dependencies = ["pkg>=1.0", ] +// +// [tool.orcaslicer.plugin] +// id = "my-plugin" +// name = "My Plugin" +// description = "Does things." +// author = "Author" +// version = "1.0.0" +// +// Returns false only on parse errors; missing block is not an error. +bool parse_pep723_toml(const std::string& toml_content, + std::vector& out_deps, + std::string& out_requires_python, + std::string& out_name, + std::string& out_description, + std::string& out_author, + std::string& out_version, + std::map& out_settings, + std::string& error) +{ + out_deps.clear(); + out_requires_python.clear(); + out_name.clear(); + out_description.clear(); + out_author.clear(); + out_version.clear(); + out_settings.clear(); + + TomlSection section = TomlSection::Root; + + std::istringstream stream(toml_content); + std::string line; + + while (std::getline(stream, line)) { + // Trim leading/trailing whitespace. + size_t start = 0; + while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) + ++start; + size_t end = line.size(); + while (end > start && (line[end - 1] == ' ' || line[end - 1] == '\t')) + --end; + std::string trimmed = line.substr(start, end - start); + + if (trimmed.empty() || trimmed[0] == '#') + continue; + + // TOML section header. + if (trimmed[0] == '[') { + if (trimmed == "[tool.orcaslicer.plugin]") { + section = TomlSection::OrcaPlugin; + } else if (trimmed == "[tool.orcaslicer.plugin.settings]") { + section = TomlSection::OrcaPluginSettings; // per-plugin params table + } else { + section = TomlSection::Root; // Unknown section — skip. + } + continue; + } + + if (section == TomlSection::InDepsArray) { + if (trimmed == "]") { + section = TomlSection::Root; + continue; + } + std::string val = trimmed; + if (!val.empty() && val.back() == ',') + val.pop_back(); + val = unquote_toml_string(val); + if (!val.empty()) + out_deps.push_back(val); + continue; + } + + // Look for key = value. + size_t eq = trimmed.find('='); + if (eq == std::string::npos) + continue; + + std::string key = trimmed.substr(0, eq); + while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) + key.pop_back(); + + std::string val = trimmed.substr(eq + 1); + while (!val.empty() && (val.front() == ' ' || val.front() == '\t')) + val.erase(0, 1); + // Trim trailing. + while (!val.empty() && (val.back() == ' ' || val.back() == '\t')) + val.pop_back(); + + if (section == TomlSection::Root) { + if (key == "requires-python") { + out_requires_python = unquote_toml_string(val); + } else if (key == "dependencies") { + if (val == "[") { + section = TomlSection::InDepsArray; + } else { + // Inline array: dependencies = ["a", "b"] + out_deps = parse_toml_inline_array(val); + } + } + } else if (section == TomlSection::OrcaPlugin) { + if (key == "name") out_name = unquote_toml_string(val); + else if (key == "description") out_description = unquote_toml_string(val); + else if (key == "author") out_author = unquote_toml_string(val); + else if (key == "version") out_version = unquote_toml_string(val); + } else if (section == TomlSection::OrcaPluginSettings) { + // collect every key as a string; the plugin parses (int/float/...) what it needs. + if (!key.empty()) + out_settings[key] = unquote_toml_string(val); + } + } + + // Check for unclosed arrays. + if (section == TomlSection::InDepsArray) { + error = "PEP 723 metadata: unclosed dependencies array"; + return false; + } + return true; +} + +// Normalize a distribution name to a Python import package name. +// Converts hyphens to underscores and lowercases. +std::string normalize_package_name(const std::string& name) +{ + std::string result; + result.reserve(name.size()); + for (unsigned char ch : name) { + if (ch == '-' || ch == '.') + result += '_'; + else + result += static_cast(std::tolower(ch)); + } + return result; +} + +// Parse METADATA (RFC 822 style) into a flat multimap. +// https://packaging.python.org/en/latest/specifications/core-metadata/ +void parse_metadata_rfc822(const std::string& content, + std::string& out_name, + std::string& out_version, + std::string& out_summary, + std::string& out_author, + std::string& out_requires_python, + std::string& out_import_name, + std::vector& out_requires_dist, + std::string& error) +{ + std::istringstream stream(content); + std::string line; + std::string current_header; + std::string current_value; + + auto flush = [&]() { + if (current_header.empty()) + return; + std::string lower = current_header; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return std::tolower(c); }); + + if (lower == "name") + out_name = current_value; + else if (lower == "version") + out_version = current_value; + else if (lower == "summary") + out_summary = current_value; + else if (lower == "author") + out_author = current_value; + else if (lower == "requires-python") + out_requires_python = current_value; + else if (lower == "import-name") + out_import_name = current_value; + else if (lower == "requires-dist") + out_requires_dist.push_back(current_value); + + current_header.clear(); + current_value.clear(); + }; + + while (std::getline(stream, line)) { + // Blank line after headers marks the start of the body. + if (line.empty() && current_header.empty()) + continue; + if (line.empty()) { + flush(); + // Remaining content is the body (description); stop parsing headers. + break; + } + + // Continuation line. + if (line[0] == ' ' || line[0] == '\t') { + if (!current_value.empty()) + current_value += '\n'; + size_t pos = line.find_first_not_of(" \t"); + current_value += (pos != std::string::npos) ? line.substr(pos) : ""; + continue; + } + + flush(); + + size_t colon = line.find(':'); + if (colon == std::string::npos) + continue; + + current_header = line.substr(0, colon); + size_t val_start = colon + 1; + while (val_start < line.size() && (line[val_start] == ' ' || line[val_start] == '\t')) + ++val_start; + current_value = line.substr(val_start); + } + + flush(); +} + +} // namespace + +bool is_ignored_plugin_directory(const boost::filesystem::path& path) +{ + const std::string name = path.filename().string(); + return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR; +} + +bool is_safe_relative_path(const boost::filesystem::path& path) +{ + if (path.empty() || path.is_absolute() || path.has_root_directory() || path.has_root_name()) + return false; + + for (const auto& part : path) { + const std::string token = part.string(); + if (token == "..") + return false; + } + + return true; +} + +bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boost::filesystem::path& destination, std::string& error) +{ + namespace fs = boost::filesystem; + + boost::system::error_code ec; + fs::create_directories(destination, ec); + if (ec) { + error = "Failed to create plugin staging directory: " + ec.message(); + return false; + } + + ZipReaderGuard reader; + if (!open_zip_reader(&reader.archive, zip_path.string())) { + error = "Failed to open plugin zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); + return false; + } + reader.opened = true; + + std::unordered_set extracted_entries; + const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive); + mz_zip_archive_file_stat stat; + for (mz_uint i = 0; i < num_entries; ++i) { + if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) { + error = "Failed to read plugin zip entry metadata: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); + return false; + } + + std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat)); + if (entry_name.empty()) + continue; + if (entry_name.find(':') != std::string::npos) { + error = "Plugin zip entry contains an invalid path: " + entry_name; + return false; + } + + const fs::path relative_path(entry_name); + if (!is_safe_archive_entry_path(relative_path)) { + error = "Plugin zip entry escapes the plugin package: " + entry_name; + return false; + } + + const std::string relative_key = relative_path.generic_string(); + if (!extracted_entries.insert(relative_key).second) { + error = "Plugin zip contains duplicate entry: " + relative_key; + return false; + } + + const fs::path output_path = destination / relative_path; + if (stat.m_is_directory || mz_zip_reader_is_file_a_directory(&reader.archive, stat.m_file_index)) { + fs::create_directories(output_path, ec); + if (ec) { + error = "Failed to create plugin zip directory " + output_path.string() + ": " + ec.message(); + return false; + } + continue; + } + + fs::create_directories(output_path.parent_path(), ec); + if (ec) { + error = "Failed to create plugin zip parent directory " + output_path.parent_path().string() + ": " + ec.message(); + return false; + } + + if (fs::exists(output_path, ec) && fs::is_directory(output_path, ec)) { + error = "Plugin zip file conflicts with an existing directory: " + output_path.string(); + return false; + } + + const std::string encoded_output_path = encode_path(output_path.string().c_str()); + mz_bool extracted = mz_zip_reader_extract_to_file(&reader.archive, stat.m_file_index, encoded_output_path.c_str(), 0); +#ifdef WIN32 + if (!extracted) { + const std::wstring wide_output_path = boost::locale::conv::utf_to_utf(output_path.generic_string()); + extracted = mz_zip_reader_extract_to_file_w(&reader.archive, stat.m_file_index, wide_output_path.c_str(), 0); + } +#endif + if (!extracted) { + error = "Failed to extract plugin zip entry " + relative_key + ": " + + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); + return false; + } + } + + return true; +} + +void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry) +{ + PluginInstallState state; + if (!read_install_state(plugin_dir, state)) + return; + + // The cloud identity and the persisted installed version are read back. plugin_key + // is always derived by the catalog scan (filename for local, the cloud uuid for + // cloud), so it is not read from the sidecar. installed_version is the source of + // truth for a cloud plugin's installed version: it records the version fetched from + // the cloud at install time, independent of the (possibly stale) manifest/PEP723 + // header that scan_directory parses into entry.version. + if (!state.installed_version.empty()) + entry.installed_version = state.installed_version; + if (!state.cloud_uuid.empty()) + entry.cloud = CloudPluginState{state.cloud_uuid, true, false, false}; + + // Package-level auto-load flag only. The per-capability enable flags stay in the sidecar: a + // capability has no existence — and so no state — until it is materialized, at which point the + // loader seeds the flag onto the capability itself. + entry.enabled = state.enabled; +} + +bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out) +{ + namespace fs = boost::filesystem; + const fs::path sidecar_path = plugin_dir / ".install_state.json"; + + if (!fs::exists(sidecar_path) || !fs::is_regular_file(sidecar_path)) + return false; + + boost::nowide::ifstream f(sidecar_path.string()); + if (!f) + return false; + + try { + nlohmann::json state = nlohmann::json::parse(f, nullptr, false, true); + if (state.is_discarded() || !state.is_object()) + return false; + + PluginInstallState parsed; + if (state.contains("installed_from") && state["installed_from"].is_string()) + parsed.installed_from = state["installed_from"].get(); + if (state.contains("installed_version") && state["installed_version"].is_string()) + parsed.installed_version = state["installed_version"].get(); + if (state.contains("plugin_name") && state["plugin_name"].is_string()) + parsed.plugin_name = state["plugin_name"].get(); + if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string()) + parsed.cloud_uuid = state["cloud_uuid"].get(); + if (state.contains("enabled") && state["enabled"].is_boolean()) + parsed.enabled = state["enabled"].get(); + + // capabilities is a JSON array of single-key objects {: }. + if (state.contains("capabilities") && state["capabilities"].is_array()) { + for (const auto& item : state["capabilities"]) { + if (!item.is_object()) + continue; + for (auto it = item.begin(); it != item.end(); ++it) { + if (it.value().is_boolean()) + parsed.capabilities.emplace_back(it.key(), it.value().get()); + } + } + } + + out = std::move(parsed); + return true; + } catch (...) { + return false; + } +} + +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state) +{ + namespace fs = boost::filesystem; + const fs::path sidecar_path = plugin_dir / ".install_state.json"; + + nlohmann::json json; + json["installed_from"] = state.installed_from; + json["installed_version"] = state.installed_version; + json["plugin_name"] = state.plugin_name; + json["enabled"] = state.enabled; + if (!state.cloud_uuid.empty()) + json["cloud_uuid"] = state.cloud_uuid; + + nlohmann::json capabilities = nlohmann::json::array(); + for (const auto& [name, enabled] : state.capabilities) + capabilities.push_back(nlohmann::json{{name, enabled}}); + json["capabilities"] = std::move(capabilities); + + boost::nowide::ofstream f(sidecar_path.string()); + if (!f) + return false; + + f << json.dump(2); + return static_cast(f); +} + +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, + const std::vector>& capabilities) +{ + PluginInstallState state; + state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local"; + // Prefer the descriptor's recorded installed_version (the version fetched from the cloud + // at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header + // never overwrites the source-of-truth version. Fall back to the manifest version for + // first-time/local installs where installed_version is not yet populated. + state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version; + state.plugin_name = entry.name; + state.cloud_uuid = entry.cloud_uuid(); + state.enabled = enabled; + state.capabilities = capabilities; + return write_install_state(plugin_dir, state); +} + +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry) +{ + // Install-time writer: the package is not loaded, so its capabilities are not known yet and the + // sidecar is (re)initialized to "auto-load, nothing disabled". PluginManager writes the real + // per-capability flags once the package is loaded, via the (dir, entry, enabled, capabilities) + // overload. + return write_install_state(plugin_dir, entry, true, {}); +} + +bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error) +{ + namespace fs = boost::filesystem; + + if (!fs::exists(py_path) || !fs::is_regular_file(py_path)) { + error = "Python plugin file does not exist: " + py_path.string(); + return false; + } + + boost::nowide::ifstream f(py_path.string()); + if (!f) { + error = "Failed to open Python plugin file: " + py_path.string(); + return false; + } + + // Scan for PEP 723 inline script metadata block. + // The block is delimited by: + // # /// script + // # + // # /// + std::string pep723_content; + bool in_block = false; + std::string line; + + while (std::getline(f, line)) { + // Strip trailing carriage return (Windows line endings). + if (!line.empty() && line.back() == '\r') + line.pop_back(); + + if (!in_block) { + // Look for opening delimiter. + if (line == "# /// script") + in_block = true; + continue; + } + + if (line == "# ///") { + in_block = false; + continue; + } + + // Extract TOML content from the comment line. + // Lines must start with "# " or "#\t" per PEP 723. + if (line.size() >= 2 && line[0] == '#' && (line[1] == ' ' || line[1] == '\t')) + pep723_content += line.substr(2) + "\n"; + else if (line == "#") + pep723_content += "\n"; + // If line doesn't start with "# ", it's still part of the block content + // but we skip it as it doesn't follow the spec. + } + + if (!pep723_content.empty()) { + std::string pep723_error; + std::string requires_python; + std::string pep_name, pep_desc, pep_author, pep_version; + if (!parse_pep723_toml(pep723_content, + descriptor.dependencies, + requires_python, + pep_name, + pep_desc, + pep_author, + pep_version, + descriptor.settings, + pep723_error)) { + error = "Failed to parse PEP 723 metadata: " + pep723_error; + return false; + } + // requires-python is stored but not validated against the bundled Python here. + (void) requires_python; + + // Populate identity fields from the PEP 723 [tool.orcaslicer.plugin] section. + // Cloud metadata overrides these when available; they serve as the local + // source of truth for side-loaded .py plugins and as fallback values. + if (!pep_name.empty()) descriptor.name = sanitize_plugin_name(pep_name); + if (!pep_desc.empty()) descriptor.description = pep_desc; + if (!pep_author.empty()) descriptor.author = pep_author; + if (!pep_version.empty()) descriptor.version = pep_version; + } + + // Validate that required identity fields are present (either from PEP 723 or + // from cloud metadata already set on the manifest by the caller). + // Validation is deferred to the install/discovery layer so cloud metadata + // can fill in gaps. + return true; +} + +bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginDescriptor& descriptor, std::string& error) +{ + namespace fs = boost::filesystem; + + if (!fs::exists(whl_path) || !fs::is_regular_file(whl_path)) { + error = "Wheel plugin file does not exist: " + whl_path.string(); + return false; + } + + ZipReaderGuard reader; + if (!open_zip_reader(&reader.archive, whl_path.string())) { + error = "Failed to open wheel as zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); + return false; + } + reader.opened = true; + + // Find the single .dist-info directory. + // Scan ALL entries, not just directory entries — some zip writers omit + // explicit directory entries. normalize_zip_entry_name strips trailing + // slashes, so we match ".dist-info" as the last path component of a + // directory entry, and ".dist-info/" embedded in a file path. + const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive); + std::string dist_info_dir; + mz_zip_archive_file_stat stat; + + for (mz_uint i = 0; i < num_entries; ++i) { + if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) + continue; + + std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat)); + if (entry_name.empty()) + continue; + + // Find .dist-info as a path component. + size_t pos = entry_name.find(".dist-info"); + if (pos == std::string::npos) + continue; + + std::string candidate; + if (pos + 10 == entry_name.size()) { + // Directory entry itself (trailing / stripped by normalize). + candidate = entry_name + "/"; + } else if (pos + 10 < entry_name.size() && entry_name[pos + 10] == '/') { + // File inside .dist-info/: name.dist-info/METADATA + candidate = entry_name.substr(0, pos + 11); // include trailing / + } else { + continue; // .dist-info mid-name, not a path component. + } + + if (!dist_info_dir.empty() && candidate != dist_info_dir) { + error = "Wheel contains multiple .dist-info directories: " + dist_info_dir + " and " + candidate; + return false; + } + dist_info_dir = candidate; + } + + if (dist_info_dir.empty()) { + error = "Wheel does not contain a .dist-info directory"; + return false; + } + + // Read METADATA. + const std::string metadata_path = dist_info_dir + "METADATA"; + std::string meta_content; + if (!read_zip_text_file(reader.archive, metadata_path.c_str(), meta_content, error)) + return false; + + std::string meta_name, meta_version, meta_summary, meta_author, meta_requires_python, meta_import_name; + std::vector requires_dist; + std::string meta_error; + parse_metadata_rfc822(meta_content, meta_name, meta_version, meta_summary, meta_author, + meta_requires_python, meta_import_name, requires_dist, meta_error); + + if (meta_name.empty()) { + error = "Wheel METADATA missing required Name field"; + return false; + } + if (meta_version.empty()) { + error = "Wheel METADATA missing required Version field"; + return false; + } + + // Read WHEEL (verify existence and Wheel-Version). + const std::string wheel_path = dist_info_dir + "WHEEL"; + std::string wheel_content; + if (!read_zip_text_file(reader.archive, wheel_path.c_str(), wheel_content, error)) + return false; + // Verify there's at least a Wheel-Version header line. + if (wheel_content.find("Wheel-Version:") == std::string::npos) { + error = "Wheel WHEEL file missing Wheel-Version header"; + return false; + } + + // Parse and validate wheel platform tags. + { + std::vector wheel_tags; + std::istringstream wstream(wheel_content); + std::string wline; + while (std::getline(wstream, wline)) { + while (!wline.empty() && (wline.back() == '\r' || wline.back() == '\n')) + wline.pop_back(); + if (wline.rfind("Tag:", 0) == 0) { + std::string tag = wline.substr(4); + size_t s = 0, e = tag.size(); + while (s < e && (tag[s] == ' ' || tag[s] == '\t')) ++s; + while (e > s && (tag[e - 1] == ' ' || tag[e - 1] == '\t')) --e; + wheel_tags.push_back(tag.substr(s, e - s)); + } + } + + if (!wheel_tags.empty()) { + bool compatible = false; + const std::string abi_tag = PythonInterpreter::python_abi_tag(); + for (const auto& tag : wheel_tags) { + // Pure Python wheel: py3-none-any or cp312-none-any + if (tag.find("-none-any") != std::string::npos) { + compatible = true; + break; + } + // Platform-specific: check ABI tag matches. + if (tag.find(abi_tag) == 0) { + // Accept if the platform tag matches the current OS. +#ifdef _WIN32 + if (tag.find("-win") != std::string::npos) + compatible = true; +#elif __APPLE__ + if (tag.find("-macosx") != std::string::npos) + compatible = true; +#else + if (tag.find("-linux") != std::string::npos || tag.find("-manylinux") != std::string::npos) + compatible = true; +#endif + } + } + if (!compatible) { + error = "Wheel is incompatible with this platform. Tags: "; + for (size_t i = 0; i < wheel_tags.size(); ++i) { + if (i > 0) error += ", "; + error += wheel_tags[i]; + } + error += "; expected ABI: " + abi_tag; + return false; + } + } + } + + // Read RECORD (verify existence). + const std::string record_path = dist_info_dir + "RECORD"; + std::string record_content; + if (!read_zip_text_file(reader.archive, record_path.c_str(), record_content, error)) + return false; + if (record_content.empty()) { + error = "Wheel RECORD file is empty"; + return false; + } + + // Parse top_level.txt if present. + std::string top_level; + const std::string top_level_path = dist_info_dir + "top_level.txt"; + std::string top_level_content; + if (read_zip_text_file(reader.archive, top_level_path.c_str(), top_level_content, error)) { + // top_level.txt contains one package name per line. + std::istringstream tl_stream(top_level_content); + std::string tl_line; + std::vector top_levels; + while (std::getline(tl_stream, tl_line)) { + while (!tl_line.empty() && (tl_line.back() == '\r' || tl_line.back() == '\n')) + tl_line.pop_back(); + if (!tl_line.empty()) + top_levels.push_back(tl_line); + } + if (top_levels.size() == 1) + top_level = top_levels[0]; + else if (top_levels.size() > 1) { + // Ambiguous: multiple top-level packages. Fall through to Name-based fallback. + } + // Zero entries: leave top_level empty. + } + // If top_level.txt is not found, that's OK — it's optional per the wheel spec. + + // Determine the entry package in priority order. + // 1. Cloud/catalog metadata — handled by caller, not here. + // 2. Core Metadata Import-Name. + // 3. top_level.txt if unambiguous. + // 4. Normalized Name as fallback. + if (!meta_import_name.empty()) { + descriptor.entry_package = meta_import_name; + } else if (!top_level.empty()) { + descriptor.entry_package = top_level; + } else { + descriptor.entry_package = normalize_package_name(meta_name); + } + + descriptor.dependencies = std::move(requires_dist); + + // Populate local identity fallbacks from wheel metadata. + // Cloud metadata will override these when available. + descriptor.name = sanitize_plugin_name(meta_name); + descriptor.version = meta_version; + descriptor.description = meta_summary; + descriptor.author = meta_author; + + return true; +} + +boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error) +{ + namespace fs = boost::filesystem; + + if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) { + error = "Plugin directory does not exist: " + plugin_dir.string(); + return {}; + } + + fs::path py_entry; + fs::path whl_entry; + + for (fs::directory_iterator it(plugin_dir); it != fs::directory_iterator(); ++it) { + if (is_ignored_plugin_directory(it->path())) + continue; + if (!fs::is_regular_file(it->status())) + continue; + + const fs::path ext = it->path().extension(); + if (ext == ".py") { + if (!py_entry.empty()) { + error = "Plugin directory contains multiple .py files: " + py_entry.filename().string() + + " and " + it->path().filename().string(); + return {}; + } + py_entry = it->path(); + } else if (ext == ".whl") { + if (!whl_entry.empty()) { + error = "Plugin directory contains multiple .whl files: " + whl_entry.filename().string() + + " and " + it->path().filename().string(); + return {}; + } + whl_entry = it->path(); + } + } + + if (!py_entry.empty() && !whl_entry.empty()) { + error = "Plugin directory contains both .py and .whl entry files"; + return {}; + } + + if (!py_entry.empty()) + return py_entry; + + if (!whl_entry.empty()) + return whl_entry; + + error = "Plugin directory does not contain a .py or .whl entry file"; + return {}; +} } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index a47f21c65f..cd3f4a6f7e 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -13,6 +13,15 @@ namespace Slic3r { extern const char* const INSTALL_STATE_FILE; +struct PluginInstallState { + std::string installed_from; // "local" | "cloud" + std::string installed_version; + std::string plugin_name; + std::string cloud_uuid; // empty for local + bool enabled = true; + std::vector> capabilities; // name -> enabled, ordered +}; + // Returns the cloud plugin install/scan directory for a given user_id. // Path: {data_dir}/orca_plugins/_subscribed/{user_id}/ std::string get_cloud_plugin_dir(const std::string& user_id); @@ -48,4 +57,38 @@ std::vector get_plugin_directories(const std::string& cloud_user_id // descriptor.enabled. Capabilities are NOT discovered here — a package has none until it is loaded. std::vector discover_plugin_packages(const std::vector& dirs, std::string& error); +bool is_ignored_plugin_directory(const boost::filesystem::path& path); +bool is_safe_relative_path(const boost::filesystem::path& path); +bool is_valid_plugin_id(const std::string& id); +bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boost::filesystem::path& destination, std::string& error); + +// Read PEP 723 inline script metadata from a .py plugin file. +// Populates metadata.dependencies and local identity fallbacks. +// Returns true on success (including when no PEP 723 block is found — deps will be empty). +bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error); + +// Read wheel metadata from a .whl plugin file (zip archive). +// Reads METADATA, WHEEL, RECORD, and top_level.txt from the .dist-info directory. +// Populates metadata.entry_package, metadata.dependencies, and local identity fallbacks. +// Returns true on success. +bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginDescriptor& descriptor, std::string& error); + +// Find the single plugin entry file (.py or .whl) in a directory. +// Ignores __whl_extracted__ and hidden files/dirs. +// Returns the path to the entry file, or an empty path with error set if zero or multiple candidates. +boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error); + +// Canonical writer: emits the .install_state.json schema for the given state. +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state); +// Builds a PluginInstallState from the descriptor and delegates to the canonical writer. +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, + const std::vector>& capabilities); +// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}). +bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry); + +// Reads only the cloud identity (uuid) back into the descriptor; plugin_key is always derived. +void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry); +// Full read of the sidecar; returns false if there is no/invalid sidecar. +bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out); + } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginLoader.cpp b/src/slic3r/plugin/PluginLoader.cpp index e8140ecc48..df0a38c50e 100644 --- a/src/slic3r/plugin/PluginLoader.cpp +++ b/src/slic3r/plugin/PluginLoader.cpp @@ -6,7 +6,6 @@ #include "PluginManager.hpp" #include "PluginFsUtils.hpp" -#include "PythonFileUtils.hpp" #include "PythonInterpreter.hpp" #include "PythonPluginBridge.hpp" #include "libslic3r/Utils.hpp" diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 6a306b9aec..b0ca443332 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -5,7 +5,6 @@ #include "PluginFsUtils.hpp" #include "PluginHooks.hpp" -#include "PythonFileUtils.hpp" #include "PythonInterpreter.hpp" #include "PythonPluginBridge.hpp" @@ -1267,7 +1266,7 @@ const char* const CLOUD_PLUGIN_NOT_FOUND_ERROR = "Plugin was not found in the cl } // namespace -void PluginManager::update_cloud_catalog(const std::vector& cloud_list) +void PluginManager::update_cloud_metadata(const std::vector& cloud_list) { std::lock_guard lock(m_mutex); @@ -1353,7 +1352,7 @@ void PluginManager::update_cloud_catalog(const std::vector& cl } } -void PluginManager::clear_cloud_plugin_catalog() +void PluginManager::clear_cloud_plugin_metadata() { // Cloud entries may own live Python modules. Unload them before erasing their vector entries, // and do so outside m_mutex because both Python teardown and lifecycle callbacks can re-enter @@ -1369,7 +1368,7 @@ void PluginManager::clear_cloud_plugin_catalog() } }); - BOOST_LOG_TRIVIAL(info) << "Cleared cloud plugin catalog entries"; + BOOST_LOG_TRIVIAL(info) << "Cleared cloud plugin metadata"; } void PluginManager::fetch_plugins_from_cloud(std::vector* out_not_found, std::vector* out_unauthorized) @@ -1394,7 +1393,7 @@ void PluginManager::fetch_plugins_from_cloud(std::vector* out_not_f } } - update_cloud_catalog(cloud_list); + update_cloud_metadata(cloud_list); { std::lock_guard lock(m_mutex); @@ -1458,7 +1457,6 @@ bool PluginManager::subscribe_and_install_cloud_plugin(const std::string& plugin PluginDescriptor descriptor; bool found = try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_cloud_plugin(); if (!found) { - // The plugin may already be subscribed or owned while the local catalog is stale. fetch_plugins_from_cloud(); found = try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_cloud_plugin(); } @@ -1544,9 +1542,9 @@ bool PluginManager::download_and_install_cloud_plugin(const std::string& plugin_ std::lock_guard lock(m_mutex); Plugin* entry = find_plugin_locked(plugin_key); if (entry == nullptr) { - error = "Plugin Manifest not found."; + error = "Plugin not found."; BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Cloud plugin " << plugin_key - << " downloaded successfully but failed to update plugin manifest. Manifest not found in catalog."; + << " downloaded successfully but failed to update plugin metadata. Plugin object not found."; } else { entry->descriptor = std::move(updated_descriptor); } @@ -1747,9 +1745,6 @@ bool PluginManager::keep_installed_plugin_as_local(const PluginDescriptor& plugi bool PluginManager::finalize_cloud_plugin_removal(const PluginDescriptor& plugin, bool keep_local, std::string& error) { - // Shared by all four cloud-removal entrypoints after the cloud-side request succeeds. Handles - // the common local follow-up of keeping a detached local copy, deleting local files, or - // dropping a cloud-only row from the catalog. if (keep_local && plugin.has_local_package()) { if (!keep_installed_plugin_as_local(plugin, error)) return false; @@ -1760,8 +1755,7 @@ bool PluginManager::finalize_cloud_plugin_removal(const PluginDescriptor& plugin if (plugin.has_local_package()) { if (!delete_installed_plugin_package(plugin, error)) return false; - // Re-sync the cloud catalog so observers/UI see the updated cloud list after the local - // package has been removed. + fetch_plugins_from_cloud(); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Deleted local package after cloud removal: " << plugin.plugin_key; return true; @@ -1773,7 +1767,7 @@ bool PluginManager::finalize_cloud_plugin_removal(const PluginDescriptor& plugin [&plugin](const Plugin& entry) { return entry.descriptor.plugin_key == plugin.plugin_key; }), m_plugins.end()); } - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Removed cloud-only plugin from catalog: " << plugin.plugin_key; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Removed cloud-only plugin: " << plugin.plugin_key; return true; } diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index 08f2659c16..5b310504a2 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -194,8 +194,8 @@ public: bool clear_plugin_error(const std::string& plugin_key); void fetch_plugins_from_cloud(std::vector* out_not_found = nullptr, std::vector* out_unauthorized = nullptr); - void update_cloud_catalog(const std::vector& cloud_list); - void clear_cloud_plugin_catalog(); + void update_cloud_metadata(const std::vector& cloud_list); + void clear_cloud_plugin_metadata(); bool download_and_install_cloud_plugin(const std::string& plugin_key, const std::string& version, std::string& error); bool subscribe_and_install_cloud_plugin(const std::string& plugin_key, std::string& error); diff --git a/src/slic3r/plugin/PythonFileUtils.cpp b/src/slic3r/plugin/PythonFileUtils.cpp deleted file mode 100644 index 9a9c117f8f..0000000000 --- a/src/slic3r/plugin/PythonFileUtils.cpp +++ /dev/null @@ -1,958 +0,0 @@ -#include "PythonFileUtils.hpp" - -#include "PluginFsUtils.hpp" -#include "PythonInterpreter.hpp" -#include "libslic3r/Utils.hpp" -#include "PluginDescriptor.hpp" -#include "libslic3r/miniz_extension.hpp" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#ifdef WIN32 -#include -#endif - -namespace Slic3r { -namespace { - -bool is_safe_archive_entry_path(const boost::filesystem::path& path) -{ - if (!is_safe_relative_path(path)) - return false; - - for (const auto& part : path) { - const std::string token = part.string(); - if (token.empty() || token == "." || token == "..") - return false; - } - - return true; -} - -std::string decode_zip_entry_extra_path(const std::string& extra, const std::string& fallback) -{ - const char* p = extra.data(); - const char* e = p + extra.length(); - while (p + 4 <= e) { - const auto len = static_cast(static_cast(p[2])) | - static_cast(static_cast(p[3]) << 8); - if (p[0] == '\x75' && p[1] == '\x70' && len >= 5 && p + 4 + len <= e && p[4] == '\x01') - return std::string(p + 9, p + 4 + len); - p += 4 + len; - } - - return decode_path(fallback.c_str()); -} - -std::string zip_entry_name(mz_zip_archive& archive, const mz_zip_archive_file_stat& stat) -{ - if (stat.m_is_utf8) - return stat.m_filename; - - std::string extra(1024, 0); - const size_t n = mz_zip_reader_get_extra(&archive, stat.m_file_index, extra.data(), extra.size()); - return decode_zip_entry_extra_path(extra.substr(0, n), stat.m_filename); -} - -std::string normalize_zip_entry_name(std::string entry_name) -{ - std::replace(entry_name.begin(), entry_name.end(), '\\', '/'); - while (!entry_name.empty() && entry_name.back() == '/') - entry_name.pop_back(); - return entry_name; -} - -struct ZipReaderGuard -{ - mz_zip_archive archive; - bool opened = false; - - ZipReaderGuard() { mz_zip_zero_struct(&archive); } - - ~ZipReaderGuard() - { - if (opened) - close_zip_reader(&archive); - } -}; - -} // namespace - -bool is_valid_plugin_id(const std::string& id) -{ - if (id.empty()) - return false; - if (id == "." || id == ".." || id[0] == '.' || id.rfind("__", 0) == 0) - return false; - - for (unsigned char ch : id) { - if (std::isalnum(ch) || ch == '_' || ch == '-' || ch == '.') - continue; - return false; - } - - return true; -} - -namespace { - -// RAII helper to free heap-allocated memory from miniz. -struct MzHeapFree { - void* ptr = nullptr; - ~MzHeapFree() { if (ptr) std::free(ptr); } -}; - -// Read a text file from within a zip archive into a string. -// Returns true on success, false if the file is not found or cannot be read. -bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::string& out, std::string& error) -{ - size_t size = 0; - void* data = mz_zip_reader_extract_file_to_heap(&archive, filename, &size, 0); - if (!data) { - error = std::string("Wheel does not contain ") + filename; - return false; - } - MzHeapFree guard{data}; - out.assign(static_cast(data), size); - return true; -} - -// TOML section parsing states. -enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray }; - -// Strip a quoted string value: "foo" → foo, 'foo' → foo. -// Returns the unquoted value or the input unchanged if not quoted. -std::string unquote_toml_string(const std::string& val) -{ - if (val.size() >= 2 && ((val.front() == '"' && val.back() == '"') || (val.front() == '\'' && val.back() == '\''))) - return val.substr(1, val.size() - 2); - return val; -} - -// Split a TOML inline array: ["a", "b"] → {"a", "b"}. -// Handles trailing commas and single-line format. -std::vector parse_toml_inline_array(const std::string& val) -{ - std::vector result; - std::string inner = val; - // Strip outer brackets. - if (!inner.empty() && inner.front() == '[') - inner.erase(0, 1); - if (!inner.empty() && inner.back() == ']') - inner.pop_back(); - - // Simple split by comma, strip quotes and whitespace. - std::istringstream ss(inner); - std::string item; - while (std::getline(ss, item, ',')) { - // Trim whitespace. - size_t s = 0, e = item.size(); - while (s < e && (item[s] == ' ' || item[s] == '\t')) ++s; - while (e > s && (item[e - 1] == ' ' || item[e - 1] == '\t')) --e; - item = item.substr(s, e - s); - if (!item.empty()) - result.push_back(unquote_toml_string(item)); - } - return result; -} - -// Parse PEP 723 TOML subset for dependencies, requires-python, and -// [tool.orcaslicer.plugin] identity fields. -// -// requires-python = ">=3.12" -// dependencies = ["pkg>=1.0", ] -// -// [tool.orcaslicer.plugin] -// id = "my-plugin" -// name = "My Plugin" -// description = "Does things." -// author = "Author" -// version = "1.0.0" -// -// Returns false only on parse errors; missing block is not an error. -bool parse_pep723_toml(const std::string& toml_content, - std::vector& out_deps, - std::string& out_requires_python, - std::string& out_name, - std::string& out_description, - std::string& out_author, - std::string& out_version, - std::map& out_settings, - std::string& error) -{ - out_deps.clear(); - out_requires_python.clear(); - out_name.clear(); - out_description.clear(); - out_author.clear(); - out_version.clear(); - out_settings.clear(); - - TomlSection section = TomlSection::Root; - - std::istringstream stream(toml_content); - std::string line; - - while (std::getline(stream, line)) { - // Trim leading/trailing whitespace. - size_t start = 0; - while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) - ++start; - size_t end = line.size(); - while (end > start && (line[end - 1] == ' ' || line[end - 1] == '\t')) - --end; - std::string trimmed = line.substr(start, end - start); - - if (trimmed.empty() || trimmed[0] == '#') - continue; - - // TOML section header. - if (trimmed[0] == '[') { - if (trimmed == "[tool.orcaslicer.plugin]") { - section = TomlSection::OrcaPlugin; - } else if (trimmed == "[tool.orcaslicer.plugin.settings]") { - section = TomlSection::OrcaPluginSettings; // per-plugin params table - } else { - section = TomlSection::Root; // Unknown section — skip. - } - continue; - } - - if (section == TomlSection::InDepsArray) { - if (trimmed == "]") { - section = TomlSection::Root; - continue; - } - std::string val = trimmed; - if (!val.empty() && val.back() == ',') - val.pop_back(); - val = unquote_toml_string(val); - if (!val.empty()) - out_deps.push_back(val); - continue; - } - - // Look for key = value. - size_t eq = trimmed.find('='); - if (eq == std::string::npos) - continue; - - std::string key = trimmed.substr(0, eq); - while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) - key.pop_back(); - - std::string val = trimmed.substr(eq + 1); - while (!val.empty() && (val.front() == ' ' || val.front() == '\t')) - val.erase(0, 1); - // Trim trailing. - while (!val.empty() && (val.back() == ' ' || val.back() == '\t')) - val.pop_back(); - - if (section == TomlSection::Root) { - if (key == "requires-python") { - out_requires_python = unquote_toml_string(val); - } else if (key == "dependencies") { - if (val == "[") { - section = TomlSection::InDepsArray; - } else { - // Inline array: dependencies = ["a", "b"] - out_deps = parse_toml_inline_array(val); - } - } - } else if (section == TomlSection::OrcaPlugin) { - if (key == "name") out_name = unquote_toml_string(val); - else if (key == "description") out_description = unquote_toml_string(val); - else if (key == "author") out_author = unquote_toml_string(val); - else if (key == "version") out_version = unquote_toml_string(val); - } else if (section == TomlSection::OrcaPluginSettings) { - // collect every key as a string; the plugin parses (int/float/...) what it needs. - if (!key.empty()) - out_settings[key] = unquote_toml_string(val); - } - } - - // Check for unclosed arrays. - if (section == TomlSection::InDepsArray) { - error = "PEP 723 metadata: unclosed dependencies array"; - return false; - } - return true; -} - -// Normalize a distribution name to a Python import package name. -// Converts hyphens to underscores and lowercases. -std::string normalize_package_name(const std::string& name) -{ - std::string result; - result.reserve(name.size()); - for (unsigned char ch : name) { - if (ch == '-' || ch == '.') - result += '_'; - else - result += static_cast(std::tolower(ch)); - } - return result; -} - -// Parse METADATA (RFC 822 style) into a flat multimap. -// https://packaging.python.org/en/latest/specifications/core-metadata/ -void parse_metadata_rfc822(const std::string& content, - std::string& out_name, - std::string& out_version, - std::string& out_summary, - std::string& out_author, - std::string& out_requires_python, - std::string& out_import_name, - std::vector& out_requires_dist, - std::string& error) -{ - std::istringstream stream(content); - std::string line; - std::string current_header; - std::string current_value; - - auto flush = [&]() { - if (current_header.empty()) - return; - std::string lower = current_header; - std::transform(lower.begin(), lower.end(), lower.begin(), - [](unsigned char c) { return std::tolower(c); }); - - if (lower == "name") - out_name = current_value; - else if (lower == "version") - out_version = current_value; - else if (lower == "summary") - out_summary = current_value; - else if (lower == "author") - out_author = current_value; - else if (lower == "requires-python") - out_requires_python = current_value; - else if (lower == "import-name") - out_import_name = current_value; - else if (lower == "requires-dist") - out_requires_dist.push_back(current_value); - - current_header.clear(); - current_value.clear(); - }; - - while (std::getline(stream, line)) { - // Blank line after headers marks the start of the body. - if (line.empty() && current_header.empty()) - continue; - if (line.empty()) { - flush(); - // Remaining content is the body (description); stop parsing headers. - break; - } - - // Continuation line. - if (line[0] == ' ' || line[0] == '\t') { - if (!current_value.empty()) - current_value += '\n'; - size_t pos = line.find_first_not_of(" \t"); - current_value += (pos != std::string::npos) ? line.substr(pos) : ""; - continue; - } - - flush(); - - size_t colon = line.find(':'); - if (colon == std::string::npos) - continue; - - current_header = line.substr(0, colon); - size_t val_start = colon + 1; - while (val_start < line.size() && (line[val_start] == ' ' || line[val_start] == '\t')) - ++val_start; - current_value = line.substr(val_start); - } - - flush(); -} - -} // namespace - -bool is_ignored_plugin_directory(const boost::filesystem::path& path) -{ - const std::string name = path.filename().string(); - return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR; -} - -bool is_safe_relative_path(const boost::filesystem::path& path) -{ - if (path.empty() || path.is_absolute() || path.has_root_directory() || path.has_root_name()) - return false; - - for (const auto& part : path) { - const std::string token = part.string(); - if (token == "..") - return false; - } - - return true; -} - -bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boost::filesystem::path& destination, std::string& error) -{ - namespace fs = boost::filesystem; - - boost::system::error_code ec; - fs::create_directories(destination, ec); - if (ec) { - error = "Failed to create plugin staging directory: " + ec.message(); - return false; - } - - ZipReaderGuard reader; - if (!open_zip_reader(&reader.archive, zip_path.string())) { - error = "Failed to open plugin zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); - return false; - } - reader.opened = true; - - std::unordered_set extracted_entries; - const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive); - mz_zip_archive_file_stat stat; - for (mz_uint i = 0; i < num_entries; ++i) { - if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) { - error = "Failed to read plugin zip entry metadata: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); - return false; - } - - std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat)); - if (entry_name.empty()) - continue; - if (entry_name.find(':') != std::string::npos) { - error = "Plugin zip entry contains an invalid path: " + entry_name; - return false; - } - - const fs::path relative_path(entry_name); - if (!is_safe_archive_entry_path(relative_path)) { - error = "Plugin zip entry escapes the plugin package: " + entry_name; - return false; - } - - const std::string relative_key = relative_path.generic_string(); - if (!extracted_entries.insert(relative_key).second) { - error = "Plugin zip contains duplicate entry: " + relative_key; - return false; - } - - const fs::path output_path = destination / relative_path; - if (stat.m_is_directory || mz_zip_reader_is_file_a_directory(&reader.archive, stat.m_file_index)) { - fs::create_directories(output_path, ec); - if (ec) { - error = "Failed to create plugin zip directory " + output_path.string() + ": " + ec.message(); - return false; - } - continue; - } - - fs::create_directories(output_path.parent_path(), ec); - if (ec) { - error = "Failed to create plugin zip parent directory " + output_path.parent_path().string() + ": " + ec.message(); - return false; - } - - if (fs::exists(output_path, ec) && fs::is_directory(output_path, ec)) { - error = "Plugin zip file conflicts with an existing directory: " + output_path.string(); - return false; - } - - const std::string encoded_output_path = encode_path(output_path.string().c_str()); - mz_bool extracted = mz_zip_reader_extract_to_file(&reader.archive, stat.m_file_index, encoded_output_path.c_str(), 0); -#ifdef WIN32 - if (!extracted) { - const std::wstring wide_output_path = boost::locale::conv::utf_to_utf(output_path.generic_string()); - extracted = mz_zip_reader_extract_to_file_w(&reader.archive, stat.m_file_index, wide_output_path.c_str(), 0); - } -#endif - if (!extracted) { - error = "Failed to extract plugin zip entry " + relative_key + ": " + - MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); - return false; - } - } - - return true; -} - -void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry) -{ - PluginInstallState state; - if (!read_install_state(plugin_dir, state)) - return; - - // The cloud identity and the persisted installed version are read back. plugin_key - // is always derived by the catalog scan (filename for local, the cloud uuid for - // cloud), so it is not read from the sidecar. installed_version is the source of - // truth for a cloud plugin's installed version: it records the version fetched from - // the cloud at install time, independent of the (possibly stale) manifest/PEP723 - // header that scan_directory parses into entry.version. - if (!state.installed_version.empty()) - entry.installed_version = state.installed_version; - if (!state.cloud_uuid.empty()) - entry.cloud = CloudPluginState{state.cloud_uuid, true, false, false}; - - // Package-level auto-load flag only. The per-capability enable flags stay in the sidecar: a - // capability has no existence — and so no state — until it is materialized, at which point the - // loader seeds the flag onto the capability itself. - entry.enabled = state.enabled; -} - -bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out) -{ - namespace fs = boost::filesystem; - const fs::path sidecar_path = plugin_dir / ".install_state.json"; - - if (!fs::exists(sidecar_path) || !fs::is_regular_file(sidecar_path)) - return false; - - boost::nowide::ifstream f(sidecar_path.string()); - if (!f) - return false; - - try { - nlohmann::json state = nlohmann::json::parse(f, nullptr, false, true); - if (state.is_discarded() || !state.is_object()) - return false; - - PluginInstallState parsed; - if (state.contains("installed_from") && state["installed_from"].is_string()) - parsed.installed_from = state["installed_from"].get(); - if (state.contains("installed_version") && state["installed_version"].is_string()) - parsed.installed_version = state["installed_version"].get(); - if (state.contains("plugin_name") && state["plugin_name"].is_string()) - parsed.plugin_name = state["plugin_name"].get(); - if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string()) - parsed.cloud_uuid = state["cloud_uuid"].get(); - if (state.contains("enabled") && state["enabled"].is_boolean()) - parsed.enabled = state["enabled"].get(); - - // capabilities is a JSON array of single-key objects {: }. - if (state.contains("capabilities") && state["capabilities"].is_array()) { - for (const auto& item : state["capabilities"]) { - if (!item.is_object()) - continue; - for (auto it = item.begin(); it != item.end(); ++it) { - if (it.value().is_boolean()) - parsed.capabilities.emplace_back(it.key(), it.value().get()); - } - } - } - - out = std::move(parsed); - return true; - } catch (...) { - return false; - } -} - -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state) -{ - namespace fs = boost::filesystem; - const fs::path sidecar_path = plugin_dir / ".install_state.json"; - - nlohmann::json json; - json["installed_from"] = state.installed_from; - json["installed_version"] = state.installed_version; - json["plugin_name"] = state.plugin_name; - json["enabled"] = state.enabled; - if (!state.cloud_uuid.empty()) - json["cloud_uuid"] = state.cloud_uuid; - - nlohmann::json capabilities = nlohmann::json::array(); - for (const auto& [name, enabled] : state.capabilities) - capabilities.push_back(nlohmann::json{{name, enabled}}); - json["capabilities"] = std::move(capabilities); - - boost::nowide::ofstream f(sidecar_path.string()); - if (!f) - return false; - - f << json.dump(2); - return static_cast(f); -} - -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, - const std::vector>& capabilities) -{ - PluginInstallState state; - state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local"; - // Prefer the descriptor's recorded installed_version (the version fetched from the cloud - // at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header - // never overwrites the source-of-truth version. Fall back to the manifest version for - // first-time/local installs where installed_version is not yet populated. - state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version; - state.plugin_name = entry.name; - state.cloud_uuid = entry.cloud_uuid(); - state.enabled = enabled; - state.capabilities = capabilities; - return write_install_state(plugin_dir, state); -} - -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry) -{ - // Install-time writer: the package is not loaded, so its capabilities are not known yet and the - // sidecar is (re)initialized to "auto-load, nothing disabled". PluginManager writes the real - // per-capability flags once the package is loaded, via the (dir, entry, enabled, capabilities) - // overload. - return write_install_state(plugin_dir, entry, true, {}); -} - -bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error) -{ - namespace fs = boost::filesystem; - - if (!fs::exists(py_path) || !fs::is_regular_file(py_path)) { - error = "Python plugin file does not exist: " + py_path.string(); - return false; - } - - boost::nowide::ifstream f(py_path.string()); - if (!f) { - error = "Failed to open Python plugin file: " + py_path.string(); - return false; - } - - // Scan for PEP 723 inline script metadata block. - // The block is delimited by: - // # /// script - // # - // # /// - std::string pep723_content; - bool in_block = false; - std::string line; - - while (std::getline(f, line)) { - // Strip trailing carriage return (Windows line endings). - if (!line.empty() && line.back() == '\r') - line.pop_back(); - - if (!in_block) { - // Look for opening delimiter. - if (line == "# /// script") - in_block = true; - continue; - } - - if (line == "# ///") { - in_block = false; - continue; - } - - // Extract TOML content from the comment line. - // Lines must start with "# " or "#\t" per PEP 723. - if (line.size() >= 2 && line[0] == '#' && (line[1] == ' ' || line[1] == '\t')) - pep723_content += line.substr(2) + "\n"; - else if (line == "#") - pep723_content += "\n"; - // If line doesn't start with "# ", it's still part of the block content - // but we skip it as it doesn't follow the spec. - } - - if (!pep723_content.empty()) { - std::string pep723_error; - std::string requires_python; - std::string pep_name, pep_desc, pep_author, pep_version; - if (!parse_pep723_toml(pep723_content, - descriptor.dependencies, - requires_python, - pep_name, - pep_desc, - pep_author, - pep_version, - descriptor.settings, - pep723_error)) { - error = "Failed to parse PEP 723 metadata: " + pep723_error; - return false; - } - // requires-python is stored but not validated against the bundled Python here. - (void) requires_python; - - // Populate identity fields from the PEP 723 [tool.orcaslicer.plugin] section. - // Cloud metadata overrides these when available; they serve as the local - // source of truth for side-loaded .py plugins and as fallback values. - if (!pep_name.empty()) descriptor.name = sanitize_plugin_name(pep_name); - if (!pep_desc.empty()) descriptor.description = pep_desc; - if (!pep_author.empty()) descriptor.author = pep_author; - if (!pep_version.empty()) descriptor.version = pep_version; - } - - // Validate that required identity fields are present (either from PEP 723 or - // from cloud metadata already set on the manifest by the caller). - // Validation is deferred to the install/discovery layer so cloud metadata - // can fill in gaps. - return true; -} - -bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginDescriptor& descriptor, std::string& error) -{ - namespace fs = boost::filesystem; - - if (!fs::exists(whl_path) || !fs::is_regular_file(whl_path)) { - error = "Wheel plugin file does not exist: " + whl_path.string(); - return false; - } - - ZipReaderGuard reader; - if (!open_zip_reader(&reader.archive, whl_path.string())) { - error = "Failed to open wheel as zip: " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); - return false; - } - reader.opened = true; - - // Find the single .dist-info directory. - // Scan ALL entries, not just directory entries — some zip writers omit - // explicit directory entries. normalize_zip_entry_name strips trailing - // slashes, so we match ".dist-info" as the last path component of a - // directory entry, and ".dist-info/" embedded in a file path. - const mz_uint num_entries = mz_zip_reader_get_num_files(&reader.archive); - std::string dist_info_dir; - mz_zip_archive_file_stat stat; - - for (mz_uint i = 0; i < num_entries; ++i) { - if (!mz_zip_reader_file_stat(&reader.archive, i, &stat)) - continue; - - std::string entry_name = normalize_zip_entry_name(zip_entry_name(reader.archive, stat)); - if (entry_name.empty()) - continue; - - // Find .dist-info as a path component. - size_t pos = entry_name.find(".dist-info"); - if (pos == std::string::npos) - continue; - - std::string candidate; - if (pos + 10 == entry_name.size()) { - // Directory entry itself (trailing / stripped by normalize). - candidate = entry_name + "/"; - } else if (pos + 10 < entry_name.size() && entry_name[pos + 10] == '/') { - // File inside .dist-info/: name.dist-info/METADATA - candidate = entry_name.substr(0, pos + 11); // include trailing / - } else { - continue; // .dist-info mid-name, not a path component. - } - - if (!dist_info_dir.empty() && candidate != dist_info_dir) { - error = "Wheel contains multiple .dist-info directories: " + dist_info_dir + " and " + candidate; - return false; - } - dist_info_dir = candidate; - } - - if (dist_info_dir.empty()) { - error = "Wheel does not contain a .dist-info directory"; - return false; - } - - // Read METADATA. - const std::string metadata_path = dist_info_dir + "METADATA"; - std::string meta_content; - if (!read_zip_text_file(reader.archive, metadata_path.c_str(), meta_content, error)) - return false; - - std::string meta_name, meta_version, meta_summary, meta_author, meta_requires_python, meta_import_name; - std::vector requires_dist; - std::string meta_error; - parse_metadata_rfc822(meta_content, meta_name, meta_version, meta_summary, meta_author, - meta_requires_python, meta_import_name, requires_dist, meta_error); - - if (meta_name.empty()) { - error = "Wheel METADATA missing required Name field"; - return false; - } - if (meta_version.empty()) { - error = "Wheel METADATA missing required Version field"; - return false; - } - - // Read WHEEL (verify existence and Wheel-Version). - const std::string wheel_path = dist_info_dir + "WHEEL"; - std::string wheel_content; - if (!read_zip_text_file(reader.archive, wheel_path.c_str(), wheel_content, error)) - return false; - // Verify there's at least a Wheel-Version header line. - if (wheel_content.find("Wheel-Version:") == std::string::npos) { - error = "Wheel WHEEL file missing Wheel-Version header"; - return false; - } - - // Parse and validate wheel platform tags. - { - std::vector wheel_tags; - std::istringstream wstream(wheel_content); - std::string wline; - while (std::getline(wstream, wline)) { - while (!wline.empty() && (wline.back() == '\r' || wline.back() == '\n')) - wline.pop_back(); - if (wline.rfind("Tag:", 0) == 0) { - std::string tag = wline.substr(4); - size_t s = 0, e = tag.size(); - while (s < e && (tag[s] == ' ' || tag[s] == '\t')) ++s; - while (e > s && (tag[e - 1] == ' ' || tag[e - 1] == '\t')) --e; - wheel_tags.push_back(tag.substr(s, e - s)); - } - } - - if (!wheel_tags.empty()) { - bool compatible = false; - const std::string abi_tag = PythonInterpreter::python_abi_tag(); - for (const auto& tag : wheel_tags) { - // Pure Python wheel: py3-none-any or cp312-none-any - if (tag.find("-none-any") != std::string::npos) { - compatible = true; - break; - } - // Platform-specific: check ABI tag matches. - if (tag.find(abi_tag) == 0) { - // Accept if the platform tag matches the current OS. -#ifdef _WIN32 - if (tag.find("-win") != std::string::npos) - compatible = true; -#elif __APPLE__ - if (tag.find("-macosx") != std::string::npos) - compatible = true; -#else - if (tag.find("-linux") != std::string::npos || tag.find("-manylinux") != std::string::npos) - compatible = true; -#endif - } - } - if (!compatible) { - error = "Wheel is incompatible with this platform. Tags: "; - for (size_t i = 0; i < wheel_tags.size(); ++i) { - if (i > 0) error += ", "; - error += wheel_tags[i]; - } - error += "; expected ABI: " + abi_tag; - return false; - } - } - } - - // Read RECORD (verify existence). - const std::string record_path = dist_info_dir + "RECORD"; - std::string record_content; - if (!read_zip_text_file(reader.archive, record_path.c_str(), record_content, error)) - return false; - if (record_content.empty()) { - error = "Wheel RECORD file is empty"; - return false; - } - - // Parse top_level.txt if present. - std::string top_level; - const std::string top_level_path = dist_info_dir + "top_level.txt"; - std::string top_level_content; - if (read_zip_text_file(reader.archive, top_level_path.c_str(), top_level_content, error)) { - // top_level.txt contains one package name per line. - std::istringstream tl_stream(top_level_content); - std::string tl_line; - std::vector top_levels; - while (std::getline(tl_stream, tl_line)) { - while (!tl_line.empty() && (tl_line.back() == '\r' || tl_line.back() == '\n')) - tl_line.pop_back(); - if (!tl_line.empty()) - top_levels.push_back(tl_line); - } - if (top_levels.size() == 1) - top_level = top_levels[0]; - else if (top_levels.size() > 1) { - // Ambiguous: multiple top-level packages. Fall through to Name-based fallback. - } - // Zero entries: leave top_level empty. - } - // If top_level.txt is not found, that's OK — it's optional per the wheel spec. - - // Determine the entry package in priority order. - // 1. Cloud/catalog metadata — handled by caller, not here. - // 2. Core Metadata Import-Name. - // 3. top_level.txt if unambiguous. - // 4. Normalized Name as fallback. - if (!meta_import_name.empty()) { - descriptor.entry_package = meta_import_name; - } else if (!top_level.empty()) { - descriptor.entry_package = top_level; - } else { - descriptor.entry_package = normalize_package_name(meta_name); - } - - descriptor.dependencies = std::move(requires_dist); - - // Populate local identity fallbacks from wheel metadata. - // Cloud metadata will override these when available. - descriptor.name = sanitize_plugin_name(meta_name); - descriptor.version = meta_version; - descriptor.description = meta_summary; - descriptor.author = meta_author; - - return true; -} - -boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error) -{ - namespace fs = boost::filesystem; - - if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) { - error = "Plugin directory does not exist: " + plugin_dir.string(); - return {}; - } - - fs::path py_entry; - fs::path whl_entry; - - for (fs::directory_iterator it(plugin_dir); it != fs::directory_iterator(); ++it) { - if (is_ignored_plugin_directory(it->path())) - continue; - if (!fs::is_regular_file(it->status())) - continue; - - const fs::path ext = it->path().extension(); - if (ext == ".py") { - if (!py_entry.empty()) { - error = "Plugin directory contains multiple .py files: " + py_entry.filename().string() + - " and " + it->path().filename().string(); - return {}; - } - py_entry = it->path(); - } else if (ext == ".whl") { - if (!whl_entry.empty()) { - error = "Plugin directory contains multiple .whl files: " + whl_entry.filename().string() + - " and " + it->path().filename().string(); - return {}; - } - whl_entry = it->path(); - } - } - - if (!py_entry.empty() && !whl_entry.empty()) { - error = "Plugin directory contains both .py and .whl entry files"; - return {}; - } - - if (!py_entry.empty()) - return py_entry; - - if (!whl_entry.empty()) - return whl_entry; - - error = "Plugin directory does not contain a .py or .whl entry file"; - return {}; -} - -} // namespace Slic3r diff --git a/src/slic3r/plugin/PythonFileUtils.hpp b/src/slic3r/plugin/PythonFileUtils.hpp deleted file mode 100644 index e25e5b49fa..0000000000 --- a/src/slic3r/plugin/PythonFileUtils.hpp +++ /dev/null @@ -1,61 +0,0 @@ -#ifndef slic3r_PythonFileUtils_hpp_ -#define slic3r_PythonFileUtils_hpp_ - -#include - -#include -#include -#include - -namespace Slic3r { - -struct PluginDescriptor; - -// Persisted per-plugin install/auto-load state, stored in the .install_state.json sidecar. -// This replaces app_config as the source of truth for auto-load and capability enable state. -struct PluginInstallState { - std::string installed_from; // "local" | "cloud" - std::string installed_version; - std::string plugin_name; - std::string cloud_uuid; // empty for local - bool enabled = true; - std::vector> capabilities; // name -> enabled, ordered -}; - -bool is_ignored_plugin_directory(const boost::filesystem::path& path); -bool is_safe_relative_path(const boost::filesystem::path& path); -bool is_valid_plugin_id(const std::string& id); -bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boost::filesystem::path& destination, std::string& error); - -// Read PEP 723 inline script metadata from a .py plugin file. -// Populates metadata.dependencies and local identity fallbacks. -// Returns true on success (including when no PEP 723 block is found — deps will be empty). -bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error); - -// Read wheel metadata from a .whl plugin file (zip archive). -// Reads METADATA, WHEEL, RECORD, and top_level.txt from the .dist-info directory. -// Populates metadata.entry_package, metadata.dependencies, and local identity fallbacks. -// Returns true on success. -bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginDescriptor& descriptor, std::string& error); - -// Find the single plugin entry file (.py or .whl) in a directory. -// Ignores __whl_extracted__ and hidden files/dirs. -// Returns the path to the entry file, or an empty path with error set if zero or multiple candidates. -boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error); - -// Canonical writer: emits the .install_state.json schema for the given state. -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state); -// Builds a PluginInstallState from the descriptor and delegates to the canonical writer. -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, - const std::vector>& capabilities); -// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}). -bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry); - -// Reads only the cloud identity (uuid) back into the descriptor; plugin_key is always derived. -void read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry); -// Full read of the sidecar; returns false if there is no/invalid sidecar. -bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstallState& out); - -} // namespace Slic3r - -#endif // slic3r_PythonFileUtils_hpp_ diff --git a/src/slic3r/plugin/PythonInterpreter.cpp b/src/slic3r/plugin/PythonInterpreter.cpp index 2ff275ccb3..e46ee58f80 100644 --- a/src/slic3r/plugin/PythonInterpreter.cpp +++ b/src/slic3r/plugin/PythonInterpreter.cpp @@ -4,7 +4,7 @@ #include "PluginAuditManager.hpp" #include #include -#include "PythonFileUtils.hpp" +#include "PluginFsUtils.hpp" #include diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp index 350f8be506..8605532c12 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp @@ -2,7 +2,7 @@ #include "SlicingPipelinePluginCapability.hpp" #include "slic3r/plugin/PyPluginTrampoline.hpp" #include "slic3r/plugin/PluginAuditManager.hpp" -#include +#include namespace Slic3r { class PySlicingPipelinePluginCapabilityTrampoline : public PyPluginCommonTrampoline { @@ -20,7 +20,7 @@ public: // granted to the geometry hooks. if (!ctx.gcode_path.empty()) ::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root( - std::filesystem::path(ctx.gcode_path).parent_path()); + boost::filesystem::path(ctx.gcode_path).parent_path()); }, PYBIND11_OVERRIDE_PURE, ExecutionResult, SlicingPipelinePluginCapability, execute, ctx); diff --git a/tests/slic3rutils/test_plugin_install.cpp b/tests/slic3rutils/test_plugin_install.cpp index 430564a4fc..1b47454691 100644 --- a/tests/slic3rutils/test_plugin_install.cpp +++ b/tests/slic3rutils/test_plugin_install.cpp @@ -3,7 +3,7 @@ #include #include #include -#include +#include #include diff --git a/tests/slic3rutils/test_plugin_lifecycle.cpp b/tests/slic3rutils/test_plugin_lifecycle.cpp index 5f8a45452b..278740c24b 100644 --- a/tests/slic3rutils/test_plugin_lifecycle.cpp +++ b/tests/slic3rutils/test_plugin_lifecycle.cpp @@ -3,7 +3,7 @@ #include #include #include -#include +#include #include #include From f2ccbfc8b5fae02bf1788687d9ce2cdcd7326531 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Thu, 16 Jul 2026 21:25:30 +0800 Subject: [PATCH 42/47] Open the plugin terminal off the webview callback stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open_terminal_dialog is reached from the plugins dialog's webview command, and TerminalDialog hosts a webview of its own — same class as the plugin-window crash. Defer the window work via CallAfter, guard the re-front Show() per #13657, and drop the redundant Raise() on creation. --- src/slic3r/GUI/GUI_App.cpp | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 901b653a5a..ed990c3ec6 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -8255,22 +8255,32 @@ void GUI_App::open_plugins_dialog(size_t open_on_tab, const std::string& highlig void GUI_App::open_terminal_dialog() { - if (m_terminal_dlg) { + // Reached from the plugins dialog's webview ("open_terminal" command), i.e. from + // inside the webview script-message callback, which GTK/macOS deliver synchronously + // (see ui_create_window in PluginHostUi.cpp). TerminalDialog hosts a webview of its + // own, so creating or presenting it on that stack is the same class as the Linux + // gtk_window_present crash — defer all window work to a clean main-loop iteration. + CallAfter([this]() { + if (m_terminal_dlg) { + // Re-front the existing window; guard Show() per #13657 (GTK re-enters + // layout when showing an already-visible window). + if (!m_terminal_dlg->IsShown()) + m_terminal_dlg->Show(); + m_terminal_dlg->Raise(); + return; + } + + m_terminal_dlg = new TerminalDialog(mainframe, wxID_ANY, _L("Plugin Terminal"), + wxDefaultPosition, wxSize(820, 600)); + m_terminal_dlg->Bind(wxEVT_DESTROY, [this](wxWindowDestroyEvent& event) { + if (event.GetEventObject() == m_terminal_dlg) + m_terminal_dlg = nullptr; + event.Skip(); + }); + + // Show() alone activates and fronts a freshly created window on every platform. m_terminal_dlg->Show(); - m_terminal_dlg->Raise(); - return; - } - - m_terminal_dlg = new TerminalDialog(mainframe, wxID_ANY, _L("Plugin Terminal"), - wxDefaultPosition, wxSize(820, 600)); - m_terminal_dlg->Bind(wxEVT_DESTROY, [this](wxWindowDestroyEvent& event) { - if (event.GetEventObject() == m_terminal_dlg) - m_terminal_dlg = nullptr; - event.Skip(); }); - - m_terminal_dlg->Show(); - m_terminal_dlg->Raise(); } void GUI_App::open_exportpresetbundledialog(size_t open_on_tab, const std::string& highlight_option) From 6f30d03e28954fd1d363a7d449d23353013167eb Mon Sep 17 00:00:00 2001 From: SoftFever Date: Thu, 16 Jul 2026 21:40:21 +0800 Subject: [PATCH 43/47] fix test errors --- tests/slic3rutils/test_plugin_install.cpp | 2 +- tests/slic3rutils/test_plugin_lifecycle.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/slic3rutils/test_plugin_install.cpp b/tests/slic3rutils/test_plugin_install.cpp index 1b47454691..5c6e902450 100644 --- a/tests/slic3rutils/test_plugin_install.cpp +++ b/tests/slic3rutils/test_plugin_install.cpp @@ -135,7 +135,7 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins CHECK(state.installed_version == "1.2.0"); // Reading the sidecar back onto a freshly-scanned descriptor (whose header version is still - // 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_catalog compare + // 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_metadata compare // the cloud's latest version against the installed version instead of the stale header, so an // already-updated plugin no longer looks perpetually out of date. PluginDescriptor scanned; diff --git a/tests/slic3rutils/test_plugin_lifecycle.cpp b/tests/slic3rutils/test_plugin_lifecycle.cpp index 278740c24b..f26af930b5 100644 --- a/tests/slic3rutils/test_plugin_lifecycle.cpp +++ b/tests/slic3rutils/test_plugin_lifecycle.cpp @@ -725,7 +725,7 @@ TEST_CASE("Signing out drops every cloud plugin row, installed or not", "[Plugin installed.plugin_root = (data_dir_guard.plugins_dir() / "_subscribed" / "user" / installed.plugin_key).string(); installed.cloud = CloudPluginState{installed.plugin_key, /*installed=*/true, false, false, false}; - manager.update_cloud_catalog({available, installed}); + manager.update_cloud_metadata({available, installed}); const auto has_key = [&manager](const std::string& key) { PluginDescriptor descriptor; @@ -739,7 +739,7 @@ TEST_CASE("Signing out drops every cloud plugin row, installed or not", "[Plugin // Sign out. The per-user _subscribed directory stops being scanned, so both cloud rows are now // stale and must go — not just the one with nothing installed behind it. manager.unload_cloud_plugins(); - manager.clear_cloud_plugin_catalog(); + manager.clear_cloud_plugin_metadata(); manager.set_cloud_user(""); CHECK_FALSE(has_key(available.plugin_key)); From 0a0d59b76b3d6ab88bc58e0d711dd7185eb870a7 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Thu, 16 Jul 2026 22:36:31 +0800 Subject: [PATCH 44/47] Fix plugins dialog hiding behind the main window on macOS --- src/slic3r/GUI/PluginsDialog.cpp | 34 ++++++++++++++++++++++++++++++-- src/slic3r/GUI/PluginsDialog.hpp | 14 +++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index 95ff99c822..d1fcbd7930 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -481,6 +481,19 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload) if (handle_common_script_command(payload)) return; + // Defer command handling out of the webview script-message callback: GTK and macOS + // deliver it synchronously inside the native webview callback (see ui_create_window + // in PluginHostUi.cpp), and window work on that stack is the crash class fixed in + // b779a7bfed/f2ccbfc8b5. Deferring at this single entry point keeps every command + // handler, current and future, off that stack by construction. + wxGetApp().CallAfter([this, alive = m_alive, payload]() { + if (alive->load(std::memory_order_acquire)) + handle_web_command(payload); + }); +} + +void PluginsDialog::handle_web_command(const nlohmann::json& payload) +{ const std::string command = payload.value("command", ""); if (command == "request_plugins") { send_plugins(); @@ -730,12 +743,24 @@ void PluginsDialog::handle_plugin_menu_action(const std::string& plugin_key, con } } +void PluginsDialog::restore_z_order() +{ + // Deferred so it runs on a clean stack after the modal has fully torn down; the + // alive guard covers the dialog being destroyed while the CallAfter is queued. + wxGetApp().CallAfter([this, alive = m_alive]() { + if (alive->load(std::memory_order_acquire) && IsShown()) + Raise(); + }); +} + void PluginsDialog::install_plugin_from_file() { wxFileDialog dialog(this, _L("Select plugin package"), wxEmptyString, wxEmptyString, _L("Plugin files (*.py;*.whl)|*.py;*.whl"), wxFD_OPEN | wxFD_FILE_MUST_EXIST); - if (dialog.ShowModal() != wxID_OK) + const int rc = dialog.ShowModal(); + restore_z_order(); + if (rc != wxID_OK) return; if (!install_plugin_package(dialog.GetPath().ToUTF8().data())) { @@ -788,7 +813,9 @@ bool PluginsDialog::install_plugin_package(const std::string& package_path) plugin_name), kOverwritePluginTitle, wxOK | wxCANCEL | wxCANCEL_DEFAULT | wxICON_WARNING); dialog.SetOKCancelLabels(_L("Overwrite"), _L("Cancel")); - if (dialog.ShowModal() != wxID_OK) { + const int overwrite_rc = dialog.ShowModal(); + restore_z_order(); + if (overwrite_rc != wxID_OK) { BOOST_LOG_TRIVIAL(info) << "Plugin package installation cancelled before overwrite. package=" << package_path << " plugin=" << plugin_descriptor.name; return false; @@ -1009,6 +1036,7 @@ void PluginsDialog::delete_local_plugin(const PluginDescriptor& plugin) const wxString plugin_name = from_u8(plugin.name); const int rc = wxMessageBox(wxString::Format(_L("Delete plugin \"%s\"?\n\nThis permanently removes the plugin folder."), plugin_name), kDeletePluginTitle, wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this); + restore_z_order(); if (rc != wxYES) return; @@ -1041,6 +1069,7 @@ void PluginsDialog::unsubscribe_cloud_plugin(const PluginDescriptor& plugin) wxString::Format(_L("Unsubscribe plugin \"%s\"?\n\nThis will stop tracking the plugin and delete any local plugin files."), plugin_name), kUnsubscribeTitle, wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this); + restore_z_order(); if (rc != wxYES) return; @@ -1172,6 +1201,7 @@ void PluginsDialog::delete_mine_local_and_cloud_plugin(const std::string& plugin "deletes the plugin from the cloud. This action cannot be undone."), plugin_name), kDeletePluginTitle, wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this); + restore_z_order(); if (rc != wxYES) return; diff --git a/src/slic3r/GUI/PluginsDialog.hpp b/src/slic3r/GUI/PluginsDialog.hpp index 52970443fb..e94547425c 100644 --- a/src/slic3r/GUI/PluginsDialog.hpp +++ b/src/slic3r/GUI/PluginsDialog.hpp @@ -52,6 +52,13 @@ private: void open_plugin_on_cloud(const std::string& sharing_token); void open_plugin_hub(); void on_script_message(const nlohmann::json& payload) override; + // Runs one web command on a clean main-loop stack; see on_script_message. + void handle_web_command(const nlohmann::json& payload); + // Re-raises this dialog after a transient modal it opened (file dialog, message box, + // progress dialog). Native macOS panels end by re-activating the app's main window + // (the mainframe) instead of this webview-hosting dialog, burying it; wx only + // compensates for generic wxDialog modals (wxDialog::EndModal raises the parent). + void restore_z_order(); void send_plugins(); void set_plugin_sort(const std::string& sort_key, const std::string& sort_order); @@ -108,7 +115,8 @@ private: timer->Start(100); - std::thread([alive, + std::thread([this, + alive, progress, timer, run = std::forward(run), @@ -125,7 +133,8 @@ private: if (wxTheApp == nullptr) return; - wxTheApp->CallAfter([alive, + wxTheApp->CallAfter([this, + alive, progress, timer, on_finish = std::move(on_finish), @@ -135,6 +144,7 @@ private: if (alive->load(std::memory_order_acquire)) { progress->Destroy(); + restore_z_order(); on_finish(); } else if (finish_after_dialog_destroyed) { on_finish(); From 1c9fda463bbd7171542341a42fadcc7df50c162b Mon Sep 17 00:00:00 2001 From: SoftFever Date: Fri, 17 Jul 2026 02:09:34 +0800 Subject: [PATCH 45/47] Fix plugin settings lost on cloud metadata refresh Cloud catalog records never carry [tool.orcaslicer.plugin.settings], so the metadata merge wiped the locally-parsed settings and plugins silently ran on their built-in defaults (ctx.params arrived empty). --- src/slic3r/plugin/PluginDescriptor.hpp | 6 + tests/slic3rutils/CMakeLists.txt | 3 + .../test_plugin_cloud_metadata.cpp | 133 +++++++++++++++ .../test_slicing_pipeline_params.cpp | 160 ++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 tests/slic3rutils/test_plugin_cloud_metadata.cpp create mode 100644 tests/slic3rutils/test_slicing_pipeline_params.cpp diff --git a/src/slic3r/plugin/PluginDescriptor.hpp b/src/slic3r/plugin/PluginDescriptor.hpp index e15dda2666..e2c9a51cf9 100644 --- a/src/slic3r/plugin/PluginDescriptor.hpp +++ b/src/slic3r/plugin/PluginDescriptor.hpp @@ -157,6 +157,12 @@ inline void apply_plugin_metadata_fallbacks(PluginDescriptor& target, const Plug target.entry_package = fallback.entry_package; if (target.dependencies.empty()) target.dependencies = fallback.dependencies; + // [tool.orcaslicer.plugin.settings] lives only in the local package's PEP-723 header; + // cloud catalog records never carry it. Without this, every cloud-metadata merge wipes + // the parsed settings and plugins silently run on their built-in defaults (ctx.params + // arrives empty). + if (target.settings.empty()) + target.settings = fallback.settings; } // Sanitize a value for use as a filesystem name and as a local plugin_key: diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index ead5846c11..d3b6af5858 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -5,7 +5,10 @@ add_executable(${_TEST_NAME}_tests test_plugin_install.cpp test_plugin_lifecycle.cpp test_slicing_pipeline_bindings.cpp + test_slicing_pipeline_params.cpp test_plugin_sort.cpp + test_plugin_cloud_metadata.cpp + ../fff_print/test_helpers.cpp ) if (MSVC) diff --git a/tests/slic3rutils/test_plugin_cloud_metadata.cpp b/tests/slic3rutils/test_plugin_cloud_metadata.cpp new file mode 100644 index 0000000000..2fac559c1d --- /dev/null +++ b/tests/slic3rutils/test_plugin_cloud_metadata.cpp @@ -0,0 +1,133 @@ +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include + +using namespace Slic3r; +namespace fs = boost::filesystem; + +namespace { + +// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous +// value afterwards (same pattern as test_plugin_lifecycle.cpp). +struct ScopedDataDir +{ + std::string previous; + fs::path dir; + + explicit ScopedDataDir(const std::string& tag) + { + previous = data_dir(); + dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%"); + fs::create_directories(dir); + set_data_dir(dir.string()); + } + + ~ScopedDataDir() + { + set_data_dir(previous); + boost::system::error_code ec; + fs::remove_all(dir, ec); + } + + fs::path plugins_dir() const { return dir / "orca_plugins"; } +}; + +// Shut both singletons down while boost::log is still alive; left to their static +// destructors, shutdown()'s logging runs after boost::log tears down its thread-local +// storage and crashes the process on exit (same reason ScopedPluginManager exists in +// test_plugin_lifecycle.cpp). No initialize() needed: discovery and the cloud-metadata +// merge never touch Python, but manager shutdown instantiates the interpreter singleton. +struct ScopedManagerShutdown +{ + ~ScopedManagerShutdown() + { + PluginManager::instance().shutdown(); + PythonInterpreter::instance().shutdown(); + } +}; + +// A plugin whose per-plugin settings live in the PEP-723 header — the only place they exist. +const char* const SETTINGS_PLUGIN_SOURCE = R"PY(# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Settings Cloud Plugin" +# type = "slicing-pipeline" +# version = "1.0" +# +# [tool.orcaslicer.plugin.settings] +# twist_deg_per_mm = "1.0" +# taper_per_mm = "0.0" +# /// +print('ok') +)PY"; + +} // namespace + +// Regression: update_cloud_metadata() replaces a matched entry's descriptor with the cloud +// catalog record. Cloud records never carry [tool.orcaslicer.plugin.settings] (it exists only +// in the local package's PEP-723 header, parsed at discovery), so the merge must preserve the +// locally-parsed settings. When it does not, get_plugin_settings() serves an empty map and +// plugins silently fall back to their built-in defaults (found via Twistify running with its +// demo defaults instead of the header values, 2026-07-17). +TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[PluginCloudMetadata]") +{ + ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last + ScopedDataDir data_dir_guard("cloud-meta-settings"); + + // A locally-installed cloud plugin: package .py with a settings header, plus an + // install-state sidecar carrying the cloud identity. + const std::string uuid = "11111111-2222-3333-4444-555555555555"; + const fs::path plugin_dir = data_dir_guard.plugins_dir() / uuid; + fs::create_directories(plugin_dir); + { + std::ofstream out((plugin_dir / "cloud_plugin-test.py").string(), std::ios::binary); + out << SETTINGS_PLUGIN_SOURCE; + } + PluginDescriptor sidecar; + sidecar.name = "Settings Cloud Plugin"; + sidecar.installed_version = "1.0"; + sidecar.cloud = CloudPluginState{uuid, true, false, false, false}; + REQUIRE(write_install_state(plugin_dir, sidecar)); + + PluginManager& manager = PluginManager::instance(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + const auto find_by_uuid = [&manager, &uuid]() -> PluginDescriptor { + for (const PluginDescriptor& d : manager.get_plugin_descriptors(/*include_invalid=*/true)) + if (d.cloud_uuid() == uuid) + return d; + return {}; + }; + + // Discovery parsed the header settings (premise). + PluginDescriptor discovered = find_by_uuid(); + REQUIRE(discovered.settings.count("twist_deg_per_mm") == 1); + CHECK(discovered.settings.at("twist_deg_per_mm") == "1.0"); + + // A cloud catalog refresh for the same plugin: the record knows name/version/uuid but has + // no settings, no local paths. + PluginDescriptor cloud_record; + cloud_record.name = "Settings Cloud Plugin"; + cloud_record.plugin_key = uuid; + cloud_record.version = "1.1"; + cloud_record.cloud = CloudPluginState{uuid, false, false, false, false}; + manager.update_cloud_metadata({cloud_record}); + + const PluginDescriptor refreshed = find_by_uuid(); + // Cloud metadata landed... + CHECK(refreshed.version == "1.1"); + // ...and the locally-parsed settings survived the merge. + REQUIRE(refreshed.settings.count("twist_deg_per_mm") == 1); + CHECK(refreshed.settings.at("twist_deg_per_mm") == "1.0"); + CHECK(refreshed.settings.count("taper_per_mm") == 1); +} diff --git a/tests/slic3rutils/test_slicing_pipeline_params.cpp b/tests/slic3rutils/test_slicing_pipeline_params.cpp new file mode 100644 index 0000000000..808baba48d --- /dev/null +++ b/tests/slic3rutils/test_slicing_pipeline_params.cpp @@ -0,0 +1,160 @@ +#include + +#include +#include +#include +#include + +#include "fff_print/test_helpers.hpp" + +#include +#include + +#include +#include + +using namespace Slic3r; +using namespace Slic3r::Test; +namespace fs = boost::filesystem; + +// End-to-end coverage of ctx.params for slicing-pipeline capabilities: discovery parses +// [tool.orcaslicer.plugin.settings] from the PEP-723 header, and the real dispatch +// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) hands it to the plugin. +// A break anywhere in that chain makes plugins silently run on their built-in defaults, +// which is invisible to the plugin author (Twistify incident, 2026-07-17). + +namespace { + +struct ScopedDataDir +{ + std::string previous; + fs::path dir; + + explicit ScopedDataDir(const std::string& tag) + { + previous = data_dir(); + // canonical(): the plugin audit canonicalizes its allowed roots, so a path through + // the macOS /var -> /private/var symlink would be rejected as "outside allowed root". + dir = fs::canonical(fs::temp_directory_path()) / fs::unique_path("orca-" + tag + "-%%%%-%%%%"); + fs::create_directories(dir); + set_data_dir(dir.string()); + } + + ~ScopedDataDir() + { + set_data_dir(previous); + boost::system::error_code ec; + fs::remove_all(dir, ec); + } + + fs::path plugins_dir() const { return dir / "orca_plugins"; } +}; + +struct ScopedPluginManager +{ + bool initialized = false; + + ScopedPluginManager() { initialized = PluginManager::instance().initialize(); } + ~ScopedPluginManager() + { + PluginManager::instance().shutdown(); + PythonInterpreter::instance().shutdown(); + } +}; + +const char* const PARAM_PROBE_SOURCE = R"PY(# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Param Probe" +# description = "Echoes ctx.params back to the test" +# author = "OrcaSlicer" +# version = "1.0" +# type = "slicing-pipeline" +# +# [tool.orcaslicer.plugin.settings] +# alpha = "1.25" +# beta = "hello" +# /// +import orca + +class ParamEcho(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): + return "ParamEcho" + + def execute(self, ctx): + if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: + return orca.ExecutionResult.success() + try: + text = repr(sorted(dict(ctx.params).items())) + except Exception as e: # what plugins' defaults-fallback code swallows silently + text = "params-error: " + repr(e) + orca._probe_params = text # read back by the test through pybind + return orca.ExecutionResult.success("params probed") + +@orca.plugin +class ParamProbePackage(orca.base): + def register_capabilities(self): + orca.register_capability(ParamEcho) +)PY"; + +fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source) +{ + const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem; + fs::create_directories(plugin_dir); + + std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary); + out << source; + out.close(); + + return plugin_dir; +} + +} // namespace + +TEST_CASE("slicing-pipeline dispatch delivers PEP-723 settings as ctx.params", "[slicing_pipeline][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + + ScopedDataDir data_dir_guard("pipeline-params"); + write_plugin(data_dir_guard, "ParamProbe", PARAM_PROBE_SOURCE); + + PluginManager& manager = PluginManager::instance(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + std::string error; + manager.load_plugin("ParamProbe", /*skip_deps=*/true, {}); + REQUIRE(manager.wait_for_plugin_load("ParamProbe", std::chrono::seconds(120), error)); + INFO("load error: " << error); + REQUIRE(manager.is_plugin_loaded("ParamProbe")); + + // The manager serves the header settings for the key the dispatch resolves by. + const auto settings = manager.get_plugin_settings("ParamProbe"); + REQUIRE(settings.count("alpha") == 1); + CHECK(settings.at("alpha") == "1.25"); + + // Slice with the capability selected, exactly as a preset would reference it. + Print print; + Model model; + auto config = DynamicPrintConfig::full_print_config(); + config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"ParamEcho"})); + config.set_key_value("plugins", new ConfigOptionStrings({"ParamProbe;;ParamEcho"})); + init_print({cube(20)}, print, model, config); + print.process(); + + std::string observed = ""; + { + PythonGILState gil; + REQUIRE(static_cast(gil)); + pybind11::module_ orca = pybind11::module_::import("orca"); + if (pybind11::hasattr(orca, "_probe_params")) + observed = orca.attr("_probe_params").cast(); + } + INFO("ctx.params observed by Python: " << observed); + CHECK(observed.find("'alpha', '1.25'") != std::string::npos); + CHECK(observed.find("'beta', 'hello'") != std::string::npos); + + manager.unload_plugin("ParamProbe"); +} From 979e56f1d0833092727156efd6aa625170ba7499 Mon Sep 17 00:00:00 2001 From: Alexandre Folle de Menezes Date: Thu, 16 Jul 2026 17:37:09 -0300 Subject: [PATCH 46/47] Standardize the Unicode chars for unit strings (#14631) Co-authored-by: Ian Bassi --- localization/i18n/OrcaSlicer.pot | 4 ++-- localization/i18n/ca/OrcaSlicer_ca.po | 6 +++--- localization/i18n/cs/OrcaSlicer_cs.po | 6 +++--- localization/i18n/de/OrcaSlicer_de.po | 6 +++--- localization/i18n/en/OrcaSlicer_en.po | 4 ++-- localization/i18n/es/OrcaSlicer_es.po | 6 +++--- localization/i18n/fr/OrcaSlicer_fr.po | 6 +++--- localization/i18n/hu/OrcaSlicer_hu.po | 4 ++-- localization/i18n/it/OrcaSlicer_it.po | 6 +++--- localization/i18n/ja/OrcaSlicer_ja.po | 4 ++-- localization/i18n/ko/OrcaSlicer_ko.po | 4 ++-- localization/i18n/lt/OrcaSlicer_lt.po | 4 ++-- localization/i18n/nl/OrcaSlicer_nl.po | 6 +++--- localization/i18n/pl/OrcaSlicer_pl.po | 14 +++++++------- localization/i18n/pt_BR/OrcaSlicer_pt_BR.po | 4 ++-- localization/i18n/ru/OrcaSlicer_ru.po | 4 ++-- localization/i18n/sv/OrcaSlicer_sv.po | 6 +++--- localization/i18n/th/OrcaSlicer_th.po | 6 +++--- localization/i18n/tr/OrcaSlicer_tr.po | 6 +++--- localization/i18n/uk/OrcaSlicer_uk.po | 6 +++--- localization/i18n/vi/OrcaSlicer_vi.po | 6 +++--- localization/i18n/zh_CN/OrcaSlicer_zh_CN.po | 4 ++-- localization/i18n/zh_TW/OrcaSlicer_zh_TW.po | 4 ++-- src/libslic3r/PrintConfig.cpp | 18 +++++++++--------- src/slic3r/GUI/ConfigWizard.cpp | 4 ++-- src/slic3r/GUI/GCodeViewer.cpp | 4 ++-- src/slic3r/GUI/Tab.cpp | 2 +- 27 files changed, 77 insertions(+), 77 deletions(-) diff --git a/localization/i18n/OrcaSlicer.pot b/localization/i18n/OrcaSlicer.pot index 4eb71d7180..3cbb4500ce 100644 --- a/localization/i18n/OrcaSlicer.pot +++ b/localization/i18n/OrcaSlicer.pot @@ -5097,7 +5097,7 @@ msgstr "" msgid "Fan speed (%)" msgstr "" -msgid "Temperature (°C)" +msgid "Temperature (℃)" msgstr "" msgid "Volumetric flow rate (mm³/s)" @@ -15194,7 +15194,7 @@ msgid "" msgstr "" msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/ca/OrcaSlicer_ca.po b/localization/i18n/ca/OrcaSlicer_ca.po index 91ceb8d4f2..5e45f1a3c5 100644 --- a/localization/i18n/ca/OrcaSlicer_ca.po +++ b/localization/i18n/ca/OrcaSlicer_ca.po @@ -5349,8 +5349,8 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Velocitat Ventilador ( % )" -msgid "Temperature (°C)" -msgstr "Temperatura ( °C )" +msgid "Temperature (℃)" +msgstr "Temperatura ( ℃ )" msgid "Volumetric flow rate (mm³/s)" msgstr "Taxa de flux volumètric ( mm³/seg )" @@ -16143,7 +16143,7 @@ msgstr "" "Si està activat, aquest paràmetre també estableix una variable de codi g anomenada chamber_temperature, que es pot utilitzar per passar la temperatura de la cambra desitjada a la macro d'inici d'impressió, o una macro de remull tèrmic com aquesta: PRINT_START (altres variables) CHAMBER_TEMP=[chamber_temperature]. Això pot ser útil si la vostra impressora no admet les ordres M141/M191, o si voleu gestionar el remull de calor a la macro d'inici d'impressió si no hi ha instal·lat un escalfador de cambra actiu." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/cs/OrcaSlicer_cs.po b/localization/i18n/cs/OrcaSlicer_cs.po index 2e7e0c09b6..d18cc9ca4d 100644 --- a/localization/i18n/cs/OrcaSlicer_cs.po +++ b/localization/i18n/cs/OrcaSlicer_cs.po @@ -5346,8 +5346,8 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Rychlost ventilátoru (%)" -msgid "Temperature (°C)" -msgstr "Teplota (°C)" +msgid "Temperature (℃)" +msgstr "Teplota (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Rychlost průtoku (mm³/s)" @@ -16186,7 +16186,7 @@ msgstr "" "Pokud je povoleno, tento parametr rovněž nastaví G-code proměnnou s názvem chamber_temperature, kterou lze použít k předání požadované teploty komory do makra spuštění tisku, nebo do makra pro tepelnou stabilizaci, například takto: PRINT_START (další proměnné) CHAMBER_TEMP=[chamber_temperature]. To může být užitečné, pokud vaše tiskárna nepodporuje příkazy M141/M191, nebo pokud chcete provádět tepelnou stabilizaci v makru spuštění tisku, pokud není instalováno aktivní topení komory." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/de/OrcaSlicer_de.po b/localization/i18n/de/OrcaSlicer_de.po index b47f37ba60..052cde4e7d 100644 --- a/localization/i18n/de/OrcaSlicer_de.po +++ b/localization/i18n/de/OrcaSlicer_de.po @@ -5350,8 +5350,8 @@ msgstr "Ruck (mm/s)" msgid "Fan speed (%)" msgstr "Lüftergeschwindigkeit (%)" -msgid "Temperature (°C)" -msgstr "Temperatur (°C)" +msgid "Temperature (℃)" +msgstr "Temperatur (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Volumetrische Flussrate (mm³/s)" @@ -16296,7 +16296,7 @@ msgstr "" "Wenn diese Option aktiviert ist, wird auch eine G-Code-Variable namens chamber_temperature gesetzt, die verwendet werden kann, um die gewünschte Druckraumtemperatur an Ihr Druckstart-Makro oder ein Wärmespeicher-Makro weiterzugeben, wie z.B. PRINT_START (andere Variablen) CHAMBER_TEMP=[chamber_temperature]. Dies kann nützlich sein, wenn Ihr Drucker die Befehle M141/M191 nicht unterstützt oder wenn Sie das Wärmespeichern im Druckstart-Makro behandeln möchten, wenn kein aktiver Druckraumheizer installiert ist." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/en/OrcaSlicer_en.po b/localization/i18n/en/OrcaSlicer_en.po index a7bce2d2d6..5e10b1c81f 100644 --- a/localization/i18n/en/OrcaSlicer_en.po +++ b/localization/i18n/en/OrcaSlicer_en.po @@ -5093,7 +5093,7 @@ msgstr "" msgid "Fan speed (%)" msgstr "" -msgid "Temperature (°C)" +msgid "Temperature (℃)" msgstr "" msgid "Volumetric flow rate (mm³/s)" @@ -15190,7 +15190,7 @@ msgid "" msgstr "" msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/es/OrcaSlicer_es.po b/localization/i18n/es/OrcaSlicer_es.po index 69b40124e8..df95bdcb22 100644 --- a/localization/i18n/es/OrcaSlicer_es.po +++ b/localization/i18n/es/OrcaSlicer_es.po @@ -5251,8 +5251,8 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Velocidad Ventilador (%)" -msgid "Temperature (°C)" -msgstr "Temperatura (°C)" +msgid "Temperature (℃)" +msgstr "Temperatura (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Tasa de flujo volumétrico (mm³/seg)" @@ -15997,7 +15997,7 @@ msgstr "" "Esta funciuón es útil para imrpesoras no compatibles con los comandos M141 o M191, o si prefiere realizar un precalentamiento usando un macro si no dispone de un sistema de calentamiento activo de cámara." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/fr/OrcaSlicer_fr.po b/localization/i18n/fr/OrcaSlicer_fr.po index 9b63eb780c..9b88ef7532 100644 --- a/localization/i18n/fr/OrcaSlicer_fr.po +++ b/localization/i18n/fr/OrcaSlicer_fr.po @@ -5248,8 +5248,8 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Vitesse du ventilateur (%)" -msgid "Temperature (°C)" -msgstr "Température (°C)" +msgid "Temperature (℃)" +msgstr "Température (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Débit volumétrique (mm³/s)" @@ -15968,7 +15968,7 @@ msgstr "" "S’il est activé, ce paramètre définit également une variable gcode nommée chamber_temperature, qui peut être utilisée pour transmettre la température de caisson souhaitée à votre macro de démarrage de l’impression, ou à une macro de trempe thermique comme celle-ci : PRINT_START (autres variables) CHAMBER_TEMP=[chamber_temperature]. Cela peut être utile si votre imprimante ne prend pas en charge les commandes M141/M191, ou si vous souhaitez gérer le préchauffage dans la macro de démarrage de l’impression si aucun chauffage de caisson actif n’est installé." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/hu/OrcaSlicer_hu.po b/localization/i18n/hu/OrcaSlicer_hu.po index f8f63db127..d41cccaed2 100644 --- a/localization/i18n/hu/OrcaSlicer_hu.po +++ b/localization/i18n/hu/OrcaSlicer_hu.po @@ -5340,7 +5340,7 @@ msgstr "Rántás (mm/s)" msgid "Fan speed (%)" msgstr "Ventilátor fordulatszám (%)" -msgid "Temperature (°C)" +msgid "Temperature (℃)" msgstr "Hőmérséklet (°C)" msgid "Volumetric flow rate (mm³/s)" @@ -16222,7 +16222,7 @@ msgstr "" "Bekapcsolva ez a paraméter egy chamber_temperature nevű G-code változót is beállít, amely felhasználható a kívánt kamrahőmérséklet átadására a nyomtatásindító makrónak, vagy egy ilyen hőkiegyenlítő makrónak: PRINT_START (egyéb változók) CHAMBER_TEMP=[chamber_temperature]. Ez akkor lehet hasznos, ha a nyomtatód nem támogatja az M141/M191 parancsokat, vagy ha az aktív kamrafűtés hiányában a hőkiegyenlítést a nyomtatásindító makróban szeretnéd kezelni." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/it/OrcaSlicer_it.po b/localization/i18n/it/OrcaSlicer_it.po index a599189886..0bdcec19a7 100644 --- a/localization/i18n/it/OrcaSlicer_it.po +++ b/localization/i18n/it/OrcaSlicer_it.po @@ -5341,8 +5341,8 @@ msgstr "Scatto (mm/s)" msgid "Fan speed (%)" msgstr "Velocità ventola (%)" -msgid "Temperature (°C)" -msgstr "Temperatura (°C)" +msgid "Temperature (℃)" +msgstr "Temperatura (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Portata volumetrica (mm³/s)" @@ -16191,7 +16191,7 @@ msgstr "" "Se abilitato, questo parametro imposta anche una variabile G-code denominata chamber_temperature, che può essere utilizzata per passare la temperatura desiderata della camera nella macro di inizio stampa o in una macro di preriscaldamento in questo modo: PRINT_START (altre variabili) CHAMBER_TEMP=[chamber_temperature]. Questo può essere utile se la stampante non supporta i comandi M141/M191 o, se non è presente un sistema di riscaldamento della camera, si desidera gestire il preriscaldamento nella macro di inizio stampa." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/ja/OrcaSlicer_ja.po b/localization/i18n/ja/OrcaSlicer_ja.po index f109c2480b..df0dc9dc7a 100644 --- a/localization/i18n/ja/OrcaSlicer_ja.po +++ b/localization/i18n/ja/OrcaSlicer_ja.po @@ -5314,7 +5314,7 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "ファン回転速度 (%)" -msgid "Temperature (°C)" +msgid "Temperature (℃)" msgstr "温度 (℃)" msgid "Volumetric flow rate (mm³/s)" @@ -15803,7 +15803,7 @@ msgid "" msgstr "" msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/ko/OrcaSlicer_ko.po b/localization/i18n/ko/OrcaSlicer_ko.po index f3a683bff0..6b514ac644 100644 --- a/localization/i18n/ko/OrcaSlicer_ko.po +++ b/localization/i18n/ko/OrcaSlicer_ko.po @@ -5340,7 +5340,7 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "팬 속도 (%)" -msgid "Temperature (°C)" +msgid "Temperature (℃)" msgstr "온도 (℃)" msgid "Volumetric flow rate (mm³/s)" @@ -16052,7 +16052,7 @@ msgstr "" "활성화된 경우 이 매개변수는 원하는 챔버 온도를 출력 시작 매크로 또는 PRINT_START(기타 변수) CHAMBER_TEMP=[chamber_temp]와 같은 열 흡수 매크로에 전달하는 데 사용할 수 있는 Chamber_temp라는 Gcode 변수도 설정합니다. 이는 프린터가 M141/M191 명령을 지원하지 않거나 활성 챔버 히터가 설치되지 않은 경우 출력 시작 매크로에서 열 흡수를 처리하려는 경우 유용할 수 있습니다." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/lt/OrcaSlicer_lt.po b/localization/i18n/lt/OrcaSlicer_lt.po index f8685b4eb2..a22a2efe86 100644 --- a/localization/i18n/lt/OrcaSlicer_lt.po +++ b/localization/i18n/lt/OrcaSlicer_lt.po @@ -5222,8 +5222,8 @@ msgstr "Trūktelėjimas (mm/s)" msgid "Fan speed (%)" msgstr "" -msgid "Temperature (°C)" -msgstr "Temperatūra (°C)" +msgid "Temperature (℃)" +msgstr "Temperatūra (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Tūrinis srautas (mm³/s)" diff --git a/localization/i18n/nl/OrcaSlicer_nl.po b/localization/i18n/nl/OrcaSlicer_nl.po index 8370b5b2c3..d70de4d1b0 100644 --- a/localization/i18n/nl/OrcaSlicer_nl.po +++ b/localization/i18n/nl/OrcaSlicer_nl.po @@ -5285,8 +5285,8 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Ventilator snelheid (%)" -msgid "Temperature (°C)" -msgstr "Temperatuur (°C)" +msgid "Temperature (℃)" +msgstr "Temperatuur (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Volumestroom (mm³/s)" @@ -15730,7 +15730,7 @@ msgid "" msgstr "" msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/pl/OrcaSlicer_pl.po b/localization/i18n/pl/OrcaSlicer_pl.po index 9cd81ae430..df50298f17 100644 --- a/localization/i18n/pl/OrcaSlicer_pl.po +++ b/localization/i18n/pl/OrcaSlicer_pl.po @@ -5337,8 +5337,8 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Prędkość wentylatora (%)" -msgid "Temperature (°C)" -msgstr "Temperatura (°C)" +msgid "Temperature (℃)" +msgstr "Temperatura (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Natężenie przepływu (mm³/s)" @@ -16069,7 +16069,7 @@ msgstr "" "Jeśli włączona, ten parametr ustawia także zmienną gcode o nazwie chamber_temperature, która może być użyta do przekazania żądanej temperatury komory do makra rozpoczynającego drukowanie, lub makra utrzymywania ciepła, na przykład: PRINT_START (inne zmienne) CHAMBER_TEMP=[chamber_temperature]. Może to być przydatne, jeśli twoja drukarka nie obsługuje poleceń M141/M191 lub jeśli chcesz zarządzać utrzymywaniem ciepła w makrze rozpoczynającym drukowanie, jeśli nie jest zainstalowany aktywna podgrzewacz komory." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" @@ -22423,8 +22423,8 @@ msgstr "" #~ msgid "Enter the nozzle_temperature needed for extruding your filament." #~ msgstr "Wprowadź temperaturę dyszy potrzebną do ekstruzji filamentu." -#~ msgid "A rule of thumb is 160 to 230 °C for PLA, and 215 to 250 °C for ABS." -#~ msgstr "Generalną zasadą jest 160 do 230 °C dla PLA i 215 do 250 °C dla ABS." +#~ msgid "A rule of thumb is 160 to 230℃ for PLA, and 215 to 250℃ for ABS." +#~ msgstr "Generalną zasadą jest 160 do 230℃ dla PLA i 215 do 250℃ dla ABS." #~ msgid "Extrusion Temperature:" #~ msgstr "Temperatura ekstrudera:" @@ -22432,8 +22432,8 @@ msgstr "" #~ msgid "Enter the bed temperature needed for getting your filament to stick to your heated bed." #~ msgstr "Wprowadź temperaturę potrzebną do dobrego przylegania filamentu do powierzchni podgrzewanego stołu." -#~ msgid "A rule of thumb is 60 °C for PLA and 110 °C for ABS. Leave zero if you have no heated bed." -#~ msgstr "Generalną zasadą jest 60 °C dla PLA i 110 °C dla ABS. Ustaw zero, jeśli nie masz podgrzewanego stołu." +#~ msgid "A rule of thumb is 60℃ for PLA and 110℃ for ABS. Leave zero if you have no heated bed." +#~ msgstr "Generalną zasadą jest 60℃ dla PLA i 110℃ dla ABS. Ustaw zero, jeśli nie masz podgrzewanego stołu." #~ msgid "Bed Temperature:" #~ msgstr "Temperatura stołu" diff --git a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po index b09793b90d..985c56e0da 100644 --- a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po +++ b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po @@ -5256,7 +5256,7 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Velocidade do ventilador (%)" -msgid "Temperature (°C)" +msgid "Temperature (℃)" msgstr "Temperatura (℃)" msgid "Volumetric flow rate (mm³/s)" @@ -16049,7 +16049,7 @@ msgstr "" "Se habilitado, este parâmetro também define uma variável G-code chamada chamber_temperature, que pode ser usada para passar a temperatura desejada da câmara para sua macro de início de impressão ou uma macro de absorção de calor como esta: PRINT_START (outras variáveis) CHAMBER_TEMP=[chamber_temperature]. Isso pode ser útil se sua impressora não suportar comandos M141/M191 ou se você desejar lidar com a absorção de calor na macro de início de impressão se nenhum aquecedor de câmara ativo estiver instalado." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/ru/OrcaSlicer_ru.po b/localization/i18n/ru/OrcaSlicer_ru.po index e3dfca8145..7bc6e42c44 100644 --- a/localization/i18n/ru/OrcaSlicer_ru.po +++ b/localization/i18n/ru/OrcaSlicer_ru.po @@ -5417,7 +5417,7 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Скорость вентилятора (%)" -msgid "Temperature (°C)" +msgid "Temperature (℃)" msgstr "Температура (°C)" msgid "Volumetric flow rate (mm³/s)" @@ -16781,7 +16781,7 @@ msgstr "" "Это особенно полезно, если принтер не поддерживает команды M141/M191, или если управление температурой термокамеры реализовано через макросы (например, при отсутствии активного нагревателя камеры)." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/sv/OrcaSlicer_sv.po b/localization/i18n/sv/OrcaSlicer_sv.po index d3f1d20d8b..8ea2df6ba9 100644 --- a/localization/i18n/sv/OrcaSlicer_sv.po +++ b/localization/i18n/sv/OrcaSlicer_sv.po @@ -5275,8 +5275,8 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Fläkt hastighet (%)" -msgid "Temperature (°C)" -msgstr "Temperatur (°C)" +msgid "Temperature (℃)" +msgstr "Temperatur (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Volymetrisk flödeshastighet (mm³/s)" @@ -15701,7 +15701,7 @@ msgid "" msgstr "" msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/th/OrcaSlicer_th.po b/localization/i18n/th/OrcaSlicer_th.po index b4e955f68e..55b5bd6a95 100644 --- a/localization/i18n/th/OrcaSlicer_th.po +++ b/localization/i18n/th/OrcaSlicer_th.po @@ -5223,8 +5223,8 @@ msgstr "กระตุก (มม./วินาที)" msgid "Fan speed (%)" msgstr "ความเร็วพัดลม (%)" -msgid "Temperature (°C)" -msgstr "อุณหภูมิ (°C)" +msgid "Temperature (℃)" +msgstr "อุณหภูมิ (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "อัตราการไหลเชิงปริมาตร (มม.³/วินาที)" @@ -15887,7 +15887,7 @@ msgstr "" "หากเปิดใช้งาน พารามิเตอร์นี้จะตั้งค่าตัวแปร G-code ชื่อ Chamber_temperature ซึ่งสามารถใช้เพื่อส่งอุณหภูมิห้องเพาะเลี้ยงที่ต้องการไปยังมาโครเริ่มการพิมพ์ของคุณ หรือมาโครความร้อนแช่เช่นนี้: PRINT_START (ตัวแปรอื่นๆ) CHAMBER_TEMP=[chamber_temperature] วิธีนี้อาจเป็นประโยชน์หากเครื่องพิมพ์ของคุณไม่รองรับคำสั่ง M141/M191 หรือหากคุณต้องการจัดการกับความร้อนที่แช่อยู่ในมาโครเริ่มการพิมพ์ หากไม่มีการติดตั้งเครื่องทำความร้อนในห้องที่ใช้งานอยู่" msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/tr/OrcaSlicer_tr.po b/localization/i18n/tr/OrcaSlicer_tr.po index 9c104a8baf..c5d91ffbf9 100644 --- a/localization/i18n/tr/OrcaSlicer_tr.po +++ b/localization/i18n/tr/OrcaSlicer_tr.po @@ -5341,8 +5341,8 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Fan hızı (%)" -msgid "Temperature (°C)" -msgstr "Sıcaklık (°C)" +msgid "Temperature (℃)" +msgstr "Sıcaklık (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Hacimsel akış hızı (mm³/s)" @@ -16113,7 +16113,7 @@ msgstr "" "Etkinleştirilirse, bu parametre aynı zamanda istenen oda sıcaklığını yazdırma başlatma makronuza veya şuna benzer bir ısı emme makrosuna iletmek için kullanılabilecek Chamber_temperature adlı bir gcode değişkenini de ayarlar: PRINT_START (diğer değişkenler) CHAMBER_TEMP=[chamber_temperature]. Yazıcınız M141/M191 komutlarını desteklemiyorsa veya aktif oda ısıtıcısı takılı değilse yazdırma başlatma makrosunda ısı bekletme işlemini gerçekleştirmek istiyorsanız bu yararlı olabilir." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/uk/OrcaSlicer_uk.po b/localization/i18n/uk/OrcaSlicer_uk.po index 4fc4fb151c..4fbe7445b3 100644 --- a/localization/i18n/uk/OrcaSlicer_uk.po +++ b/localization/i18n/uk/OrcaSlicer_uk.po @@ -5344,8 +5344,8 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Швидкість Вентилятора (%)" -msgid "Temperature (°C)" -msgstr "Температура (°С)" +msgid "Temperature (℃)" +msgstr "Температура (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Об'ємна витрата (мм³/с)" @@ -16089,7 +16089,7 @@ msgstr "" "Це може бути корисним, якщо ваш принтер не підтримує команди M141/M191 або якщо ви хочете керувати прогрівом камери у макросі запуску друку за відсутності активного нагрівача камери." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/vi/OrcaSlicer_vi.po b/localization/i18n/vi/OrcaSlicer_vi.po index fffd7dd6e6..f0fe2f3613 100644 --- a/localization/i18n/vi/OrcaSlicer_vi.po +++ b/localization/i18n/vi/OrcaSlicer_vi.po @@ -5315,8 +5315,8 @@ msgstr "Jerk (mm/s)" msgid "Fan speed (%)" msgstr "Tốc độ quạt (%)" -msgid "Temperature (°C)" -msgstr "Nhiệt độ (°C)" +msgid "Temperature (℃)" +msgstr "Nhiệt độ (℃)" msgid "Volumetric flow rate (mm³/s)" msgstr "Tốc độ flow thể tích (mm³/s)" @@ -16000,7 +16000,7 @@ msgstr "" "Nếu được bật, tham số này cũng đặt biến G-code có tên chamber_temperature, có thể được sử dụng để truyền nhiệt độ buồng mong muốn cho macro bắt đầu in của bạn, hoặc macro ngâm nhiệt như thế này: PRINT_START (các biến khác) CHAMBER_TEMP=[chamber_temperature]. Điều này có thể hữu ích nếu máy in của bạn không hỗ trợ lệnh M141/M191, hoặc nếu bạn muốn xử lý ngâm nhiệt trong macro bắt đầu in nếu không có bộ sưởi buồng hoạt động được cài đặt." msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po b/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po index 66ac4a246b..0209b27104 100644 --- a/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po +++ b/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po @@ -5328,7 +5328,7 @@ msgstr "抖动 (mm/s)" msgid "Fan speed (%)" msgstr "风扇速度(%)" -msgid "Temperature (°C)" +msgid "Temperature (℃)" msgstr "温度(℃)" msgid "Volumetric flow rate (mm³/s)" @@ -16215,7 +16215,7 @@ msgid "" msgstr "对于 ABS、ASA、PC 和 PA 等高温材料,较高的腔室温度有助于抑制或减少翘曲,并有可能提高层间粘合强度。但同时,较高的腔室温度会降低 ABS 和 ASA 的空气过滤效率。 对于 PLA、PETG、TPU、PVA 等低温材料,应禁用此选项(设置为 0),因为腔室温度应较低,以避免热断时材料软化导致挤出机堵塞。 如果启用,此参数还会设置一个名为 chamber_temple 的 G-code 变量,该变量可用于将所需的腔室温度传递给打印启动宏,或热浸宏,如下所示:PRINT_START(其他变量)CHAMBER_TEMP=[cham​​ber_Temperature]。如果您的打印机不支持 M141/M191 命令,或者如果您希望在未安装活动室加热器的情况下在打印启动宏中处理热浸,则这可能很有用。" msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po b/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po index 6ab21ea1cc..dbca75c52d 100644 --- a/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po +++ b/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po @@ -5370,7 +5370,7 @@ msgstr "急衝速度 (mm/s)" msgid "Fan speed (%)" msgstr "風扇速度(%)" -msgid "Temperature (°C)" +msgid "Temperature (℃)" msgstr "溫度(℃)" msgid "Volumetric flow rate (mm³/s)" @@ -16263,7 +16263,7 @@ msgid "" msgstr "對於 ABS、ASA、PC 和 PA 等高溫材料,較高的機箱溫度能有效抑制或減少翹曲,並可能提升層間結合強度。然而,較高的機箱溫度也會降低 ABS 和 ASA 的空氣過濾效率。對於 PLA、PETG、TPU、PVA 和其他低溫材料,建議將此選項停用(設為 0),因機箱溫度應保持較低,以避免因材料在熱端軟化而導致擠出機堵塞。啟用此選項後,會設置一個名為 chamber_temperature 的 G-code 變數,可用於將所需的機箱溫度傳遞給列印開始宏,或像以下的熱浸泡宏(預熱):PRINT_START (其他變數) CHAMBER_TEMP=[chamber_temperature]。這對於不支援 M141/M191 指令的列印設備,或者希望在列印開始宏中處理熱浸泡(預熱)但未安裝主動機箱加熱器的情況下非常實用。" msgid "" -"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n" +"This is the chamber temperature at which printing should start, while the chamber continues heating toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n" "\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n" "\n" diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 7a71c444f9..0e02ea1ec3 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -2017,7 +2017,7 @@ void PrintConfigDef::init_fff_params() def = this->add("initial_layer_travel_acceleration", coFloatsOrPercents); def->label = L("First layer travel"); def->tooltip = L("Travel acceleration of first layer.\nThe percentage value is relative to Travel Acceleration."); - def->sidetext = L("mm/s² or %"); + def->sidetext = L(u8"mm/s² or %"); def->min = 0; def->mode = comAdvanced; def->ratio_over = "travel_acceleration"; @@ -2028,7 +2028,7 @@ void PrintConfigDef::init_fff_params() def->label = L("Bridge"); def->category = L("Speed"); def->tooltip = L("Acceleration of bridges. If the value is expressed as a percentage (e.g. 50%), it will be calculated based on the outer wall acceleration."); - def->sidetext = L("mm/s² or %"); + def->sidetext = L(u8"mm/s² or %"); def->min = 0; def->mode = comAdvanced; def->ratio_over = "outer_wall_acceleration"; @@ -3452,7 +3452,7 @@ void PrintConfigDef::init_fff_params() def->label = L("Sparse infill"); def->category = L("Speed"); def->tooltip = L("Acceleration of sparse infill. If the value is expressed as a percentage (e.g. 100%), it will be calculated based on the default acceleration."); - def->sidetext = L("mm/s² or %"); + def->sidetext = L(u8"mm/s² or %"); def->min = 0; def->mode = comAdvanced; def->ratio_over = "default_acceleration"; @@ -3463,7 +3463,7 @@ void PrintConfigDef::init_fff_params() def->label = L("Internal solid infill"); def->category = L("Speed"); def->tooltip = L("Acceleration of internal solid infill. If the value is expressed as a percentage (e.g. 100%), it will be calculated based on the default acceleration."); - def->sidetext = L("mm/s² or %"); + def->sidetext = L(u8"mm/s² or %"); def->min = 0; def->mode = comAdvanced; def->ratio_over = "default_acceleration"; @@ -3919,7 +3919,7 @@ void PrintConfigDef::init_fff_params() "The shift is applied once every number of layers set by Layers between ripple offset, so layers within the same group are printed identically."); def->min = 0; def->max = 100; - def->sidetext = ("%"); + def->sidetext = "%"; def->mode = comAdvanced; def->set_default_value(new ConfigOptionPercent(50)); @@ -4127,7 +4127,7 @@ void PrintConfigDef::init_fff_params() "value that the firmware would silently drop, and the fan never receives a value below the one " "you know it can actually spool at." "\nSet to 0 to deactivate."); - def->sidetext = L("%"); + def->sidetext = "%"; def->min = 0; def->max = 100; def->mode = comAdvanced; @@ -5082,7 +5082,7 @@ void PrintConfigDef::init_fff_params() def = this->add("input_shaping_freq_x", coFloat); def->label = L("X"); def->tooltip = L("Resonant frequency for the X axis input shaper.\nZero will use the firmware frequency.\nTo disable input shaping, use the Disable type.\nRRF: X and Y values are equal."); - def->sidetext = "Hz"; + def->sidetext = L("Hz"); // Hertz, CIS languages need translation def->min = 0; def->max = 1000; def->mode = comExpert; @@ -5091,7 +5091,7 @@ void PrintConfigDef::init_fff_params() def = this->add("input_shaping_freq_y", coFloat); def->label = L("Y"); def->tooltip = L("Resonant frequency for the Y axis input shaper.\nZero will use the firmware frequency.\nTo disable input shaping, use the Disable type."); - def->sidetext = "Hz"; + def->sidetext = L("Hz"); // Hertz, CIS languages need translation def->min = 0; def->max = 1000; def->mode = comExpert; @@ -7125,7 +7125,7 @@ void PrintConfigDef::init_fff_params() def->label = L("Minimal"); def->tooltip = L("This is the chamber temperature at which printing should start, while the chamber continues heating " "toward the \"Target\" chamber temperature. For example, set the Target to 60 and the Minimal to 50 to " - "begin printing once the chamber reaches 50°C, without waiting for the full 60°C.\n\n" + "begin printing once the chamber reaches 50℃, without waiting for the full 60℃.\n\n" "It sets a G-code variable named chamber_minimal_temperature, which can be passed to your print start macro " "or a heat soak macro, like this: PRINT_START (other variables) CHAMBER_MIN_TEMP=[chamber_minimal_temperature].\n\n" "Unlike the \"Target\" chamber temperature, this option does not emit any M141/M191 commands; it only exposes " diff --git a/src/slic3r/GUI/ConfigWizard.cpp b/src/slic3r/GUI/ConfigWizard.cpp index 9d2a6e9905..0bbbc15f87 100644 --- a/src/slic3r/GUI/ConfigWizard.cpp +++ b/src/slic3r/GUI/ConfigWizard.cpp @@ -1440,7 +1440,7 @@ PageTemperatures::PageTemperatures(ConfigWizard *parent) spin_bed->SetValue(default_bed != nullptr && default_bed->size() > 0 ? default_bed->get_at(0) : 0); append_text(_L("Enter the nozzle_temperature needed for extruding your filament.")); - append_text(_L("A rule of thumb is 160 to 230 °C for PLA, and 215 to 250 °C for ABS.")); + append_text(_L("A rule of thumb is 160 to 230℃ for PLA, and 215 to 250℃ for ABS.")); #endif auto *sizer_extr = new wxFlexGridSizer(3, 5, 5); @@ -1455,7 +1455,7 @@ PageTemperatures::PageTemperatures(ConfigWizard *parent) append_spacer(VERTICAL_SPACING); append_text(_L("Enter the bed temperature needed for getting your filament to stick to your heated bed.")); - append_text(_L("A rule of thumb is 60 °C for PLA and 110 °C for ABS. Leave zero if you have no heated bed.")); + append_text(_L("A rule of thumb is 60℃ for PLA and 110℃ for ABS. Leave zero if you have no heated bed.")); auto *sizer_bed = new wxFlexGridSizer(3, 5, 5); auto *text_bed = new wxStaticText(this, wxID_ANY, _L("Bed Temperature:")); diff --git a/src/slic3r/GUI/GCodeViewer.cpp b/src/slic3r/GUI/GCodeViewer.cpp index 61b3e52a98..3b3d4248e3 100644 --- a/src/slic3r/GUI/GCodeViewer.cpp +++ b/src/slic3r/GUI/GCodeViewer.cpp @@ -445,7 +445,7 @@ void GCodeViewer::SequentialView::Marker::render_position_window(const libvgcode add_row(_u8L("Flow rate"), buff); sprintf(buff, "%.0f %%", vertex.fan_speed); add_row(_u8L("Fan speed"), buff); - sprintf(buff, ("%.0f " + _u8L("°C")).c_str(), vertex.temperature); + sprintf(buff, ("%.0f " + _u8L("\u2103" /* °C */)).c_str(), vertex.temperature); add_row(_u8L("Temperature"), buff); sprintf(buff, "%.4f", vertex.pressure_advance); add_row(_u8L("Pressure Advance"), buff); @@ -3711,7 +3711,7 @@ void GCodeViewer::render_legend(float &legend_height, int canvas_width, int canv break; } case libvgcode::EViewType::FanSpeed: { imgui.title(_u8L("Fan speed (%)")); break; } - case libvgcode::EViewType::Temperature: { imgui.title(_u8L("Temperature (°C)")); break; } + case libvgcode::EViewType::Temperature: { imgui.title(_u8L("Temperature (℃)")); break; } // ORCA: Add Pressure Advance visualization support case libvgcode::EViewType::PressureAdvance:{ imgui.title(_u8L("Pressure Advance")); break; } case libvgcode::EViewType::VolumetricFlowRate: diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index bf0b1337bf..5410d663f2 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -7881,7 +7881,7 @@ bool Tab::validate_filament_temperature_pairs() if (delta <= rule.max_delta) continue; - const wxString deg_c = wxString::FromUTF8("°C"); + const wxString deg_c = wxString::FromUTF8("℃"); const wxString bullet = wxString::FromUTF8("•"); invalid_pairs += wxString::Format(_L(" - %s:\n %s first layer %d %s, other layers %d %s\n %s max delta %d %s, current delta %d %s\n"), rule.label, bullet, first_temp, deg_c, other_temp, deg_c, bullet, rule.max_delta, deg_c, delta, deg_c); From 33909a51cd064c45bde8e6719c606210c9c4087c Mon Sep 17 00:00:00 2001 From: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:04:36 -0300 Subject: [PATCH 47/47] Fix external outline Thickness (#14741) * Fix external outline * Format and values * frag_color -> gl_FragColor * Remove Transform3d& view_matrix * Clarify rendering comments in 3DScene.cpp Updated comments to clarify rendering process using stencil buffer. --------- Co-authored-by: Ian Bassi Co-authored-by: Noisyfox --- resources/shaders/110/gouraud.fs | 2 ++ resources/shaders/110/gouraud.vs | 11 +++++++++++ resources/shaders/110/phong.fs | 2 ++ resources/shaders/110/phong.vs | 11 +++++++++++ resources/shaders/110/ssao.fs | 5 +++++ resources/shaders/140/gouraud.fs | 2 ++ resources/shaders/140/gouraud.vs | 11 +++++++++++ resources/shaders/140/phong.fs | 2 ++ resources/shaders/140/phong.vs | 11 +++++++++++ resources/shaders/140/ssao.fs | 5 +++++ src/slic3r/GUI/3DScene.cpp | 24 +++++++++++++++++++++++- 11 files changed, 85 insertions(+), 1 deletion(-) diff --git a/resources/shaders/110/gouraud.fs b/resources/shaders/110/gouraud.fs index 010baaa0dd..59c4e4d410 100644 --- a/resources/shaders/110/gouraud.fs +++ b/resources/shaders/110/gouraud.fs @@ -231,6 +231,8 @@ void main() s = max(s, DetectSilho(fragCoord.xy + vec2(i, 0))); s = max(s, DetectSilho(fragCoord.xy + vec2(0, i))); } + if (s < 0.01) + discard; gl_FragColor = vec4(mix(color.rgb, getBackfaceColor(color.rgb), s), color.a); } #ifdef ENABLE_ENVIRONMENT_MAP diff --git a/resources/shaders/110/gouraud.vs b/resources/shaders/110/gouraud.vs index ff3a190c79..184529be82 100644 --- a/resources/shaders/110/gouraud.vs +++ b/resources/shaders/110/gouraud.vs @@ -30,6 +30,8 @@ uniform mat4 projection_matrix; uniform mat3 view_normal_matrix; uniform mat4 volume_world_matrix; uniform SlopeDetection slope; +uniform bool is_outline; +uniform vec2 screen_size; // Clipping plane, x = min z, y = max z. Used by the FFF and SLA previews to clip with a top / bottom plane. uniform vec2 z_range; @@ -75,6 +77,15 @@ void main() world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0; gl_Position = projection_matrix * position; + if (is_outline) { + vec3 n = normalize((view_normal_matrix * v_normal).xyz); + vec2 dir = normalize(n.xy); + if (dot(dir, dir) > 0.0) { + //set outline thickness + float px = 3.0; + gl_Position.xy += dir * (px * 2.0 / screen_size) * gl_Position.w; + } + } // Fill in the scalars for fragment shader clipping. Fragments with any of these components lower than zero are discarded. clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z); color_clip_plane_dot = dot(world_pos, color_clip_plane); diff --git a/resources/shaders/110/phong.fs b/resources/shaders/110/phong.fs index a5c1c5b8ab..5fedf7edff 100644 --- a/resources/shaders/110/phong.fs +++ b/resources/shaders/110/phong.fs @@ -273,6 +273,8 @@ void main() s = max(s, DetectSilho(fragCoord.xy + vec2(i, 0))); s = max(s, DetectSilho(fragCoord.xy + vec2(0, i))); } + if (s < 0.01) + discard; gl_FragColor = vec4(mix(shaded_color.rgb, getBackfaceColor(shaded_color.rgb), s), shaded_color.a); } #ifdef ENABLE_ENVIRONMENT_MAP diff --git a/resources/shaders/110/phong.vs b/resources/shaders/110/phong.vs index b9e90e7cc2..10d36e233f 100644 --- a/resources/shaders/110/phong.vs +++ b/resources/shaders/110/phong.vs @@ -14,6 +14,8 @@ uniform mat4 projection_matrix; uniform mat3 view_normal_matrix; uniform mat4 volume_world_matrix; uniform SlopeDetection slope; +uniform bool is_outline; +uniform vec2 screen_size; // Clipping plane, x = min z, y = max z. Used by the FFF and SLA previews to clip with a top / bottom plane. uniform vec2 z_range; @@ -48,6 +50,15 @@ void main() world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0; gl_Position = projection_matrix * position; + if (is_outline) { + vec3 n = normalize((view_normal_matrix * v_normal).xyz); + vec2 dir = normalize(n.xy); + if (dot(dir, dir) > 0.0) { + //set outline thickness + float px = 3.0; + gl_Position.xy += dir * (px * 2.0 / screen_size) * gl_Position.w; + } + } // Fill in the scalars for fragment shader clipping. Fragments with any of these components lower than zero are discarded. clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z); color_clip_plane_dot = dot(world_pos, color_clip_plane); diff --git a/resources/shaders/110/ssao.fs b/resources/shaders/110/ssao.fs index 43b52a3f29..5aada05883 100644 --- a/resources/shaders/110/ssao.fs +++ b/resources/shaders/110/ssao.fs @@ -11,6 +11,7 @@ uniform sampler2D normal_texture; uniform vec2 inv_tex_size; uniform float z_near; uniform float z_far; +uniform bool is_outline; varying vec2 tex_coord; @@ -22,6 +23,10 @@ float linearize_depth(float depth) void main() { + if (is_outline) { + gl_FragColor = vec4(texture2D(color_texture, tex_coord).rgb, 1.0); + return; + } vec3 base = texture2D(color_texture, tex_coord).rgb; float depth_center = linearize_depth(texture2D(depth_texture, tex_coord).r); diff --git a/resources/shaders/140/gouraud.fs b/resources/shaders/140/gouraud.fs index 8ebb1d8a60..728b7f942c 100644 --- a/resources/shaders/140/gouraud.fs +++ b/resources/shaders/140/gouraud.fs @@ -232,6 +232,8 @@ void main() s = max(s, DetectSilho(fragCoord.xy + vec2(i, 0))); s = max(s, DetectSilho(fragCoord.xy + vec2(0, i))); } + if (s < 0.01) + discard; out_color = vec4(mix(color.rgb, getBackfaceColor(color.rgb), s), color.a); } #ifdef ENABLE_ENVIRONMENT_MAP diff --git a/resources/shaders/140/gouraud.vs b/resources/shaders/140/gouraud.vs index 72dd4ff1bf..a31e751964 100644 --- a/resources/shaders/140/gouraud.vs +++ b/resources/shaders/140/gouraud.vs @@ -30,6 +30,8 @@ uniform mat4 projection_matrix; uniform mat3 view_normal_matrix; uniform mat4 volume_world_matrix; uniform SlopeDetection slope; +uniform bool is_outline; +uniform vec2 screen_size; // Clipping plane, x = min z, y = max z. Used by the FFF and SLA previews to clip with a top / bottom plane. uniform vec2 z_range; @@ -75,6 +77,15 @@ void main() world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0; gl_Position = projection_matrix * position; + if (is_outline) { + vec3 n = normalize((view_normal_matrix * v_normal).xyz); + vec2 dir = normalize(n.xy); + if (dot(dir, dir) > 0.0) { + //set outline thickness + float px = 3.0; + gl_Position.xy += dir * (px * 2.0 / screen_size) * gl_Position.w; + } + } // Fill in the scalars for fragment shader clipping. Fragments with any of these components lower than zero are discarded. clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z); color_clip_plane_dot = dot(world_pos, color_clip_plane); diff --git a/resources/shaders/140/phong.fs b/resources/shaders/140/phong.fs index 894e3eb863..bbde72592c 100644 --- a/resources/shaders/140/phong.fs +++ b/resources/shaders/140/phong.fs @@ -277,6 +277,8 @@ void main() s = max(s, DetectSilho(fragCoord.xy + vec2(i, 0))); s = max(s, DetectSilho(fragCoord.xy + vec2(0, i))); } + if (s < 0.01) + discard; out_color = vec4(mix(shaded_color.rgb, getBackfaceColor(shaded_color.rgb), s), shaded_color.a); } #ifdef ENABLE_ENVIRONMENT_MAP diff --git a/resources/shaders/140/phong.vs b/resources/shaders/140/phong.vs index 2ccbaf16ee..c7570edb95 100644 --- a/resources/shaders/140/phong.vs +++ b/resources/shaders/140/phong.vs @@ -14,6 +14,8 @@ uniform mat4 projection_matrix; uniform mat3 view_normal_matrix; uniform mat4 volume_world_matrix; uniform SlopeDetection slope; +uniform bool is_outline; +uniform vec2 screen_size; // Clipping plane, x = min z, y = max z. Used by the FFF and SLA previews to clip with a top / bottom plane. uniform vec2 z_range; @@ -48,6 +50,15 @@ void main() world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0; gl_Position = projection_matrix * position; + if (is_outline) { + vec3 n = normalize((view_normal_matrix * v_normal).xyz); + vec2 dir = normalize(n.xy); + if (dot(dir, dir) > 0.0) { + //set outline thickness + float px = 3.0; + gl_Position.xy += dir * (px * 2.0 / screen_size) * gl_Position.w; + } + } // Fill in the scalars for fragment shader clipping. Fragments with any of these components lower than zero are discarded. clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z); color_clip_plane_dot = dot(world_pos, color_clip_plane); diff --git a/resources/shaders/140/ssao.fs b/resources/shaders/140/ssao.fs index 86ea6a54bf..b3b6df6877 100644 --- a/resources/shaders/140/ssao.fs +++ b/resources/shaders/140/ssao.fs @@ -10,6 +10,7 @@ uniform sampler2D depth_texture; uniform sampler2D normal_texture; uniform float z_near; uniform float z_far; +uniform bool is_outline; in vec2 tex_coord; out vec4 frag_color; @@ -22,6 +23,10 @@ float linearize_depth(float depth) void main() { + if (is_outline) { + frag_color = vec4(texture(color_texture, tex_coord).rgb, 1.0); + return; + } ivec2 pixel = ivec2(gl_FragCoord.xy); float center_depth = linearize_depth(texelFetch(depth_texture, pixel, 0).r); diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index 84709dd13b..335dd6b825 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -495,7 +495,29 @@ void GLVolume::render_with_outline(const GUI::Size& cnv_size) simple_render(shader, model_objects, colors); return; } - + // 0th. render pass, render the model using stencil buffer + glsafe(::glEnable(GL_STENCIL_TEST)); + glsafe(::glStencilMask(0xFF)); + glsafe(::glStencilOp(GL_KEEP, GL_REPLACE, GL_REPLACE)); + glsafe(::glClearStencil(0)); + glsafe(::glClear(GL_STENCIL_BUFFER_BIT)); + glsafe(::glStencilFunc(GL_ALWAYS, 0xFF, 0xFF)); + if (tverts_range == std::make_pair(0, -1)) + model.render(shader); + else + model.render(this->tverts_range, shader); + glsafe(::glStencilFunc(GL_NOTEQUAL, 0xFF, 0xFF)); + glsafe(::glStencilMask(0x00)); + shader->set_uniform("is_outline", true); + shader->set_uniform("screen_size", Vec2f{cnv_size.get_width(), cnv_size.get_height()}); + if (tverts_range == std::make_pair(0, -1)) + model.render(shader); + else + model.render(this->tverts_range, shader); + shader->set_uniform("is_outline", false); + glsafe(::glStencilMask(0xFF)); + glsafe(::glDisable(GL_STENCIL_TEST)); + // render the outline using depth buffer and discard the pixels that are not on the outline // 1st. render pass, render the model into a separate render target that has only depth buffer GLuint depth_fbo = 0; GLuint depth_tex = 0;