diff --git a/.gitignore b/.gitignore index d994d922c1..a2a1520fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ __pycache__/ *.pyc *.opc /.test/ +docs/superpowers/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 236aa54c05..4be195b40d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,13 @@ ctest --test-dir ./tests/libslic3r # individual suite ctest --test-dir ./tests/fff_print ``` +## Documentation + +- Docs live in `docs/`; the high-level design of a subsystem goes in `docs/HLSD/.md`. +- Describe the design as it stands — what the subsystem does, why it exists, and the constraints that shape it. Not the route that got there: no phases, task lists, status markers, or "before/after this PR" framing. +- Planning and investigation output (brainstorms, superpowers design and plan docs) stays in `docs/superpowers/`, which is gitignored. Never commit it. +- Write a doc only when the design is not evident from the code, and when a change invalidates an existing one, update it in the same PR. + ## Code Style - C++17, selective C++20. PascalCase classes, snake_case functions/variables diff --git a/docs/superpowers/plans/2026-07-23-wx-inspectable-on-dpiaware-plan.md b/docs/superpowers/plans/2026-07-23-wx-inspectable-on-dpiaware-plan.md deleted file mode 100644 index 5b3669ace4..0000000000 --- a/docs/superpowers/plans/2026-07-23-wx-inspectable-on-dpiaware-plan.md +++ /dev/null @@ -1,111 +0,0 @@ -# Move `wxInspectable` into `DPIAware` — 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:** Move `wxInspector::wxInspectable` from individual leaf classes into the common `DPIAware

` template so every DPIAware widget is automatically inspectable and gets the inspector keyboard shortcut. - -**Architecture:** `DPIAware

` gains `wxInspector::wxInspectable` as a second base class and calls `SetupInspectorAccelerator(this)` in its constructor. `DPIDialog` and `MainFrame` drop their now-redundant `wxInspectable` inheritance and `SetupInspectorAccelerator` calls. - -**Tech Stack:** C++17, wxWidgets, wxInspector - -## Global Constraints - -- Build with `D:\VisualStudio\2026\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe` -- Use `--config RelWithDebInfo` for all builds -- Cross-platform: must compile on Windows, macOS, and Linux -- Match existing code style: PascalCase classes, `#pragma once` -- Do NOT commit files under `.superpowers/` -- Do NOT commit `task.md` - ---- - -### Task 1: Move `wxInspectable` and `SetupInspectorAccelerator` into `DPIAware

` - -**Files:** -- Modify: `src/slic3r/GUI/GUI_Utils.hpp:92` (DPIAware template — add wxInspectable base + SetupInspectorAccelerator call) -- Modify: `src/slic3r/GUI/GUI_Utils.hpp:276` (DPIDialog — drop wxInspectable + SetupInspectorAccelerator) -- Modify: `src/slic3r/GUI/MainFrame.hpp:96` (MainFrame — drop wxInspectable) -- Modify: `src/slic3r/GUI/MainFrame.cpp:304` (MainFrame constructor — drop SetupInspectorAccelerator) - -**Interfaces:** -- Consumes: Nothing (standalone refactor) -- Produces: All DPIAware widgets automatically inherit `wxInspector::wxInspectable` and get Ctrl+Shift+I accelerator - -- [ ] **Step 1: Add `wxInspectable` to `DPIAware

` and call `SetupInspectorAccelerator`** - -In `src/slic3r/GUI/GUI_Utils.hpp`, line 92, change the base class: - -```cpp -// Before: -template class DPIAware : public P -// After: -template class DPIAware : public P, public wxInspector::wxInspectable -``` - -In the constructor body of `DPIAware

`, after `this->CenterOnParent();` (currently line 110), add: - -```cpp -SetupInspectorAccelerator(this); -``` - -(`` is already included at line 23.) - -- [ ] **Step 2: Remove redundant `wxInspectable` and `SetupInspectorAccelerator` from `DPIDialog`** - -In `src/slic3r/GUI/GUI_Utils.hpp`, line 276, change: - -```cpp -// Before: -class DPIDialog : public DPIAware, public wxInspector::wxInspectable -// After: -class DPIDialog : public DPIAware -``` - -In the `DPIDialog` constructor body, remove the `SetupInspectorAccelerator(this);` line (currently line 286). The rest of the constructor stays. - -- [ ] **Step 3: Remove redundant `wxInspectable` from `MainFrame`** - -In `src/slic3r/GUI/MainFrame.hpp`, line 96, change: - -```cpp -// Before: -class MainFrame : public DPIFrame, public wxInspector::wxInspectable -// After: -class MainFrame : public DPIFrame -``` - -`MainFrame` now gets `wxInspectable` through `DPIFrame` → `DPIAware`. - -- [ ] **Step 4: Remove redundant `SetupInspectorAccelerator` from `MainFrame` constructor** - -In `src/slic3r/GUI/MainFrame.cpp`, line 304, remove the line: - -```cpp -SetupInspectorAccelerator(this); -``` - -It is now called automatically by the `DPIAware` constructor. - -- [ ] **Step 5: Build to verify compilation** - -```powershell -$cmakePath = "D:\VisualStudio\2026\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe" -& $cmakePath --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -Expected: Build succeeds with zero new errors or warnings. - -- [ ] **Step 6: Commit** - -```bash -git add src/slic3r/GUI/GUI_Utils.hpp src/slic3r/GUI/MainFrame.hpp src/slic3r/GUI/MainFrame.cpp -git commit -m "refactor: move wxInspectable and SetupInspectorAccelerator into DPIAware - -DPIAware

now inherits wxInspector::wxInspectable and calls -SetupInspectorAccelerator in its constructor, making all DPIAware -widgets automatically appear in the inspector tree with the -Ctrl+Shift+I shortcut. Remove redundant wxInspectable inheritance -and SetupInspectorAccelerator calls from DPIDialog and MainFrame. - -Co-Authored-By: Claude " -``` diff --git a/docs/superpowers/plans/2026-07-23-wx-inspector-plugins-plan.md b/docs/superpowers/plans/2026-07-23-wx-inspector-plugins-plan.md deleted file mode 100644 index c3061b223d..0000000000 --- a/docs/superpowers/plans/2026-07-23-wx-inspector-plugins-plan.md +++ /dev/null @@ -1,753 +0,0 @@ -# wxInspector Plugins for OrcaSlicer — 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:** Build two wxInspector plugins (DPIAware + CustomWidgets) that expose OrcaSlicer custom control properties in the inspector's property grid. - -**Architecture:** Two plugins in a shared folder under `src/slic3r/Utils/wxInspectorPlugins/`. DPIAwarePlugin uses `dynamic_cast/` for detection; CustomWidgetsPlugin uses per-type `dynamic_cast`. Both registered as static singletons via a single inline function in `Registration.hpp`, called from `MainFrame` constructor. - -**Tech Stack:** C++17, wxWidgets, wxInspector plugin API (`wx/inspector/plugin.h`, `wx/inspector/inspector.h`), OrcaSlicer custom widget headers - -## Global Constraints - -- Plugins placed under `src/slic3r/Utils/wxInspectorPlugins/` -- Build with `D:\VisualStudio\2026\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe` -- Minimal source changes: only trivial (one-line) getters/setters added to existing classes -- Cross-platform: must compile on Windows, macOS, and Linux -- Match existing code style: PascalCase classes, snake_case functions, `#pragma once` - ---- - -### Task 1: Add getters/setters to existing Orca widget headers - -**Files:** -- Modify: `src/slic3r/GUI/GUI_Utils.hpp` (DPIAware template — add 4 methods) -- Modify: `src/slic3r/GUI/Widgets/Button.hpp` (add 3 getters) -- Modify: `src/slic3r/GUI/Widgets/CheckBox.hpp` (add 1 getter) -- Modify: `src/slic3r/GUI/Widgets/TextInput.hpp` (add 1 getter) -- Modify: `src/slic3r/GUI/Widgets/LabeledStaticBox.hpp` (add 4 getter declarations) -- Modify: `src/slic3r/GUI/Widgets/LabeledStaticBox.cpp` (add 4 getter implementations) - -**Interfaces:** -- Consumes: Nothing (prerequisite for all other tasks) -- Produces: - - `DPIAware

::set_scale_factor(float)`, `DPIAware

::set_prev_scale_factor(float)`, `DPIAware

::set_em_unit(int)`, `DPIAware

::force_rescale() const` - - `Button::GetStyle()`, `Button::GetType()`, `Button::IsSelected()` - - `CheckBox::IsHalfChecked()` - - `TextInput::GetCornerRadius()` - - `LabeledStaticBox::GetCornerRadius()`, `LabeledStaticBox::GetBorderWidth()`, `LabeledStaticBox::GetBorderColor()`, `LabeledStaticBox::GetScale()` - -- [ ] **Step 1: Add DPIAware setters/getter in GUI_Utils.hpp** - -After line 184 (`float prev_scale_factor() const { return m_prev_scale_factor; }`), add: - -```cpp -void set_scale_factor(float v) { m_scale_factor = v; } -void set_prev_scale_factor(float v) { m_prev_scale_factor = v; } -void set_em_unit(int v) { m_em_unit = v; } -bool force_rescale() const { return m_force_rescale; } -``` - -- [ ] **Step 2: Add Button getters in Button.hpp** - -After line 79 (`void SetSelected(bool selected = true) { m_selected = selected; }`), add: - -```cpp -ButtonStyle GetStyle() const { return m_style; } -ButtonType GetType() const { return m_type; } -bool IsSelected() const { return m_selected; } -``` - -- [ ] **Step 3: Add CheckBox getter in CheckBox.hpp** - -After line 16 (`void SetHalfChecked(bool value = true);`), add: - -```cpp -bool IsHalfChecked() const { return m_half_checked; } -``` - -- [ ] **Step 4: Add TextInput getter in TextInput.hpp** - -After line 44 (`void SetCornerRadius(double radius);`), add: - -```cpp -int GetCornerRadius() const { return static_cast(radius); } -``` - -(Note: `radius` is inherited from `StaticBox` which has it as a protected `double` member.) - -- [ ] **Step 5: Add LabeledStaticBox getter declarations in LabeledStaticBox.hpp** - -After line 46 (`bool Enable(bool enable) override;`), add: - -```cpp -int GetCornerRadius() const { return m_radius; } -int GetBorderWidth() const { return m_border_width; } -StateColor GetBorderColor() const { return border_color; } -float GetScale() const { return m_scale; } -``` - -(Note: all of `m_radius`, `m_border_width`, `border_color`, `m_scale` are protected members, accessible to inline methods.) - -- [ ] **Step 6: Commit** - -```bash -git add src/slic3r/GUI/GUI_Utils.hpp src/slic3r/GUI/Widgets/Button.hpp src/slic3r/GUI/Widgets/CheckBox.hpp src/slic3r/GUI/Widgets/TextInput.hpp src/slic3r/GUI/Widgets/LabeledStaticBox.hpp -git commit -m "feat: add getters/setters for wxInspector plugin access - -Add minimal public accessors to DPIAware (set_scale_factor, -set_prev_scale_factor, set_em_unit, force_rescale), Button -(GetStyle, GetType, IsSelected), CheckBox (IsHalfChecked), -TextInput (GetCornerRadius), and LabeledStaticBox -(GetCornerRadius, GetBorderWidth, GetBorderColor, GetScale)." -``` - ---- - -### Task 2: Create Registration helper header - -**Files:** -- Create: `src/slic3r/Utils/wxInspectorPlugins/Registration.hpp` - -**Interfaces:** -- Consumes: Nothing (forward-declares plugin classes) -- Produces: `RegisterOrcaInspectorPlugins()` - -- [ ] **Step 1: Create directory** - -```bash -mkdir -p src/slic3r/Utils/wxInspectorPlugins -``` - -- [ ] **Step 2: Write Registration.hpp** - -```cpp -#pragma once - -namespace wxInspector { -class wxInspectorPlugin; -void RegisterPlugin(wxInspectorPlugin* plugin); -} - -// Forward declare our plugins -class DPIAwarePlugin; -class CustomWidgetsPlugin; - -inline void RegisterOrcaInspectorPlugins() -{ - static DPIAwarePlugin dpiaware; - static CustomWidgetsPlugin customWidgets; - wxInspector::RegisterPlugin(&dpiaware); - wxInspector::RegisterPlugin(&customWidgets); -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/slic3r/Utils/wxInspectorPlugins/Registration.hpp -git commit -m "feat: add wxInspector plugin registration helper - -Add RegisterOrcaInspectorPlugins() inline function that creates -and registers the DPIAwarePlugin and CustomWidgetsPlugin as -static instances (matching wxInspector's built-in pattern)." -``` - ---- - -### Task 3: Create DPIAwarePlugin - -**Files:** -- Create: `src/slic3r/Utils/wxInspectorPlugins/DPIAwarePlugin.hpp` -- Create: `src/slic3r/Utils/wxInspectorPlugins/DPIAwarePlugin.cpp` - -**Interfaces:** -- Consumes: Task 1 (DPIAware getters/setters), Task 2 (registration pattern) -- Produces: `class DPIAwarePlugin : public wxInspector::wxInspectorPlugin` - -- [ ] **Step 1: Write DPIAwarePlugin.hpp** - -```cpp -#pragma once - -#include - -class DPIAwarePlugin : public wxInspector::wxInspectorPlugin -{ -public: - wxString GetName() const override; - - bool CanProvideProperties(wxClassInfo* info) override; - - wxVector GetProperties( - wxInspector::InspectableObject& obj) override; -}; -``` - -- [ ] **Step 2: Write DPIAwarePlugin.cpp** - -```cpp -#include "DPIAwarePlugin.hpp" - -#include "slic3r/GUI/GUI_Utils.hpp" // DPIFrame, DPIDialog, DPIAware

- -#include - -namespace { - -template -void addDPIProps(T* dpi, wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Scale Factor", "DPI Scaling", PropertyType::String, - wxString::Format("%.2f", dpi->scale_factor()), false, {}, - [dpi]() { return wxString::Format("%.2f", dpi->scale_factor()); }, - [dpi](const wxString& v) { - double val; - if (wxSscanf(v, "%lf", &val) != 1) return false; - dpi->set_scale_factor((float) val); - return true; - }}); - - props.push_back({"Prev Scale Factor", "DPI Scaling", PropertyType::String, - wxString::Format("%.2f", dpi->prev_scale_factor()), false, {}, - [dpi]() { return wxString::Format("%.2f", dpi->prev_scale_factor()); }, - [dpi](const wxString& v) { - double val; - if (wxSscanf(v, "%lf", &val) != 1) return false; - dpi->set_prev_scale_factor((float) val); - return true; - }}); - - props.push_back({"EM Unit", "DPI Scaling", PropertyType::Integer, - wxString::Format("%d", dpi->em_unit()), false, {}, - [dpi]() { return wxString::Format("%d", dpi->em_unit()); }, - [dpi](const wxString& v) { - long val; - if (!v.ToLong(&val)) return false; - dpi->set_em_unit((int) val); - return true; - }}); - - props.push_back({"Normal Font", "DPI Scaling", PropertyType::ReadOnly, - dpi->normal_font().GetNativeFontInfoDesc(), true, {}, - [dpi]() { return dpi->normal_font().GetNativeFontInfoDesc(); }, - nullptr}); - - props.push_back({"Force Rescale", "DPI Scaling", PropertyType::Boolean, - dpi->force_rescale() ? "true" : "false", true, {}, - [dpi]() { return dpi->force_rescale() ? "true" : "false"; }, - nullptr}); -} - -} // anonymous namespace - -wxString DPIAwarePlugin::GetName() const -{ - return "OrcaDPIAware"; -} - -bool DPIAwarePlugin::CanProvideProperties(wxClassInfo* info) -{ - return info->IsKindOf(CLASSINFO(wxWindow)); -} - -wxVector DPIAwarePlugin::GetProperties( - wxInspector::InspectableObject& obj) -{ - wxVector props; - wxWindow* win = obj.AsWindow(); - if (!win) return props; - - if (auto* frame = dynamic_cast(win)) { - addDPIProps(frame, props); - } else if (auto* dlg = dynamic_cast(win)) { - addDPIProps(dlg, props); - } - - return props; -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/slic3r/Utils/wxInspectorPlugins/DPIAwarePlugin.hpp src/slic3r/Utils/wxInspectorPlugins/DPIAwarePlugin.cpp -git commit -m "feat: add DPIAware wxInspector plugin - -Exposes DPI scaling properties (scale_factor, prev_scale_factor, -em_unit, normal_font, force_rescale) on DPIFrame and DPIDialog -widgets. Uses dynamic_cast for detection and a template helper -to capture the correct static type for lambda accessors." -``` - ---- - -### Task 4: Create CustomWidgetsPlugin - -**Files:** -- Create: `src/slic3r/Utils/wxInspectorPlugins/CustomWidgetsPlugin.hpp` -- Create: `src/slic3r/Utils/wxInspectorPlugins/CustomWidgetsPlugin.cpp` - -**Interfaces:** -- Consumes: Task 1 (all widget getters), Task 2 (registration pattern) -- Produces: `class CustomWidgetsPlugin : public wxInspector::wxInspectorPlugin` - -- [ ] **Step 1: Write CustomWidgetsPlugin.hpp** - -```cpp -#pragma once - -#include - -class CustomWidgetsPlugin : public wxInspector::wxInspectorPlugin -{ -public: - wxString GetName() const override; - - bool CanProvideProperties(wxClassInfo* info) override; - - wxVector GetProperties( - wxInspector::InspectableObject& obj) override; - -private: - void addButtonProps(class Button* btn, - wxVector& props); - void addCheckBoxProps(class CheckBox* cb, - wxVector& props); - void addTextInputProps(class TextInput* ti, - wxVector& props); - void addSwitchButtonProps(class SwitchButton* sb, - wxVector& props); - void addProgressBarProps(class ProgressBar* pb, - wxVector& props); - void addLabelProps(class Label* lbl, - wxVector& props); - void addLabeledStaticBoxProps(class LabeledStaticBox* lsb, - wxVector& props); -}; -``` - -- [ ] **Step 2: Write CustomWidgetsPlugin.cpp — includes and GetName/CanProvideProperties** - -```cpp -#include "CustomWidgetsPlugin.hpp" - -#include "slic3r/GUI/Widgets/Button.hpp" -#include "slic3r/GUI/Widgets/CheckBox.hpp" -#include "slic3r/GUI/Widgets/TextInput.hpp" -#include "slic3r/GUI/Widgets/SwitchButton.hpp" -#include "slic3r/GUI/Widgets/ProgressBar.hpp" -#include "slic3r/GUI/Widgets/Label.hpp" -#include "slic3r/GUI/Widgets/LabeledStaticBox.hpp" - -#include -#include - -wxString CustomWidgetsPlugin::GetName() const -{ - return "OrcaCustomWidgets"; -} - -bool CustomWidgetsPlugin::CanProvideProperties(wxClassInfo* info) -{ - return info->IsKindOf(CLASSINFO(wxWindow)); -} - -wxVector CustomWidgetsPlugin::GetProperties( - wxInspector::InspectableObject& obj) -{ - wxVector props; - wxWindow* win = obj.AsWindow(); - if (!win) return props; - - if (auto* btn = dynamic_cast(win)) - addButtonProps(btn, props); - if (auto* cb = dynamic_cast(win)) - addCheckBoxProps(cb, props); - if (auto* ti = dynamic_cast(win)) - addTextInputProps(ti, props); - if (auto* sb = dynamic_cast(win)) - addSwitchButtonProps(sb, props); - if (auto* pb = dynamic_cast(win)) - addProgressBarProps(pb, props); - if (auto* lbl = dynamic_cast(win)) - addLabelProps(lbl, props); - if (auto* lsb = dynamic_cast(win)) - addLabeledStaticBoxProps(lsb, props); - - return props; -} -``` - -- [ ] **Step 3: Write CustomWidgetsPlugin.cpp — addButtonProps** - -```cpp -void CustomWidgetsPlugin::addButtonProps(Button* btn, - wxVector& props) -{ - using namespace wxInspector; - - wxVector styleChoices; - styleChoices.push_back("Regular"); - styleChoices.push_back("Confirm"); - styleChoices.push_back("Alert"); - styleChoices.push_back("Disabled"); - - auto styleToStr = [](ButtonStyle s) -> wxString { - switch (s) { - case ButtonStyle::Regular: return "Regular"; - case ButtonStyle::Confirm: return "Confirm"; - case ButtonStyle::Alert: return "Alert"; - case ButtonStyle::Disabled: return "Disabled"; - } - return "Regular"; - }; - - props.push_back({"Button Style", "Orca Button", PropertyType::Choice, - styleToStr(btn->GetStyle()), false, styleChoices, - [btn, styleToStr]() { return styleToStr(btn->GetStyle()); }, - [btn](const wxString& v) { - ButtonStyle s = ButtonStyle::Regular; - if (v == "Confirm") s = ButtonStyle::Confirm; - else if (v == "Alert") s = ButtonStyle::Alert; - else if (v == "Disabled") s = ButtonStyle::Disabled; - btn->SetStyle(s, btn->GetType()); - return true; - }}); - - wxVector typeChoices; - typeChoices.push_back("Compact"); - typeChoices.push_back("Window"); - typeChoices.push_back("Choice"); - typeChoices.push_back("Parameter"); - typeChoices.push_back("Icon"); - typeChoices.push_back("Expanded"); - - auto typeToStr = [](ButtonType t) -> wxString { - switch (t) { - case ButtonType::Compact: return "Compact"; - case ButtonType::Window: return "Window"; - case ButtonType::Choice: return "Choice"; - case ButtonType::Parameter: return "Parameter"; - case ButtonType::Icon: return "Icon"; - case ButtonType::Expanded: return "Expanded"; - } - return "Compact"; - }; - - props.push_back({"Button Type", "Orca Button", PropertyType::Choice, - typeToStr(btn->GetType()), false, typeChoices, - [btn, typeToStr]() { return typeToStr(btn->GetType()); }, - [btn](const wxString& v) { - ButtonType t = ButtonType::Compact; - if (v == "Window") t = ButtonType::Window; - else if (v == "Choice") t = ButtonType::Choice; - else if (v == "Parameter") t = ButtonType::Parameter; - else if (v == "Icon") t = ButtonType::Icon; - else if (v == "Expanded") t = ButtonType::Expanded; - btn->SetStyle(btn->GetStyle(), t); - return true; - }}); - - props.push_back({"Selected", "Orca Button", PropertyType::Boolean, - btn->IsSelected() ? "true" : "false", false, {}, - [btn]() { return btn->IsSelected() ? "true" : "false"; }, - [btn](const wxString& v) { - btn->SetSelected(v == "true"); - btn->Refresh(); - return true; - }}); -} -``` - -- [ ] **Step 4: Write CustomWidgetsPlugin.cpp — addCheckBoxProps** - -```cpp -void CustomWidgetsPlugin::addCheckBoxProps(CheckBox* cb, - wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Half Checked", "Orca CheckBox", PropertyType::Boolean, - cb->IsHalfChecked() ? "true" : "false", false, {}, - [cb]() { return cb->IsHalfChecked() ? "true" : "false"; }, - [cb](const wxString& v) { - cb->SetHalfChecked(v == "true"); - return true; - }}); -} -``` - -- [ ] **Step 5: Write CustomWidgetsPlugin.cpp — addTextInputProps** - -```cpp -void CustomWidgetsPlugin::addTextInputProps(TextInput* ti, - wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Label", "Orca TextInput", PropertyType::String, - ti->GetLabel(), false, {}, - [ti]() { return ti->GetLabel(); }, - [ti](const wxString& v) { ti->SetLabel(v); return true; }}); - - props.push_back({"Text Value", "Orca TextInput", PropertyType::String, - ti->GetTextCtrl()->GetValue(), false, {}, - [ti]() { return ti->GetTextCtrl()->GetValue(); }, - [ti](const wxString& v) { ti->GetTextCtrl()->SetValue(v); return true; }}); - - props.push_back({"Corner Radius", "Orca TextInput", PropertyType::Integer, - wxString::Format("%d", ti->GetCornerRadius()), false, {}, - [ti]() { return wxString::Format("%d", ti->GetCornerRadius()); }, - [ti](const wxString& v) { - long val; - if (!v.ToLong(&val)) return false; - ti->SetCornerRadius((double) val); - ti->Refresh(); - return true; - }}); -} -``` - -- [ ] **Step 6: Write CustomWidgetsPlugin.cpp — addSwitchButtonProps** - -```cpp -void CustomWidgetsPlugin::addSwitchButtonProps(SwitchButton* sb, - wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Value", "Orca SwitchButton", PropertyType::Boolean, - sb->GetValue() ? "true" : "false", false, {}, - [sb]() { return sb->GetValue() ? "true" : "false"; }, - [sb](const wxString& v) { - sb->SetValue(v == "true"); - return true; - }}); -} -``` - -(Note: `GetValue()` and `SetValue()` are inherited from `wxBitmapToggleButton` → `wxToggleButton`.) - -- [ ] **Step 7: Write CustomWidgetsPlugin.cpp — addProgressBarProps** - -```cpp -void CustomWidgetsPlugin::addProgressBarProps(ProgressBar* pb, - wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Proportion", "Orca ProgressBar", PropertyType::String, - wxString::Format("%.2f", pb->m_proportion), false, {}, - [pb]() { return wxString::Format("%.2f", pb->m_proportion); }, - [pb](const wxString& v) { - double val; - if (wxSscanf(v, "%lf", &val) != 1) return false; - pb->m_proportion = val; - pb->Refresh(); - return true; - }}); - - props.push_back({"Show Number", "Orca ProgressBar", PropertyType::Boolean, - pb->m_shownumber ? "true" : "false", false, {}, - [pb]() { return pb->m_shownumber ? "true" : "false"; }, - [pb](const wxString& v) { - pb->m_shownumber = (v == "true"); - pb->Refresh(); - return true; - }}); -} -``` - -(Note: `m_proportion` and `m_shownumber` are public members on `ProgressBar`.) - -- [ ] **Step 8: Write CustomWidgetsPlugin.cpp — addLabelProps** - -```cpp -void CustomWidgetsPlugin::addLabelProps(Label* lbl, - wxVector& props) -{ - using namespace wxInspector; - - bool isHyperlink = (lbl->GetWindowStyleFlag() & 0x0020) != 0; // LB_HYPERLINK - - props.push_back({"Is Hyperlink", "Orca Label", PropertyType::Boolean, - isHyperlink ? "true" : "false", true, {}, - [lbl]() { - return (lbl->GetWindowStyleFlag() & 0x0020) ? "true" : "false"; - }, - nullptr}); - - props.push_back({"Font Point Size", "Orca Label", PropertyType::ReadOnly, - wxString::Format("%d", lbl->GetFont().GetPointSize()), true, {}, - [lbl]() { - return wxString::Format("%d", lbl->GetFont().GetPointSize()); - }, - nullptr}); -} -``` - -- [ ] **Step 9: Write CustomWidgetsPlugin.cpp — addLabeledStaticBoxProps** - -```cpp -void CustomWidgetsPlugin::addLabeledStaticBoxProps(LabeledStaticBox* lsb, - wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Corner Radius", "LabeledStaticBox", PropertyType::Integer, - wxString::Format("%d", lsb->GetCornerRadius()), false, {}, - [lsb]() { return wxString::Format("%d", lsb->GetCornerRadius()); }, - [lsb](const wxString& v) { - long val; - if (!v.ToLong(&val)) return false; - lsb->SetCornerRadius((int) val); - return true; - }}); - - props.push_back({"Border Width", "LabeledStaticBox", PropertyType::Integer, - wxString::Format("%d", lsb->GetBorderWidth()), false, {}, - [lsb]() { return wxString::Format("%d", lsb->GetBorderWidth()); }, - [lsb](const wxString& v) { - long val; - if (!v.ToLong(&val)) return false; - lsb->SetBorderWidth((int) val); - return true; - }}); - - // Border Color: display as hex string - wxColour bc = lsb->GetBorderColor().colorForStates(0); - props.push_back({"Border Color", "LabeledStaticBox", PropertyType::String, - bc.GetAsString(wxC2S_HTML_SYNTAX), false, {}, - [lsb]() { - return lsb->GetBorderColor() - .colorForStates(0) - .GetAsString(wxC2S_HTML_SYNTAX); - }, - [lsb](const wxString& v) { - wxColour c(v); - if (!c.IsOk()) return false; - lsb->SetBorderColor(StateColor(c)); - return true; - }}); - - props.push_back({"Scale", "LabeledStaticBox", PropertyType::ReadOnly, - wxString::Format("%.2f", lsb->GetScale()), true, {}, - [lsb]() { return wxString::Format("%.2f", lsb->GetScale()); }, - nullptr}); -} -``` - -- [ ] **Step 10: Commit** - -```bash -git add src/slic3r/Utils/wxInspectorPlugins/CustomWidgetsPlugin.hpp src/slic3r/Utils/wxInspectorPlugins/CustomWidgetsPlugin.cpp -git commit -m "feat: add OrcaCustomWidgets wxInspector plugin - -Exposes Orca-specific properties on 7 widget types: -- Button: Style, Type, Selected -- CheckBox: Half Checked -- TextInput: Label, Text Value, Corner Radius -- SwitchButton: Value -- ProgressBar: Proportion, Show Number -- Label: Is Hyperlink, Font Point Size -- LabeledStaticBox: Corner Radius, Border Width, Border Color, Scale - -Each widget type uses dynamic_cast for safe detection." -``` - ---- - -### Task 5: Wire plugins into MainFrame and CMakeLists - -**Files:** -- Modify: `src/slic3r/GUI/MainFrame.cpp` (add include + registration call) -- Modify: `src/slic3r/CMakeLists.txt` (add 4 source files) - -**Interfaces:** -- Consumes: Tasks 1-4 (all plugins and registration helper) -- Produces: Registered plugins available at runtime, buildable project - -- [ ] **Step 1: Add include in MainFrame.cpp** - -After the existing includes (around line 30, near the other Utils includes), add: - -```cpp -#include "slic3r/Utils/wxInspectorPlugins/Registration.hpp" -``` - -- [ ] **Step 2: Add registration call in MainFrame constructor** - -After `SetupInspectorAccelerator(this);` (currently line ~303), add: - -```cpp -RegisterOrcaInspectorPlugins(); -``` - -- [ ] **Step 3: Add source files to CMakeLists.txt** - -Find the `SLIC3R_GUI_SOURCES` list in `src/slic3r/CMakeLists.txt`. After the existing `Utils/*.cpp` entries (around line 650-754), add: - -```cmake -Utils/wxInspectorPlugins/DPIAwarePlugin.hpp -Utils/wxInspectorPlugins/DPIAwarePlugin.cpp -Utils/wxInspectorPlugins/CustomWidgetsPlugin.hpp -Utils/wxInspectorPlugins/CustomWidgetsPlugin.cpp -Utils/wxInspectorPlugins/Registration.hpp -``` - -(Note: Add all 5 files — 2 .hpp + 2 .cpp + 1 Registration.hpp. wxWidgets cmake needs headers listed too for the resource system.) - -- [ ] **Step 4: Commit** - -```bash -git add src/slic3r/GUI/MainFrame.cpp src/slic3r/CMakeLists.txt -git commit -m "feat: wire wxInspector plugins into MainFrame and build - -- Call RegisterOrcaInspectorPlugins() after SetupInspectorAccelerator -- Add all plugin source files to SLIC3R_GUI_SOURCES" -``` - ---- - -### Task 6: Build and verify - -**Files:** -- None modified (verification only) - -- [ ] **Step 1: Configure the build** - -```powershell -$cmakePath = "D:\VisualStudio\2026\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe" -& $cmakePath --build . --config Debug --target ALL_BUILD -- -m -``` - -Expected: Build succeeds with zero errors and zero warnings from our new files. - -- [ ] **Step 2: Fix any compilation errors** - -If the build fails: -- Check that `#include` paths resolve (the `slic3r/GUI/…` relative paths use `src/` as the include root — verify this is set up in CMake via `include_directories`) -- Check that `ButtonStyle` and `ButtonType` enums are visible (they're defined in `Button.hpp`) -- Check that `StateColor` constructor from `wxColour` is valid (it has `StateColor(wxColour const&)`) -- Check that `LabeledStaticBox::GetBorderColor()` returns by value (StateColor copy is fine) -- On macOS: static box margin removal call needs `#ifdef __WXOSX__` guard - -- [ ] **Step 3: Launch OrcaSlicer and verify inspector** - -Launch the built OrcaSlicer, press Ctrl+Shift+I to open the inspector: -1. Select the MainFrame in the tree — verify "DPI Scaling" category appears with Scale Factor, Prev Scale Factor, EM Unit, Normal Font, Force Rescale -2. Select an Orca Button — verify "Orca Button" category appears -3. Select an Orca CheckBox — verify "Orca CheckBox" category appears -4. Edit a property value (e.g., Scale Factor) — verify the setter applies correctly -5. Select a LabeledStaticBox — verify corner radius, border width, border color, scale appear - -- [ ] **Step 5: Commit (if fixes were needed) or mark complete** - -```bash -git status -``` - -If clean: verification complete. If changes were made: `git add` and commit with fix message. diff --git a/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md b/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md deleted file mode 100644 index 811626fc8b..0000000000 --- a/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md +++ /dev/null @@ -1,376 +0,0 @@ -# macOS FFmpeg Media Player 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:** Make macOS use the same FFmpeg-based media player (`wxMediaCtrl3` + `AVVideoDecoder`) as Windows/Linux, linking the static FFmpeg libraries from the deps build, and remove the old `wxMediaCtrl2.mm` BambuPlayer-based player. - -**Architecture:** The new player is platform-neutral C++ already used on Linux/Windows. Enabling it on macOS is pure build wiring: compile `wxMediaCtrl3.cpp` + `AVVideoDecoder.cpp` on macOS, drop the `__WXMAC__` alias that redirects `wxMediaCtrl3` to the old `wxMediaCtrl2`, and link static FFmpeg (`libavcodec.a`/`libswscale.a`/`libavutil.a`) from the deps install. The Bambu stream API is dlsym'd at runtime from the network plugin (`libBambuSource.dylib`), which already exports it — no plugin changes needed. Rendering reuses the existing `wxImage` → `DrawBitmap` paint path (same as Linux). - -**Tech Stack:** C++17, wxWidgets, CMake, FFmpeg 7.0.3 (libavcodec/libswscale/libavutil), macOS (Xcode generator), `deps/` ExternalProject build system. - -## Global Constraints - -- Branch: `dev/ffmpeg-player-macos`. Commit after every task. -- **Linux and Windows builds must not change** — the FFmpeg deps flag change is guarded by `APPLE`; Linux keeps `--enable-shared`, Windows keeps its prebuilt DLL zips. -- Static FFmpeg only on macOS: deps produce `libavcodec.a`/`libswscale.a`/`libavutil.a`; the app links those explicitly — the app binary must have **no** `libav*` dylib references (`otool -L` check). -- Follow existing code style: PascalCase classes, snake_case functions, C++17. -- No changes to `StatusPanel.cpp`, `MediaPlayCtrl.*`, or the BambuTunnel interface — the app already creates `wxMediaCtrl3` and uses only its public interface. -- The player cannot be unit-tested (hardware/plugin-dependent GUI code); verification is build-level, link-level, and manual runtime on a Mac. -- `localization/i18n/list.txt` references only `wxMediaCtrl2.cpp` (Win/Linux, stays) — no translation-list changes needed. -- Build dirs on the dev machine: main app = `build_arm64/` (Xcode generator, multi-config), deps = `deps/build/arm64/` (Unix Makefiles). App target name: `OrcaSlicer`. Substitute your own configured build dirs where noted. - ---- - -### Task 1: Enable wxMediaCtrl3 on macOS and link static FFmpeg - -**Files:** -- Modify: `src/slic3r/GUI/wxMediaCtrl3.h` (lines 18–22: the `#ifdef __WXMAC__` alias branch) -- Modify: `src/slic3r/GUI/wxMediaCtrl3.cpp:13` (uncomment the event define) -- Modify: `src/slic3r/GUI/wxMediaCtrl2.cpp:101` (remove the event define) -- Modify: `src/slic3r/CMakeLists.txt` (APPLE source list ~lines 779–792; FFmpeg link block ~lines 905–910) - -**Interfaces:** -- Consumes: nothing new (all classes already exist). -- Produces: `wxMediaCtrl3` class compiled on macOS with the same interface as Linux/Windows — `Load(wxURI)`, `Play()`, `Stop()`, `SetIdleImage(wxString)`, `GetState()`, `GetLastError()`, `GetVideoSize()`, event `EVT_MEDIA_CTRL_STAT` defined once in the lib (from `wxMediaCtrl3.cpp`). - -- [ ] **Step 1: Remove the macOS alias in wxMediaCtrl3.h** - -Current (lines 16–23 of `src/slic3r/GUI/wxMediaCtrl3.h`): - -```cpp -void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); - -#ifdef __WXMAC__ - -#include "wxMediaCtrl2.h" -#define wxMediaCtrl3 wxMediaCtrl2 - -#else - -#define BAMBU_DYNAMIC -``` - -New: - -```cpp -void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); - -#define BAMBU_DYNAMIC -``` - -Also remove the matching `#endif` that closed the `#else` branch (the one before the final `#endif /* wxMediaCtrl3_h */`), so the file's `#ifndef`/`#endif` guard pair stays balanced. - -- [ ] **Step 2: Move the EVT_MEDIA_CTRL_STAT definition into wxMediaCtrl3.cpp** - -In `src/slic3r/GUI/wxMediaCtrl3.cpp:13`, uncomment: - -```cpp -//wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); -``` - -becomes: - -```cpp -wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); -``` - -In `src/slic3r/GUI/wxMediaCtrl2.cpp:101`, delete: - -```cpp -wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); -``` - -(One definition total in the lib — `MediaPlayCtrl.cpp:59` binds this event on the media ctrl.) - -- [ ] **Step 3: Update the APPLE source list in CMakeLists.txt** - -In `src/slic3r/CMakeLists.txt`, the APPLE branch (currently compiles `wxMediaCtrl2.mm`, which becomes dead on macOS): - -```cmake - GUI/wxMediaCtrl2.mm - GUI/wxMediaCtrl2.h - GUI/wxMediaCtrl3.h - ) -``` - -becomes: - -```cmake - GUI/AVVideoDecoder.cpp - GUI/AVVideoDecoder.hpp - GUI/wxMediaCtrl3.cpp - GUI/wxMediaCtrl3.h - ) -``` - -(The `else ()` branch — Win/Linux — stays exactly as it is.) - -- [ ] **Step 4: Link static FFmpeg on macOS** - -In `src/slic3r/CMakeLists.txt`, the FFmpeg block (currently `if (NOT APPLE)`): - -```cmake -if (NOT APPLE) - pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET - libavcodec - libswscale - libavutil - ) - target_link_libraries(libslic3r_gui PkgConfig::LIBAV) -endif() -``` - -becomes: - -```cmake -if (APPLE) - # Static FFmpeg from the deps install: nothing to bundle into the .app, - # no rpath/install_name handling. Order matters: avcodec -> swscale -> avutil. - find_library(LIBAVCODEC_LIBRARY NAMES libavcodec.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) - find_library(LIBSWSCALE_LIBRARY NAMES libswscale.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) - find_library(LIBAVUTIL_LIBRARY NAMES libavutil.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) - target_link_libraries(libslic3r_gui ${LIBAVCODEC_LIBRARY} ${LIBSWSCALE_LIBRARY} ${LIBAVUTIL_LIBRARY}) - target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_PREFIX_PATH}/include) -else () - pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET - libavcodec - libswscale - libavutil - ) - target_link_libraries(libslic3r_gui PkgConfig::LIBAV) -endif() -``` - -The deps install (`${CMAKE_PREFIX_PATH}/lib`) already contains the three `.a` files from the existing arm64 deps build — no deps rebuild needed for this task. - -- [ ] **Step 5: Reconfigure and build the app** - -Run (Xcode generator; `cmake` re-runs automatically on build): - -```bash -cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer -``` - -Expected: configure succeeds (no `pkg_check_modules` errors on macOS, `find_library` finds all three `.a` files), compile succeeds (`wxMediaCtrl3.cpp` and `AVVideoDecoder.cpp` compile on macOS without changes), link succeeds. - -If CMake complains that `wxMediaCtrl3.h` is included but not in the source list or similar IDE-only warnings — ignore; headers in the list are cosmetic. - -- [ ] **Step 6: Verify no dynamic FFmpeg dependency** - -```bash -otool -L build_arm64/src/RelWithDebInfo/OrcaSlicer.app/Contents/MacOS/OrcaSlicer | grep -i "libav" || echo "OK: no dynamic FFmpeg" -``` - -Expected: prints `OK: no dynamic FFmpeg` (empty grep output). This is the whole point of static linking — nothing to bundle into the `.app`. - -- [ ] **Step 7: Quick sanity — macOS unit tests still pass** - -```bash -ctest --test-dir build_arm64/tests/libslic3r --output-on-failure -``` - -Expected: passes (add `-C RelWithDebInfo` if the multi-config generator requires it). If no tests were built in this build dir, build target `tests` first (`cmake --build build_arm64 --config RelWithDebInfo --target tests`). - -- [ ] **Step 8: Commit** - -```bash -git add src/slic3r/CMakeLists.txt src/slic3r/GUI/wxMediaCtrl3.h src/slic3r/GUI/wxMediaCtrl3.cpp src/slic3r/GUI/wxMediaCtrl2.cpp -git commit -m "feat: use FFmpeg media player on macOS with static FFmpeg" -``` - ---- - -### Task 2: Static-only FFmpeg in the macOS deps build - -**Files:** -- Modify: `deps/FFMPEG/FFMPEG.cmake` (non-MSVC branch, APPLE section and CONFIGURE_COMMAND) - -**Interfaces:** -- Consumes: nothing. -- Produces: a deps install on macOS containing only `libavcodec.a`, `libswscale.a`, `libavutil.a` (+ headers) — no `libav*` dylibs, so no bundling/rpath machinery is ever needed on macOS. Linux and Windows output are unchanged. - -- [ ] **Step 1: Add the static flag variable** - -In `deps/FFMPEG/FFMPEG.cmake`, inside the non-MSVC `else ()` branch, in the existing `if (APPLE)` block: - -```cmake - if (APPLE) - set(_minos_cmd - "CFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" - "LDFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" - ) -``` - -add after the `_minos_cmd` set: - -```cmake - # Static FFmpeg: nothing to bundle into the .app, no rpath handling. - # Shared flags must come AFTER --enable-shared below so they win. - set(_link_cmd --enable-static --disable-shared) -``` - -and add a matching `else ()` after the `if (IS_CROSS_COMPILE) ... endif()` block inside that `if (APPLE)`, so non-Apple Unix keeps shared: - -```cmake - else () - set(_link_cmd --enable-shared) - endif () -``` - -(If the existing `if (IS_CROSS_COMPILE)` block is the last thing inside `if (APPLE)`, the new `else ()` closes the `if (APPLE)` itself.) - -- [ ] **Step 2: Use the variable in CONFIGURE_COMMAND** - -In the `ExternalProject_Add(dep_FFMPEG ...)` configure command: - -```cmake - "--prefix=${DESTDIR}" - --enable-shared -``` - -becomes: - -```cmake - "--prefix=${DESTDIR}" - --enable-shared - ${_link_cmd} -``` - -Order matters: `--enable-shared` comes first, then `--enable-static --disable-shared` (APPLE) or `--enable-shared` (Linux) — the last flag wins in FFmpeg configure. - -- [ ] **Step 3: Rebuild the FFmpeg dep (slow — several minutes, run in background)** - -The changed CONFIGURE_COMMAND invalidates the ExternalProject stamp, so this re-configures and rebuilds FFmpeg: - -```bash -cmake --build deps/build/arm64 --target dep_FFMPEG -``` - -For a fully clean static-only check (removes the previous shared build tree, which can leave stale `.dylib` files behind in the in-source build): - -```bash -rm -rf deps/build/arm64/dep_FFMPEG-prefix -cmake --build deps/build/arm64 --target dep_FFMPEG -``` - -- [ ] **Step 4: Verify the artifacts** - -```bash -ls deps/build/arm64/dep_FFMPEG-prefix/src/dep_FFMPEG/libavcodec/*.a -ls deps/build/arm64/dep_FFMPEG-prefix/src/dep_FFMPEG/libavcodec/*.dylib 2>/dev/null || echo "OK: no dylibs" -``` - -Expected: `libavcodec.a` present, second command prints `OK: no dylibs`. Check `libavutil` and `libswscale` the same way. - -- [ ] **Step 5: Verify the app still links against the static libs** - -```bash -cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer -otool -L build_arm64/src/RelWithDebInfo/OrcaSlicer.app/Contents/MacOS/OrcaSlicer | grep -i "libav" || echo "OK: no dynamic FFmpeg" -``` - -Expected: build succeeds, `OK: no dynamic FFmpeg`. - -- [ ] **Step 6: Commit** - -```bash -git add deps/FFMPEG/FFMPEG.cmake -git commit -m "build: build static-only FFmpeg for macOS deps" -``` - ---- - -### Task 3: Remove the old macOS player - -**Files:** -- Delete: `src/slic3r/GUI/wxMediaCtrl2.mm` -- Delete: `src/slic3r/GUI/BambuPlayer/BambuPlayer.h` (and the empty `BambuPlayer/` dir) -- Modify: `src/slic3r/GUI/wxMediaCtrl2.h` (remove the `#ifdef __WXMAC__` section, lines 22–60) - -**Interfaces:** -- Consumes: Task 1 (macOS no longer references `wxMediaCtrl2` — nothing includes `wxMediaCtrl2.h` on macOS anymore; `wxMediaCtrl2` is never instantiated on any platform). -- Produces: a clean tree where the old BambuPlayer-based player is gone from macOS. The `BambuPlayer` ObjC class itself remains inside the network plugin (external prebuilt binary) — only the GUI-side consumer is removed. - -- [ ] **Step 1: Delete the old player files** - -```bash -git rm src/slic3r/GUI/wxMediaCtrl2.mm -git rm src/slic3r/GUI/BambuPlayer/BambuPlayer.h -rmdir src/slic3r/GUI/BambuPlayer 2>/dev/null || true -``` - -- [ ] **Step 2: Strip the __WXMAC__ section from wxMediaCtrl2.h** - -In `src/slic3r/GUI/wxMediaCtrl2.h`, remove the entire macOS branch of the `#ifdef __WXMAC__` guard — from `#ifdef __WXMAC__` (line 22) through the closing `};` of the mac class (line 60), and the `#else` marker — leaving only the non-mac `class wxMediaCtrl2 : public wxMediaCtrl { ... };` definition followed by the final `#endif /* wxMediaCtrl2_h */`. The resulting file keeps its `#ifndef`/`#endif` include guard pair balanced. - -The file stays on disk because Win/Linux compile `wxMediaCtrl2.cpp`, which includes it. - -- [ ] **Step 3: Grep for leftover references** - -```bash -grep -rn "wxMediaCtrl2.mm\|BambuPlayer/BambuPlayer.h\|BambuPlayer" src/slic3r --include="*.cpp" --include="*.h" --include="*.mm" --include="*.txt" -``` - -Expected: no hits in `src/slic3r/GUI` (ignore `localization/i18n/list.txt:196`, which lists the Win/Linux `wxMediaCtrl2.cpp` and stays). - -- [ ] **Step 4: Rebuild the app** - -```bash -cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer -``` - -Expected: configure + compile + link succeed with the deleted files gone. - -- [ ] **Step 5: Commit** - -```bash -git add -A src/slic3r/GUI -git commit -m "refactor: remove old BambuPlayer-based media player from macOS" -``` - ---- - -### Task 4: Runtime verification on hardware - -**Files:** none — manual verification. - -**Interfaces:** consumes all prior tasks. Final gate: the new player must actually stream on a Mac. - -- [ ] **Step 1: Launch the freshly built app** - -```bash -open build_arm64/src/RelWithDebInfo/OrcaSlicer.app -``` - -Expected: app launches normally; no crash in the network/device subsystem. - -- [ ] **Step 2: Load the network plugin and open the Device tab** - -Log in / ensure the network plugin (`libBambuSource.dylib`) loads, select a printer, open the Device tab (camera monitoring panel). - -Expected: the camera preview area shows the idle image initially (no crash — this exercises `wxMediaCtrl3::SetIdleImage` and the `wxImage` load path on macOS for the first time). - -- [ ] **Step 3: Start the stream and watch it render** - -Click play / wait for `MediaPlayCtrl` to start the stream. - -Expected: live video renders in the panel. Check the console/log output (`BOOST_LOG` goes to the terminal if run from it, or check the log file): -- `stat_log ...` lines appear (the `EVT_MEDIA_CTRL_STAT` path is live — proves the Bambu C API dlsym worked from `libBambuSource.dylib`); -- no repeated decode/error messages like `AVVideoDecoder: ...` or `can not find function ...` (proves `StaticBambuLib::get` resolved all Bambu functions); -- Stop/Play toggle works; idle image reappears on stop; -- window resize keeps aspect ratio (exercises `DoSetSize`/`adjust_frame_size`/`paintEvent`). - -- [ ] **Step 4: Confirm the old player is really gone** - -Expected: nothing in the logs references `BambuPlayer` (the ObjC class is no longer dlsym'd); the video path is entirely `wxMediaCtrl3` + `AVVideoDecoder`. - -If a printer is unavailable, at minimum verify Steps 1–2 (launch + idle image) and note in the PR that live-stream verification needs hardware. - -- [ ] **Step 5: Final review pass** - -```bash -git log --oneline -6 -git show --stat HEAD # and each of the three task commits -``` - -Expected: the last 4 commits are the design doc + the 3 implementation tasks (each task commit touches only its listed files). Review the diff for scope: no Linux/Windows changes beyond the two `EVT_MEDIA_CTRL_STAT` lines in Task 1, no `StatusPanel`/`MediaPlayCtrl` changes. diff --git a/docs/superpowers/specs/2026-07-23-wx-inspectable-on-dpiaware-design.md b/docs/superpowers/specs/2026-07-23-wx-inspectable-on-dpiaware-design.md deleted file mode 100644 index 3a4e873af6..0000000000 --- a/docs/superpowers/specs/2026-07-23-wx-inspectable-on-dpiaware-design.md +++ /dev/null @@ -1,102 +0,0 @@ -# Move `wxInspectable` into `DPIAware` — Design Spec - -Date: 2026-07-23 -Branch: `dev/layout-inspector` - -## Overview - -Move the `wxInspector::wxInspectable` base class from individual leaf classes (`DPIDialog`, `MainFrame`) into the common `DPIAware

` template. This makes every DPIAware widget automatically visible in the inspector tree without requiring each subclass to opt in. - -## Motivation - -Currently, only `DPIDialog` and `MainFrame` explicitly inherit `wxInspectable`. `DPIFrame` (which `MainFrame` inherits from) does not — `MainFrame` adds it manually. This means: - -- Any `DPIAware` widget that isn't `DPIDialog` or `MainFrame` is invisible in the inspector tree -- `DPIFrame` subclasses (`BaseTransparentDPIFrame`, `ImageDPIFrame`, `ModelMallDialog`, `MediaFileFrame`, `SecondaryCheckDialog`, `PrintErrorDialog`, etc.) don't appear -- Adding a new DPIAware widget type requires remembering to also inherit `wxInspectable` - -Moving `wxInspectable` to `DPIAware` fixes this for all current and future DPIAware widgets at once. - -## Design - -### Change 1: `GUI_Utils.hpp` — `DPIAware

` - -Add `wxInspector::wxInspectable` as a second base class, and call `SetupInspectorAccelerator(this)` in the constructor (after `this->CenterOnParent()`): - -```cpp -// Before: -template class DPIAware : public P - -// After: -template class DPIAware : public P, public wxInspector::wxInspectable -``` - -Add in the constructor body (after `this->CenterOnParent()` at line 110): -```cpp -SetupInspectorAccelerator(this); -``` - -This gives every `DPIAware` widget both inspectability and the Ctrl+Shift+I keyboard shortcut automatically. `#include ` is already present in the file. - -### Change 2: `GUI_Utils.hpp` — `DPIDialog` - -Remove the now-redundant `wxInspector::wxInspectable` and the `SetupInspectorAccelerator(this)` call: - -```cpp -// Before: -class DPIDialog : public DPIAware, public wxInspector::wxInspectable -// ... - SetupInspectorAccelerator(this); - -// After: -class DPIDialog : public DPIAware -// (SetupInspectorAccelerator call removed — now done in DPIAware constructor) -``` - -`DPIDialog` gets `wxInspectable` and the accelerator through `DPIAware` now. - -### Change 3: `MainFrame.hpp` — `MainFrame` - -Remove the now-redundant `wxInspector::wxInspectable`: - -```cpp -// Before: -class MainFrame : public DPIFrame, public wxInspector::wxInspectable - -// After: -class MainFrame : public DPIFrame -``` - -`MainFrame` gets `wxInspectable` through `DPIFrame` → `DPIAware`. - -### Change 4: `MainFrame.cpp` — `MainFrame` constructor - -Remove the now-redundant `SetupInspectorAccelerator(this)` call (line 304). It will be called automatically by the `DPIAware` constructor. - -## Impact - -| Widget | Before | After | -|--------|--------|-------| -| `DPIDialog` subclasses (~80) | ✓ inspectable | ✓ inspectable (transitive) | -| `MainFrame` | ✓ inspectable | ✓ inspectable (transitive) | -| `DPIFrame` subclasses (8 others) | ✗ invisible | ✓ inspectable | -| Future `DPIAware` | ✗ invisible | ✓ inspectable | - -## Files Modified - -| File | Change | -|------|--------| -| `src/slic3r/GUI/GUI_Utils.hpp` | `DPIAware

` gains `wxInspector::wxInspectable` + `SetupInspectorAccelerator(this)` call; `DPIDialog` drops redundant `wxInspector::wxInspectable` and `SetupInspectorAccelerator(this)` | -| `src/slic3r/GUI/MainFrame.hpp` | `MainFrame` drops redundant `wxInspector::wxInspectable` | -| `src/slic3r/GUI/MainFrame.cpp` | Remove redundant `SetupInspectorAccelerator(this)` from MainFrame constructor | - -## Non-Goals - -- The `DPIAwarePlugin` detection logic (`dynamic_cast` / `dynamic_cast`) is unchanged -- No new DPI properties — this is purely about tree visibility and accelerator setup - -## Risk Assessment - -- **Multiple inheritance**: `DPIAware

` already has a vtable (virtual destructor). Adding `wxInspectable` adds a second base but no additional data members. The `wxInspector::wxInspectable` class is expected to be a lightweight marker interface. -- **Build**: No new includes needed; `` is already included in `GUI_Utils.hpp`. -- **Cross-platform**: The change is standard C++ multiple inheritance — no platform-specific concerns. diff --git a/docs/superpowers/specs/2026-07-23-wx-inspector-plugins-design.md b/docs/superpowers/specs/2026-07-23-wx-inspector-plugins-design.md deleted file mode 100644 index 5715635969..0000000000 --- a/docs/superpowers/specs/2026-07-23-wx-inspector-plugins-design.md +++ /dev/null @@ -1,244 +0,0 @@ -# wxInspector Plugins for OrcaSlicer Custom Controls — Design Spec - -Date: 2026-07-23 -Branch: `dev/layout-inspector` - -## Overview - -Create wxInspector plugins that expose OrcaSlicer's custom widget properties in the inspector's property grid. Without these plugins, the inspector shows only generic wxWidgets properties — missing all DPI-awareness data, custom styling, and Orca-specific control state. - -## Goals - -1. **DPIAware properties** — Inspect and update `scale_factor`, `prev_scale_factor`, `em_unit`, and `normal_font` on any DPIAware-derived widget -2. **Custom widget properties** — Surface Orca-specific properties on `Button`, `CheckBox`, `TextInput`, `SwitchButton`, `ProgressBar`, `Label`, and `LabeledStaticBox` -3. **Minimal source changes** — Only add trivial (one-line) getters/setters to existing classes; no architectural refactoring of Orca's widget hierarchy - -## Non-Goals - -- Custom inspector panels or AUI tabs (use the existing property grid and method invoker) -- Python-plugin integration (this is C++ wxInspector, not Orca's Python plugin system) -- Event logging customization (the built-in event logger already works) - -## Architecture - -### Two Plugins - -| Plugin | Class | Files | -|--------|-------|-------| -| DPIAware plugin | `DPIAwarePlugin` | `DPIAwarePlugin.hpp`, `DPIAwarePlugin.cpp` | -| Custom widgets plugin | `CustomWidgetsPlugin` | `CustomWidgetsPlugin.hpp`, `CustomWidgetsPlugin.cpp` | -| Registration helper | inline function | `Registration.hpp` | - -All files live under `src/slic3r/Utils/wxInspectorPlugins/`. - -### Plugin Detection Strategy - -**DPIAware plugin**: Uses `dynamic_cast` and `dynamic_cast` as detection gates. `DPIFrame` = `DPIAware`, `DPIDialog` = `DPIAware`. Since these are concrete typedefs, `dynamic_cast` works at runtime. This covers `MainFrame`, `SettingsDialog`, and all 8 calibration dialogs (which inherit `DPIDialog`). - -**Custom widgets plugin**: Gates broadly on `CLASSINFO(wxWindow)`, then uses per-type `dynamic_cast` inside `GetProperties` to check each Orca-specific type. Only matching types append properties. - -### Registration - -A single `RegisterOrcaInspectorPlugins()` inline function in `Registration.hpp` creates both plugins as function-local statics (matching the wxInspector built-in provider pattern) and registers them via `wxInspector::RegisterPlugin()`. - -Called once from `MainFrame::MainFrame()` after `SetupInspectorAccelerator(this)`. - -### Why Separate Plugins? - -- DPIAware is a C++ template concept (not a wxClassInfo-isKindOf check), so it needs its own detection logic -- Custom widgets use standard wxClassInfo-based detection, matching the built-in provider pattern -- Two focused files are easier to review and maintain than one monolithic plugin -- Compile-time failure isolation: if a widget header changes, only one plugin breaks - -## DPIAware Plugin — Property Specification - -### Source Changes (GUI_Utils.hpp) - -Four one-liner methods added to the `DPIAware

` template class (public section): - -```cpp -float scale_factor() const { return m_scale_factor; } // already exists -float prev_scale_factor() const { return m_prev_scale_factor; } // already exists -int em_unit() const { return m_em_unit; } // already exists -void set_scale_factor(float v) { m_scale_factor = v; } // NEW -void set_prev_scale_factor(float v) { m_prev_scale_factor = v; } // NEW -void set_em_unit(int v) { m_em_unit = v; } // NEW -bool force_rescale() const { return m_force_rescale; } // NEW -// m_normal_font getter already exists: normal_font() -``` - -### Detection - -```cpp -bool CanProvideProperties(wxClassInfo* info) override { - // Gated in GetProperties via dynamic_cast on the window itself - return info->IsKindOf(CLASSINFO(wxWindow)); -} -``` - -In `GetProperties`: -```cpp -auto* win = obj.AsWindow(); -bool isDPI = dynamic_cast(win) || dynamic_cast(win); -if (!isDPI) return props; -``` - -### Property Table (category: "DPI Scaling") - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Scale Factor | String (float) | Yes | `dpi->scale_factor()` | `dpi->set_scale_factor(v)` | -| Prev Scale Factor | String (float) | Yes | `dpi->prev_scale_factor()` | `dpi->set_prev_scale_factor(v)` | -| EM Unit | Integer | Yes | `dpi->em_unit()` | `dpi->set_em_unit(v)` | -| Normal Font | ReadOnly | No | `dpi->normal_font().GetNativeFontInfoDesc()` | — | -| Force Rescale | Boolean (ReadOnly) | No | `dpi->force_rescale()` | — | - -**Note on setters**: The setters simply store values. They do NOT trigger a widget rescale/layout. To see the effect of a changed scale factor, use the inspector's Methods panel to call `Layout()` or resize the window — which triggers the DPI_CHANGED event path naturally. - -## Custom Widgets Plugin — Property Specification - -All properties are appended to the built-in wxWindow properties. Each widget type is independently detected via `dynamic_cast`. - -### Detection gates (in `GetProperties`) - -```cpp -auto* win = obj.AsWindow(); -if (auto* btn = dynamic_cast(win)) { addButtonProperties(btn, props); } -if (auto* cb = dynamic_cast(win)) { addCheckBoxProperties(cb, props); } -if (auto* ti = dynamic_cast(win)) { addTextInputProperties(ti, props); } -if (auto* sb = dynamic_cast(win)) { addSwitchButtonProperties(sb, props); } -if (auto* pb = dynamic_cast(win)) { addProgressBarProperties(pb, props); } -if (auto* lbl = dynamic_cast(win)) { addLabelProperties(lbl, props); } -if (auto* lsb = dynamic_cast(win)) { addLabeledStaticBoxProperties(lsb, props); } -``` - -### Orca Button (`Button`) — category: "Orca Button" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Button Style | Choice | Yes | enum→string | string→enum | -| Button Type | Choice | Yes | enum→string | string→enum | -| Selected | Boolean | Yes | `m_selected` (needs getter) | `SetSelected(v)` | -| Active Icon | ReadOnly | No | icon name string | — | -| Inactive Icon | ReadOnly | No | icon name string | — | - -Choices for Button Style: `Regular`, `Confirm`, `Alert`, `Disabled` -Choices for Button Type: `Compact`, `Window`, `Choice`, `Parameter`, `Icon`, `Expanded` - -**Source changes needed**: Button's `m_selected` is private. Add one-liner getter: -```cpp -bool IsSelected() const { return m_selected; } -``` - -### Orca CheckBox (`CheckBox`) — category: "Orca CheckBox" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Half Checked | Boolean | Yes | `m_half_checked` (needs getter) | `SetHalfChecked(v)` | - -**Source changes needed**: `m_half_checked` is private. Add one-liner getter: -```cpp -bool IsHalfChecked() const { return m_half_checked; } -``` - -### Orca TextInput (`TextInput`) — category: "Orca TextInput" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Label | String | Yes | `GetLabel()` (inherited from wxWindow) | `SetLabel(v)` (exists) | -| Text Value | String | Yes | `GetTextCtrl()->GetValue()` (GetTextCtrl is public) | `GetTextCtrl()->SetValue(v)` | -| Corner Radius | Integer | Yes | `GetCornerRadius()` (NEW) | `SetCornerRadius(v)` (exists) | - -**Source changes needed**: Add one getter to `TextInput`: -```cpp -int GetCornerRadius() const { return static_cast(radius); } -``` -(`radius` is inherited from StaticBox. `SetCornerRadius(double)` already exists. `GetTextCtrl()` is already public.) - -### Orca SwitchButton (`SwitchButton`) — category: "Orca SwitchButton" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Value | Boolean | Yes | existing getter | existing setter | - -### Orca ProgressBar (`ProgressBar`) — category: "Orca ProgressBar" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Proportion | Float (0-1) | Yes | `pb->m_proportion` (public member) | `pb->m_proportion = v` | -| Show Number | Boolean | Yes | `pb->m_shownumber` (public member) | `pb->m_shownumber = v` | - -**No source changes needed**: `m_proportion` and `m_shownumber` are already public members. `SetValue(int)` and `SetProgress(int)` already exist as public methods. - -### Orca Label (`Label`) — category: "Orca Label" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Is Hyperlink | Boolean | No | existing flag check | — | -| Font Size | ReadOnly | No | `GetFont().GetPointSize()` | — | - -### LabeledStaticBox — category: "LabeledStaticBox" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Corner Radius | Integer | Yes | `GetCornerRadius()` (NEW) | `SetCornerRadius(v)` (exists) | -| Border Width | Integer | Yes | `GetBorderWidth()` (NEW) | `SetBorderWidth(v)` (exists) | -| Border Color | String (hex) | Yes | `GetBorderColor()` (NEW) | `SetBorderColor(v)` (exists) | -| Scale | Float (ReadOnly) | No | `m_scale` (protected, needs getter) | — | - -**Source changes needed**: Four one-liner getters added to `LabeledStaticBox`: -```cpp -int GetCornerRadius() const { return m_radius; } -int GetBorderWidth() const { return m_border_width; } -StateColor GetBorderColor() const { return border_color; } -float GetScale() const { return m_scale; } -``` - -## Files Modified (Existing Code) - -| File | Changes | -|------|---------| -| `src/slic3r/GUI/GUI_Utils.hpp` | +4 methods in `DPIAware

`: `set_scale_factor()`, `set_prev_scale_factor()`, `set_em_unit()`, `force_rescale()` | -| `src/slic3r/GUI/Widgets/LabeledStaticBox.hpp` | +4 getter declarations: `GetCornerRadius()`, `GetBorderWidth()`, `GetBorderColor()`, `GetScale()` | -| `src/slic3r/GUI/Widgets/LabeledStaticBox.cpp` | +4 getter implementations | -| `src/slic3r/GUI/Widgets/Button.hpp` | +1 getter: `IsSelected()` | -| `src/slic3r/GUI/Widgets/CheckBox.hpp` | +1 getter: `IsHalfChecked()` | -| `src/slic3r/GUI/Widgets/TextInput.hpp` | +1 getter: `GetCornerRadius()` | -| `src/slic3r/GUI/Widgets/ProgressBar.hpp` | None (public members are used directly) | -| `src/slic3r/GUI/MainFrame.cpp` | +1 `#include`, +1 call to `RegisterOrcaInspectorPlugins()` | -| `src/slic3r/CMakeLists.txt` | +4 entries in `SLIC3R_GUI_SOURCES` (the .cpp plugin files) | - -## Files Created - -``` -src/slic3r/Utils/wxInspectorPlugins/ -├── DPIAwarePlugin.hpp -├── DPIAwarePlugin.cpp -├── CustomWidgetsPlugin.hpp -├── CustomWidgetsPlugin.cpp -└── Registration.hpp -``` - -## Build & Linking - -The `wxInspector` dependency is already wired: -- `deps/wxInspector/wxInspector.cmake` fetches and builds wxInspector -- `src/CMakeLists.txt` lines 92-93 link `wxInspector::wxInspector` into `wxWidgets_LIBRARIES` -- The plugin files only need `#include ` and `#include ` — both available from the installed dependency - -No new CMake dependencies needed. Only the new source files need listing in `SLIC3R_GUI_SOURCES`. - -## Error Handling & Edge Cases - -- **Stale pointers**: Plugin lambdas capture raw pointers, regenerated on every `GetProperties` call (matching wxInspector's built-in provider pattern). Pointers live only until the next tree selection. -- **Widget destruction**: If a widget is destroyed while the inspector is showing its properties, `InspectableObject::IsValid()` returns false and properties are not displayed. The inspector won't show stale data. -- **Invalid property values**: Setters use `sscanf` / `ToLong` with validation (matching built-in patterns). Bogus input is rejected — setter returns `false`, property grid shows error state. -- **DPI drift**: Setting `scale_factor` without triggering rescale means displayed sizes don't match the new factor. This is acceptable — the inspector is a developer tool; operators know to call `Layout()` after making changes. -- **Missing widget type**: If a `dynamic_cast` fails for all types, only built-in wxWindow properties are shown. No crash, no error — just reduced info. - -## Future Work (Out of Scope) - -- **StateColor visualization**: `StateColor` is a multi-value type (maps bitmask states to colors). A full solution would need a custom property editor (e.g., a table showing each state→color pair). Keep it simple for now. -- **ScalableBitmap display**: Could show the bitmap as an inline thumbnail. Complex property editor work — deferred. -- **More widget types**: `SwitchBoard`, `MultiSwitchButton`, `StepCtrl`, `FanControl`, `DropDown`, `ComboBox`, `AMS*` widgets could all benefit. Add as needed. -- **Property refresh on tree selection**: Currently properties are static snapshots. A "refresh" button or auto-poll could keep values current for rapidly-changing widgets (progress bars, etc.). The built-in wxInspector already provides a tree-refresh button. diff --git a/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md b/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md deleted file mode 100644 index 812a7bf735..0000000000 --- a/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md +++ /dev/null @@ -1,110 +0,0 @@ -# FFmpeg Media Player for macOS — Design - -Date: 2026-08-14 -Branch: `dev/ffmpeg-player-macos` - -## Problem - -The branch's new FFmpeg-based media player (`wxMediaCtrl3` + `AVVideoDecoder`) is used on -Windows and Linux, but macOS still runs the old player: `wxMediaCtrl2.mm`, an ObjC -`BambuPlayer` class dlsym'd from the Bambu network plugin that renders via CALayer. -On macOS, `wxMediaCtrl3` is currently aliased to `wxMediaCtrl2` and FFmpeg is not linked -into the app at all. - -Goal: make macOS use the same FFmpeg player as Windows/Linux, linking the **static** -FFmpeg libraries from the deps build instead of dynamic ones. - -## Current state (verified) - -- New player (Win/Linux): `GUI/wxMediaCtrl3.cpp` + `GUI/AVVideoDecoder.cpp`. Decodes with - FFmpeg (libavcodec/libswscale/libavutil), renders frames into `wxImage` (non-Windows) / - `wxBitmap` (Windows) drawn in a `paintEvent`, feeds via the `Bambu_*` C API - (`BambuTunnel.h`, `BAMBU_DYNAMIC`) dlsym'd from the network plugin through - `StaticBambuLib::get()` (`GUI/Printer/PrinterFileSystem.cpp`, compiled on all platforms). -- Old player (macOS): `GUI/wxMediaCtrl2.mm` uses the ObjC `BambuPlayer` class found via - `dlsym(module, "OBJC_CLASS_$_BambuPlayer")` in `libBambuSource.dylib`. -- The macOS network plugin `libBambuSource.dylib` already exports the full Bambu C API - (verified with `nm`), so the new player needs zero plugin changes. -- FFmpeg linking in `src/slic3r/CMakeLists.txt` is guarded by `if (NOT APPLE)` — - macOS currently does not link FFmpeg. -- `deps/FFMPEG/FFMPEG.cmake`: non-MSVC branch builds FFmpeg from source with - `--enable-shared`. The existing arm64 deps build on the dev machine happened to be - configured with both static and shared enabled, so `libavcodec.a` / `libswscale.a` / - `libavutil.a` are already present at - `deps/build/arm64/OrcaSlicer_dep/usr/local/lib/`. -- `EVT_MEDIA_CTRL_STAT` is `wxDEFINE_EVENT`'d in `wxMediaCtrl2.cpp` (Win/Linux) and - `wxMediaCtrl2.mm` (macOS); the define in `wxMediaCtrl3.cpp` is commented out. -- `wxMediaCtrl2` is never instantiated anywhere on any platform — dead code. -- `StatusPanel` already creates `wxMediaCtrl3`; `MediaPlayCtrl` only uses the - `wxMediaCtrl3` interface (`Load/Play/Stop/GetState/GetVideoSize/GetLastError/SetIdleImage`), - so no UI-side changes are needed. - -## Approach (approved) - -**Reuse the shared player on macOS.** Compile the existing `wxMediaCtrl3.cpp` + -`AVVideoDecoder.cpp` on macOS so all three platforms run one implementation. -Rendering uses the existing `wxImage` → `DrawBitmap` paint path, identical to Linux. -Known trade-off: frames are scaled to the widget's logical (1x) size, so Retina is -slightly soft compared to the old CALayer player. Accepted for now; a Retina-aware -scaling follow-up is possible later. - -Rejected alternative: a native CGImage/CALayer renderer for macOS — faster and -Retina-crisp, but adds a second render implementation to maintain. - -## Changes - -### 1. Enable the FFmpeg player on macOS (source) - -- `GUI/wxMediaCtrl3.h`: remove the `#ifdef __WXMAC__` branch (lines 18–22) that aliases - `wxMediaCtrl3` → `wxMediaCtrl2`. macOS then compiles the real `wxMediaCtrl3` class, - including the `BAMBU_DYNAMIC` BambuTunnel path used on Linux. -- Event symbol fix: move `wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent)` into - `wxMediaCtrl3.cpp` (uncomment the existing line) and remove it from - `wxMediaCtrl2.cpp`. One definition total in the lib; all three platforms resolve it. - -### 2. Static FFmpeg linking (deps + app) - -- `deps/FFMPEG/FFMPEG.cmake`: in the non-MSVC branch, pass - `--disable-shared --enable-static` when `APPLE`. Linux keeps `--enable-shared`; - Windows keeps its prebuilt shared DLL zips. Fresh macOS deps builds install only - `libavcodec.a` / `libswscale.a` / `libavutil.a` — no dylibs to bundle, no - rpath/install_name handling. (The existing local arm64 deps build already contains - the `.a` files, so no deps rebuild is strictly needed to try the change locally, - but a fresh CI deps build must produce them.) -- `src/slic3r/CMakeLists.txt`: - - APPLE branch of `SLIC3R_GUI_SOURCES`: add `GUI/wxMediaCtrl3.cpp`, - `GUI/wxMediaCtrl3.h`, `GUI/AVVideoDecoder.cpp`, `GUI/AVVideoDecoder.hpp`; - remove `GUI/wxMediaCtrl2.mm` and `GUI/wxMediaCtrl2.h` (the `.h` stays on - disk for the Win/Linux build of `wxMediaCtrl2.cpp`, but nothing on macOS - includes it after this change). - - Add an APPLE mirror of the `NOT APPLE` FFmpeg block: `find_library` for - `libavcodec.a`, `libswscale.a`, `libavutil.a` under `${CMAKE_PREFIX_PATH}/lib` - with `NO_DEFAULT_PATH`, link them (order avcodec → swscale → avutil), and add - `${CMAKE_PREFIX_PATH}/include` as a SYSTEM include directory. Deps are built with - `--disable-zlib` and no external codecs, so the three static libs link cleanly. - -### 3. Remove the old player - -- Delete `GUI/wxMediaCtrl2.mm` and `GUI/BambuPlayer/BambuPlayer.h` (header used only - by the `.mm`; the real `BambuPlayer` lives inside the network plugin). -- Remove the now-dead `__WXMAC__` section of `GUI/wxMediaCtrl2.h`. -- `wxMediaCtrl2.cpp` (Win/Linux) stays in the build as-is (dead but harmless; out of - scope to remove on this branch). - -### 4. Verification - -- Build on macOS: `cmake --build build_arm64` (or `build/arm64`). -- Confirm no dynamic FFmpeg dependency: `otool -L` on the app binary shows no `libav*` - dylib references. -- Runtime: with the network plugin loaded, the Device tab camera preview streams via - the FFmpeg player (check the device page / `MediaPlayCtrl`). -- macOS `ctest` still passes — static linking means no test-executable `.so` copying - hacks (unlike the Linux shared-lib setup). - -## Out of scope - -- Linux (shared libs, AppImage/flatpak bundling) and Windows (prebuilt DLL zips) - keep their current FFmpeg setup. -- Retina-aware frame scaling / native CGImage rendering (follow-up if visual quality - is judged insufficient). -- Audio streaming (neither player plays audio in this UI path). diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 0a770e0d8a..31f39921f4 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -1974,7 +1974,79 @@ int CLI::run(int argc, char **argv) } } - auto load_config_file = [](const std::string& file, DynamicPrintConfig& config, std::string& config_type, + std::unique_ptr cli_preset_bundle; + auto ensure_cli_preset_bundle = [&cli_preset_bundle, config_substitution_rule](std::string &error) -> PresetBundle * { + if (cli_preset_bundle) + return cli_preset_bundle.get(); + try { + AppConfig app_config; + const std::string app_config_error = app_config.load_if_exists(); + if (!app_config_error.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Ignoring invalid app config during CLI preset resolution: " << app_config_error; + app_config.reset(); + } + + auto bundle = std::make_unique(); + std::string load_error; + bundle->load_presets(app_config, config_substitution_rule, + PresetBundle::PresetPreferences(), &load_error, true); + if (!load_error.empty()) { + error = "Failed to load presets for inheritance resolution: " + load_error; + return nullptr; + } + cli_preset_bundle = std::move(bundle); + return cli_preset_bundle.get(); + } catch (const std::exception &ex) { + error = ex.what(); + return nullptr; + } + }; + + auto resolve_preset = [&ensure_cli_preset_bundle, config_substitution_rule](const std::string &file, DynamicPrintConfig &config, + std::string &config_type, const std::string &config_from, + bool probe_type, std::string &error) { + const auto *inherits = config.option(BBL_JSON_KEY_INHERITS); + if (!probe_type && (inherits == nullptr || inherits->value.empty())) + return true; + + std::unique_ptr source_bundle; + PresetBundle *bundle = nullptr; + bool allow_source_manifest = false; + if (config_from == "system") { + source_bundle = std::make_unique(); + bundle = source_bundle.get(); + allow_source_manifest = true; + } else { + bundle = ensure_cli_preset_bundle(error); + if (bundle == nullptr) + return false; + } + + if (probe_type) { + Preset::Type preset_type; + if (!bundle->resolve_preset_config_type(config, preset_type, file, config_substitution_rule, + error, allow_source_manifest)) + return false; + config_type = Preset::get_type_string(preset_type); + return true; + } + + Preset::Type preset_type; + if (config_type == "process") + preset_type = Preset::TYPE_PRINT; + else if (config_type == "filament") + preset_type = Preset::TYPE_FILAMENT; + else if (config_type == "machine") + preset_type = Preset::TYPE_PRINTER; + else { + error = "Unsupported preset type: " + config_type; + return false; + } + return bundle->resolve_preset_config(config, preset_type, file, config_substitution_rule, + error, allow_source_manifest); + }; + + auto load_config_file = [config_substitution_rule, &resolve_preset](const std::string& file, DynamicPrintConfig& config, std::string& config_type, std::string& config_name, std::string& filament_id, std::string& config_from) { if (! boost::filesystem::exists(file)) { boost::nowide::cerr << __FUNCTION__<< ": can not find setting file: " << file << std::endl; @@ -2003,9 +2075,15 @@ int CLI::run(int argc, char **argv) } auto type_iter = key_values.find(BBL_JSON_KEY_TYPE); - if (type_iter != key_values.end()) { + const bool probe_type = type_iter == key_values.end(); + if (!probe_type) config_type = type_iter->second; + + if (!resolve_preset(file, config, config_type, config_from, probe_type, reason)) { + boost::nowide::cerr << __FUNCTION__ << boost::format(": can not resolve preset %1%: %2%") % file % reason << std::endl; + return CLI_CONFIG_FILE_ERROR; } + if (config_type == "machine") { //config.set("printer_settings_id", config_name, true); //printer_inherits = config.option("inherits", true)->value; diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 3c98341cb5..4a6280d3ae 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -1843,4 +1843,9 @@ bool AppConfig::exists() return boost::filesystem::exists(config_path()); } +std::string AppConfig::load_if_exists() +{ + return boost::filesystem::exists(loading_path()) ? load() : std::string(); +} + }; // namespace Slic3r diff --git a/src/libslic3r/AppConfig.hpp b/src/libslic3r/AppConfig.hpp index 0a278f4f1f..c799502993 100644 --- a/src/libslic3r/AppConfig.hpp +++ b/src/libslic3r/AppConfig.hpp @@ -113,8 +113,10 @@ public: void set_defaults(); // Load the slic3r.ini from a user profile directory (or a datadir, if configured). - // return error string or empty strinf + // Return an error string, or an empty string on success. std::string load(); + // Treat a missing config as default state; otherwise load it normally. + std::string load_if_exists(); // Store the slic3r.ini into a user profile directory (or a datadir, if configured). void save(); diff --git a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp index 97f8f743fb..e5fbeb5cdb 100644 --- a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp +++ b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp @@ -342,7 +342,7 @@ void fuzzy_polyline(Points& poly, bool closed, coordf_t slice_z, const FuzzySkin } // Thanks Cura developers for this function. -void fuzzy_extrusion_line(Arachne::ExtrusionJunctions& ext_lines, coordf_t slice_z, const FuzzySkinConfig& cfg, bool closed) +void fuzzy_extrusion_line(Arachne::ExtrusionJunctions& ext_lines, coordf_t slice_z, coordf_t layer_height, const FuzzySkinConfig& cfg, bool closed) { if (cfg.noise_type == NoiseType::Ripple) { @@ -356,7 +356,9 @@ void fuzzy_extrusion_line(Arachne::ExtrusionJunctions& ext_lines, coordf_t slice const double min_dist_between_points = cfg.point_distance * 3. / 4.; // hardcoded: the point distance may vary between 3/4 and 5/4 the supplied value const double range_random_point_dist = cfg.point_distance / 2.; - const double min_extrusion_width = 0.01; // workaround for many print options. Need overwrite formula with the layer height parameter. The width must more than >>> layer_height * (1 - 0.25 * PI) * 1.05 <<< (last num is the coeff of overlay error case) + // ExtrusionJunction::w is a scaled coord_t, so this floor must be scaled too. + // Flow::rounded_rectangle_extrusion_spacing() requires width > height * (1 - 0.25 * PI); keep 5% above it. + const double min_extrusion_width = scaled(layer_height * (1. - 0.25 * M_PI) * 1.05); double dist_left_over = random_value() * (min_dist_between_points / 2.); // the distance to be traversed on the line before making the first new point auto* p0 = &ext_lines.front(); @@ -685,12 +687,13 @@ Polygon apply_fuzzy_skin(const Polygon& polygon, const PerimeterGenerator& perim void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerator& perimeter_generator, const bool is_contour, const bool closed) { const auto slice_z = perimeter_generator.slice_z; + const auto layer_height = perimeter_generator.layer_height; const auto& regions = perimeter_generator.regions_by_fuzzify; if (regions.size() == 1) { // optimization const auto& config = regions.begin()->first; const bool fuzzify = should_fuzzify(config, perimeter_generator.layer_id, extrusion->inset_idx, is_contour); if (fuzzify) - fuzzy_extrusion_line(extrusion->junctions, slice_z, config, closed); + fuzzy_extrusion_line(extrusion->junctions, slice_z, perimeter_generator.layer_height, config, closed); } else { // Merge regions that produce identical fuzzy effects (differ only in type). // When the style (e.g. External) and a painted region (All) both fuzzify this loop @@ -701,7 +704,7 @@ void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerato // Fast path: single merged region — apply directly without splitting if (merged_regions.size() == 1 && merged_regions.front().expolygons.empty()) { - fuzzy_extrusion_line(extrusion->junctions, slice_z, *merged_regions.front().config, closed); + fuzzy_extrusion_line(extrusion->junctions, slice_z, perimeter_generator.layer_height, *merged_regions.front().config, closed); return; } @@ -761,7 +764,7 @@ void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerato // Fuzzy splitted extrusion if (std::all_of(splitted.begin(), splitted.end(), [](const Algorithm::SplitLineJunction& j) { return j.clipped; })) { // The entire polygon is fuzzified - fuzzy_extrusion_line(extrusion->junctions, slice_z, *r.config, closed); + fuzzy_extrusion_line(extrusion->junctions, slice_z, perimeter_generator.layer_height, *r.config, closed); continue; } else { const auto current_ext = extrusion->junctions; @@ -769,12 +772,12 @@ void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerato segment.reserve(current_ext.size()); extrusion->junctions.clear(); - const auto fuzzy_current_segment = [&segment, &extrusion, &r, slice_z]() { + const auto fuzzy_current_segment = [&segment, &extrusion, &r, slice_z, layer_height]() { // Orca: non fuzzy points to isolate fuzzy region const auto front = segment.front(); const auto back = segment.back(); - fuzzy_extrusion_line(segment, slice_z, *r.config, false); + fuzzy_extrusion_line(segment, slice_z, layer_height, *r.config, false); // Orca: only add non fuzzy point if it's not in the extrusion closing point. if (!extrusion->junctions.empty() && extrusion->junctions.front().p != front.p) { extrusion->junctions.push_back(front); diff --git a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp index 51d503a3c9..ab0c491b38 100644 --- a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp +++ b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp @@ -9,7 +9,7 @@ namespace Slic3r::Feature::FuzzySkin { void fuzzy_polyline(Points& poly, bool closed, coordf_t slice_z, const FuzzySkinConfig& cfg); -void fuzzy_extrusion_line(Arachne::ExtrusionJunctions& ext_lines, coordf_t slice_z, const FuzzySkinConfig& cfg, bool closed = true); +void fuzzy_extrusion_line(Arachne::ExtrusionJunctions& ext_lines, coordf_t slice_z, coordf_t layer_height, const FuzzySkinConfig& cfg, bool closed = true); void group_region_by_fuzzify(PerimeterGenerator& g); diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index de17a678ce..279ad49d92 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1653,7 +1653,7 @@ std::string PresetCollection::canonical_preset_name(const std::string &name, con void PresetCollection::load_presets( const std::string &dir_path, const std::string &subdir, PresetsConfigSubstitutions& substitutions, ForwardCompatibilitySubstitutionRule substitution_rule, - std::function preset_loaded_fn, const PresetOrigin &load_origin) + std::function preset_loaded_fn, const PresetOrigin &load_origin, bool read_only) { // Don't use boost::filesystem::canonical() on Windows, it is broken in regard to reparse points, // see https://github.com/prusa3d/PrusaSlicer/issues/732 @@ -1662,7 +1662,7 @@ void PresetCollection::load_presets( // Load custom roots first if (fs::exists(dir / "base")) { - load_presets(dir.string(), "base", substitutions, substitution_rule, nullptr, resolved_origin); + load_presets(dir.string(), "base", substitutions, substitution_rule, nullptr, resolved_origin, read_only); } //BBS: add config related logs @@ -1670,7 +1670,8 @@ void PresetCollection::load_presets( //BBS do not parse folder if not exists m_dir_path = dir.string(); if (!fs::exists(dir)) { - fs::create_directory(dir); + if (!read_only) + fs::create_directory(dir); return; } @@ -1720,10 +1721,10 @@ void PresetCollection::load_presets( substitutions.push_back({ preset.name, m_type, PresetConfigSubstitutions::Source::UserFile, preset.file, std::move(config_substitutions) }); if (!reason.empty()) { fs::path file_path(preset.file); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); file_path.replace_extension(".info"); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); BOOST_LOG_TRIVIAL(error) << boost::format("parse config %1% failed")%preset.file; ++m_errors; @@ -1794,7 +1795,8 @@ void PresetCollection::load_presets( size_t at_pos = name.find('@'); if (at_pos != std::string::npos && at_pos + 1 < name.length()) { compatible_printers->values.push_back(name.substr(at_pos + 1)); - preset.save(nullptr); + if (!read_only) + preset.save(nullptr); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " added compatible_printers for preset: " << name; } } @@ -1812,10 +1814,10 @@ void PresetCollection::load_presets( ++m_errors; BOOST_LOG_TRIVIAL(error) << boost::format("The user-config cannot be loaded: %1%. Reason: %2%")%preset.file %err.what(); fs::path file_path(preset.file); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); file_path.replace_extension(".info"); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); //throw Slic3r::RuntimeError(std::string("The selected preset cannot be loaded: ") + preset.file + "\n\tReason: " + err.what()); } catch (const std::runtime_error &err) { @@ -1823,10 +1825,10 @@ void PresetCollection::load_presets( BOOST_LOG_TRIVIAL(error) << boost::format("Failed loading the user-config file: %1%. Reason: %2%")%preset.file %err.what(); //throw Slic3r::RuntimeError(std::string("Failed loading the preset file: ") + preset.file + "\n\tReason: " + err.what()); fs::path file_path(preset.file); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); file_path.replace_extension(".info"); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); } diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp index e0728d7fa2..6a7871d07d 100644 --- a/src/libslic3r/Preset.hpp +++ b/src/libslic3r/Preset.hpp @@ -558,7 +558,7 @@ public: void add_default_preset(const std::vector &keys, const Slic3r::StaticPrintConfig &defaults, const std::string &preset_name); // Load ini files of the particular type from the provided directory path. - void load_presets(const std::string &dir_path, const std::string &subdir, PresetsConfigSubstitutions& substitutions, ForwardCompatibilitySubstitutionRule rule, std::function preset_loaded_fn = nullptr, const PresetOrigin &load_origin = PresetOrigin()); + void load_presets(const std::string &dir_path, const std::string &subdir, PresetsConfigSubstitutions& substitutions, ForwardCompatibilitySubstitutionRule rule, std::function preset_loaded_fn = nullptr, const PresetOrigin &load_origin = PresetOrigin(), bool read_only = false); //BBS: update user presets directory void update_user_presets_directory(const std::string& dir_path, const std::string& type); diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 29bccf0966..6d4e77837a 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -453,6 +453,158 @@ PresetBundle::PresetBundle() this->project_config.apply_only(FullPrintConfig::defaults(), s_project_options); } +bool PresetBundle::resolve_preset_config(DynamicPrintConfig &config, Preset::Type type, + const std::string &source_file, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error, bool allow_source_manifest) +{ + if (compatibility_rule == ForwardCompatibilitySubstitutionRule::EnableSystemSilent) + compatibility_rule = ForwardCompatibilitySubstitutionRule::EnableSilent; + else if (compatibility_rule == ForwardCompatibilitySubstitutionRule::EnableSilentDisableSystem) + compatibility_rule = ForwardCompatibilitySubstitutionRule::Disable; + + auto collection_for_type = [](PresetBundle &bundle, Preset::Type preset_type) -> PresetCollection * { + switch (preset_type) { + case Preset::TYPE_PRINT: return &bundle.prints; + case Preset::TYPE_FILAMENT: return &bundle.filaments; + case Preset::TYPE_PRINTER: return &bundle.printers; + default: return nullptr; + } + }; + + PresetCollection *collection = collection_for_type(*this, type); + if (collection == nullptr) { + error = "Unsupported preset type"; + return false; + } + + const boost::filesystem::path source_path = boost::filesystem::absolute(source_file).lexically_normal(); + auto find_loaded = [&](PresetBundle &bundle) -> const Preset * { + PresetCollection *loaded_collection = collection_for_type(bundle, type); + const Preset *resolved = nullptr; + for (const Preset &preset : loaded_collection->get_presets()) { + if (preset.file.empty()) + continue; + + boost::system::error_code ec; + const bool same_file = boost::filesystem::equivalent(source_path, boost::filesystem::path(preset.file), ec); + if (ec || !same_file) + continue; + if (resolved != nullptr) { + error = "Preset identity is ambiguous"; + return nullptr; + } + resolved = &preset; + } + return resolved; + }; + + if (const Preset *resolved = find_loaded(*this)) { + config = resolved->config; + error.clear(); + return true; + } + if (error == "Preset identity is ambiguous") + return false; + if (!allow_source_manifest) { + error = "Preset was not found in the loaded bundle"; + return false; + } + + // A manifest-backed source file can be resolved without requiring the vendor + // to have been copied into data_dir()/system. Find the nearest ancestor whose + // sibling manifest names it, then let the canonical vendor loader flatten the + // complete tree (including nested sub_path entries and library inheritance). + for (boost::filesystem::path vendor_dir = source_path.parent_path(); !vendor_dir.empty(); vendor_dir = vendor_dir.parent_path()) { + const std::string vendor_id = vendor_dir.filename().string(); + if (vendor_id.empty()) + continue; + const boost::filesystem::path root_dir = vendor_dir.parent_path(); + const boost::filesystem::path manifest = root_dir / (vendor_id + ".json"); + if (!boost::filesystem::is_regular_file(manifest)) + continue; + const boost::filesystem::path manifest_relative = source_path.lexically_relative(vendor_dir); + if (manifest_relative.empty() || *manifest_relative.begin() == "..") + continue; + + try { + PresetBundle library_bundle; + const PresetBundle *base_bundle = nullptr; + if (vendor_id != ORCA_FILAMENT_LIBRARY && + boost::filesystem::is_regular_file(root_dir / (std::string(ORCA_FILAMENT_LIBRARY) + ".json"))) { + library_bundle.m_preserve_vendor_source_paths = true; + library_bundle.load_vendor_configs_from_json(root_dir.string(), ORCA_FILAMENT_LIBRARY, LoadSystem, + compatibility_rule, nullptr, false); + if (library_bundle.error_count() != 0) { + error = "OrcaFilamentLibrary contains invalid presets"; + return false; + } + base_bundle = &library_bundle; + } + + PresetBundle source_bundle; + source_bundle.m_preserve_vendor_source_paths = true; + source_bundle.load_vendor_configs_from_json(root_dir.string(), vendor_id, LoadSystem, + compatibility_rule, base_bundle, false); + if (source_bundle.error_count() != 0) { + error = "Vendor bundle contains invalid presets"; + return false; + } + + const Preset *resolved = find_loaded(source_bundle); + if (resolved == nullptr) { + if (error.empty()) + error = "Source file is not an instantiated preset in its vendor manifest"; + return false; + } + config = resolved->config; + error.clear(); + return true; + } catch (const std::exception &ex) { + error = ex.what(); + return false; + } + } + + error = "Preset was not found in the loaded bundle"; + return false; +} + +bool PresetBundle::resolve_preset_config_type(DynamicPrintConfig &config, Preset::Type &type, + const std::string &source_file, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error, bool allow_source_manifest) +{ + std::optional> resolved; + for (Preset::Type candidate_type : types_list(ptFFF)) { + DynamicPrintConfig candidate_config(config); + std::string candidate_error; + if (!resolve_preset_config(candidate_config, candidate_type, source_file, compatibility_rule, + candidate_error, allow_source_manifest)) { + if (candidate_error == "Preset identity is ambiguous") { + error = std::move(candidate_error); + return false; + } + continue; + } + if (resolved) { + error = "Preset type is ambiguous"; + return false; + } + resolved.emplace(candidate_type, std::move(candidate_config)); + } + + if (!resolved) { + error = "Preset type could not be resolved"; + return false; + } + + type = resolved->first; + config = std::move(resolved->second); + error.clear(); + return true; +} + PresetBundle::PresetBundle(const PresetBundle &rhs) { *this = rhs; @@ -574,7 +726,8 @@ void PresetBundle::copy_files(const std::string& from) } PresetsConfigSubstitutions PresetBundle::load_presets(AppConfig &config, ForwardCompatibilitySubstitutionRule substitution_rule, - const PresetPreferences& preferred_selection/* = PresetPreferences()*/) + const PresetPreferences& preferred_selection/* = PresetPreferences()*/, + std::string *errors, bool read_only) { // First load the vendor specific system presets. PresetsConfigSubstitutions substitutions; @@ -585,16 +738,20 @@ PresetsConfigSubstitutions PresetBundle::load_presets(AppConfig &config, Forward const auto startup_t0 = std::chrono::steady_clock::now(); //BBS: change system config to json - std::tie(substitutions, errors_cummulative) = this->load_system_presets_from_json(substitution_rule); + std::tie(substitutions, errors_cummulative) = this->load_system_presets_from_json(substitution_rule, !read_only); + if (errors != nullptr) + *errors = errors_cummulative; // BBS load preset from user's folder, load system default if // BBS: change directories by design std::string dir_user_presets = config.get("preset_folder"); if (dir_user_presets.empty()) { - load_user_presets(DEFAULT_USER_FOLDER_NAME, substitution_rule); + load_user_presets(DEFAULT_USER_FOLDER_NAME, substitution_rule, read_only); } else { - load_user_presets(dir_user_presets, substitution_rule); + load_user_presets(dir_user_presets, substitution_rule, read_only); } + if (errors != nullptr && errors->empty() && m_errors != 0) + *errors = "Preset loading reported " + std::to_string(m_errors) + " error(s)"; // Rewrite renamed compatible_printers / compatible_prints references before selection. Skipped // in validation mode so the profile validator (has_errors -> check_preset_references) sees the @@ -1010,18 +1167,26 @@ std::string PresetBundle::get_hotend_model_for_printer_model(std::string model_n return out; } -PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule substitution_rule) +PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule substitution_rule, bool read_only) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " entry and user is: " << user; PresetsConfigSubstitutions substitutions; std::string errors_cummulative; fs::path user_folder(data_dir() + "/" + PRESET_USER_DIR); - if (!fs::exists(user_folder)) fs::create_directory(user_folder); + if (!fs::exists(user_folder)) { + if (read_only) + return substitutions; + fs::create_directory(user_folder); + } std::string dir_user_presets = data_dir() + "/" + PRESET_USER_DIR + "/" + user; fs::path folder(user_folder / user); - if (!fs::exists(folder)) fs::create_directory(folder); + if (!fs::exists(folder)) { + if (read_only) + return substitutions; + fs::create_directory(folder); + } bundles.WriteLock(); bundles.m_bundles.clear(); @@ -1049,13 +1214,13 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For this->prints.load_presets(bundle_dir, PRESET_PRINT_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.print_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only); this->filaments.load_presets(bundle_dir, PRESET_FILAMENT_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.filament_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only); this->printers.load_presets(bundle_dir, PRESET_PRINTER_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.printer_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only); metadata.bundle_type = BundleType::Local; metadata.path = metadata_file.string(); @@ -1085,13 +1250,13 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For this->prints.load_presets(bundle_dir, PRESET_PRINT_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.print_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only); this->filaments.load_presets(bundle_dir, PRESET_FILAMENT_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.filament_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only); this->printers.load_presets(bundle_dir, PRESET_PRINTER_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.printer_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only); metadata.bundle_type = BundleType::Subscribed; metadata.path = metadata_file.string(); @@ -1110,17 +1275,20 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For const auto json_t0 = std::chrono::steady_clock::now(); try { std::string sel = prints.get_selected_preset().name; - this->prints.load_presets(dir_user_presets, PRESET_PRINT_NAME, substitutions, substitution_rule); + this->prints.load_presets(dir_user_presets, PRESET_PRINT_NAME, substitutions, substitution_rule, + nullptr, PresetOrigin(), read_only); prints.select_preset_by_name(sel, false); } catch (const std::runtime_error& err) { errors_cummulative += err.what(); } try { std::string sel = filaments.get_selected_preset().name; - this->filaments.load_presets(dir_user_presets, PRESET_FILAMENT_NAME, substitutions, substitution_rule); + this->filaments.load_presets(dir_user_presets, PRESET_FILAMENT_NAME, substitutions, substitution_rule, + nullptr, PresetOrigin(), read_only); filaments.select_preset_by_name(sel, false); } catch (const std::runtime_error& err) { errors_cummulative += err.what(); } try { std::string sel = printers.get_selected_preset().name; - this->printers.load_presets(dir_user_presets, PRESET_PRINTER_NAME, substitutions, substitution_rule); + this->printers.load_presets(dir_user_presets, PRESET_PRINTER_NAME, substitutions, substitution_rule, + nullptr, PresetOrigin(), read_only); printers.select_preset_by_name(sel, false); } catch (const std::runtime_error& err) { errors_cummulative += err.what(); } if (!errors_cummulative.empty()) throw Slic3r::RuntimeError(errors_cummulative); @@ -2266,7 +2434,8 @@ void PresetBundle::clear_printer_hold_aliases() } //BBS: add json related logic, load system presets from json -std::pair PresetBundle::load_system_presets_from_json(ForwardCompatibilitySubstitutionRule compatibility_rule) +std::pair PresetBundle::load_system_presets_from_json( + ForwardCompatibilitySubstitutionRule compatibility_rule, bool allow_cache) { //BBS: add config related logs BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(" enter, compatibility_rule %1%")%compatibility_rule; @@ -2288,7 +2457,7 @@ std::pair PresetBundle::load_system_pre // The vendors below are loaded whole and against each other — the filament // library first, then every other vendor with it as the base — so each parse // is complete enough to be worth caching. - m_generate_vendor_caches = m_generate_vendor_caches || ! validation_mode; + m_generate_vendor_caches = allow_cache && (m_generate_vendor_caches || !validation_mode); PresetsConfigSubstitutions substitutions; std::string errors_cummulative; @@ -2318,7 +2487,8 @@ std::pair PresetBundle::load_system_pre // state into this load. this->clear_printer_hold_aliases(); this->m_errors = 0; - append(substitutions, this->load_vendor_configs_from_json(dir.string(), orca_lib_vendor, PresetBundle::LoadSystem, compatibility_rule).first); + append(substitutions, this->load_vendor_configs_from_json( + dir.string(), orca_lib_vendor, PresetBundle::LoadSystem, compatibility_rule, nullptr, allow_cache).first); first = false; } catch (const std::runtime_error &err) { if (validation_mode) @@ -2343,7 +2513,7 @@ std::pair PresetBundle::load_system_pre bundle->set_generate_vendor_caches(m_generate_vendor_caches); try { auto result = bundle->load_vendor_configs_from_json( - dir.string(), other_vendors[i], PresetBundle::LoadSystem, compatibility_rule, this); + dir.string(), other_vendors[i], PresetBundle::LoadSystem, compatibility_rule, this, allow_cache); parallel_substitutions[i] = std::move(result.first); parallel_bundles[i] = std::move(bundle); } catch (const std::runtime_error &err) { @@ -5280,9 +5450,11 @@ std::string PresetBundle::load_vendor_preset( return reason; } - auto file_path = (boost::filesystem::path(data_dir()) /PRESET_SYSTEM_DIR/ vendor_name / entry.sub_path).make_preferred(); - if(validation_mode) + auto file_path = (boost::filesystem::path(data_dir()) / PRESET_SYSTEM_DIR / vendor_name / entry.sub_path).make_preferred(); + if (validation_mode) file_path = (boost::filesystem::path(data_dir()) / vendor_name / entry.sub_path).make_preferred(); + if (m_preserve_vendor_source_paths) + file_path = (boost::filesystem::path(path) / vendor_name / entry.sub_path).make_preferred(); // Load the preset into the list of presets, save it to disk. Preset &loaded = presets_collection->load_preset(file_path.string(), preset_name, std::move(config), false); @@ -5348,7 +5520,8 @@ std::string PresetBundle::load_vendor_preset( //BBS: Load a config bundle file from json std::pair PresetBundle::load_vendor_configs_from_json( - const std::string &dir, const std::string &vendor_name, LoadConfigBundleAttributes flags, ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle) + const std::string &dir, const std::string &vendor_name, LoadConfigBundleAttributes flags, + ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle, bool allow_cache) { // Enable substitutions for user config bundle, throw an exception when loading a system profile. ConfigSubstitutionContext substitution_context { compatibility_rule }; @@ -5366,7 +5539,7 @@ std::pair PresetBundle::load_vendor_configs_ // Orca: only a whole-vendor load has a cache — the vendor-only and filament-only // scans want a slice of one. Validation reads the JSONs whatever is cached. const boost::filesystem::path dir_path(dir); - const bool cacheable = flags.has(LoadConfigBundleAttribute::LoadSystem) && ! flags.has(LoadConfigBundleAttribute::LoadFilamentOnly); + const bool cacheable = allow_cache && flags.has(LoadConfigBundleAttribute::LoadSystem) && ! flags.has(LoadConfigBundleAttribute::LoadFilamentOnly); if (cacheable && ! validation_mode && this->load_vendor_cache(dir_path, vendor_name, base_bundle)) { size_t presets_loaded = 0; for (const PresetCollection* coll : std::initializer_list{ diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index b927c5ee6f..07e5444906 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -230,7 +230,22 @@ public: // Load selections (current print, current filaments, current printer) from config.ini // select preferred presets, if any exist PresetsConfigSubstitutions load_presets(AppConfig &config, ForwardCompatibilitySubstitutionRule rule, - const PresetPreferences& preferred_selection = PresetPreferences()); + const PresetPreferences& preferred_selection = PresetPreferences(), + std::string *errors = nullptr, bool read_only = false); + + // Resolve an explicitly named source file through a canonical flattened + // preset. Exact loaded-file identity is preferred; otherwise a manifest- + // backed vendor tree is loaded from that source root without using caches. + bool resolve_preset_config(DynamicPrintConfig &config, Preset::Type type, + const std::string &source_file, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error, bool allow_source_manifest = true); + // Resolve a source file whose JSON omits `type`. Succeeds only when exactly + // one FFF preset collection owns the file and returns that collection's type. + bool resolve_preset_config_type(DynamicPrintConfig &config, Preset::Type &type, + const std::string &source_file, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error, bool allow_source_manifest = true); // Load selections (current print, current filaments, current printer) from config.ini // This is done just once on application start up. @@ -238,7 +253,7 @@ public: void load_selections(AppConfig &config, const PresetPreferences& preferred_selection = PresetPreferences()); // BBS Load user presets - PresetsConfigSubstitutions load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule rule); + PresetsConfigSubstitutions load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule rule, bool read_only = false); PresetsConfigSubstitutions load_user_presets(AppConfig &config, std::map>& my_presets, ForwardCompatibilitySubstitutionRule rule); // Orca: Import subscribed bundle presets (load and save to disk in one operation), handles one bundle at a time PresetsConfigSubstitutions update_subscribed_presets(AppConfig& config, @@ -481,10 +496,13 @@ public: //Orca: load config bundle from json, pass the base bundle to support cross vendor inheritance // Orca: `dir` is where the vendor is looked for — its own directory, whether or // not the profile JSONs are still there. A whole-vendor load comes from the - // vendor's preset cache whenever one covers the profile on disk, and is parsed - // from the JSONs in `dir` only when none does. Nothing here reads resources. + // vendor's preset cache whenever one covers the profile on disk and allow_cache + // is true, and is parsed from the JSONs in `dir` otherwise. Nothing here reads + // resources implicitly. std::pair load_vendor_configs_from_json( - const std::string &dir, const std::string &vendor_name, LoadConfigBundleAttributes flags, ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle = nullptr); + const std::string &dir, const std::string &vendor_name, LoadConfigBundleAttributes flags, + ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle = nullptr, + bool allow_cache = true); // Export a config bundle file containing all the presets and the names of the active presets. //void export_configbundle(const std::string &path, bool export_system_settings = false, bool export_physical_printers = false); @@ -606,6 +624,7 @@ private: // Whether to (re)write a per-vendor cache after a JSON parse. bool m_generate_vendor_caches { false }; + bool m_preserve_vendor_source_paths { false }; // Orca: validation only - flag any printer with two or more compatible // filament presets sharing one filament_id (ambiguous AMS subtype match). @@ -613,7 +632,7 @@ private: //std::pair load_system_presets(ForwardCompatibilitySubstitutionRule compatibility_rule); //BBS: add json related logic - std::pair load_system_presets_from_json(ForwardCompatibilitySubstitutionRule compatibility_rule); + std::pair load_system_presets_from_json(ForwardCompatibilitySubstitutionRule compatibility_rule, bool allow_cache = true); // Update the multicolor information for filaments. void update_filament_multi_color(); // Update renamed_from and alias maps of system profiles. diff --git a/src/libslic3r/TriangleMeshSlicer.cpp b/src/libslic3r/TriangleMeshSlicer.cpp index 417ca354d3..4ff18165cb 100644 --- a/src/libslic3r/TriangleMeshSlicer.cpp +++ b/src/libslic3r/TriangleMeshSlicer.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -607,6 +608,17 @@ static inline std::vector slice_make_lines( } } ); + // Facet processing above is parallel, so per-layer line order depends on thread scheduling, + // and make_loops() derives island order and loop start vertices from it. Sort canonically; + // edge_type and flags only break ties, std::sort being unstable. + tbb::parallel_for(tbb::blocked_range(0, lines.size()), + [&lines](const tbb::blocked_range &range) { + for (size_t i = range.begin(); i < range.end(); ++ i) + std::sort(lines[i].begin(), lines[i].end(), [](const IntersectionLine &l, const IntersectionLine &r) { + return std::make_tuple(l.edge_a_id, l.edge_b_id, l.a_id, l.b_id, l.a.x(), l.a.y(), l.b.x(), l.b.y(), l.edge_type, l.flags) < + std::make_tuple(r.edge_a_id, r.edge_b_id, r.a_id, r.b_id, r.a.x(), r.a.y(), r.b.x(), r.b.y(), r.edge_type, r.flags); + }); + }); return lines; } diff --git a/tests/libslic3r/test_arachne_walls.cpp b/tests/libslic3r/test_arachne_walls.cpp index 4ca18400af..2b6580ed11 100644 --- a/tests/libslic3r/test_arachne_walls.cpp +++ b/tests/libslic3r/test_arachne_walls.cpp @@ -22,6 +22,8 @@ #include "libslic3r/Arachne/utils/ExtrusionLine.hpp" #include "libslic3r/Arachne/BeadingStrategy/BeadingStrategyFactory.hpp" #include "libslic3r/Arachne/BeadingStrategy/BeadingStrategy.hpp" +#include "libslic3r/Feature/FuzzySkin/FuzzySkin.hpp" +#include "libslic3r/Flow.hpp" #include "libslic3r/Polygon.hpp" #include "libslic3r/ExPolygon.hpp" #include "libslic3r/ClipperUtils.hpp" @@ -309,3 +311,71 @@ TEST_CASE("Beading interpolation tolerates a thicker side with fewer insets", "[ CHECK(result.bead_widths[i] == expected.bead_widths[i]); } } + +namespace { + +// Closed 20 mm square loop at a uniform width. +Arachne::ExtrusionJunctions square_loop(coord_t width) +{ + const coord_t s = scaled(20.); + return {{Point(0, 0), width, 0}, {Point(s, 0), width, 0}, {Point(s, s), width, 0}, {Point(0, s), width, 0}, {Point(0, 0), width, 0}}; +} + +FuzzySkinConfig thick_fuzzy_config(FuzzySkinMode mode, NoiseType noise_type, double thickness_mm) +{ + FuzzySkinConfig cfg{}; + cfg.type = FuzzySkinType::All; + cfg.thickness = scaled(thickness_mm); + cfg.point_distance = scaled(0.3); + cfg.fuzzy_first_layer = true; + cfg.noise_type = noise_type; + cfg.noise_scale = 1.0; + cfg.noise_octaves = 4; + cfg.noise_persistence = 0.5; + cfg.mode = mode; + cfg.layer_id = 5; + return cfg; +} + +} // namespace + +// Extrusion and Combined mode add noise to each junction's width. A junction narrower than +// height * (1 - PI/4) makes Flow::rounded_rectangle_extrusion_spacing() throw and fails the slice. +// The fuzz thickness is 3x the line width so the clamp is hit on every run regardless of RNG seed. +// Ridged multifractal is covered because its output is not bounded to [-1, 1], so it scales past +// the configured thickness; the floor has to hold for any noise value, not just an in-range one. +TEST_CASE("Fuzzy skin extrusion width is floored at the minimum the flow accepts", "[Arachne][FuzzySkin]") { + using namespace Slic3r::Feature::FuzzySkin; + + const double layer_height = GENERATE(0.08, 0.2, 0.28); + const auto mode = GENERATE(FuzzySkinMode::Extrusion, FuzzySkinMode::Combined); + const auto noise_type = GENERATE(NoiseType::Classic, NoiseType::Perlin, NoiseType::Billow, NoiseType::RidgedMulti, NoiseType::Voronoi); + CAPTURE(layer_height, int(mode), int(noise_type)); + + const double line_width_mm = 0.42; + auto loop = square_loop(scaled(line_width_mm)); + fuzzy_extrusion_line(loop, /*slice_z*/ 1.0, layer_height, thick_fuzzy_config(mode, noise_type, 3 * line_width_mm)); + + REQUIRE(loop.size() > 100); + + const auto narrowest = std::min_element(loop.begin(), loop.end(), [](const auto& a, const auto& b) { return a.w < b.w; }); + const double narrowest_mm = unscaled(narrowest->w); + const double floor_mm = layer_height * (1. - 0.25 * PI); + CAPTURE(narrowest_mm, floor_mm); + + CHECK(narrowest_mm < line_width_mm); // the clamp was exercised + CHECK(narrowest_mm > floor_mm); + CHECK_NOTHROW(Flow::rounded_rectangle_extrusion_spacing(float(narrowest_mm), float(layer_height))); +} + +// Displacement mode only moves points; widths must pass through unchanged. +TEST_CASE("Fuzzy skin displacement mode leaves widths untouched", "[Arachne][FuzzySkin]") { + using namespace Slic3r::Feature::FuzzySkin; + + const coord_t width = scaled(0.42); + auto loop = square_loop(width); + fuzzy_extrusion_line(loop, /*slice_z*/ 1.0, /*layer_height*/ 0.2, thick_fuzzy_config(FuzzySkinMode::Displacement, NoiseType::Classic, 1.26)); + + REQUIRE(loop.size() > 100); + CHECK(std::all_of(loop.begin(), loop.end(), [width](const auto& j) { return j.w == width; })); +} diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 59aae221b5..26cbf387ec 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -554,6 +554,401 @@ struct LibraryFilamentTestCollection : public PresetCollection } // namespace +TEST_CASE("Missing app config is accepted as default CLI state", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + AppConfig app_config; + app_config.set_loading_path((dir.path() / "missing.conf").string()); + CHECK(app_config.load_if_exists().empty()); +} + +TEST_CASE("Read-only user preset loading does not create or delete files", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + PresetBundle bundle; + PresetsConfigSubstitutions substitutions; + + const fs::path missing_root = dir.path() / "missing-user"; + bundle.prints.load_presets(missing_root.string(), PRESET_PRINT_NAME, substitutions, + ForwardCompatibilitySubstitutionRule::EnableSilent, nullptr, + PresetOrigin(), true); + CHECK_FALSE(fs::exists(missing_root / PRESET_PRINT_NAME)); + + const fs::path malformed = dir.path() / "existing-user" / PRESET_PRINT_NAME / "malformed.json"; + fs::create_directories(malformed.parent_path()); + std::ofstream(malformed.string()) << "{not-json"; + bundle.prints.load_presets((dir.path() / "existing-user").string(), PRESET_PRINT_NAME, substitutions, + ForwardCompatibilitySubstitutionRule::EnableSilent, nullptr, + PresetOrigin(), true); + CHECK(fs::exists(malformed)); +} + +TEST_CASE("Typeless preset resolution probes loaded FFF collections", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "typeless-process.json"; + std::ofstream(source_file.string()) << R"({"name":"Typeless Process","from":"User"})"; + + PresetBundle bundle; + Preset &process = add_inmemory_preset(bundle.prints, "Typeless Process"); + process.file = source_file.string(); + process.config.option("travel_speed", true)->values = {321.0}; + + DynamicPrintConfig raw; + Preset::Type resolved_type = Preset::TYPE_INVALID; + std::string error; + REQUIRE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error.empty()); + CHECK(resolved_type == Preset::TYPE_PRINT); + REQUIRE(raw.option("travel_speed")->values.size() == 1); + CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6)); +} + +TEST_CASE("Typeless preset resolution preserves duplicate identity ambiguity", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "duplicate-process.json"; + std::ofstream(source_file.string()) << "{}"; + + PresetBundle bundle; + add_inmemory_preset(bundle.prints, "First Process Identity").file = source_file.string(); + add_inmemory_preset(bundle.prints, "Second Process Identity").file = source_file.string(); + + DynamicPrintConfig raw; + Preset::Type resolved_type = Preset::TYPE_INVALID; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error == "Preset identity is ambiguous"); + CHECK(resolved_type == Preset::TYPE_INVALID); +} + +TEST_CASE("Typeless preset resolution rejects cross-type ambiguity", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "ambiguous.json"; + std::ofstream(source_file.string()) << "{}"; + + PresetBundle bundle; + add_inmemory_preset(bundle.prints, "Process Identity").file = source_file.string(); + add_inmemory_preset(bundle.filaments, "Filament Identity").file = source_file.string(); + + DynamicPrintConfig raw; + Preset::Type resolved_type = Preset::TYPE_INVALID; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error == "Preset type is ambiguous"); + CHECK(resolved_type == Preset::TYPE_INVALID); +} + +TEST_CASE("Typeless preset resolution rejects a missing type candidate", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "unknown.json"; + std::ofstream(source_file.string()) << "{}"; + + PresetBundle bundle; + DynamicPrintConfig raw; + Preset::Type resolved_type = Preset::TYPE_INVALID; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error == "Preset type could not be resolved"); + CHECK(resolved_type == Preset::TYPE_INVALID); +} + +TEST_CASE("Exact file resolution rejects multiple preset identities", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "duplicate.json"; + std::ofstream(source_file.string()) << "{}"; + + PresetBundle bundle; + Preset &first = add_inmemory_preset(bundle.prints, "First Identity"); + first.file = source_file.string(); + Preset &second = add_inmemory_preset(bundle.prints, "Second Identity"); + second.file = source_file.string(); + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Parent"; + + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error == "Preset identity is ambiguous"); +} + +TEST_CASE("System preset resolution returns the canonical vendor configuration", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir source_dir; + PresetBundle bundle; + + VendorProfile vendor("VendorB"); + vendor.name = "Vendor B"; + auto [vendor_it, inserted] = bundle.vendors.emplace(vendor.id, std::move(vendor)); + REQUIRE(inserted); + + Preset &resolved = add_inmemory_preset(bundle.prints, "Vendor B Process", "fdm_process_common"); + resolved.is_system = true; + resolved.vendor = &vendor_it->second; + resolved.file = (source_dir.path() / "vendor-b-process.json").string(); + std::ofstream(resolved.file) << "{}"; + resolved.config.option("travel_speed", true)->values = {321.0}; + resolved.config.option("wall_loops", true)->value = 2; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; + raw.option("wall_loops", true)->value = 5; + + std::string error; + REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, resolved.file, + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error.empty()); + REQUIRE(raw.option("travel_speed")->values.size() == 1); + CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6)); + CHECK(raw.option("wall_loops")->value == 2); +} + +TEST_CASE("Manifest-backed preset resolution loads the source vendor tree", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path vendor_dir = dir.path() / "Acme"; + const fs::path child_file = vendor_dir / "process" / "nested" / "child.json"; + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"fdm_process_common","sub_path":"process/base.json"},)" + << R"({"name":"Acme Process","sub_path":"process/nested/child.json"}]})"; + fs::create_directories(child_file.parent_path()); + std::ofstream((vendor_dir / "process" / "base.json").string()) + << R"({"type":"process","name":"fdm_process_common","from":"system",)" + << R"("instantiation":"false","travel_speed":["321"]})"; + std::ofstream(child_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","inherits":"fdm_process_common","wall_loops":"5"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; + raw.option("wall_loops", true)->value = 5; + + PresetBundle bundle; + std::string error; + REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error.empty()); + REQUIRE(raw.option("travel_speed")->values.size() == 1); + CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6)); + CHECK(raw.option("wall_loops")->value == 5); +} + +TEST_CASE("Manifest-backed resolution is scoped to the explicit source root", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + auto write_vendor = [&](const std::string &root_name, double travel_speed) { + const fs::path root = dir.path() / root_name; + const fs::path child_file = root / "Acme" / "process" / "child.json"; + fs::create_directories(child_file.parent_path()); + std::ofstream((root / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"fdm_process_common","sub_path":"process/base.json"},)" + << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; + std::ofstream((root / "Acme" / "process" / "base.json").string()) + << R"({"type":"process","name":"fdm_process_common","from":"system",)" + << R"("instantiation":"false","travel_speed":[")" << travel_speed << R"("]})"; + std::ofstream(child_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","inherits":"fdm_process_common"})"; + return child_file; + }; + + const fs::path source_a = write_vendor("root-a", 111.0); + const fs::path source_b = write_vendor("root-b", 222.0); + REQUIRE(fs::exists(source_a)); + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "synthetic-parent-marker"; + + PresetBundle bundle; + std::string error; + REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_b.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + REQUIRE(raw.option("travel_speed")->values.size() == 1); + CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(222.0, 1e-6)); +} + +TEST_CASE("Exact-only resolution rejects an unconfigured manifest-backed file", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "Acme" / "process" / "child.json"; + fs::create_directories(source_file.parent_path()); + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; + std::ofstream(source_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","layer_height":"0.2"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Some Parent"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error == "Preset was not found in the loaded bundle"); +} + +TEST_CASE("Vendor filament resolution uses the shared Orca library base", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path library_dir = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY; + const fs::path vendor_dir = dir.path() / "Acme"; + const fs::path child_file = vendor_dir / "filament" / "nested" / "petg.json"; + + std::ofstream((dir.path() / (std::string(PresetBundle::ORCA_FILAMENT_LIBRARY) + ".json")).string()) + << R"({"version":"1.0.0","name":"OrcaFilamentLibrary","filament_list":[)" + << R"({"name":"fdm_filament_pet","sub_path":"filament/pet.json","filament_id":"GFL99"}]})"; + fs::create_directories(library_dir / "filament"); + std::ofstream((library_dir / "filament" / "pet.json").string()) + << R"({"type":"filament","name":"fdm_filament_pet","from":"system",)" + << R"("filament_id":"GFL99","instantiation":"false",)" + << R"("filament_type":["PETG"],"filament_density":["1.27"]})"; + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","filament_list":[)" + << R"({"name":"Acme PETG","sub_path":"filament/nested/petg.json","filament_id":"GFA00"}]})"; + fs::create_directories(child_file.parent_path()); + std::ofstream(child_file.string()) + << R"({"type":"filament","name":"Acme PETG","from":"system",)" + << R"("filament_id":"GFA00","instantiation":"true","inherits":"fdm_filament_pet"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet"; + + PresetBundle bundle; + std::string error; + REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, child_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error.empty()); + CHECK(raw.opt_string("filament_type", 0u) == "PETG"); + REQUIRE(raw.option("filament_density")->values.size() == 1); + CHECK_THAT(raw.option("filament_density")->values.front(), Catch::Matchers::WithinAbs(1.27, 1e-6)); +} + +TEST_CASE("Manifest-backed resolution rejects a missing parent", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path child_file = dir.path() / "Acme" / "process" / "child.json"; + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; + fs::create_directories(child_file.parent_path()); + std::ofstream(child_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","inherits":"Missing Parent","layer_height":"0.2"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Missing Parent"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK_FALSE(error.empty()); +} + +TEST_CASE("Manifest-backed resolution rejects a vendor load with malformed entries", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path child_file = dir.path() / "Acme" / "process" / "child.json"; + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[123,)" + << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; + fs::create_directories(child_file.parent_path()); + std::ofstream(child_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","layer_height":"0.2"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK_FALSE(error.empty()); +} + +TEST_CASE("Manifest-backed resolution rejects files absent from the vendor manifest", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path listed_file = dir.path() / "Acme" / "process" / "listed.json"; + const fs::path unlisted_file = dir.path() / "Acme" / "process" / "unlisted.json"; + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"Listed Process","sub_path":"process/listed.json"}]})"; + fs::create_directories(listed_file.parent_path()); + std::ofstream(listed_file.string()) + << R"({"type":"process","name":"Listed Process","from":"system",)" + << R"("instantiation":"true","layer_height":"0.2"})"; + std::ofstream(unlisted_file.string()) + << R"({"type":"process","name":"Unlisted Process","from":"system",)" + << R"("instantiation":"true","inherits":"fdm_process_common"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, unlisted_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error == "Source file is not an instantiated preset in its vendor manifest"); +} + +TEST_CASE("Manifest-backed resolution rejects a mismatched preset type", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path process_file = dir.path() / "Acme" / "process" / "child.json"; + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; + fs::create_directories(process_file.parent_path()); + std::ofstream(process_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","layer_height":"0.2"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_common"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, process_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error == "Source file is not an instantiated preset in its vendor manifest"); +} + +TEST_CASE("Resolution terminates when no vendor manifest exists", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path detached_file = dir.path() / "detached.json"; + std::ofstream(detached_file.string()) << "{}"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Missing Parent"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, detached_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error == "Preset was not found in the loaded bundle"); +} + // Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic // library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible // with that printer and the plater combo box lists the shared alias twice.