Compare commits

...
Author SHA1 Message Date
Hanif Koh 2403855d93 Add a Dockable HTML Panel API for Plugins
orca.host.ui.create_dock_panel(html, title, width, height, on_message,
on_close, dock) hosts plugin HTML in a pane of the Plater's dock manager,
next to the sidebar, and returns a UiDockPanel handle
(post/show/hide/close/is_open). The arguments follow create_window(). The
panel uses the window.orca bridge of plugin windows, restores its position
and size from the saved window layout, hides with the Plater off the Prepare
and Preview tabs when floating, and is closed with its plugin; plugin panes
are removed in MainFrame::shutdown().

The web view hosting moves out of PluginPage into a shared PluginWebPanel
base: bootstrap page and swap to the plugin HTML, theme, element-default and
bridge scripts, window.orca message parsing, delivery to the page, and live
re-theming, also re-applied on every load after the swap. Pages tabs and
docked panels both derive from it, and it now owns the bootstrap page, base
URL and plugin-window bridge that PluginWebDialog uses. Pages tabs now
re-theme in place on a theme change instead of being reloaded, and a
window.orca call a host does not support is logged.

Includes a sample plugin (sandboxes/orca_dock_panel_plugin_any.py) and
binding and layout-helper tests in slic3rutils.
2026-09-17 17:46:50 +08:00
packerlschupfer ca668a3bc9 CLI: --inspect-paint — dump per-facet paint state as JSON (#14608)
* CLI: --inspect-paint — dump per-facet paint state as JSON

Reads the per-facet enforcer/blocker/extruder/fuzzy-skin state stored
on every ModelVolume (supported_facets / seam_facets /
mmu_segmentation_facets / fuzzy_skin_facets) and emits a structured
JSON summary to stdout. Machine-readable alternative to opening the
paint gizmos.

Per (object, volume, layer, state): facet count, surface area in
mm², and mesh-local bounding box. Empty layers collapse to
{"empty": true}. Summary at the top level rolls up totals.

One correctness detail worth calling out: FacetsAnnotation::
get_facets_strict returns an indexed_triangle_set whose `vertices`
array is the whole source mesh — only `indices` are filtered to the
painted triangles. A naive bounding_box(its) would report the whole
mesh's bbox even when only a few facets are painted. The helper
its_referenced_bbox() walks only the vertices actually indexed by
the painted triangles, so `bbox` correctly localizes the painted
region.

Rationale: every paint-driven workflow — GUI-painted .3mf verified
in CI, AI agents planning support enforcers, MMU color layout checks
— needs to know what's already painted on a model. Today that's a
GUI-only read. --inspect-paint closes that loop for scripted callers.

New file src/slic3r/Utils/PaintCLI.{hpp,cpp} (~215 lines). Depends
only on Model, TriangleMesh, TriangleSelector, FacetsAnnotation, and
nlohmann::json — all already in tree. No new dependencies, no
signature changes, no behavior change when the flag is absent.

Registered as an action (parallel to --info) so it satisfies the
"needs an action" check and bypasses the GUI fallback; control falls
through the normal post-action path to a clean exit 0.

Verification:
  unpainted STL:     every layer {"empty": true}, summary zero
  GUI-painted .3mf:  enforcer count / area / bbox match painter
  clean JSON:        parseable via jq

* CLI --inspect-paint: exit after printing, reject conflicting actions

- Finish like the end of CLI::run once the JSON is written, as the
  tooltip says. The callback manager is Linux-only, so its use is
  guarded.
- Reject actions that would otherwise be skipped without notice
  (--slice, --export-3mf, ...) before loading. Load-time options such as
  --uptodate are still accepted.
- Replace invalid UTF-8 in object names and paths instead of throwing.
- Report every input file as sources; inputs are merged into one model
  before actions run.

* CLI --inspect-paint: reject a run without input

Without an input file or --load-assemble-list there is nothing to
inspect, and the run printed nothing and exited 0. Reject it up front
with CLI_INVALID_PARAMS, next to the other invalid-parameter checks.
2026-09-17 12:01:49 +08:00
Kris Austin 6b0e190e64 ci: key the Windows compiler cache on the MSVC toolset version (#15729) 2026-09-16 18:30:18 -03:00
Kris Austin 8effa27f4a build: build the dependencies with clang-cl under the Visual Studio generator (#15673)
The deps superbuild passes the Visual Studio generator and platform to
every sub-build but not the toolset, so build_win.bat -d -l without -x
compiled every dependency with cl even though the superbuild had been
configured with -T ClangCL; CMake replaces the forwarded
CMAKE_<LANG>_COMPILER with whatever the toolset ran. The recipes that
adapt to clang-cl then disagreed with what had been built, and
wxInspector told FindwxWidgets to look in lib/clang_x64_lib while the
cl-built wxWidgets had installed into lib/vc_x64_lib:

  Could NOT find wxWidgets (missing: wxWidgets_LIBRARIES
  wxWidgets_INCLUDE_DIRS core base aui propgrid)

Forward CMAKE_GENERATOR_TOOLSET as well, so the dependencies compile
with clang-cl under MSBuild the way they already do under Ninja. Four
of them need more than that:

- OpenSSL always builds with cl, and MSBuild runs its nmake steps in
  the project's toolset environment, where ClangCL puts clang's include
  directory first and cl trips over clang's stdint.h. The project gets
  the default toolset.
- Boost.Container's dlmalloc needs -Wno-incompatible-pointer-types
  under clang. boost_container links as C++, and the Visual Studio
  generator writes only the link language's flags into the project, so
  its C file never saw CMAKE_C_FLAGS. Under that generator the option
  goes through the C++ flags as well, with the defaults kept.
- Draco's tools and NLopt's testopt compile sources their own static
  library also contains. MSBuild lists libraries before objects and
  lld-link resolves archive members as each input arrives, so the
  library's copy is pulled in before the executable's own object and
  the link fails on duplicate symbols; link.exe defers the search and
  Ninja lists the objects first. Nothing uses those executables, so
  they get /FORCE:MULTIPLE there.

The Ninja path is unchanged: the generated configure commands of all
29 dependencies are identical before and after. OCCT's arm64 override
to cl still applies under Ninja but not under the Visual Studio
generator, where the toolset wins; that combination never built and is
left for a follow-up.
2026-09-16 14:28:50 -03:00
Ian BassiandRodrigo Faselli 72774e5398 Toolchange Cyclic Order (#14868)
* Toolchange Cyclic Order

* Apply cyclic order to first layer

* Unit test

* Copilot fixes

---------

Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
2026-09-16 12:19:03 -03:00
35 changed files with 1347 additions and 167 deletions
+9
View File
@@ -85,6 +85,15 @@ jobs:
shell: bash shell: bash
run: | run: |
leg="${{ runner.os }}-${{ inputs.arch || 'amd64' }}${{ runner.os == 'Windows' && format('-{0}', inputs.compiler) || '' }}" leg="${{ runner.os }}-${{ inputs.arch || 'amd64' }}${{ runner.os == 'Windows' && format('-{0}', inputs.compiler) || '' }}"
# clang-cl refuses a precompiled header from another cl.exe build and ccache
# does not hash that build, so each one gets its own cache. The build number
# is read from cl.exe itself; the toolset directory keeps its name across patches.
if [ "${{ runner.os }}" = Windows ]; then
vswhere='/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe'
toolset=$(tr -d '\r\n' < "$("$vswhere" -latest -products '*' -find 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt' | tr -d '\r')")
cl=$("$vswhere" -latest -products '*' -find 'VC\Tools\MSVC\'"$toolset"'\**\cl.exe' | tr -d '\r' | head -1)
leg="$leg-vc$("$cl" 2>&1 | grep -o -E 'Version [0-9.]+' | cut -d' ' -f2)"
fi
echo "CCACHE_LEG=$leg" >> "$GITHUB_ENV" echo "CCACHE_LEG=$leg" >> "$GITHUB_ENV"
echo "CCACHE_ENTRY=ccache-$leg-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_ENV" echo "CCACHE_ENTRY=ccache-$leg-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_ENV"
+8
View File
@@ -27,8 +27,15 @@ endif ()
# Boost.Container's bundled dlmalloc passes int* where the Win32 Interlocked API # Boost.Container's bundled dlmalloc passes int* where the Win32 Interlocked API
# takes volatile long*; cl compiles that with a warning, clang errors out. # takes volatile long*; cl compiles that with a warning, clang errors out.
set(_boost_c_flags_line "") set(_boost_c_flags_line "")
set(_boost_cxx_flags_line "")
if (MSVC AND CMAKE_C_COMPILER_ID STREQUAL "Clang") if (MSVC AND CMAKE_C_COMPILER_ID STREQUAL "Clang")
set(_boost_c_flags_line "-DCMAKE_C_FLAGS:STRING=-Wno-incompatible-pointer-types") set(_boost_c_flags_line "-DCMAKE_C_FLAGS:STRING=-Wno-incompatible-pointer-types")
# The Visual Studio generator applies only the link language's flags to a
# project, and boost_container links as C++, so its C file never sees
# CMAKE_C_FLAGS. The C++ flags reach every file; keep CMake's defaults.
if (CMAKE_GENERATOR MATCHES "Visual Studio")
set(_boost_cxx_flags_line "-DCMAKE_CXX_FLAGS:STRING=${CMAKE_CXX_FLAGS} -Wno-incompatible-pointer-types")
endif ()
endif () endif ()
orcaslicer_add_cmake_project(Boost orcaslicer_add_cmake_project(Boost
@@ -46,6 +53,7 @@ orcaslicer_add_cmake_project(Boost
"${_context_arch_line}" "${_context_arch_line}"
"${_context_impl_line}" "${_context_impl_line}"
"${_boost_c_flags_line}" "${_boost_c_flags_line}"
"${_boost_cxx_flags_line}"
) )
set(DEP_Boost_DEPENDS ZLIB) set(DEP_Boost_DEPENDS ZLIB)
+5
View File
@@ -184,6 +184,11 @@ function(orcaslicer_add_cmake_project projectname)
if (_dep_msvc_gen) if (_dep_msvc_gen)
set(_gen CMAKE_GENERATOR "${DEP_MSVC_GEN}" CMAKE_GENERATOR_PLATFORM "${DEP_PLATFORM}") set(_gen CMAKE_GENERATOR "${DEP_MSVC_GEN}" CMAKE_GENERATOR_PLATFORM "${DEP_PLATFORM}")
# The toolset picks the compiler here, not the CMAKE_<LANG>_COMPILER
# forwarded below, so without it a clang-cl superbuild builds with cl.
if (CMAKE_GENERATOR_TOOLSET)
list(APPEND _gen CMAKE_GENERATOR_TOOLSET "${CMAKE_GENERATOR_TOOLSET}")
endif ()
else() else()
set(_gen "") set(_gen "")
endif() endif()
+3
View File
@@ -7,4 +7,7 @@ orcaslicer_add_cmake_project(Draco
${_options} ${_options}
URL https://github.com/google/draco/archive/refs/tags/1.5.7.zip URL https://github.com/google/draco/archive/refs/tags/1.5.7.zip
URL_HASH SHA256=27b72ba2d5ff3d0a9814ad40d4cb88f8dc89a35491c0866d952473f8f9416b77 URL_HASH SHA256=27b72ba2d5ff3d0a9814ad40d4cb88f8dc89a35491c0866d952473f8f9416b77
CMAKE_ARGS
# The encoder and decoder tools duplicate draco.lib; see deps-windows.cmake.
"${DEP_LLD_FORCE_MULTIPLE}"
) )
+2
View File
@@ -8,6 +8,8 @@ orcaslicer_add_cmake_project(NLopt
-DNLOPT_GUILE:BOOL=OFF -DNLOPT_GUILE:BOOL=OFF
-DNLOPT_SWIG:BOOL=OFF -DNLOPT_SWIG:BOOL=OFF
-DNLOPT_TESTS:BOOL=OFF -DNLOPT_TESTS:BOOL=OFF
# testopt is built regardless of NLOPT_TESTS; see deps-windows.cmake.
"${DEP_LLD_FORCE_MULTIPLE}"
) )
if (MSVC) if (MSVC)
+6
View File
@@ -80,6 +80,12 @@ ExternalProject_Add(dep_OpenSSL
INSTALL_COMMAND ${_install_cmd} INSTALL_COMMAND ${_install_cmd}
) )
if (CMAKE_GENERATOR MATCHES "Visual Studio")
# OpenSSL builds with cl, but MSBuild runs nmake in this project's toolset
# environment, and ClangCL's puts clang's headers first. Use the default.
set_target_properties(dep_OpenSSL PROPERTIES VS_PLATFORM_TOOLSET "$(DefaultPlatformToolset)")
endif ()
ExternalProject_Add_Step(dep_OpenSSL install_cmake_files ExternalProject_Add_Step(dep_OpenSSL install_cmake_files
DEPENDEES install DEPENDEES install
+9
View File
@@ -42,6 +42,15 @@ else ()
message(FATAL_ERROR "Unsupported OS architecture: ${DEPS_ARCH}") message(FATAL_ERROR "Unsupported OS architecture: ${DEPS_ARCH}")
endif () endif ()
# Draco's tools and NLopt's testopt compile sources that are also in their
# static library. MSBuild passes the library before the objects and lld-link
# resolves as it goes, so the library's copy wins and the object then reads as
# a duplicate. Nothing uses those executables, so let lld keep the first one.
set(DEP_LLD_FORCE_MULTIPLE "")
if (CMAKE_GENERATOR MATCHES "Visual Studio" AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
set(DEP_LLD_FORCE_MULTIPLE "-DCMAKE_EXE_LINKER_FLAGS:STRING=${CMAKE_EXE_LINKER_FLAGS} /FORCE:MULTIPLE")
endif ()
if (${DEP_DEBUG}) if (${DEP_DEBUG})
set(DEP_BOOST_DEBUG "debug") set(DEP_BOOST_DEBUG "debug")
else () else ()
+146
View File
@@ -0,0 +1,146 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Dock Panel Demo"
# description = "Opens a dockable panel beside the 3D view that lists the objects on the plate."
# author = "OrcaSlicer"
# version = "0.0.1"
# ///
"""Dock Panel Demo -- orca.host.ui.create_dock_panel().
Run it from the Plugins dialog. It opens an HTML panel docked on the right of the 3D view, in the
same dock area as the sidebar. Drag its caption to dock it on another side (or float it, where the
platform allows), hide it from the page and run the plugin again to bring it back, or close it with
its close button or from the page.
page --orca.postMessage({command: 'refresh'})--> plugin.on_message()
page --orca.postMessage({command: 'hide'})--> plugin.on_message() -> panel.hide()
page --orca.close()--> panel closes, plugin.on_close()
plugin --panel.post({command: 'objects', ...})--> page (orca.onMessage)
"""
import orca
PAGE = """
<style>
body { margin: 0; padding: 12px; font-size: 13px; }
h3 { margin: 0 0 4px; font-size: 14px; }
.note { margin: 0 0 12px; color: var(--orca-muted); font-size: 12px; }
.actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.actions button.quiet { background: transparent; color: var(--orca-fg); border-color: var(--orca-border); }
table { width: 100%; border-collapse: collapse; }
td.count { text-align: right; font-variant-numeric: tabular-nums; }
#status { margin-top: 10px; color: var(--orca-muted); font-size: 12px; }
</style>
<h3>Objects on the plate</h3>
<p class="note">Docked beside the 3D view. Drag the caption to move it.</p>
<div class="actions">
<button type="button" id="refresh">Refresh</button>
<button type="button" id="hide" class="quiet">Hide</button>
<button type="button" id="close" class="quiet">Close</button>
</div>
<table>
<thead><tr><th>Name</th><th>Parts</th><th>Copies</th></tr></thead>
<tbody id="rows"></tbody>
</table>
<p id="status">Waiting for the plugin...</p>
<script>
(function () {
function text(value) {
var span = document.createElement("span");
span.textContent = value;
return span.innerHTML;
}
function render(message) {
var rows = document.getElementById("rows");
var status = document.getElementById("status");
if (message.error) {
rows.innerHTML = "";
status.textContent = message.error;
return;
}
rows.innerHTML = message.objects.map(function (object) {
return "<tr><td>" + text(object.name) + "</td><td class=\\"count\\">" + object.volumes +
"</td><td class=\\"count\\">" + object.instances + "</td></tr>";
}).join("");
status.textContent = message.objects.length + " object(s), refreshed " + new Date().toLocaleTimeString();
}
orca.onMessage(function (message) {
if (message && message.command === "objects")
render(message);
});
document.getElementById("refresh").addEventListener("click", function () {
orca.postMessage({ command: "refresh" });
});
document.getElementById("hide").addEventListener("click", function () {
orca.postMessage({ command: "hide" });
});
document.getElementById("close").addEventListener("click", function () {
orca.close();
});
orca.postMessage({ command: "refresh" });
})();
</script>
"""
def plate_objects():
try:
model = orca.host.model()
except RuntimeError as error:
return {"command": "objects", "error": str(error)}
return {
"command": "objects",
"objects": [
{"name": obj.name or "(unnamed)", "volumes": obj.volume_count(), "instances": obj.instance_count()}
for obj in model.objects()
],
}
class DockPanelDemo(orca.script.ScriptPluginCapabilityBase):
panel = None
def get_name(self):
return "Dock Panel Demo"
def execute(self):
# The capability instance lives as long as the plugin, so a second run finds the open panel.
if self.panel is not None and self.panel.is_open():
self.panel.show()
return orca.ExecutionResult.success("Dock Panel Demo is already open.")
self.panel = orca.host.ui.create_dock_panel(
html=PAGE,
title="Dock Panel Demo",
width=320,
height=480,
on_message=self.on_message,
on_close=self.on_close,
dock="right",
)
return orca.ExecutionResult.success("Dock Panel Demo opened.")
# Called on the UI thread when the page posts.
def on_message(self, message):
command = (message or {}).get("command")
if command == "refresh":
self.panel.post(plate_objects())
elif command == "hide":
self.panel.hide()
def on_close(self):
self.panel = None
@orca.plugin
class DockPanelDemoPlugin(orca.base):
def register_capabilities(self):
orca.register_capability(DockPanelDemo)
+48
View File
@@ -87,6 +87,7 @@ using namespace nlohmann;
#include "dev-utils/BaseException.h" #include "dev-utils/BaseException.h"
#endif #endif
#include "slic3r/Utils/MeshInspect.hpp" #include "slic3r/Utils/MeshInspect.hpp"
#include "slic3r/Utils/PaintCLI.hpp"
#include "slic3r/GUI/PartPlate.hpp" #include "slic3r/GUI/PartPlate.hpp"
#include "slic3r/GUI/BitmapCache.hpp" #include "slic3r/GUI/BitmapCache.hpp"
#include "slic3r/GUI/OpenGLManager.hpp" #include "slic3r/GUI/OpenGLManager.hpp"
@@ -1443,6 +1444,29 @@ int CLI::run(int argc, char **argv)
} }
} }
// --inspect-paint prints its JSON and exits, so any action that does work of its
// own (slicing, exporting) would be skipped without notice. Reject those up front;
// only options that merely tune how the input is loaded may come along.
if (std::find(m_actions.begin(), m_actions.end(), "inspect_paint") != m_actions.end()) {
static const std::set<std::string> inspect_compatible = { "inspect_paint", "uptodate", "load_defaultfila", "min_save",
"mtcpp", "mstpp", "no_check", "normative_check", "pipe" };
for (const std::string &action : m_actions) {
if (inspect_compatible.count(action) == 0) {
std::string flag = action;
std::replace(flag.begin(), flag.end(), '_', '-');
boost::nowide::cerr << "--inspect-paint cannot be combined with --" << flag << std::endl;
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
}
// Without input there is nothing to inspect; fail rather than print nothing and exit 0.
if (m_input_files.empty() && m_config.opt_string("load_assemble_list").empty()) {
boost::nowide::cerr << "--inspect-paint needs an input file or --load-assemble-list" << std::endl;
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
}
// --export-settings - writes its JSON to stdout, so reject every action or transform that may write there // --export-settings - writes its JSON to stdout, so reject every action or transform that may write there
// too (--info, --help, --orient, slicing and exporting). The allowed ones do nothing when nothing is // too (--info, --help, --orient, slicing and exporting). The allowed ones do nothing when nothing is
// sliced or exported. // sliced or exported.
@@ -6100,6 +6124,30 @@ int CLI::run(int argc, char **argv)
cli_status_callback(slicing_status); cli_status_callback(slicing_status);
} }
g_cli_callback_mgr.stop(); g_cli_callback_mgr.stop();
#endif
for (Model &m : m_models)
m.remove_backup_path_if_exist();
record_exit_reson(outfile_dir, CLI_SUCCESS, plate_to_slice, cli_errors[CLI_SUCCESS], sliced_info);
boost::nowide::cerr.flush();
return CLI_SUCCESS;
} else if (opt_key == "inspect_paint") {
// --inspect-paint — read the per-facet enforcer/blocker/extruder/
// fuzzy state from the loaded model and emit a JSON summary.
// Machine-readable alternative to opening the paint gizmos.
for (Model &model : m_models) {
model.add_default_instances();
Slic3r::PaintCLI::inspect_to_json(model, m_input_files, boost::nowide::cout);
}
boost::nowide::cout.flush();
// The tooltip promises "then exit"; conflicting actions were rejected before
// loading. Finish like the end of run(). flush_and_exit() is not usable here:
// it prints "found error ..." to stdout, which would corrupt the JSON.
#if defined(__linux__) || defined(__LINUX__)
if (g_cli_callback_mgr.is_started()) {
PrintBase::SlicingStatus slicing_status{100, "All done, Success"};
cli_status_callback(slicing_status);
}
g_cli_callback_mgr.stop();
#endif #endif
for (Model &m : m_models) for (Model &m : m_models)
m.remove_backup_path_if_exist(); m.remove_backup_path_if_exist();
+60 -5
View File
@@ -2735,6 +2735,28 @@ void ToolOrdering::enforce_mixed_component_order()
} }
} }
// Declared in ToolOrdering.hpp (exposed for unit testing).
std::vector<unsigned int> parse_cyclic_order(const std::string& str, unsigned int number_of_extruders)
{
std::vector<unsigned int> order;
for (const std::string& token : split_string(str, ',')) {
try {
size_t pos = 0;
int filament = std::stoi(token, &pos); // stoi skips leading whitespace by itself
// stoi stops at the first non-digit, so "2x" would parse as 2. Require the whole token to be
// consumed (bar trailing whitespace) to drop it like any other garbage.
if (token.find_first_not_of(" \t\r\n", pos) != std::string::npos)
continue;
if (filament >= 1 && (unsigned int)filament <= number_of_extruders
&& std::find(order.begin(), order.end(), (unsigned int)(filament - 1)) == order.end())
order.emplace_back((unsigned int)(filament - 1));
} catch (const std::exception&) {
// Not a number, ignore it.
}
}
return order;
}
void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first_layer) void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first_layer)
{ {
const PrintConfig* print_config = m_print_config_ptr; const PrintConfig* print_config = m_print_config_ptr;
@@ -2832,11 +2854,41 @@ void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first
const bool use_cyclic_ordering = const bool use_cyclic_ordering =
(print_config->toolchange_ordering == ToolChangeOrderingType::Cyclic); (print_config->toolchange_ordering == ToolChangeOrderingType::Cyclic);
// By default the first layer keeps its adhesion-optimized order (and any custom first layer
// sequence); the cyclic sequence is only forced onto it when the user opts in.
const bool cyclic_first_layer = use_cyclic_ordering && print_config->toolchange_cyclic_first_layer.value;
// Optional user defined cyclic sequence, given as 1-based filament numbers ("3,2,1,4"). Filaments
// missing from it keep their ascending order after the listed ones, so a partial or bogus entry
// still yields the default cyclic order.
const std::vector<unsigned int> cyclic_order =
use_cyclic_ordering ? parse_cyclic_order(print_config->toolchange_cyclic_order.value, number_of_extruders)
: std::vector<unsigned int>();
// Reorder a layer's filaments (0-based) for cyclic ordering: ascending by default, or following the
// user defined sequence when one was given. Filaments absent from the sequence keep ascending order
// after the listed ones.
auto apply_cyclic_order = [&cyclic_order](std::vector<unsigned int>& filaments) {
std::sort(filaments.begin(), filaments.end());
if (!cyclic_order.empty())
std::stable_sort(filaments.begin(), filaments.end(), [&cyclic_order](unsigned int lhs, unsigned int rhs) {
auto rank = [&cyclic_order](unsigned int filament) {
return size_t(std::find(cyclic_order.begin(), cyclic_order.end(), filament) - cyclic_order.begin());
};
return rank(lhs) < rank(rhs);
});
};
// other_layers_seq: the layer_idx and extruder_idx are base on 1 // other_layers_seq: the layer_idx and extruder_idx are base on 1
auto get_custom_seq = [&other_layers_seqs, &reorder_first_layer, &first_layer_filaments, &layer_filaments, use_cyclic_ordering](int layer_idx, std::vector<int>& out_seq) -> bool { auto get_custom_seq = [&other_layers_seqs, &reorder_first_layer, &first_layer_filaments, &layer_filaments, use_cyclic_ordering, cyclic_first_layer, &apply_cyclic_order](int layer_idx, std::vector<int>& out_seq) -> bool {
if (!reorder_first_layer && layer_idx == 0) { if (!reorder_first_layer && layer_idx == 0) {
out_seq.resize(first_layer_filaments.size()); // The first layer tool order is already decided (adhesion-optimized, plus any custom first
std::transform(first_layer_filaments.begin(), first_layer_filaments.end(), out_seq.begin(), [](auto item) {return item + 1; }); // layer sequence). Only override it with the cyclic sequence when the user opted in.
std::vector<unsigned int> ordered = first_layer_filaments;
if (cyclic_first_layer)
apply_cyclic_order(ordered);
out_seq.resize(ordered.size());
std::transform(ordered.begin(), ordered.end(), out_seq.begin(), [](auto item) {return int(item) + 1; });
return true; return true;
} }
for (size_t idx = other_layers_seqs.size() - 1; idx != size_t(-1); --idx) { for (size_t idx = other_layers_seqs.size() - 1; idx != size_t(-1); --idx) {
@@ -2847,9 +2899,12 @@ void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first
} }
} }
if (use_cyclic_ordering && layer_idx >= 0 && size_t(layer_idx) < layer_filaments.size()) { // Skip the first layer here (layer_idx == 0 only reaches this point on the reorder_first_layer
// path) unless the user asked for cyclic order on it, so it keeps the default flush ordering.
if (use_cyclic_ordering && layer_idx >= 0 && (layer_idx != 0 || cyclic_first_layer)
&& size_t(layer_idx) < layer_filaments.size()) {
std::vector<unsigned int> ordered = layer_filaments[size_t(layer_idx)]; std::vector<unsigned int> ordered = layer_filaments[size_t(layer_idx)];
std::sort(ordered.begin(), ordered.end()); apply_cyclic_order(ordered);
out_seq.resize(ordered.size()); out_seq.resize(ordered.size());
std::transform(ordered.begin(), ordered.end(), out_seq.begin(), [](auto item) { return int(item) + 1; }); std::transform(ordered.begin(), ordered.end(), out_seq.begin(), [](auto item) { return int(item) + 1; });
return true; return true;
+5
View File
@@ -417,6 +417,11 @@ private:
int most_used_extruder; int most_used_extruder;
}; };
// Parse the user defined cyclic toolchange sequence ("3,2 , 1 , 4") into 0-based filament indices.
// Out-of-range entries, duplicates and non-numeric tokens are dropped, so a partially valid string
// still orders the filaments it does name. Exposed for unit testing.
std::vector<unsigned int> parse_cyclic_order(const std::string& str, unsigned int number_of_extruders);
} // namespace SLic3r } // namespace SLic3r
#endif /* slic3r_ToolOrdering_hpp_ */ #endif /* slic3r_ToolOrdering_hpp_ */
+2
View File
@@ -1320,6 +1320,8 @@ static std::vector<std::string> s_Preset_print_options{
"wipe_tower_extra_flow", "wipe_tower_extra_flow",
"single_extruder_multi_material_priming", "single_extruder_multi_material_priming",
"toolchange_ordering", "toolchange_ordering",
"toolchange_cyclic_order",
"toolchange_cyclic_first_layer",
"wipe_tower_rotation_angle", "wipe_tower_rotation_angle",
"tree_support_branch_distance_organic", "tree_support_branch_distance_organic",
"tree_support_branch_diameter_organic", "tree_support_branch_diameter_organic",
+2
View File
@@ -360,6 +360,8 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
|| opt_key == "other_layers_print_sequence" || opt_key == "other_layers_print_sequence"
|| opt_key == "other_layers_print_sequence_nums" || opt_key == "other_layers_print_sequence_nums"
|| opt_key == "toolchange_ordering" || opt_key == "toolchange_ordering"
|| opt_key == "toolchange_cyclic_order"
|| opt_key == "toolchange_cyclic_first_layer"
|| opt_key == "extruder_ams_count" || opt_key == "extruder_ams_count"
|| opt_key == "extruder_nozzle_stats" || opt_key == "extruder_nozzle_stats"
|| opt_key == "filament_map_mode" || opt_key == "filament_map_mode"
+41
View File
@@ -6700,6 +6700,34 @@ void PrintConfigDef::init_fff_params()
def->enum_labels.emplace_back(L("Cyclic")); def->enum_labels.emplace_back(L("Cyclic"));
def->set_default_value(new ConfigOptionEnum<ToolChangeOrderingType>(ToolChangeOrderingType::Default)); def->set_default_value(new ConfigOptionEnum<ToolChangeOrderingType>(ToolChangeOrderingType::Default));
def = this->add("toolchange_cyclic_order", coString);
def->label = L("Cyclic order");
def->category = L("Advanced");
def->tooltip = L(
"Custom filament sequence used by the cyclic toolchange ordering, as filament numbers separated by commas (e.g. \"3,2,1,4\").\n"
"Each layer prints its filaments following this sequence; filaments not listed are printed last, in ascending order.\n"
"Leave empty to cycle through the filaments in ascending order."
);
def->mode = comExpert;
def->set_default_value(new ConfigOptionString(""));
def = this->add("toolchange_cyclic_first_layer", coBool);
def->label = L("Apply cyclic order to first layer");
def->category = L("Advanced");
def->tooltip = L(
"Applies the cyclic toolchange order to the first layer as well.\n"
"By default this is disabled, because the first layer is instead ordered for the best bed "
"adhesion: filaments that print small, fragile first-layer features are printed last, so the "
"following tool changes and travel moves are less likely to knock those weakly anchored parts "
"loose. This first-layer order also honors a custom first layer filament sequence when one is set. "
"The cyclic order's benefit (extra tool changes give each layer more time to cool) does not apply "
"to the first layer, which is printed slowly and hot for adhesion.\n"
"Enable this only if you need the exact same tool sequence on every layer, including the first, at "
"the cost of that adhesion optimization."
);
def->mode = comExpert;
def->set_default_value(new ConfigOptionBool(false));
def = this->add("slice_closing_radius", coFloat); def = this->add("slice_closing_radius", coFloat);
def->label = L("Slice gap closing radius"); def->label = L("Slice gap closing radius");
def->category = L("Quality"); def->category = L("Quality");
@@ -11963,6 +11991,19 @@ CLIActionsConfigDef::CLIActionsConfigDef()
"the --ground-* options choose from. Machine-readable alternative to --info."); "the --ground-* options choose from. Machine-readable alternative to --info.");
def->set_default_value(new ConfigOptionBool(false)); def->set_default_value(new ConfigOptionBool(false));
// --inspect-paint \u2014 dump the per-facet enforcer/blocker/extruder/fuzzy
// paint state stored on the loaded model (supports, seam, MMU color,
// fuzzy-skin) as JSON. Read-only; lets CI / scripted / AI tooling
// reason about existing paint on a .3mf without loading the GUI.
def = this->add("inspect_paint", coBool);
def->label = L("Inspect paint (JSON to stdout)");
def->tooltip = L("Print a structured JSON summary of every painted layer "
"(supports, seam, MMU color, fuzzy-skin) already stored on "
"the loaded model \u2014 per-state facet count, surface area, "
"and mesh-local bounding box \u2014 then exit. Machine-readable "
"alternative to opening the paint gizmos in the GUI.");
def->set_default_value(new ConfigOptionBool(false));
def = this->add("export_settings", coString); def = this->add("export_settings", coString);
def->label = L("Export Settings"); def->label = L("Export Settings");
def->tooltip = L("This exports settings to a file. Use - to write them to stdout."); def->tooltip = L("This exports settings to a file. Use - to write them to stdout.");
+2
View File
@@ -1627,6 +1627,8 @@ PRINT_CONFIG_CLASS_DEFINE(
((ConfigOptionBool, manual_filament_change)) ((ConfigOptionBool, manual_filament_change))
((ConfigOptionBool, single_extruder_multi_material_priming)) ((ConfigOptionBool, single_extruder_multi_material_priming))
((ConfigOptionEnum<ToolChangeOrderingType>, toolchange_ordering)) ((ConfigOptionEnum<ToolChangeOrderingType>, toolchange_ordering))
((ConfigOptionString, toolchange_cyclic_order))
((ConfigOptionBool, toolchange_cyclic_first_layer))
((ConfigOptionBool, wipe_tower_no_sparse_layers)) ((ConfigOptionBool, wipe_tower_no_sparse_layers))
((ConfigOptionString, change_filament_gcode)) ((ConfigOptionString, change_filament_gcode))
((ConfigOptionString, change_extrusion_role_gcode)) ((ConfigOptionString, change_extrusion_role_gcode))
+6
View File
@@ -139,6 +139,10 @@ set(SLIC3R_GUI_SOURCES
GUI/PluginProgressDialog.hpp GUI/PluginProgressDialog.hpp
GUI/PluginWebDialog.cpp GUI/PluginWebDialog.cpp
GUI/PluginWebDialog.hpp GUI/PluginWebDialog.hpp
GUI/PluginDockPanel.cpp
GUI/PluginDockPanel.hpp
GUI/PluginWebPanel.cpp
GUI/PluginWebPanel.hpp
GUI/DragCanvas.cpp GUI/DragCanvas.cpp
GUI/DragCanvas.hpp GUI/DragCanvas.hpp
GUI/EditGCodeDialog.cpp GUI/EditGCodeDialog.cpp
@@ -682,6 +686,8 @@ set(SLIC3R_GUI_SOURCES
Utils/Bonjour.hpp Utils/Bonjour.hpp
Utils/MeshInspect.cpp Utils/MeshInspect.cpp
Utils/MeshInspect.hpp Utils/MeshInspect.hpp
Utils/PaintCLI.cpp
Utils/PaintCLI.hpp
Utils/CalibUtils.cpp Utils/CalibUtils.cpp
Utils/CalibUtils.hpp Utils/CalibUtils.hpp
Utils/ColorSpaceConvert.cpp Utils/ColorSpaceConvert.cpp
+4
View File
@@ -1055,6 +1055,10 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in
toggle_line("single_extruder_multi_material_priming", !bSEMM && have_prime_tower && supports_wipe_tower_2); toggle_line("single_extruder_multi_material_priming", !bSEMM && have_prime_tower && supports_wipe_tower_2);
bool use_cyclic_ordering = config->opt_enum<ToolChangeOrderingType>("toolchange_ordering") == ToolChangeOrderingType::Cyclic;
toggle_line("toolchange_cyclic_order", use_cyclic_ordering);
toggle_line("toolchange_cyclic_first_layer", use_cyclic_ordering);
toggle_line("prime_volume",have_prime_tower && (!purge_in_primetower || !bSEMM)); toggle_line("prime_volume",have_prime_tower && (!purge_in_primetower || !bSEMM));
for (auto el : {"flush_into_infill", "flush_into_support", "flush_into_objects"}) for (auto el : {"flush_into_infill", "flush_into_support", "flush_into_objects"})
+2
View File
@@ -1114,6 +1114,8 @@ void MainFrame::shutdown()
if (m_project != nullptr) if (m_project != nullptr)
m_project->shutdown(); m_project->shutdown();
m_plugin_pages.shutdown(); m_plugin_pages.shutdown();
if (m_plater != nullptr)
m_plater->remove_plugin_panes();
#ifdef __WXGTK__ #ifdef __WXGTK__
// Edge panels are child windows — wxWidgets destroys them automatically. // Edge panels are child windows — wxWidgets destroys them automatically.
m_edge_bottom = nullptr; m_edge_bottom = nullptr;
+132
View File
@@ -86,6 +86,7 @@
#ifdef __WXGTK__ #ifdef __WXGTK__
#include "LinuxDisplayBackend.hpp" #include "LinuxDisplayBackend.hpp"
#endif #endif
#include "PluginDockPanel.hpp"
#include "GUI_Utils.hpp" #include "GUI_Utils.hpp"
#include "GUI_Factories.hpp" #include "GUI_Factories.hpp"
#include "wxExtensions.hpp" #include "wxExtensions.hpp"
@@ -6720,6 +6721,14 @@ struct Plater::priv
// GUI elements // GUI elements
AuiMgr m_aui_mgr; AuiMgr m_aui_mgr;
// Live plugin panes. `on_close` runs when the user closes one from its close button; `shown` is
// what the plugin asked for.
struct PluginPane
{
std::function<void()> on_close;
bool shown{true};
};
std::map<wxWindow*, PluginPane> m_plugin_panes;
wxString m_default_window_layout; wxString m_default_window_layout;
wxPanel* current_panel{ nullptr }; wxPanel* current_panel{ nullptr };
std::vector<wxPanel*> panels; std::vector<wxPanel*> panels;
@@ -6891,6 +6900,11 @@ struct Plater::priv
void update_sidebar(bool force_update = false); void update_sidebar(bool force_update = false);
void reset_window_layout(); void reset_window_layout();
Sidebar::DockingState get_sidebar_docking_state(); Sidebar::DockingState get_sidebar_docking_state();
void add_plugin_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
const wxSize& size, std::function<void()> on_close);
void remove_plugin_pane(wxWindow* window);
void show_plugin_pane(wxWindow* window, bool show);
bool plugin_pane_visible(const PluginPane& plugin_pane, const wxAuiPaneInfo& pane) const;
bool is_view3D_layers_editing_enabled() const { return (current_panel == view3D) && view3D->get_canvas3d()->is_layers_editing_enabled(); } bool is_view3D_layers_editing_enabled() const { return (current_panel == view3D) && view3D->get_canvas3d()->is_layers_editing_enabled(); }
@@ -7471,6 +7485,18 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame)
panel_3d->SetSizer(panel_sizer); panel_3d->SetSizer(panel_sizer);
m_aui_mgr.AddPane(panel_3d, wxAuiPaneInfo().Name("main").CenterPane().PaneBorder(false)); m_aui_mgr.AddPane(panel_3d, wxAuiPaneInfo().Name("main").CenterPane().PaneBorder(false));
q->Bind(wxEVT_AUI_PANE_CLOSE, [this](wxAuiManagerEvent& evt) {
const wxAuiPaneInfo* pane = evt.GetPane();
auto it = pane != nullptr ? m_plugin_panes.find(pane->window) : m_plugin_panes.end();
if (it != m_plugin_panes.end()) {
const std::function<void()> on_close = std::move(it->second.on_close);
m_plugin_panes.erase(it);
if (on_close)
on_close();
}
evt.Skip();
});
m_default_window_layout = m_aui_mgr.SavePerspective(); m_default_window_layout = m_aui_mgr.SavePerspective();
{ {
@@ -8141,6 +8167,14 @@ void Plater::priv::update_sidebar(bool force_update) {
} }
} }
for (const auto& [window, plugin_pane] : m_plugin_panes) {
wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
if (pane.IsOk() && pane.IsShown() != plugin_pane_visible(plugin_pane, pane)) {
pane.Show(!pane.IsShown());
needs_update = true;
}
}
if (needs_update) { if (needs_update) {
notification_manager->set_sidebar_collapsed(sidebar.IsShown()); notification_manager->set_sidebar_collapsed(sidebar.IsShown());
m_aui_mgr.Update(); m_aui_mgr.Update();
@@ -8150,10 +8184,95 @@ void Plater::priv::update_sidebar(bool force_update) {
void Plater::priv::reset_window_layout() void Plater::priv::reset_window_layout()
{ {
m_aui_mgr.LoadPerspective(m_default_window_layout, false); m_aui_mgr.LoadPerspective(m_default_window_layout, false);
// Loading a layout hides every pane it does not list, and the default layout lists no plugin panes.
for (const auto& [window, plugin_pane] : m_plugin_panes)
if (wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window); pane.IsOk())
pane.Show(plugin_pane.shown);
sidebar_layout.is_collapsed = false; sidebar_layout.is_collapsed = false;
update_sidebar(true); update_sidebar(true);
} }
bool Plater::priv::plugin_pane_visible(const PluginPane& plugin_pane, const wxAuiPaneInfo& pane) const
{
// A floating pane is a top-level window, so it does not hide with the Plater on other tabs.
return plugin_pane.shown && (!pane.IsFloating() || sidebar_layout.show);
}
void Plater::priv::add_plugin_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
const wxSize& size, std::function<void()> on_close)
{
const wxString base_name = wxString::FromUTF8(name);
wxString unique_name = base_name;
for (int i = 2; m_aui_mgr.GetPane(unique_name).IsOk(); ++i)
unique_name = base_name + wxString::Format("#%d", i);
// A restored layout below already holds pixels.
const wxSize pixels = q->FromDIP(size);
wxAuiPaneInfo info;
info.Name(unique_name).Caption(caption).BestSize(pixels).FloatingSize(pixels).DestroyOnClose(true);
if (dock == "left")
info.Left();
else if (dock == "bottom")
info.Bottom();
else
info.Right();
if (dock == "float")
info.Float();
// Put the pane back where it was the last time the window layout was saved with it open.
const std::string saved = plugin_pane_layout_entry(wxGetApp().app_config->get("window_layout"), unique_name.utf8_string());
if (!saved.empty()) {
m_aui_mgr.LoadPaneInfo(wxString::FromUTF8(saved), info);
info.Caption(caption).DestroyOnClose(true).Show();
}
// Floating is disabled on Wayland.
if ((m_aui_mgr.GetFlags() & wxAUI_MGR_ALLOW_FLOATING) == 0) {
info.Dock().Floatable(false);
if (info.dock_direction == wxAUI_DOCK_NONE)
info.Right();
}
const PluginPane& plugin_pane = m_plugin_panes[window] = PluginPane{std::move(on_close)};
info.Show(plugin_pane_visible(plugin_pane, info));
m_aui_mgr.AddPane(window, info);
// wxAUI does not record a dragged sash in best_size, so track the docked size like the sidebar
// does, for the saved layout.
window->Bind(wxEVT_IDLE, [this, window](wxIdleEvent& evt) {
wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
if (pane.IsOk() && pane.IsShown() && pane.IsDocked() && pane.rect.GetWidth() > 0 && pane.rect.GetHeight() > 0) {
const bool horizontal = pane.dock_direction == wxAUI_DOCK_TOP || pane.dock_direction == wxAUI_DOCK_BOTTOM;
pane.BestSize(horizontal ? pane.best_size.GetWidth() : pane.rect.GetWidth(),
horizontal ? pane.rect.GetHeight() : pane.best_size.GetHeight());
}
evt.Skip();
});
m_aui_mgr.Update();
}
void Plater::priv::remove_plugin_pane(wxWindow* window)
{
m_plugin_panes.erase(window);
if (m_aui_mgr.DetachPane(window))
m_aui_mgr.Update();
window->Destroy();
}
void Plater::priv::show_plugin_pane(wxWindow* window, bool show)
{
const auto it = m_plugin_panes.find(window);
wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
if (it == m_plugin_panes.end() || !pane.IsOk())
return;
it->second.shown = show;
if (pane.IsShown() == plugin_pane_visible(it->second, pane))
return;
pane.Show(!pane.IsShown());
m_aui_mgr.Update();
}
Sidebar::DockingState Plater::priv::get_sidebar_docking_state() { Sidebar::DockingState Plater::priv::get_sidebar_docking_state() {
if (!sidebar_layout.is_enabled) { if (!sidebar_layout.is_enabled) {
return Sidebar::None; return Sidebar::None;
@@ -17712,6 +17831,19 @@ Sidebar::DockingState Plater::get_sidebar_docking_state() const { return p->get_
void Plater::reset_window_layout() { p->reset_window_layout(); } void Plater::reset_window_layout() { p->reset_window_layout(); }
void Plater::add_plugin_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
const wxSize& size, std::function<void()> on_close)
{
p->add_plugin_pane(window, name, caption, dock, size, std::move(on_close));
}
void Plater::remove_plugin_pane(wxWindow* window) { p->remove_plugin_pane(window); }
void Plater::remove_plugin_panes()
{
while (!p->m_plugin_panes.empty())
p->remove_plugin_pane(p->m_plugin_panes.begin()->first);
}
void Plater::show_plugin_pane(wxWindow* window, bool show) { p->show_plugin_pane(window, show); }
//BBS //BBS
void Plater::select_curr_plate_all() { p->select_curr_plate_all(); } void Plater::select_curr_plate_all() { p->select_curr_plate_all(); }
void Plater::remove_curr_plate_all() { p->remove_curr_plate_all(); } void Plater::remove_curr_plate_all() { p->remove_curr_plate_all(); }
+11
View File
@@ -475,6 +475,17 @@ public:
void reset_window_layout(); void reset_window_layout();
// Plugin panes dock alongside the sidebar; `window` must be a child of the Plater. `dock` is
// "left", "right", "bottom" or "float", and `size` is in DIPs. A pane closed from its own close
// button is destroyed after on_close runs; remove_plugin_pane() destroys it without calling on_close.
void add_plugin_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
const wxSize& size, std::function<void()> on_close);
void remove_plugin_pane(wxWindow* window);
void show_plugin_pane(wxWindow* window, bool show);
// Removes every plugin pane without calling on_close, for MainFrame::shutdown() (app exit and a
// language switch), while the Plater and any floating frames still exist.
void remove_plugin_panes();
// Called after the Preferences dialog is closed and the program settings are saved. // Called after the Preferences dialog is closed and the program settings are saved.
// Update the UI based on the current preferences. // Update the UI based on the current preferences.
void update_ui_from_settings(); void update_ui_from_settings();
+111
View File
@@ -0,0 +1,111 @@
#include "PluginDockPanel.hpp"
#include "GUI_App.hpp"
#include "Plater.hpp"
#include <algorithm>
#include <utility>
namespace Slic3r { namespace GUI {
std::string plugin_pane_name(const std::string& plugin_key, const std::string& title)
{
std::string name = "plugin:" + plugin_key + ":" + title;
std::replace_if(name.begin(), name.end(), [](char c) { return c == '|' || c == ';' || c == '=' || c == '\\'; }, '_');
return name;
}
std::string plugin_pane_layout_entry(const std::string& layout, const std::string& pane_name)
{
// Panes are separated by '|'; SavePerspective() escapes a '|' inside a caption as "\|".
const std::string prefix = "name=" + pane_name + ";";
size_t begin = 0;
for (size_t i = 0; i <= layout.size(); ++i) {
if (i < layout.size() && (layout[i] != '|' || (i > 0 && layout[i - 1] == '\\')))
continue;
if (layout.compare(begin, prefix.size(), prefix) == 0)
return layout.substr(begin, i - begin);
begin = i + 1;
}
return {};
}
PluginDockPanel::PluginDockPanel(wxWindow* parent,
const std::string& html,
MessageHandler on_message,
CloseHandler on_close,
CloseHandler on_destroyed)
: PluginWebPanel(parent, orca_bridge_script())
, m_html(html)
, m_on_message(std::move(on_message))
, m_on_close(std::move(on_close))
, m_on_destroyed(std::move(on_destroyed))
{
// A link asking for a new window has nowhere to open from a docked panel.
browser()->Bind(wxEVT_WEBVIEW_NEWWINDOW, [](wxWebViewEvent& event) { event.Veto(); });
}
PluginDockPanel::~PluginDockPanel()
{
if (m_on_destroyed)
m_on_destroyed();
}
bool PluginDockPanel::on_page_message(const std::string& kind, const nlohmann::json& data)
{
if (kind == "message") {
if (m_on_message)
m_on_message(data);
return true;
}
if (kind == "close") {
request_close();
return true;
}
return false;
}
void PluginDockPanel::push_message(const nlohmann::json& data)
{
if (!m_closing)
post_to_page(data.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace));
}
void PluginDockPanel::fire_close()
{
if (m_closing)
return;
m_closing = true;
if (m_on_close) {
CloseHandler on_close = std::move(m_on_close);
m_on_close = nullptr;
on_close();
}
}
void PluginDockPanel::request_close()
{
if (m_closing)
return;
fire_close();
// A close requested by the page arrives inside the web view's own script-message callback,
// which must return before the web view is destroyed.
CallAfter([this]() { remove_pane(); });
}
void PluginDockPanel::destroy_for_plugin()
{
m_closing = true;
m_on_close = nullptr;
remove_pane();
}
void PluginDockPanel::remove_pane()
{
if (Plater* plater = wxGetApp().plater())
plater->remove_plugin_pane(this);
else
Destroy();
}
}} // namespace Slic3r::GUI
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include "PluginWebPanel.hpp"
#include <functional>
#include <string>
namespace Slic3r { namespace GUI {
// Name of a plugin's pane in the Plater's dock manager. It stays the same across sessions, so the
// saved window layout can put the pane back, and it never contains a wxAuiManager layout delimiter.
std::string plugin_pane_name(const std::string& plugin_key, const std::string& title);
// The pane part saved for `pane_name` in a wxAuiManager layout string, in the form
// wxAuiManager::LoadPaneInfo() takes, or empty when the layout has no such pane.
std::string plugin_pane_layout_entry(const std::string& layout, const std::string& pane_name);
// A PluginWebPanel docked in the Plater's dock manager, using the same window.orca bridge as
// PluginWebDialog (without submit, which only a plugin window handles). Python-agnostic for the same
// reason: it can be destroyed on the main thread without the GIL, so its hooks must not capture
// pybind11 objects.
class PluginDockPanel : public PluginWebPanel
{
public:
using MessageHandler = std::function<void(const nlohmann::json& data)>;
using CloseHandler = std::function<void()>;
// on_close fires once, on a user or page initiated close. on_destroyed runs from the destructor
// on every path and must touch host-side state only.
PluginDockPanel(wxWindow* parent,
const std::string& html,
MessageHandler on_message,
CloseHandler on_close,
CloseHandler on_destroyed);
~PluginDockPanel() override;
// Main thread only.
void push_message(const nlohmann::json& data);
// Fires on_close, then removes the pane.
void request_close();
// Removes the pane without firing on_close, for plugin unload. Unlike request_close() it destroys
// the panel at once, which is safe because nothing a plugin page can call unloads a plugin: unload
// comes from the host (Plugins dialog, install, logout), never from this panel's own callbacks.
void destroy_for_plugin();
// Fires on_close at most once. The Plater calls it when the pane's own close button is used.
void fire_close();
protected:
std::optional<std::string> page_html() override { return m_html; }
bool on_page_message(const std::string& kind, const nlohmann::json& data) override;
private:
void remove_pane();
std::string m_html;
bool m_closing{false};
MessageHandler m_on_message;
CloseHandler m_on_close;
CloseHandler m_on_destroyed;
};
}} // namespace Slic3r::GUI
+4 -52
View File
@@ -1,11 +1,7 @@
#include "PluginWebDialog.hpp" #include "PluginWebDialog.hpp"
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/PluginWebPanel.hpp"
#include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
#include <wx/event.h> #include <wx/event.h>
@@ -13,50 +9,6 @@
namespace Slic3r { namespace GUI { namespace Slic3r { namespace GUI {
namespace {
// Injected into the top-level page at document start (before the plugin's own
// scripts). Defines window.orca as the only host surface the page may use. It
// references window.wx lazily (at call time) so it never races the backend's
// deferred registration of the "wx" message handler. Guarded against
// double-injection so it is harmless if also prepended.
constexpr char ORCA_BRIDGE_JS[] = R"JS(
(function () {
if (window.top !== window.self) return;
if (window.orca) return;
var handlers = [];
function send(kind, data) {
try {
window.wx.postMessage(JSON.stringify({
channel: 'orca', kind: kind, data: (data === undefined ? null : data)
}));
} catch (e) { /* bridge not ready yet */ }
}
window.orca = {
postMessage: function (d) { send('message', d); },
submit: function (d) { send('submit', d); },
close: function () { send('close'); },
onMessage: function (cb) { if (typeof cb === 'function') handlers.push(cb); }
};
window.__orcaDispatch = function (payload) {
var data = payload ? payload.data : null;
for (var i = 0; i < handlers.length; i++) {
try { handlers[i](data); } catch (e) {}
}
};
})();
)JS";
// file:// base URL for plugin HTML loaded via SetPage, so self-referencing
// relative URLs resolve against the bundled web resources directory.
wxString web_base_url()
{
const std::string dir = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string();
return wxString("file://") + from_u8(dir) + "/";
}
} // namespace
PluginWebDialog::PluginWebDialog(wxWindow* parent, PluginWebDialog::PluginWebDialog(wxWindow* parent,
const wxString& title, const wxString& title,
const std::string& html, const std::string& html,
@@ -75,7 +27,7 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent,
{ {
// A tiny bundled bootstrap page brings the webview up; the real plugin HTML // A tiny bundled bootstrap page brings the webview up; the real plugin HTML
// is swapped in via SetPage once the bootstrap finishes loading. // is swapped in via SetPage once the bootstrap finishes loading.
create_webview("web/dialog/PluginWebDialog/blank.html", title, size, wxSize(320, 240)); create_webview(PluginWebPanel::BOOTSTRAP_PAGE, title, size, wxSize(320, 240));
// Paint the window/webview in the themed background so there is no white // Paint the window/webview in the themed background so there is no white
// flash before the (transparent) bootstrap page and plugin HTML render. // flash before the (transparent) bootstrap page and plugin HTML render.
@@ -97,7 +49,7 @@ void PluginWebDialog::add_user_scripts()
{ {
if (wxWebView* wv = browser()) { if (wxWebView* wv = browser()) {
wv->AddUserScript(wxString::FromUTF8(WebViewHostDialog::plugin_defaults_user_script())); wv->AddUserScript(wxString::FromUTF8(WebViewHostDialog::plugin_defaults_user_script()));
wv->AddUserScript(ORCA_BRIDGE_JS); wv->AddUserScript(wxString::FromUTF8(PluginWebPanel::orca_bridge_script()));
} }
} }
@@ -151,7 +103,7 @@ void PluginWebDialog::load_plugin_content()
return; return;
m_content_loaded = true; m_content_loaded = true;
if (wxWebView* wv = browser()) if (wxWebView* wv = browser())
wv->SetPage(wxString::FromUTF8(m_html), web_base_url()); wv->SetPage(wxString::FromUTF8(m_html), PluginWebPanel::content_base_url());
} }
void PluginWebDialog::on_script_message(const nlohmann::json& payload) void PluginWebDialog::on_script_message(const nlohmann::json& payload)
+132
View File
@@ -0,0 +1,132 @@
#include "PluginWebPanel.hpp"
#include "GUI.hpp"
#include "GUI_App.hpp"
#include "Widgets/WebView.hpp"
#include "Widgets/WebViewHostDialog.hpp"
#include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
#include <wx/sizer.h>
namespace Slic3r { namespace GUI {
namespace {
// Injected into the top-level page at document start (before the plugin's own
// scripts). Defines window.orca as the only host surface the page may use. It
// references window.wx lazily (at call time) so it never races the backend's
// deferred registration of the "wx" message handler. Guarded against
// double-injection so it is harmless if also prepended.
constexpr char ORCA_BRIDGE_JS[] = R"JS(
(function () {
if (window.top !== window.self) return;
if (window.orca) return;
var handlers = [];
function send(kind, data) {
try {
window.wx.postMessage(JSON.stringify({
channel: 'orca', kind: kind, data: (data === undefined ? null : data)
}));
} catch (e) { /* bridge not ready yet */ }
}
window.orca = {
postMessage: function (d) { send('message', d); },
submit: function (d) { send('submit', d); },
close: function () { send('close'); },
onMessage: function (cb) { if (typeof cb === 'function') handlers.push(cb); }
};
window.__orcaDispatch = function (payload) {
var data = payload ? payload.data : null;
for (var i = 0; i < handlers.length; i++) {
try { handlers[i](data); } catch (e) {}
}
};
})();
)JS";
} // namespace
wxString PluginWebPanel::content_base_url()
{
const std::string dir = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string();
return wxString("file://") + from_u8(dir) + "/";
}
const char* PluginWebPanel::orca_bridge_script() { return ORCA_BRIDGE_JS; }
PluginWebPanel::PluginWebPanel(wxWindow* parent, const char* bridge_script)
: wxPanel(parent, wxID_ANY)
{
SetBackgroundColour(wxGetApp().get_window_default_clr());
auto* sizer = new wxBoxSizer(wxVERTICAL);
SetSizer(sizer);
// Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available.
const std::string bootstrap = (boost::filesystem::path(resources_dir()) / BOOTSTRAP_PAGE).make_preferred().string();
m_browser = WebView::CreateWebView(this, wxString("file://") + from_u8(bootstrap));
m_browser->SetBackgroundColour(GetBackgroundColour());
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script()));
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::plugin_defaults_user_script()));
m_browser->AddUserScript(wxString::FromUTF8(bridge_script));
m_browser->Bind(wxEVT_WEBVIEW_LOADED, &PluginWebPanel::on_load_event, this);
m_browser->Bind(wxEVT_WEBVIEW_ERROR, &PluginWebPanel::on_load_event, this);
m_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &PluginWebPanel::on_script_message, this);
m_browser->Bind(EVT_WEBVIEW_RECREATED, &PluginWebPanel::on_webview_recreated, this);
sizer->Add(m_browser, 1, wxEXPAND);
}
void PluginWebPanel::on_load_event(wxWebViewEvent& event)
{
if (!m_content_loaded) {
// The first bootstrap load (or its error) triggers the swap to the plugin HTML.
m_content_loaded = true;
if (const std::optional<std::string> html = page_html())
m_browser->SetPage(wxString::FromUTF8(*html), content_base_url());
} else if (event.GetEventType() == wxEVT_WEBVIEW_LOADED) {
// The document-start theme script keeps the theme the web view was created with, so bring
// every later document (the plugin page, or one a link loaded) onto the current app theme.
apply_theme();
}
event.Skip();
}
void PluginWebPanel::on_script_message(wxWebViewEvent& event)
{
const nlohmann::json payload = nlohmann::json::parse(event.GetString().utf8_string(), nullptr, false);
if (!payload.is_object() || payload.value("channel", std::string()) != "orca")
return;
const std::string kind = payload.value("kind", std::string());
if (!on_page_message(kind, payload.contains("data") ? payload["data"] : nlohmann::json()))
BOOST_LOG_TRIVIAL(warning) << "Plugin web panel ignored a window.orca '" << kind << "' call; this host does not support it";
}
void PluginWebPanel::on_webview_recreated(wxCommandEvent&)
{
SetBackgroundColour(wxGetApp().get_window_default_clr());
m_browser->SetBackgroundColour(GetBackgroundColour());
Refresh();
// Handled without Skip(), so WebView::RecreateAll() does not reload the plugin page.
apply_theme();
}
void PluginWebPanel::apply_theme()
{
WebView::RunScript(m_browser, wxString::FromUTF8(WebViewHostDialog::theme_apply_script()));
}
void PluginWebPanel::post_to_page(const std::string& json)
{
WebView::RunScript(m_browser, wxString::Format(
"(function dispatch(payload, attempts) {\n"
" if (typeof window.__orcaDispatch === 'function') { window.__orcaDispatch(payload); return; }\n"
" if (attempts < 100) window.setTimeout(function() { dispatch(payload, attempts + 1); }, 25);\n"
"})({data: %s}, 0);",
wxString::FromUTF8(json)));
}
}} // namespace Slic3r::GUI
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <nlohmann/json.hpp>
#include <wx/panel.h>
#include <wx/string.h>
#include <wx/webview.h>
#include <optional>
#include <string>
namespace Slic3r { namespace GUI {
// A panel hosting plugin-supplied HTML in a web view, shared by plugin Pages tabs and docked panels.
// It loads the bundled bootstrap page, swaps the plugin HTML in once that has loaded, registers the
// host theme, the plugin element defaults and the given window.orca bridge, keeps the page on the
// app theme, and routes the bridge's messages to on_page_message().
class PluginWebPanel : public wxPanel
{
public:
PluginWebPanel(wxWindow* parent, const char* bridge_script);
// Shared by every plugin web view, PluginWebDialog included.
// The bundled blank page a plugin web view loads before the plugin HTML is swapped in.
static constexpr const char* BOOTSTRAP_PAGE = "web/dialog/PluginWebDialog/blank.html";
// The file:// base URL plugin HTML is loaded against, so relative URLs resolve against the
// bundled web resources.
static wxString content_base_url();
// The window.orca bridge of plugin windows and docked panels: postMessage, submit, close and
// onMessage. Each host acts only on the message kinds it supports.
static const char* orca_bridge_script();
protected:
wxWebView* browser() const { return m_browser; }
// Delivers an already serialised JSON value to the page's window.orca.onMessage handlers,
// waiting briefly for the bridge while the page is still loading. Main thread only.
void post_to_page(const std::string& json);
// The plugin HTML to show once the bootstrap page has loaded; std::nullopt leaves it blank.
virtual std::optional<std::string> page_html() = 0;
// A window.orca message from the page. Returns false for a kind this host does not handle,
// which is logged so a dropped call is diagnosable.
virtual bool on_page_message(const std::string& kind, const nlohmann::json& data) = 0;
private:
void on_load_event(wxWebViewEvent& event);
void on_script_message(wxWebViewEvent& event);
void on_webview_recreated(wxCommandEvent& event);
void apply_theme();
wxWebView* m_browser{nullptr};
bool m_content_loaded{false};
};
}} // namespace Slic3r::GUI
+2
View File
@@ -3026,6 +3026,8 @@ void TabPrint::build()
optgroup = page->new_optgroup(L("Advanced"), L"advanced"); optgroup = page->new_optgroup(L("Advanced"), L"advanced");
optgroup->append_single_option_line("interlocking_beam", "multimaterial_settings_advanced#interlocking-beam"); optgroup->append_single_option_line("interlocking_beam", "multimaterial_settings_advanced#interlocking-beam");
optgroup->append_single_option_line("toolchange_ordering", "multimaterial_settings_advanced#toolchange-ordering"); optgroup->append_single_option_line("toolchange_ordering", "multimaterial_settings_advanced#toolchange-ordering");
optgroup->append_single_option_line("toolchange_cyclic_order", "multimaterial_settings_advanced#toolchange-order");
optgroup->append_single_option_line("toolchange_cyclic_first_layer", "multimaterial_settings_advanced#toolchange-order");
optgroup->append_single_option_line("interface_shells", "multimaterial_settings_advanced#interface-shells"); optgroup->append_single_option_line("interface_shells", "multimaterial_settings_advanced#interface-shells");
optgroup->append_single_option_line("mmu_segmented_region_max_width", "multimaterial_settings_advanced#maximum-width-of-segmented-region"); optgroup->append_single_option_line("mmu_segmented_region_max_width", "multimaterial_settings_advanced#maximum-width-of-segmented-region");
optgroup->append_single_option_line("mmu_segmented_region_interlocking_depth", "multimaterial_settings_advanced#interlocking-depth-of-segmented-region"); optgroup->append_single_option_line("mmu_segmented_region_interlocking_depth", "multimaterial_settings_advanced#interlocking-depth-of-segmented-region");
@@ -75,6 +75,8 @@ if(document.documentElement)
} // namespace } // namespace
std::string WebViewHostDialog::theme_apply_script() { return host_theme_apply_js(); }
// Document-start user script: injects the contract <style>, stamps data-orca-theme before // Document-start user script: injects the contract <style>, stamps data-orca-theme before
// first paint, and raises a JS flag so the legacy globalapi.js dark.css poll stands down for // first paint, and raises a JS flag so the legacy globalapi.js dark.css poll stands down for
// host-themed pages. The WebView2 timing guard lives in document_start_injector(). // host-themed pages. The WebView2 timing guard lives in document_start_injector().
@@ -52,6 +52,8 @@ public:
// Shared by modeless Pages tabs and PluginWebDialog. // Shared by modeless Pages tabs and PluginWebDialog.
static std::string theme_user_script(); static std::string theme_user_script();
static std::string plugin_defaults_user_script(); static std::string plugin_defaults_user_script();
// Re-themes an already-loaded page in place, for web views hosted outside a dialog.
static std::string theme_apply_script();
protected: protected:
wxWebView* browser() const { return m_browser; } wxWebView* browser() const { return m_browser; }
+204
View File
@@ -0,0 +1,204 @@
// PaintCLI.cpp — CLI paint-inspection primitives. See PaintCLI.hpp.
#include "PaintCLI.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include <nlohmann/json.hpp>
#include <cmath>
#include <string>
#include <utility>
#include <vector>
namespace Slic3r {
namespace PaintCLI {
namespace {
using json = nlohmann::json;
double its_surface_area(const indexed_triangle_set &its)
{
double total = 0.0;
for (const stl_triangle_vertex_indices &t : its.indices) {
const Vec3f &a = its.vertices[t(0)];
const Vec3f &b = its.vertices[t(1)];
const Vec3f &c = its.vertices[t(2)];
total += 0.5 * (b - a).cross(c - a).norm();
}
return total;
}
// Bbox over triangle-referenced vertices only. get_facets_strict() returns
// an itset with the full source vertex list — using bounding_box() on it
// would report the whole mesh's bbox even when only a few facets are painted.
BoundingBoxf3 its_referenced_bbox(const indexed_triangle_set &its)
{
BoundingBoxf3 bb;
bool first = true;
for (const stl_triangle_vertex_indices &t : its.indices) {
for (int k = 0; k < 3; ++k) {
const Vec3d v = its.vertices[t(k)].cast<double>();
if (first) { bb.min = bb.max = v; first = false; }
else bb.merge(v);
}
}
return bb;
}
json vec3_to_json(const Vec3d &v)
{
return json::array({ v.x(), v.y(), v.z() });
}
json bbox_to_json(const BoundingBoxf3 &bb)
{
return {
{ "min", vec3_to_json(bb.min) },
{ "max", vec3_to_json(bb.max) },
{ "size", vec3_to_json(Vec3d(bb.max - bb.min)) },
};
}
// One (layer, state) row — empty ones are omitted at the caller level.
json state_entry(const std::string &label, const indexed_triangle_set &its)
{
return {
{ "state", label },
{ "facets", its.indices.size() },
{ "area_mm2", its_surface_area(its) },
{ "bbox", bbox_to_json(its_referenced_bbox(its)) },
};
}
// Iterate the states relevant to one FacetsAnnotation kind, collecting
// non-empty entries. Empty layer → {"empty": true}. `n_facets_out` is the
// running total of painted facets — bumped for the summary.
json inspect_layer(const ModelVolume &mv, const FacetsAnnotation &fa,
const std::vector<std::pair<EnforcerBlockerType, std::string>> &states,
size_t &n_facets_out)
{
if (fa.empty())
return { { "empty", true } };
json entries = json::array();
for (const auto &st : states) {
if (!fa.has_facets(mv, st.first))
continue;
indexed_triangle_set its = fa.get_facets_strict(mv, st.first);
if (its.indices.empty())
continue;
n_facets_out += its.indices.size();
entries.push_back(state_entry(st.second, its));
}
return {
{ "empty", entries.empty() },
{ "states", std::move(entries) },
};
}
const std::vector<std::pair<EnforcerBlockerType, std::string>> &supports_states()
{
static const std::vector<std::pair<EnforcerBlockerType, std::string>> s = {
{ EnforcerBlockerType::ENFORCER, "ENFORCER" },
{ EnforcerBlockerType::BLOCKER, "BLOCKER" },
};
return s;
}
const std::vector<std::pair<EnforcerBlockerType, std::string>> &fuzzy_states()
{
// FUZZY_SKIN is an enum alias for ENFORCER; the layer is single-state.
static const std::vector<std::pair<EnforcerBlockerType, std::string>> s = {
{ EnforcerBlockerType::FUZZY_SKIN, "FUZZY_SKIN" },
};
return s;
}
const std::vector<std::pair<EnforcerBlockerType, std::string>> &mmu_states()
{
static std::vector<std::pair<EnforcerBlockerType, std::string>> s = []{
std::vector<std::pair<EnforcerBlockerType, std::string>> v;
for (int i = 1; i <= int(EnforcerBlockerType::ExtruderMax); ++i)
v.emplace_back(EnforcerBlockerType(i), "extruder_" + std::to_string(i));
return v;
}();
return s;
}
} // namespace
void inspect_to_json(const Model &model, const std::vector<std::string> &source_paths,
std::ostream &out)
{
json root;
root["sources"] = source_paths;
root["frame"] = "mesh_local";
root["note"] = "Coordinates are mesh-local (each volume's own frame). "
"Paint gizmos operate in this frame.";
json objects = json::array();
size_t total_objects = 0, total_volumes = 0, total_painted = 0, total_facets = 0;
for (size_t oi = 0; oi < model.objects.size(); ++oi) {
const ModelObject *mo = model.objects[oi];
if (!mo) continue;
++total_objects;
json obj;
obj["index"] = oi;
obj["name"] = mo->name;
json volumes = json::array();
for (size_t vi = 0; vi < mo->volumes.size(); ++vi) {
const ModelVolume *mv = mo->volumes[vi];
if (!mv) continue;
++total_volumes;
const indexed_triangle_set &its = mv->mesh().its;
json vol;
vol["index"] = vi;
vol["name"] = mv->name;
vol["n_facets"] = its.indices.size();
vol["is_model_part"] = mv->is_model_part();
vol["bbox_mesh_local"] = bbox_to_json(bounding_box(its));
size_t vol_painted = 0;
json paints;
paints["supports"] = inspect_layer(*mv, mv->supported_facets,
supports_states(), vol_painted);
paints["seam"] = inspect_layer(*mv, mv->seam_facets,
supports_states(), vol_painted);
paints["mmu_segmentation"] = inspect_layer(*mv, mv->mmu_segmentation_facets,
mmu_states(), vol_painted);
paints["fuzzy_skin"] = inspect_layer(*mv, mv->fuzzy_skin_facets,
fuzzy_states(), vol_painted);
vol["paints"] = std::move(paints);
vol["painted_facets_total"] = vol_painted;
if (vol_painted > 0) ++total_painted;
total_facets += vol_painted;
volumes.push_back(std::move(vol));
}
obj["volumes"] = std::move(volumes);
objects.push_back(std::move(obj));
}
root["objects"] = std::move(objects);
root["summary"] = {
{ "objects", total_objects },
{ "volumes", total_volumes },
{ "volumes_with_paint", total_painted },
{ "painted_facets_total", total_facets },
};
// Object names and file paths are arbitrary bytes, and dump() throws on invalid
// UTF-8 by default. Replace such sequences with U+FFFD so the output is always
// valid JSON rather than an exception out of the CLI.
out << root.dump(2, ' ', false, json::error_handler_t::replace) << std::endl;
}
} // namespace PaintCLI
} // namespace Slic3r
+31
View File
@@ -0,0 +1,31 @@
// PaintCLI.hpp — CLI paint-inspection primitives.
//
// Backs the --inspect-paint CLI action. Reads the per-facet enforcer /
// blocker / extruder / fuzzy-skin state that OrcaSlicer stores on every
// ModelVolume (supports, seam, MMU color, fuzzy-skin) and emits a
// structured JSON summary — facet count, surface area, and mesh-local
// bounding box per state — so CI / scripted / AI tooling can reason
// about existing paint on a .3mf without opening the GUI.
//
// Coordinates are mesh-local (each volume's own frame), matching the
// frame that the paint gizmos operate in.
#ifndef slic3r_PaintCLI_hpp_
#define slic3r_PaintCLI_hpp_
#include <iosfwd>
#include <string>
#include <vector>
namespace Slic3r {
class Model;
namespace PaintCLI {
// `source_paths` lists every input file; the CLI merges them into one Model.
void inspect_to_json(const Model &model, const std::vector<std::string> &source_paths,
std::ostream &out);
} // namespace PaintCLI
} // namespace Slic3r
#endif
+124 -20
View File
@@ -7,6 +7,8 @@
#include <slic3r/GUI/GUI_App.hpp> #include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/MainFrame.hpp> #include <slic3r/GUI/MainFrame.hpp>
#include <slic3r/GUI/MsgDialog.hpp> #include <slic3r/GUI/MsgDialog.hpp>
#include <slic3r/GUI/Plater.hpp>
#include <slic3r/GUI/PluginDockPanel.hpp>
#include <slic3r/GUI/PluginProgressDialog.hpp> #include <slic3r/GUI/PluginProgressDialog.hpp>
#include <slic3r/GUI/PluginWebDialog.hpp> #include <slic3r/GUI/PluginWebDialog.hpp>
#include <slic3r/GUI/NotificationManager.hpp> #include <slic3r/GUI/NotificationManager.hpp>
@@ -20,6 +22,7 @@
#include <wx/defs.h> #include <wx/defs.h>
#include <wx/window.h> #include <wx/window.h>
#include <algorithm>
#include <atomic> #include <atomic>
#include <cstdint> #include <cstdint>
#include <future> #include <future>
@@ -109,6 +112,25 @@ GUI::PluginWebDialog::SubmitHandler make_submit_adapter(py::object on_submit)
}; };
} }
// The plugin's on_close: fired only on a user/JS-initiated close (not forced teardown), while the
// window is alive. Empty if the plugin passed None.
std::function<void()> make_close_adapter(const CallablePtr& holder)
{
if (!holder)
return nullptr;
return [holder]() {
PythonGILState gil;
if (!gil)
return;
try {
holder->fn();
} catch (py::error_already_set& e) {
BOOST_LOG_TRIVIAL(error) << "orca.host.ui on_close handler raised: " << e.what();
PyErr_Clear();
}
};
}
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Registry of live plugin UI resources. Keyed by an opaque id; tracks the // Registry of live plugin UI resources. Keyed by an opaque id; tracks the
// owning plugin so all of a plugin's UI can be torn down on unload. // owning plugin so all of a plugin's UI can be torn down on unload.
@@ -350,22 +372,7 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
if (!UiRegistry::instance().is_open(new_id)) if (!UiRegistry::instance().is_open(new_id))
return; return;
// Plugin's on_close: fired only on a user/JS-initiated close (not forced GUI::PluginWebDialog::CloseHandler on_close = make_close_adapter(close_holder);
// teardown), while the dialog is alive. Empty if the plugin passed None.
GUI::PluginWebDialog::CloseHandler on_close;
if (close_holder) {
on_close = [close_holder]() {
PythonGILState gil;
if (!gil)
return;
try {
close_holder->fn();
} catch (py::error_already_set& e) {
BOOST_LOG_TRIVIAL(error) << "orca.host.ui on_close handler raised: " << e.what();
PyErr_Clear();
}
};
}
// Registry cleanup: GIL-free, runs from the dialog destructor on every path. // Registry cleanup: GIL-free, runs from the dialog destructor on every path.
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); }; auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
@@ -387,14 +394,69 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
return py::cast(UiWindowHandle{new_id}); return py::cast(UiWindowHandle{new_id});
} }
// --------------------------------------------------------------------------
// orca.host.ui.create_dock_panel + UiDockPanel handle
// --------------------------------------------------------------------------
constexpr const char* DOCK_POSITIONS[] = {"left", "right", "bottom", "float"};
struct UiDockPanelHandle
{
int id{0};
};
py::object ui_create_dock_panel(const std::string& html, const std::string& title, int width, int height,
py::object on_message, py::object on_close, const std::string& dock)
{
if (std::find(std::begin(DOCK_POSITIONS), std::end(DOCK_POSITIONS), dock) == std::end(DOCK_POSITIONS))
throw std::invalid_argument("orca.host.ui.create_dock_panel dock must be \"left\", \"right\", \"bottom\" or \"float\"");
auto msg_adapter = make_message_adapter(std::move(on_message));
CallablePtr close_holder = make_holder(std::move(on_close));
const std::string plugin_key = PluginAuditManager::instance().current_plugin();
const int w = width > 0 ? width : 320;
const int h = height > 0 ? height : 480;
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
// Deferred and pre-bound for the same reasons as create_window().
const int new_id = UiRegistry::instance().reserve_id();
UiRegistry::instance().bind(new_id, nullptr, plugin_key);
GUI::wxGetApp().CallAfter([new_id, plugin_key, html, title, dock, w, h,
msg_adapter = std::move(msg_adapter),
close_holder = std::move(close_holder)]() mutable {
if (!UiRegistry::instance().is_open(new_id))
return;
GUI::Plater* plater = GUI::wxGetApp().plater();
if (plater == nullptr || GUI::wxGetApp().is_closing()) {
UiRegistry::instance().remove(new_id);
return;
}
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
auto* panel = new GUI::PluginDockPanel(plater, html, std::move(msg_adapter), make_close_adapter(close_holder),
std::move(on_destroyed));
UiRegistry::instance().bind(new_id, panel, plugin_key);
plater->add_plugin_pane(panel, GUI::plugin_pane_name(plugin_key, title), wxString::FromUTF8(title), dock,
wxSize(w, h), [panel]() { panel->fire_close(); });
});
return py::cast(UiDockPanelHandle{new_id});
}
void handle_post(int id, py::object data) void handle_post(int id, py::object data)
{ {
if (wxTheApp == nullptr) if (wxTheApp == nullptr)
return; return;
json j = py_to_json(data); // GIL held (binding body) json j = py_to_json(data); // GIL held (binding body)
GUI::wxGetApp().CallAfter([id, j = std::move(j)]() { GUI::wxGetApp().CallAfter([id, j = std::move(j)]() {
auto* d = UiRegistry::instance().get_as<GUI::PluginWebDialog>(id); auto* window = UiRegistry::instance().get_as<wxWindow>(id);
GUI::PluginWebDialog::post_message(d, j); if (auto* panel = dynamic_cast<GUI::PluginDockPanel*>(window))
panel->push_message(j);
else
GUI::PluginWebDialog::post_message(dynamic_cast<GUI::PluginWebDialog*>(window), j);
}); });
} }
@@ -403,8 +465,23 @@ void handle_close(int id)
if (wxTheApp == nullptr) if (wxTheApp == nullptr)
return; return;
GUI::wxGetApp().CallAfter([id]() { GUI::wxGetApp().CallAfter([id]() {
auto* d = UiRegistry::instance().get_as<GUI::PluginWebDialog>(id); auto* window = UiRegistry::instance().get_as<wxWindow>(id);
GUI::PluginWebDialog::request_close(d); if (auto* panel = dynamic_cast<GUI::PluginDockPanel*>(window))
panel->request_close();
else
GUI::PluginWebDialog::request_close(dynamic_cast<GUI::PluginWebDialog*>(window));
});
}
void handle_show(int id, bool show)
{
if (wxTheApp == nullptr)
return;
GUI::wxGetApp().CallAfter([id, show]() {
auto* panel = UiRegistry::instance().get_as<GUI::PluginDockPanel>(id);
GUI::Plater* plater = GUI::wxGetApp().plater();
if (panel != nullptr && plater != nullptr)
plater->show_plugin_pane(panel, show);
}); });
} }
@@ -563,6 +640,31 @@ void PluginHostUi::RegisterBindings(pybind11::module_& host)
"or WINDOW_MODAL. on_message(data) is called on the UI thread when the page posts; on_submit(data) " "or WINDOW_MODAL. on_message(data) is called on the UI thread when the page posts; on_submit(data) "
"is called when the page submits; offload heavy work to a thread and push results back with window.post()."); "is called when the page submits; offload heavy work to a thread and push results back with window.post().");
py::class_<UiDockPanelHandle>(ui, "UiDockPanel", "Handle to a dockable plugin HTML panel created by create_dock_panel().")
.def_property_readonly("id", [](const UiDockPanelHandle& h) { return h.id; })
.def(
"post", [](const UiDockPanelHandle& h, py::object data) { handle_post(h.id, std::move(data)); },
py::arg("data"), "Send a payload to the page (delivered to window.orca.onMessage handlers).")
.def(
"show", [](const UiDockPanelHandle& h) { handle_show(h.id, true); }, "Show the panel again after hide().")
.def(
"hide", [](const UiDockPanelHandle& h) { handle_show(h.id, false); }, "Hide the panel without closing it.")
.def(
"close", [](const UiDockPanelHandle& h) { handle_close(h.id); }, "Close the panel (fires on_close).")
.def(
"is_open", [](const UiDockPanelHandle& h) { return UiRegistry::instance().is_open(h.id); },
"Return True until the panel is closed; a hidden panel is still open.");
ui.def("create_dock_panel", &ui_create_dock_panel, py::arg("html"), py::arg("title") = "OrcaSlicer",
py::arg("width") = 320, py::arg("height") = 480, py::arg("on_message") = py::none(),
py::arg("on_close") = py::none(), py::arg("dock") = "right",
"Open an HTML panel docked beside the 3D view and return a UiDockPanel. dock is \"left\", \"right\", "
"\"bottom\" or \"float\", and width/height are in DIPs; the user can move and resize the panel, and the "
"saved window layout remembers both. The panel belongs to the Prepare and Preview tabs. on_message(data) "
"is called on the UI thread when the page posts; window.orca.close() or the panel's close button closes "
"it and calls on_close(). A post() made before the page has loaded can be dropped, so have the page "
"request its first data.");
py::class_<UiProgressHandle>(ui, "ProgressDialog", "Handle to a native progress dialog.") py::class_<UiProgressHandle>(ui, "ProgressDialog", "Handle to a native progress dialog.")
.def(py::init(&new_progress_dialog), py::arg("title"), py::arg("message"), py::arg("maximum") = 100, .def(py::init(&new_progress_dialog), py::arg("title"), py::arg("message"), py::arg("maximum") = 100,
py::arg("style") = wxPD_APP_MODAL | wxPD_AUTO_HIDE) py::arg("style") = wxPD_APP_MODAL | wxPD_AUTO_HIDE)
@@ -632,6 +734,8 @@ void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key)
for (auto* window : UiRegistry::instance().take_for_plugin(plugin_key)) { for (auto* window : UiRegistry::instance().take_for_plugin(plugin_key)) {
if (auto* dialog = dynamic_cast<GUI::PluginWebDialog*>(window)) if (auto* dialog = dynamic_cast<GUI::PluginWebDialog*>(window))
GUI::PluginWebDialog::destroy_for_plugin(dialog); GUI::PluginWebDialog::destroy_for_plugin(dialog);
else if (auto* panel = dynamic_cast<GUI::PluginDockPanel*>(window))
panel->destroy_for_plugin();
else if (window != nullptr) else if (window != nullptr)
window->Destroy(); window->Destroy();
} }
+19 -79
View File
@@ -1,17 +1,12 @@
#include "PluginPages.hpp" #include "PluginPages.hpp"
#include "libslic3r/AppConfig.hpp" #include "libslic3r/AppConfig.hpp"
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/Notebook.hpp" #include "slic3r/GUI/Notebook.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/Widgets/Button.hpp" #include "slic3r/GUI/Widgets/Button.hpp"
#include "slic3r/GUI/Widgets/WebView.hpp"
#include "slic3r/GUI/Widgets/WebViewHostDialog.hpp"
#include "slic3r/GUI/wxExtensions.hpp" #include "slic3r/GUI/wxExtensions.hpp"
#include "slic3r/plugin/PluginManager.hpp" #include "slic3r/plugin/PluginManager.hpp"
#include <libslic3r/Utils.hpp>
#include <algorithm> #include <algorithm>
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
@@ -66,27 +61,11 @@ constexpr char PLUGIN_PAGE_BRIDGE_JS[] = R"JS(
} // namespace } // namespace
PluginPage::PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability) PluginPage::PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability)
: wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize) : GUI::PluginWebPanel(parent, PLUGIN_PAGE_BRIDGE_JS)
, m_cap(std::move(capability)) , m_cap(std::move(capability))
, m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this)) , m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this))
{ {
auto* topsizer = new wxBoxSizer(wxVERTICAL); browser()->Bind(wxEVT_WEBVIEW_NEWWINDOW, &PluginPage::on_new_window, this);
SetSizer(topsizer);
m_browser = WebView::CreateWebView(this, bootstrap_url());
if (m_browser == nullptr) {
wxLogError("Could not initialize plugin page web view");
return;
}
topsizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
m_browser->Bind(wxEVT_WEBVIEW_LOADED, &PluginPage::on_bootstrap_event, this);
m_browser->Bind(wxEVT_WEBVIEW_ERROR, &PluginPage::on_bootstrap_event, this);
m_browser->Bind(wxEVT_WEBVIEW_NEWWINDOW, &PluginPage::on_new_window, this);
m_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &PluginPage::on_script_message, this);
m_browser->AddUserScript(wxString::FromUTF8(GUI::WebViewHostDialog::theme_user_script()));
m_browser->AddUserScript(wxString::FromUTF8(GUI::WebViewHostDialog::plugin_defaults_user_script()));
m_browser->AddUserScript(PLUGIN_PAGE_BRIDGE_JS);
const std::shared_ptr<std::atomic<PluginPage*>> lifetime = m_lifetime; const std::shared_ptr<std::atomic<PluginPage*>> lifetime = m_lifetime;
m_cap->set_message_sender([lifetime](const std::string& message) { m_cap->set_message_sender([lifetime](const std::string& message) {
@@ -117,89 +96,54 @@ void PluginPage::detach_capability()
m_cap.reset(); m_cap.reset();
} }
wxString PluginPage::web_base_url() const std::optional<std::string> PluginPage::page_html()
{ {
const auto path = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string(); if (m_cap == nullptr)
return wxString("file://") + GUI::from_u8(path) + "/"; return std::nullopt;
}
wxString PluginPage::bootstrap_url() const
{
const auto path = (boost::filesystem::path(resources_dir()) / "web/dialog/PluginWebDialog/blank.html").make_preferred().string();
return wxString("file://") + GUI::from_u8(path);
}
void PluginPage::on_bootstrap_event(wxWebViewEvent& event)
{
load_plugin_content();
event.Skip();
}
void PluginPage::load_plugin_content()
{
if (m_content_loaded || m_browser == nullptr || m_cap == nullptr)
return;
m_content_loaded = true;
try { try {
m_browser->SetPage(wxString::FromUTF8(m_cap->get_ui()), web_base_url()); return m_cap->get_ui();
} catch (const std::exception& error) { } catch (const std::exception& error) {
BOOST_LOG_TRIVIAL(error) << "Failed to load plugin page '" << m_cap->name() << "': " << error.what(); BOOST_LOG_TRIVIAL(error) << "Failed to load plugin page '" << m_cap->name() << "': " << error.what();
detach_capability();
} catch (...) { } catch (...) {
BOOST_LOG_TRIVIAL(error) << "Failed to load plugin page '" << m_cap->name() << "'"; BOOST_LOG_TRIVIAL(error) << "Failed to load plugin page '" << m_cap->name() << "'";
detach_capability();
} }
detach_capability();
return std::nullopt;
} }
void PluginPage::on_new_window(wxWebViewEvent& event) void PluginPage::on_new_window(wxWebViewEvent& event)
{ {
const wxString url = event.GetURL(); const wxString url = event.GetURL();
if (!url.empty() && m_browser != nullptr) if (!url.empty())
m_browser->LoadURL(url); browser()->LoadURL(url);
event.Veto(); event.Veto();
} }
void PluginPage::on_script_message(wxWebViewEvent& event) bool PluginPage::on_page_message(const std::string& kind, const nlohmann::json& data)
{ {
if (kind != "message")
return false;
if (!m_cap) if (!m_cap)
return; return true;
const wxString payload = event.GetString();
nlohmann::json root = nlohmann::json::parse(payload.utf8_string(), nullptr, false);
if (root.is_discarded() || root.value("channel", std::string()) != "orca" ||
root.value("kind", std::string()) != "message")
return;
const auto data = root.find("data");
try { try {
m_cap->on_message(data == root.end() m_cap->on_message(data.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace));
? "null"
: data->dump(-1, ' ', false, nlohmann::json::error_handler_t::replace));
} catch (const std::exception& error) { } catch (const std::exception& error) {
BOOST_LOG_TRIVIAL(error) << "Plugin page message handler failed for '" << m_cap->name() << "': " << error.what(); BOOST_LOG_TRIVIAL(error) << "Plugin page message handler failed for '" << m_cap->name() << "': " << error.what();
} catch (...) { } catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin page message handler failed for '" << m_cap->name() << "'"; BOOST_LOG_TRIVIAL(error) << "Plugin page message handler failed for '" << m_cap->name() << "'";
} }
return true;
} }
void PluginPage::push_message(const std::string& message) void PluginPage::push_message(const std::string& message)
{ {
if (m_browser == nullptr)
return;
// PagesPluginCapability::post_message() already dumps JSON, so accept it as-is; only a // PagesPluginCapability::post_message() already dumps JSON, so accept it as-is; only a
// non-JSON payload needs wrapping as a string literal. // non-JSON payload needs wrapping as a string literal.
const std::string payload = nlohmann::json::accept(message) post_to_page(nlohmann::json::accept(message)
? message ? message
: nlohmann::json(message).dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); : nlohmann::json(message).dump(-1, ' ', false, nlohmann::json::error_handler_t::replace));
WebView::RunScript(m_browser, wxString::Format(
"(function dispatch(payload, attempts) {\n"
" if (typeof window.__orcaDispatch === 'function') { window.__orcaDispatch(payload); return; }\n"
" if (attempts < 100) window.setTimeout(function() { dispatch(payload, attempts + 1); }, 25);\n"
"})({data: %s}, 0);",
wxString::FromUTF8(payload)));
} }
PluginPages::~PluginPages() PluginPages::~PluginPages()
@@ -268,10 +212,6 @@ bool PluginPages::create_page(const PluginCapabilityId& id)
} }
auto* page = new PluginPage(m_parent, std::move(capability)); auto* page = new PluginPage(m_parent, std::move(capability));
if (!page->is_valid()) {
page->Destroy();
return false;
}
if (!icon.empty()) { if (!icon.empty()) {
try { try {
+7 -11
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <slic3r/GUI/PluginWebPanel.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp> #include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/plugin/pluginTypes/pages/PagesPluginCapability.hpp> #include <slic3r/plugin/pluginTypes/pages/PagesPluginCapability.hpp>
@@ -11,14 +12,13 @@
#include <vector> #include <vector>
#include <wx/bitmap.h> #include <wx/bitmap.h>
#include <wx/panel.h>
#include <wx/webview.h> #include <wx/webview.h>
class Notebook; class Notebook;
namespace Slic3r { namespace Slic3r {
class PluginPage : public wxPanel class PluginPage : public GUI::PluginWebPanel
{ {
public: public:
PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability); PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability);
@@ -26,24 +26,20 @@ public:
PluginPage() = delete; PluginPage() = delete;
bool is_valid() const { return m_browser != nullptr && m_cap != nullptr; }
void detach_capability(); void detach_capability();
void on_bootstrap_event(wxWebViewEvent& event);
void on_new_window(wxWebViewEvent& event);
void on_script_message(wxWebViewEvent& event);
void push_message(const std::string& message); void push_message(const std::string& message);
void set_icon(const wxBitmap& icon) { m_icon = icon; } void set_icon(const wxBitmap& icon) { m_icon = icon; }
const wxBitmap& icon() const { return m_icon; } const wxBitmap& icon() const { return m_icon; }
protected:
std::optional<std::string> page_html() override;
bool on_page_message(const std::string& kind, const nlohmann::json& data) override;
private: private:
void load_plugin_content(); void on_new_window(wxWebViewEvent& event);
wxString bootstrap_url() const;
wxString web_base_url() const;
wxWebView* m_browser{nullptr};
std::shared_ptr<PagesPluginCapability> m_cap; std::shared_ptr<PagesPluginCapability> m_cap;
std::shared_ptr<std::atomic<PluginPage*>> m_lifetime; std::shared_ptr<std::atomic<PluginPage*>> m_lifetime;
bool m_content_loaded{false};
wxBitmap m_icon; wxBitmap m_icon;
}; };
@@ -1025,3 +1025,41 @@ TEST_CASE("Selector slicing keeps the result valid across re-apply", "[Print][H2
REQUIRE(status != PrintBase::APPLY_STATUS_INVALIDATED); REQUIRE(status != PrintBase::APPLY_STATUS_INVALIDATED);
REQUIRE(print.is_step_done(psSlicingFinished)); REQUIRE(print.is_step_done(psSlicingFinished));
} }
TEST_CASE("parse_cyclic_order parses user cyclic toolchange sequences", "[ToolOrdering][Cyclic]")
{
// Filament numbers are 1-based in the UI; the parser returns 0-based indices.
SECTION("well-formed sequence") {
REQUIRE(parse_cyclic_order("3,2,1,4", 4) == std::vector<unsigned int>({2, 1, 0, 3}));
}
SECTION("surrounding whitespace is tolerated") {
REQUIRE(parse_cyclic_order(" 3 , 2 ,1, 4 ", 4) == std::vector<unsigned int>({2, 1, 0, 3}));
}
SECTION("out-of-range and non-positive entries are dropped") {
// 0 is below the 1-based range, 5 is above it for a 4-filament setup, -1 is invalid.
REQUIRE(parse_cyclic_order("0,5,-1,2", 4) == std::vector<unsigned int>({1}));
}
SECTION("duplicates keep only the first occurrence") {
REQUIRE(parse_cyclic_order("2,2,1,2", 4) == std::vector<unsigned int>({1, 0}));
}
SECTION("garbage tokens are ignored") {
REQUIRE(parse_cyclic_order("3,abc,,2,x1", 4) == std::vector<unsigned int>({2, 1}));
}
SECTION("tokens that only start with a number are ignored") {
// "2x" must be dropped rather than parsed as filament 2.
REQUIRE(parse_cyclic_order("3,2x,1", 4) == std::vector<unsigned int>({2, 0}));
}
SECTION("empty string yields an empty order") {
REQUIRE(parse_cyclic_order("", 4).empty());
}
SECTION("a partial sequence only names the filaments it lists") {
REQUIRE(parse_cyclic_order("3,1", 4) == std::vector<unsigned int>({2, 0}));
}
}
@@ -3,6 +3,7 @@
#include <libslic3r/Model.hpp> #include <libslic3r/Model.hpp>
#include <libslic3r/PresetBundle.hpp> #include <libslic3r/PresetBundle.hpp>
#include <libslic3r/TriangleMesh.hpp> #include <libslic3r/TriangleMesh.hpp>
#include <slic3r/GUI/PluginDockPanel.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp> #include <slic3r/plugin/PythonPluginBridge.hpp>
#include "python_test_support.hpp" #include "python_test_support.hpp"
@@ -141,6 +142,8 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
CHECK(ui.attr("WINDOW_MODELESS").cast<long>() == 0); CHECK(ui.attr("WINDOW_MODELESS").cast<long>() == 0);
CHECK(ui.attr("WINDOW_MODAL").cast<long>() == 1); CHECK(ui.attr("WINDOW_MODAL").cast<long>() == 1);
CHECK(has_attr(ui, "UiWindow")); CHECK(has_attr(ui, "UiWindow"));
CHECK(has_attr(ui, "create_dock_panel"));
CHECK(has_attr(ui, "UiDockPanel"));
// With no wx application the UI calls marshal to a main thread that does not // With no wx application the UI calls marshal to a main thread that does not
// exist here; they must fail cleanly with a clear error, not crash. // exist here; they must fail cleanly with a clear error, not crash.
@@ -151,6 +154,53 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
CHECK(error.matches(PyExc_RuntimeError)); CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos); CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
} }
try {
ui.attr("create_dock_panel")("<p>panel</p>");
FAIL("orca.host.ui.create_dock_panel unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
// Positional arguments follow create_window(): width and height come straight after the title.
try {
ui.attr("create_dock_panel")("<p>panel</p>", "Panel", 400, 300);
FAIL("orca.host.ui.create_dock_panel unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
}
// An unknown dock position is rejected before the application is needed.
try {
ui.attr("create_dock_panel")("<p>panel</p>", py::arg("dock") = "top");
FAIL("orca.host.ui.create_dock_panel accepted an unknown dock position");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_ValueError));
}
}
TEST_CASE("Plugin pane names identify the plugin and title without layout delimiters", "[PluginHost]")
{
using Slic3r::GUI::plugin_pane_name;
CHECK(plugin_pane_name("dock_demo", "Scene") == "plugin:dock_demo:Scene");
CHECK(plugin_pane_name("key", "a|b;c=d\\e").find_first_of("|;=\\") == std::string::npos);
}
TEST_CASE("A plugin pane's saved layout entry is found by pane name", "[PluginHost]")
{
using Slic3r::GUI::plugin_pane_layout_entry;
const std::string sidebar = "name=sidebar;caption=;state=2099196;dir=4;layer=0;row=0;pos=0;bestw=390;besth=900";
// The caption holds an escaped '|', which must not end the entry.
const std::string plugin = "name=plugin:demo:Scene;caption=Scene \\| stats;state=2099198;dir=2;layer=0;row=1;pos=0;bestw=320;besth=480";
const std::string layout = "layout3|" + sidebar + "|" + plugin + "|dock_size(4,0,0)=392|";
CHECK(plugin_pane_layout_entry(layout, "plugin:demo:Scene") == plugin);
CHECK(plugin_pane_layout_entry(layout, "sidebar") == sidebar);
CHECK(plugin_pane_layout_entry(layout, "plugin:demo").empty());
CHECK(plugin_pane_layout_entry("", "plugin:demo:Scene").empty());
} }
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]") TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]")