Compare commits

...

7 Commits

Author SHA1 Message Date
SoftFever
ed9c97283b feat(plugin)!: merge G-code post-processing into the slicing pipeline as psGCodePostProcess
G-code post-processing is now a step of the slicing-pipeline plugin rather than a
separate capability type. One capability class can transform slices at the geometry
seams AND edit the final G-code, behind a single picker/option.

- Add SlicingPipelineStepPlugin::psGCodePostProcess (bound as
  orca.slicing.Step.psGCodePostProcess). Unlike the geometry steps it fires from the
  GUI export path in PostProcessor.cpp, not from Print::process(): ctx.print/ctx.object
  are None and the plugin edits the file at ctx.gcode_path in place. It may run more
  than once per slice (file export and/or upload) and its output is not shown in the
  preview.
- Extend SlicingPipelineContext with gcode_path/host/output_name and a C++-only
  full_config; config_value() falls back to it when there is no live Print.
- PostProcessor.cpp dispatches SlicingPipelinePluginCapability at psGCodePostProcess,
  driven by the existing slicing_pipeline_plugin option.
- The exported G-code lives outside data_dir(), so the plugin audit sandbox would
  block the write; the trampoline's audit setup grants ctx.gcode_path's folder as a
  scoped allowed root, gated on a non-empty gcode_path so the geometry-step hooks gain
  no extra filesystem access.

BREAKING CHANGE: the separate G-code post-processing capability type is removed.
- orca.gcode.GCodePluginCapabilityBase and orca.PluginType.PostProcessing are gone;
  post-processing plugins migrate to orca.slicing.SlicingPipelineCapabilityBase +
  Step.psGCodePostProcess (and gain ctx.params / ctx.config_value()).
- The post_process_plugin config option is removed; use slicing_pipeline_plugin.
  Presets carrying the old key degrade to the standard unknown-key warning.
- Manifest type = "post-processing" now maps to Unknown (advisory only; the loader
  dispatches on the C++ get_type()).

Also repairs two latent build breaks the branch carried: stale Step enum value usages
in test_slicing_pipeline_hook.cpp and a reference to the removed
ConfigOptionDef::PluginType::None in Tab::on_value_change (now is_plugin_backed()).
Adds the orca_gcode_stamp sample plugin and a psGCodePostProcess binding test.
2026-07-10 19:57:35 +08:00
SoftFever
23a32e763e refactor(plugin): prefix SlicingPipelineStepPlugin values with pos/ps to mirror Print steps 2026-07-10 17:28:26 +08:00
SoftFever
1354d04778 feat(plugin)!: faithful mutable geometry bindings; edit slices via the class API
Replaces the plugin-only set_slices/set_fill_surfaces/set_lslices mutators with a
faithful, mutable binding of the core geometry types, so a plugin edits the slicing
graph through the same object model the C++ code uses.

- Point, Polygon, ExPolygon, Surface and SurfaceCollection gain constructors,
  writable accessors (contour/holes, set/append/clear, filter_by_type), transforms
  (rotate/scale/translate), boolean ops and offset. Polygon exposes a zero-copy
  writable numpy view via a make_writable_rows helper.
- LayerRegion.slices/fill_surfaces stay read-only refs but are now live,
  in-place-editable SurfaceCollections; Layer.make_slices() re-derives the islands
  and refreshes lslice bounding boxes.
- Rewrites the Inset and Twistify samples on the new API (in-place ExPolygon
  transforms, ExPolygon.offset, SurfaceCollection.set), dropping their numpy
  dependency; each touched layer calls make_slices() so downstream steps see the
  edited footprint. Adds tests covering in-place edits through a live collection.

BREAKING CHANGE: set_slices/set_fill_surfaces/set_lslices and the internal
parse_expolygon(_list)/surfaces_from_py helpers are removed. Plugins mutate through
the class API (SurfaceCollection.set/append/clear, Polygon.set_points/append,
ExPolygon.set_holes) instead.
2026-07-08 15:04:40 +08:00
SoftFever
06910bc891 feat(plugin): plugins dialog search + sortable columns (#14610, #14611)
Adds as-you-type fuzzy search with match highlighting, and clickable column-header
sorting (name, version, source, status) to the Plugins dialog.
2026-07-08 01:03:34 +08:00
SoftFever
773ad7943e feat(plugin): expose the slicing print-graph as raw orca.host classes + Twistify sample
Adds PluginHostSlicing, which registers the print-graph data model (Print,
PrintObject, Layer, LayerRegion, Surface, ExPolygon, extrusions, ...) into the
orca.host submodule in the same raw-class style as PluginHostApi's Model/Preset
graph, with shared helpers in PluginBindingUtils. SlicingPipelinePluginCapability
is trimmed to the capability surface (the standalone SlicingNumpy helper is folded
away). Adds the Twistify example plugin next to Inset and broadens the binding,
hook, and plugin-install tests.
2026-07-08 00:05:28 +08:00
SoftFever
aafcccc83c Merge branch 'feat/plugin-feature' into feature/plugin-slicing 2026-07-06 01:12:05 +08:00
SoftFever
b0bacdd00b feat(plugin): add the slicing-pipeline plugin capability
Introduces a plugin capability that runs Python at the seams of Print::process(),
letting a plugin read and rewrite slicing state as it is computed.

- New slicing_pipeline_plugin config option; selected plugin refs are serialized
  into the print manifest.
- Print gains an injectable hook fired at each pipeline step (posSlice,
  posPerimeters, posInfill, ...). It is a no-op when unset, fires only on genuine
  (re)computation, and never on the use-cache path.
- orca.slicing submodule: SlicingPipelineCapabilityBase plus a trampoline and a
  Step enum. Capabilities read the live graph through zero-copy int64 numpy views
  (contour/holes geometry with unscaled coordinates, flattened toolpath data) and
  edit it through 2D-geometry mutators with cache-invariant refresh.
- GUI dispatcher runs capabilities during slicing under the GIL, turns plugin
  errors into slicing errors, honors cancellation, and adds the plugin picker.
- Ships the InsetEverySlice sample plugin and binding/hook tests.
2026-07-04 04:33:20 +08:00
55 changed files with 3898 additions and 416 deletions

View File

@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Plugins</title>
<link rel="stylesheet" href="./styles.css" />
<link rel="stylesheet" href="./plugin-sort.css" />
<link rel="stylesheet" href="./plugin-search.css" />
<link rel="stylesheet" type="text/css" href="../../include/global.css" />
<link rel="stylesheet" type="text/css" href="../css/common.css" />
<link rel="stylesheet" type="text/css" href="../css/theme.css" />
@@ -14,10 +16,33 @@
<script type="text/javascript" src="../js/globalapi.js"></script>
<script type="text/javascript" src="../js/common.js"></script>
<script src="./index.js"></script>
<script src="./plugin-sort.js"></script>
<script src="./plugin-search.js"></script>
</head>
<body onLoad="OnInit()">
<div class="app">
<div class="toolbar">
<div id="pluginSearch" class="plugin-search">
<span class="plugin-search-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4">
<circle cx="6.5" cy="6.5" r="4.5" />
<line x1="10" y1="10" x2="14" y2="14" />
</svg>
</span>
<input id="plugin_search_input" class="plugin-search-input" type="text"
placeholder="Search plugins" autocomplete="off" spellcheck="false" aria-label="Search plugins" />
<button id="plugin_search_clear" class="plugin-search-clear" type="button" title="Clear" aria-label="Clear search">
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" aria-hidden="true">
<line x1="5" y1="5" x2="11" y2="11" />
<line x1="11" y1="5" x2="5" y2="11" />
</svg>
</button>
<button id="plugin_search_cc" class="plugin-search-toggle" type="button"
aria-pressed="false" title="Match case">Aa</button>
<button id="plugin_search_w" class="plugin-search-toggle" type="button"
aria-pressed="false" title="Match whole word"><span class="plugin-search-underline">ab</span></button>
</div>
<!-- <button id="open_terminal" class="ButtonStyleRegular ButtonTypeChoice left-btn">-->
<!-- Open Terminal-->
<!-- </button>-->
@@ -43,9 +68,14 @@
<section class="pane plugin-list-pane">
<div class="hdr plugin-cols">
<span>Activate</span>
<span>Name</span>
<span>Plugin Version</span>
<span>Status</span>
<span class="sort-th" data-sort-field="name" role="button" tabindex="0"
title="Sort by name">Name<span class="sort-tri" aria-hidden="true"></span></span>
<span class="sort-th" data-sort-field="version" role="button" tabindex="0"
title="Sort by version">Plugin Version<span class="sort-tri" aria-hidden="true"></span></span>
<span class="sort-th" data-sort-field="source" role="button" tabindex="0"
title="Sort by source">Source<span class="sort-tri" aria-hidden="true"></span></span>
<span class="sort-th" data-sort-field="status" role="button" tabindex="0"
title="Sort by status">Status<span class="sort-tri" aria-hidden="true"></span></span>
</div>
<div id="pluginList" class="body thin-scroll"></div>
</section>

View File

@@ -11,6 +11,12 @@ const pluginInstallActions = {
};
let expandedPluginIds = new Set();
// why: transient per-search override on top of expandedPluginIds. A search
// auto-expands rows whose capabilities match, display-only. This lets a
// triangle click during search collapse/reopen such a row without touching
// the base (id -> bool).
let searchExpandOverride = new Map();
let selectedPluginId = "";
let contextPluginId = "";
let activeDetailTab = "plugin-info";
@@ -214,6 +220,10 @@ function HandleStudio(value) {
if (payload.command === "list_plugins") {
SetSelectedInstallAction(payload.install_action, false);
if (typeof NormalizePluginSort === "function") {
pluginSort = NormalizePluginSort(payload.sort_key, payload.sort_order);
RenderSortHeaders();
}
ApplyPlugins(payload.data || []);
} else if (payload.command === "status_message") {
ShowStatusMessage(String(payload.message || ""), String(payload.level || "info"));
@@ -261,7 +271,6 @@ function ApplyPlugins(plugins) {
expandedPluginIds = new Set(Array.from(expandedPluginIds).filter((pluginKey) => pluginsById.has(pluginKey)));
RenderPlugins();
SyncPluginListHeaderGutter();
RenderDetails();
}
@@ -295,6 +304,27 @@ function SyncPluginListHeaderGutter() {
listPane.style.setProperty("--plugin-list-scrollbar-width", `${scrollbarWidth}px`);
}
// why: paint matched-character ranges as <mark> without an innerHTML build
// note: if no ranges -> return the plain text node
function ApplyHighlight(container, text, ranges) {
if (!ranges || !ranges.length) {
container.appendChild(document.createTextNode(text));
return;
}
let pos = 0;
for (const [start, end] of ranges) {
if (start > pos)
container.appendChild(document.createTextNode(text.slice(pos, start)));
const mark = document.createElement("mark");
mark.className = "plugin-search-hit";
mark.textContent = text.slice(start, end);
container.appendChild(mark);
pos = end;
}
if (pos < text.length)
container.appendChild(document.createTextNode(text.slice(pos)));
}
function RenderPlugins() {
if (!pluginList)
return;
@@ -306,13 +336,32 @@ function RenderPlugins() {
empty.className = "empty-state";
empty.textContent = "No plugins found";
pluginList.appendChild(empty);
SyncPluginListHeaderGutter();
return;
}
// why: stable filter over the existing C++ sort order - no scoring, no reorder. The empty query
// short-circuits (searching=false), leaving every existing render path untouched.
const searching = typeof PluginSearchActive === "function" && PluginSearchActive();
let shown = 0;
for (const plugin of pluginsById.values()) {
const pluginKey = String(plugin.plugin_key || "");
const capabilities = GetCapabilities(plugin);
const isExpanded = expandedPluginIds.has(pluginKey) && capabilities.length > 0;
const match = searching ? ComputePluginMatch(plugin) : null;
if (searching && !match.matched)
continue;
shown++;
// why: transient override wins. Otherwise while searching start collapsed and auto-expand only
// capability matches (the persistent expand state is ignored so unrelated caps don't clutter
// results); when not searching use the persistent state. The base is never written while
// searching, so clearing the search restores exactly what the user had.
const open = searchExpandOverride.has(pluginKey)
? searchExpandOverride.get(pluginKey)
: (searching ? match.hasCapMatch : expandedPluginIds.has(pluginKey));
const isExpanded = open && capabilities.length > 0;
const block = document.createElement("div");
block.className = "plugin-block";
block.dataset.pluginKey = pluginKey;
@@ -327,15 +376,31 @@ function RenderPlugins() {
row.classList.add("selected");
row.appendChild(CheckCell(row, plugin));
row.appendChild(LabelCell(plugin, isExpanded, capabilities.length));
row.appendChild(LabelCell(plugin, isExpanded, capabilities.length, match?.nameRanges));
row.appendChild(VersionCell(plugin));
row.appendChild(SourceCell(plugin));
row.appendChild(StatusCell(plugin));
block.appendChild(row);
if (isExpanded)
block.appendChild(RenderCapabilityTree(plugin, capabilities));
block.appendChild(RenderCapabilityTree(plugin, capabilities, match?.capRanges));
pluginList.appendChild(block);
}
// why: distinct from the size===0 "no plugins" state - here plugins exist but none match the query.
if (searching && shown === 0) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.appendChild(document.createTextNode('No plugins match "'));
const term = document.createElement("b");
term.textContent = pluginSearch.query;
empty.appendChild(term);
empty.appendChild(document.createTextNode('"'));
pluginList.appendChild(empty);
}
// why: recompute the scrollbar gutter on every render - search and sort re-render via RenderPlugins
SyncPluginListHeaderGutter();
}
function GetErrorText(plugin) {
@@ -474,7 +539,7 @@ function CheckCell(row, plugin) {
return checkCell;
}
function LabelCell(plugin, isExpanded = false, capabilityCount = 0) {
function LabelCell(plugin, isExpanded = false, capabilityCount = 0, nameRanges = null) {
const labelCell = document.createElement("span");
labelCell.className = "label-cell";
@@ -505,7 +570,7 @@ function LabelCell(plugin, isExpanded = false, capabilityCount = 0) {
nameWrap.className = "plugin-name-wrap";
const labelElement = document.createElement(hasCloudLink ? "a" : "span");
labelElement.textContent = pluginLabelText;
ApplyHighlight(labelElement, pluginLabelText, nameRanges);
labelElement.className = "plugin-name-text";
if (hasCloudLink) {
@@ -523,25 +588,38 @@ function LabelCell(plugin, isExpanded = false, capabilityCount = 0) {
nameWrap.appendChild(countBadge);
}
labelCell.appendChild(nameWrap);
labelCell.appendChild(SourceBadge(plugin.source));
return labelCell;
}
function RenderCapabilityTree(plugin, capabilities) {
function SourceCell(plugin) {
const cell = document.createElement("span");
const normalized = String(plugin.source || "").toLowerCase();
const variant = (normalized === "mine" || normalized === "subscribed") ? normalized : "local";
cell.className = `source-cell source-${variant}`;
const sourceLabel = document.createElement("span");
sourceLabel.className = "source-label";
sourceLabel.textContent = SourceLabel(plugin.source);
cell.appendChild(sourceLabel);
return cell;
}
function RenderCapabilityTree(plugin, capabilities, capRanges = null) {
const tree = document.createElement("div");
tree.className = "capabilities-tree";
tree.setAttribute("role", "group");
tree.setAttribute("aria-label", "Capabilities");
capabilities.forEach((capability, index) => {
tree.appendChild(RenderCapabilityRow(plugin, capability, index === capabilities.length - 1));
tree.appendChild(RenderCapabilityRow(plugin, capability, index === capabilities.length - 1, capRanges));
});
return tree;
}
function RenderCapabilityRow(plugin, capability, isLast) {
function RenderCapabilityRow(plugin, capability, isLast, capRanges = null) {
const row = document.createElement("div");
row.className = "capability-row plugin-cols";
row.classList.toggle("is-last", isLast);
@@ -556,7 +634,8 @@ function RenderCapabilityRow(plugin, capability, isLast) {
branch.setAttribute("aria-hidden", "true");
const name = document.createElement("span");
name.className = "capability-name";
name.textContent = String(capability?.name || "") || "-";
const capabilityLabel = String(capability?.name || "");
ApplyHighlight(name, capabilityLabel || "-", capRanges?.get(capabilityLabel));
nameCell.appendChild(branch);
nameCell.appendChild(name);
row.appendChild(nameCell);
@@ -566,6 +645,11 @@ function RenderCapabilityRow(plugin, capability, isLast) {
typeCell.textContent = String(capability?.type || "-");
row.appendChild(typeCell);
// why: empty placeholder for the new Source column so the run-action cell stays under Status.
const sourceSpacer = document.createElement("span");
sourceSpacer.className = "capability-source-cell";
row.appendChild(sourceSpacer);
const actionsCell = document.createElement("span");
actionsCell.className = "capability-actions-cell";
const capabilityName = String(capability?.name || "");
@@ -898,7 +982,12 @@ function OnPluginListClick(event) {
const pluginKey = String(block.dataset.pluginKey || "");
selectedPluginId = pluginKey;
if (expandedPluginIds.has(pluginKey))
// why: during a search the triangle writes to the transient override (read from the on-screen open
// state), so an auto-expanded row collapses without touching the saved layout. With no search
// active, toggle the persistent base exactly as before.
if (typeof PluginSearchActive === "function" && PluginSearchActive())
searchExpandOverride.set(pluginKey, !block.classList.contains("expanded"));
else if (expandedPluginIds.has(pluginKey))
expandedPluginIds.delete(pluginKey);
else
expandedPluginIds.add(pluginKey);

View File

@@ -0,0 +1,138 @@
.plugin-search {
--search-width: 300px;
flex: 0 0 auto;
width: var(--search-width);
/* why: the search bar takes the auto margin (pinned far left); sort + Refresh + Install cluster right. */
margin-right: auto;
display: flex;
align-items: center;
gap: 2px;
/* why: match the compact toolbar buttons (.toolbar .ButtonTypeChoice is 26px) so the row aligns. */
height: 26px;
padding: 0 6px;
box-sizing: border-box;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
}
.plugin-search:focus-within {
border-color: var(--main-color);
box-shadow: 0 0 0 2px rgba(0, 150, 136, 0.25);
}
.plugin-search-icon {
display: inline-flex;
color: var(--muted);
}
.plugin-search-input {
flex: 1;
min-width: 0;
background: transparent;
border: 0;
outline: 0;
color: var(--text);
font: inherit;
}
.plugin-search-input::placeholder {
color: var(--muted);
}
/* why: compact squarish toggles - min-width keeps single-char W from collapsing while two-char Cc
grows just enough to fit; tight horizontal padding keeps them from reading as wide pills. */
.plugin-search-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 1px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--muted);
font-size: 12px;
font-weight: 600;
line-height: 1;
cursor: pointer;
box-sizing: border-box;
}
/* why: set the clear x apart from the Cc/W pair while keeping the pair itself tight - margins on the
toggles only, so the icon-to-text gap is left as-is. clear+toggle = Cc, toggle+toggle = W. */
.plugin-search-clear + .plugin-search-toggle {
margin-left: 4px;
}
.plugin-search-toggle + .plugin-search-toggle {
margin-left: -1px;
}
/* why: subtle round clear affordance - a small muted disc, not a square button. The x is an inline SVG
(not a text glyph) so it centers pixel-perfectly regardless of the platform font. JS flips its
visibility (not display) so its reserved slot never reflows Cc/W. */
.plugin-search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 10px;
height: 10px;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--muted);
cursor: pointer;
visibility: hidden;
background: rgba(127, 127, 127, 0.20);
}
.plugin-search-clear svg {
display: block;
}
.plugin-search-clear:hover {
color: var(--text);
background: rgba(127, 127, 127, 0.45);
}
.plugin-search-toggle:hover {
color: var(--text);
background: var(--row-hover);
}
.plugin-search-toggle.on {
color: var(--button-fg-light, #fff);
background: var(--main-color);
border-color: var(--main-color-hover);
}
/* "ab" over a bracket drawn by ::after box draws it */
.plugin-search-underline {
position: relative;
display: inline-block;
padding-bottom: 2px;
}
.plugin-search-underline::after {
content: "";
position: absolute;
/* why: ticks stick out past the outer edges of "a"/"b" on each side. */
left: -1px;
right: -1px;
bottom: 0;
/* why: short ticks + bottom rule, tucked near the baseline. */
height: 2px;
border: 1px solid currentColor;
border-top: 0;
/* why: soften the two joints where the ticks meet the bottom rule. */
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* why: reuse the themed warn tokens so matched-char marks track light and dark automatically.
note: no padding/margin/border - a highlight must not change text width, else rows reflow. */
mark.plugin-search-hit {
border-radius: 2px;
background: var(--plugin-status-warn-bg);
color: var(--plugin-status-warn);
}

View File

@@ -0,0 +1,156 @@
const pluginSearch = { query: "", caseSensitive: false, wholeWord: false };
function PluginSearchActive() {
return pluginSearch.query.length > 0;
}
// --- matcher: fold per-character on the fly so matched offsets stay in ORIGINAL coordinates ---
// why: highlighting marks slices of the original string; a separate folded string would desync offsets.
function FoldChar(ch) {
return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); // accents always folded (both Cc states)
}
function Norm(ch, caseSensitive) {
const folded = FoldChar(ch);
return caseSensitive ? folded : folded.toLowerCase(); // Cc controls case only
}
function EscapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function MatchText(text, query) {
if (!query)
return [];
return pluginSearch.wholeWord ? WholeWordRanges(text, query) : FuzzyRanges(text, query);
}
// Fuzzy: ordered subsequence. Builds ranges in original coordinates, merging adjacent runs on the fly.
function FuzzyRanges(text, query) {
const caseSensitive = pluginSearch.caseSensitive;
const needle = Array.from(query).map((ch) => Norm(ch, caseSensitive)).join("");
const ranges = [];
let qi = 0;
for (let i = 0; i < text.length && qi < needle.length; i++) {
if (Norm(text[i], caseSensitive) === needle[qi]) {
const last = ranges[ranges.length - 1];
if (last && last[1] === i)
last[1] = i + 1;
else
ranges.push([i, i + 1]);
qi++;
}
}
return qi === needle.length ? ranges : null;
}
// Whole word: literal \b-bounded match that bypasses fuzzy; Cc still applies. The per-char fold keeps the
// haystack length-aligned to the original text, so regex indices map straight back to original offsets.
// note: one-to-many folds (ligatures, eszett) shift offsets by a char; rare in plugin names, cosmetic only.
function WholeWordRanges(text, query) {
const caseSensitive = pluginSearch.caseSensitive;
const haystack = Array.from(text).map((ch) => Norm(ch, caseSensitive)).join("");
const needle = Array.from(query).map((ch) => Norm(ch, caseSensitive)).join("");
if (!needle)
return null;
const re = new RegExp(`\\b${EscapeRegExp(needle)}\\b`, "g");
const ranges = [];
let match;
// why: needle is non-empty, so \b-bounded matches are never zero-length - no empty-match guard needed.
while ((match = re.exec(haystack)) !== null)
ranges.push([match.index, match.index + match[0].length]);
return ranges.length > 0 ? ranges : null;
}
// Per-plugin evaluator consumed by RenderPlugins. The name text mirrors LabelCell's pluginLabelText so
// highlight offsets line up with what is rendered. Capability names exist for loaded plugins only.
function ComputePluginMatch(plugin) {
const name = plugin.label || plugin.name || plugin.plugin_id || "";
const nameRanges = MatchText(name, pluginSearch.query);
const capabilities = Array.isArray(plugin?.capabilities) ? plugin.capabilities : [];
const capRanges = new Map();
for (const capability of capabilities) {
const key = String(capability?.name || "");
const ranges = MatchText(key, pluginSearch.query);
if (ranges)
capRanges.set(key, ranges);
}
return {
matched: !!nameRanges || capRanges.size > 0,
nameRanges,
capRanges,
hasCapMatch: capRanges.size > 0,
};
}
// --- widget wiring ---
let pluginSearchInput = null;
let pluginSearchClear = null;
let pluginSearchCc = null;
let pluginSearchW = null;
function InitPluginSearch() {
pluginSearchInput = document.getElementById("plugin_search_input");
pluginSearchClear = document.getElementById("plugin_search_clear");
pluginSearchCc = document.getElementById("plugin_search_cc");
pluginSearchW = document.getElementById("plugin_search_w");
if (!pluginSearchInput)
return;
// why: common.js installs a document-level onkeydown that cancels the default action of every key
// (returnValue=false) to block webview shortcuts; on the way up it also swallows typing. Stop the
// field's keydowns from bubbling to it so the input stays editable, leaving the global guard intact.
pluginSearchInput.addEventListener("keydown", (event) => event.stopPropagation());
pluginSearchInput.addEventListener("input", OnPluginSearchInput);
pluginSearchClear?.addEventListener("click", ClearPluginSearch);
pluginSearchCc?.addEventListener("click", () => TogglePluginSearchFlag(pluginSearchCc, "caseSensitive"));
pluginSearchW?.addEventListener("click", () => TogglePluginSearchFlag(pluginSearchW, "wholeWord"));
SyncPluginSearchClear();
}
function OnPluginSearchInput() {
pluginSearch.query = pluginSearchInput.value;
// why: emptying the box by editing (not just the x) also ends the search - drop the transient vetoes.
if (!pluginSearch.query)
ClearSearchExpandOverride();
SyncPluginSearchClear();
RenderPluginsIfReady();
}
function ClearPluginSearch() {
pluginSearch.query = "";
if (pluginSearchInput)
pluginSearchInput.value = "";
ClearSearchExpandOverride();
SyncPluginSearchClear();
RenderPluginsIfReady();
pluginSearchInput?.focus();
}
function TogglePluginSearchFlag(button, key) {
pluginSearch[key] = !pluginSearch[key];
button.classList.toggle("on", pluginSearch[key]);
button.setAttribute("aria-pressed", String(pluginSearch[key]));
RenderPluginsIfReady();
}
// why: toggle visibility (not display / the hidden attribute) so the x keeps its reserved slot and
// showing or hiding it never reflows the Cc / W buttons.
function SyncPluginSearchClear() {
if (pluginSearchClear)
pluginSearchClear.style.visibility = pluginSearch.query.length ? "visible" : "hidden";
}
// why: searchExpandOverride lives in index.js; guard so this module stays loadable on its own.
function ClearSearchExpandOverride() {
if (typeof searchExpandOverride !== "undefined")
searchExpandOverride.clear();
}
function RenderPluginsIfReady() {
if (typeof RenderPlugins === "function")
RenderPlugins();
}
// why: guarded so the module can be loaded in headless syntax checks; mirrors plugin-sort.js.
if (typeof document !== "undefined")
document.addEventListener("DOMContentLoaded", InitPluginSearch);

View File

@@ -0,0 +1,42 @@
/* why: sort affordance lives on the list column headers, not a toolbar dropdown. */
.hdr .sort-th {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.hdr .sort-th .sort-tri {
width: 0;
height: 0;
flex: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
display: none;
}
/* faint up-triangle hint on hover, only while the column is not the active sort */
.hdr .sort-th:hover .sort-tri {
display: block;
border-bottom: 5px solid var(--muted);
}
/* active column wins over the hover hint (same specificity, declared later) */
.hdr .sort-th[data-sort="asc"] .sort-tri {
display: block;
border-bottom: 5px solid var(--text);
border-top: 0;
}
.hdr .sort-th[data-sort="desc"] .sort-tri {
display: block;
border-top: 5px solid var(--text);
border-bottom: 0;
}
.hdr .sort-th[data-sort="asc"],
.hdr .sort-th[data-sort="desc"] {
color: var(--text);
}

View File

@@ -0,0 +1,67 @@
// why: C++ owns ordering; this file only sends and reflects sort state.
const DEFAULT_PLUGIN_SORT = { key: "none", order: "asc" };
// note: SORT_FIELDS are the clickable columns. "none" is the baseline/cleared state, not a field -
// it is special-cased in NormalizePluginSort and produced by CyclePluginSort's third click.
const SORT_FIELDS = new Set(["status", "name", "source", "version"]);
let pluginSort = { ...DEFAULT_PLUGIN_SORT };
// why: C++ returns canonical sort state; guard stale or malformed values before reflecting them.
function NormalizePluginSort(sortKey, sortOrder) {
const key = String(sortKey || "");
return {
key: key === "none" ? "none" : (SORT_FIELDS.has(key) ? key : DEFAULT_PLUGIN_SORT.key),
order: sortOrder === "desc" ? "desc" : DEFAULT_PLUGIN_SORT.order,
};
}
function RequestPluginSort(sortKey, sortOrder) {
pluginSort = NormalizePluginSort(sortKey, sortOrder);
RenderSortHeaders();
if (typeof SendMessage === "function")
SendMessage("set_plugin_sort", {
sort_key: pluginSort.key,
sort_order: pluginSort.order,
});
}
// why: one click per column cycles asc -> desc -> clear; setting any column clears the previous
// one for free because C++ (and pluginSort) only ever hold a single key.
function CyclePluginSort(field) {
if (!SORT_FIELDS.has(field))
return;
if (pluginSort.key !== field)
RequestPluginSort(field, "asc");
else if (pluginSort.order === "asc")
RequestPluginSort(field, "desc");
else
RequestPluginSort("none", "asc"); // third click: back to baseline
}
// why: paints the sort indicator for headers
// e.g., when user clicks triangle to change sort order, or change to sort by a new different field
function RenderSortHeaders() {
document.querySelectorAll(".hdr .sort-th").forEach((th) => {
// "" | "asc" | "desc" - renders the triangle via plugin-sort.css [data-sort=...].
th.dataset.sort = th.dataset.sortField === pluginSort.key ? pluginSort.order : "";
});
}
function InitSortHeaders() {
document.querySelectorAll(".hdr .sort-th").forEach((th) => {
th.addEventListener("click", () => CyclePluginSort(th.dataset.sortField));
// note: role="button" cells need Enter/Space to match the old dropdown's keyboard access.
th.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
CyclePluginSort(th.dataset.sortField);
}
});
});
RenderSortHeaders(); // paint the initial state (baseline = no triangle)
}
// why: guarded so the module can be loaded in headless syntax checks.
if (typeof document !== "undefined")
document.addEventListener("DOMContentLoaded", InitSortHeaders);

View File

@@ -187,7 +187,27 @@ body {
}
.plugin-cols {
grid-template-columns: 70px minmax(0, 2.8fr) minmax(120px, 0.9fr) minmax(140px, 1fr);
grid-template-columns: 70px minmax(0, 2.4fr) minmax(110px, 0.85fr) minmax(96px, 0.7fr) minmax(130px, 0.95fr);
}
/* Source is its own (sortable) column, shown as colored text (mirrors .status-cell), not a chip. */
.source-cell {
display: flex;
align-items: center;
}
.source-cell.source-mine {
color: var(--plugin-source-mine-text);
font-weight: 600;
}
.source-cell.source-subscribed {
color: var(--plugin-source-subscribed-text);
font-weight: 600;
}
.source-cell.source-local {
color: var(--plugin-source-neutral-text);
}
/* Center the "Activate" header over the centered checkbox in each row. */
@@ -835,11 +855,6 @@ body {
white-space: nowrap;
}
/* In a list row, sit at the right edge of the Name column (the name fills the rest). */
.label-cell .plugin-source-badge {
margin-right: 4px;
}
.plugin-source-badge.source-local {
background: var(--plugin-source-neutral-bg);
color: var(--plugin-source-neutral-text);

View File

@@ -0,0 +1,74 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "G-code Stamp"
# description = "Stamps a comment line into the exported G-code at the post-process step (demo)."
# author = "OrcaSlicer"
# version = "0.01"
# type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
# stamp_text = "processed by the OrcaSlicer G-code Stamp plugin"
# ///
"""G-code Stamp -- the post-processing half of the slicing-pipeline plugin.
Post-processing is now a step of the slicing pipeline: Step.psGCodePostProcess.
It fires from the G-code export path AFTER the classic post_process scripts, on the
exported G-code file -- NOT from Print::process(). So unlike the geometry steps
(posSlice, posPerimeters, ...) there is no live slicing graph here: ctx.print and
ctx.object are None. Instead the context carries ctx.gcode_path (the working G-code
file on disk, edited IN PLACE), ctx.host ("File", "OctoPrint", ...) and
ctx.output_name (the final file name). ctx.params and ctx.config_value() still work.
This sample inserts a single comment line near the top of the file. Because the same
capability class can also implement the geometry steps, one plugin can transform slices
AND stamp the final G-code; a geometry-only plugin just returns success here.
The step may fire more than once per slice (file export and/or upload each run it on a
separate working copy), and its output is not reflected in the G-code preview -- the
viewer maps the pre-post-process file.
"""
import orca
_DEFAULT_STAMP = "processed by the OrcaSlicer G-code Stamp plugin"
def _stamp_text(ctx):
try:
text = dict(ctx.params).get("stamp_text", _DEFAULT_STAMP)
except (AttributeError, TypeError):
text = _DEFAULT_STAMP
return str(text).replace("\n", " ").strip() or _DEFAULT_STAMP
class GCodeStamp(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "G-code Stamp"
def execute(self, ctx):
# Only act at the post-process seam; at every geometry step this is a no-op.
if ctx.step != orca.slicing.Step.psGCodePostProcess:
return orca.ExecutionResult.success()
if not ctx.gcode_path:
return orca.ExecutionResult.success("G-code Stamp: no gcode_path, nothing to do")
comment = "; " + _stamp_text(ctx) + " (host=" + (ctx.host or "?") + ")\n"
# Edit the exported G-code in place: keep the original first line first (some flavors
# expect a specific leading line), then insert the stamp right after it.
with open(ctx.gcode_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
insert_at = 1 if lines else 0
lines.insert(insert_at, comment)
with open(ctx.gcode_path, "w", encoding="utf-8") as f:
f.writelines(lines)
return orca.ExecutionResult.success(
"G-code Stamp: stamped '" + (ctx.output_name or ctx.gcode_path) + "'")
@orca.plugin
class GCodeStampPackage(orca.base):
def register_capabilities(self):
orca.register_capability(GCodeStamp)

View File

@@ -0,0 +1,83 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Inset Every Slice"
# description = "Insets every layer's slices by 1mm at the Slice boundary (demo)."
# author = "OrcaSlicer"
# version = "0.02"
# type = "slicing-pipeline"
# ///
"""Inset Every Slice -- a small, WORKING SlicingPipeline sample plugin.
At Step.posSlice, for every layer/region of the sliced object, this shrinks each
sliced surface by INSET_MM using a real polygon offset (ExPolygon.offset) and
writes the result back with SurfaceCollection.set(). After the per-region edits,
layer.make_slices() re-derives the layer's merged islands (lslices) so
overhang/bridge detection, skirt/brim and support stay coherent with the inset
geometry. At Step.posSlice the split slice loop runs make_perimeters() right after
the hook, so the change cascades into perimeters, infill and the final G-code
-- the toolpath preview shrinks.
Unlike the old axis-aligned demo, ExPolygon.offset() is a correct inward offset
for any contour (it is Clipper under the hood), and it naturally handles holes.
A surface may split into several islands or vanish when shrunk; both are handled.
No numpy required: the whole edit is expressed with the host geometry classes.
"""
import orca
INSET_MM = 1.0
class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "Inset Every Slice"
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
# Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6).
inset_scaled = int(round(INSET_MM / orca.slicing.unscale(1)))
regions_touched = 0
for layer in ctx.object.layers():
if ctx.cancelled():
break
layer_touched = False
for region in layer.regions():
surfaces = region.slices.surfaces
if not surfaces:
continue
# Group the inward-offset geometry by surface type so each type is
# preserved when written back (set() tags all its expolygons one type).
by_type = {}
for surface in surfaces:
shrunk = surface.expolygon.offset(-inset_scaled) # [ExPolygon], may be empty
if shrunk:
by_type.setdefault(surface.surface_type, []).extend(shrunk)
if not by_type:
continue # every surface collapsed: leave the region untouched this demo
# Rebuild the collection type-by-type: first set(), then append() the rest.
items = list(by_type.items())
first_type, first_expolys = items[0]
region.slices.set(first_expolys, first_type)
for st, expolys in items[1:]:
region.slices.append(expolys, st)
regions_touched += 1
layer_touched = True
if layer_touched:
# Re-derive the merged islands from the inset region slices.
layer.make_slices()
return orca.ExecutionResult.success(f"inset applied to {regions_touched} region(s)")
@orca.plugin
class InsetEverySlicePackage(orca.base):
def register_capabilities(self):
orca.register_capability(InsetEverySlice)

View File

@@ -0,0 +1,146 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Twistify"
# description = "Twists, tapers, and wobbles every layer's slice polygons as a function of Z (demo)."
# author = "OrcaSlicer"
# version = "0.02"
# type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
# twist_deg_per_mm = "1.0"
# taper_per_mm = "0.0"
# wobble_ampl_mm = "0.0"
# wobble_period_mm = "20.0"
# min_scale = "0.05"
# ///
"""Twistify -- twist/taper/wobble any model at slice time.
At Step.posSlice, every layer's sliced surfaces are transformed by a similarity
about the object's bounding-box center as a function of Z -- edited IN PLACE
through the host geometry classes (ExPolygon.rotate/scale/translate). Each
surface is rotated about the center, then (if tapering) translated to the
origin, uniformly scaled, and translated back, so the taper stays centered on
the object instead of drifting toward the coordinate origin. An optional X
wobble is applied last. After the per-region edits, layer.make_slices()
re-derives the layer's merged islands so overhang/bridge/skirt/support stay
coherent. The split slice loop runs make_perimeters() right after the hook, so
the transform cascades into perimeters, infill, and the final G-code -- the
preview corkscrews and the print keeps correct walls/infill/flow.
Because we edit geometry in place, surface types are preserved automatically
(no per-surface type carry needed), and no numpy is required --
rotate/scale/translate are host methods. Parameters come from ctx.params (the
settings table above). The first object layer is untouched (z_rel = 0), so bed
adhesion is unaffected.
"""
import math
import orca
_DEFAULTS = {
"twist_deg_per_mm": 1.0,
"taper_per_mm": 0.0,
"wobble_ampl_mm": 0.0,
"wobble_period_mm": 20.0,
"min_scale": 0.05,
}
def _params(ctx):
try:
src = dict(ctx.params)
except (AttributeError, TypeError):
src = {}
out = {}
for key, default in _DEFAULTS.items():
try:
out[key] = float(src[key])
except (KeyError, TypeError, ValueError):
out[key] = default
return out
def _is_identity(p):
return p["twist_deg_per_mm"] == 0.0 and p["taper_per_mm"] == 0.0 and p["wobble_ampl_mm"] == 0.0
def _layer_params(z_rel, mm_to_scaled, p):
"""(angle_rad, scale, x_offset_scaled) for one layer. Exact identity at z_rel == 0."""
theta = math.radians(p["twist_deg_per_mm"] * z_rel)
s = max(p["min_scale"], 1.0 + p["taper_per_mm"] * z_rel)
ox = 0.0
if p["wobble_ampl_mm"] != 0.0 and p["wobble_period_mm"] > 0.0:
ox = p["wobble_ampl_mm"] * math.sin(2.0 * math.pi * z_rel / p["wobble_period_mm"]) * mm_to_scaled
return theta, s, ox
class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "Twistify"
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
p = _params(ctx)
if _is_identity(p):
return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do")
mm_to_scaled = 1.0 / orca.slicing.unscale(1)
layers = ctx.object.layers()
if not layers:
return orca.ExecutionResult.success("Twistify: object has no layers")
# Twist/taper axis = the object's bounding-box center (scaled coords, same frame
# as the slice polygons), so each object on the plate transforms about its own
# center. Keep the float center for translate-to-origin/back around scale(), and
# a rounded-to-Point center for rotate() (which takes an integer Point).
min_x, min_y, max_x, max_y = ctx.object.bounding_box()
cx = (min_x + max_x) / 2.0
cy = (min_y + max_y) / 2.0
center = orca.host.Point(int(round(cx)), int(round(cy)))
z0 = float(layers[0].print_z) # z_rel = 0 on the first layer -> footprint untouched
layers_touched = 0
for layer in layers:
if ctx.cancelled():
break
z_rel = float(layer.print_z) - z0
theta, s, ox = _layer_params(z_rel, mm_to_scaled, p)
if theta == 0.0 and s == 1.0 and ox == 0.0:
continue # exact identity (always the first layer)
edited = False
for region in layer.regions():
for surface in region.slices.surfaces:
ex = surface.expolygon
ex.rotate(theta, center) # rotate about the object center (in place)
if s != 1.0:
# scale() scales about the coordinate ORIGIN, so re-center the
# geometry on the origin first and translate back after, making
# this a true similarity transform about the object's center.
ex.translate(-cx, -cy)
ex.scale(s)
ex.translate(cx, cy)
if ox != 0.0:
ex.translate(ox, 0.0) # wobble in X
edited = True
if edited:
# Re-derive the merged islands from the twisted region slices.
layer.make_slices()
layers_touched += 1
name = ctx.object.model_object().name or "object"
return orca.ExecutionResult.success(
f"Twistify: transformed {layers_touched} layer(s) of '{name}' "
f"(twist {p['twist_deg_per_mm']} deg/mm, taper {p['taper_per_mm']}/mm, "
f"wobble {p['wobble_ampl_mm']} mm)")
@orca.plugin
class TwistifyPackage(orca.base):
def register_capabilities(self):
orca.register_capability(Twistify)

View File

@@ -1521,8 +1521,6 @@ void ConfigBase::save_to_json(const std::string &file, const std::string &name,
j[BBL_JSON_KEY_NAME] = name;
j[BBL_JSON_KEY_FROM] = from;
std::vector<std::string> plugin_refs;
//record all the key-values
for (const std::string &opt_key : this->keys())
{
@@ -1548,24 +1546,14 @@ void ConfigBase::save_to_json(const std::string &file, const std::string &name,
json j_array(string_values);
j[opt_key] = j_array;
}
this->save_plugin_collection(opt_key, opt, plugin_refs);
}
// Lazily serialize the top-level "plugins" manifest: the individual plugin-backed options keep
// bare capability names, and the full "name;uuid;capability" references are derived here from
// those options via the registered resolver. Only do this when a resolver is available (GUI);
// without one (CLI/headless) leave whatever the "plugins" option already serialized above, so a
// round-trip never drops the manifest. De-duplicate while preserving order and skip empties.
// Serialize the top-level "plugins" manifest: the individual plugin-backed options keep bare
// capability names; the full "name;uuid;capability" references are derived here (same helper as
// update_plugin_manifest). Only with a resolver (GUI); without one (CLI/headless) leave whatever
// the "plugins" option already serialized above, so a round-trip never drops the manifest.
if (resolve_capability_fn) {
std::vector<std::string> unique_refs;
unique_refs.reserve(plugin_refs.size());
for (std::string& ref : plugin_refs) {
if (ref.empty())
continue;
if (std::find(unique_refs.begin(), unique_refs.end(), ref) == unique_refs.end())
unique_refs.emplace_back(std::move(ref));
}
std::vector<std::string> unique_refs = this->collect_plugin_manifest();
if (unique_refs.empty())
j.erase("plugins");
else
@@ -1610,24 +1598,60 @@ void ConfigBase::save_plugin_collection(const std::string& opt_key, const Config
if (!resolve_capability_fn)
return;
// Resolve a single bare capability value into its full reference and append it, skipping
// unset values and capabilities that could not be resolved (resolver returns "").
const auto append_ref = [&plugin_refs](const std::string& capability_value, const std::string& type) {
// A plugin-backed option declares its capability type via ConfigOptionDef::plugin_type (the same
// metadata PluginResolver::find_option_for_capability scans). Deriving off the def rather than a
// per-key branch keeps this generic across every plugin-backed option.
const ConfigDef* def = this->def();
const ConfigOptionDef* opt_def = def ? def->get(opt_key) : nullptr;
if (opt_def == nullptr || !opt_def->is_plugin_backed())
return;
const std::string& type = opt_def->plugin_type;
// Resolve a single bare capability value into its full reference and append it, skipping unset
// values, capabilities that could not be resolved (resolver returns ""), and duplicates already
// collected (preserving insertion order).
const auto append_ref = [&plugin_refs, &type](const std::string& capability_value) {
if (capability_value.empty())
return;
std::string ref = resolve_capability_fn(capability_value, type);
if (!ref.empty())
if (!ref.empty() && std::find(plugin_refs.begin(), plugin_refs.end(), ref) == plugin_refs.end())
plugin_refs.emplace_back(std::move(ref));
};
if (opt_key == "post_process_plugin") {
const ConfigOptionVectorBase* vec = static_cast<const ConfigOptionVectorBase*>(opt);
for (const std::string& val : vec->vserialize())
append_ref(val, "post-processing");
} else if (opt_key == "printer_agent") {
append_ref((dynamic_cast<const ConfigOptionString *>(opt))->value, "printer-connection");
}
// Extend for other plugin-backed settings as needed.
// Scalar options carry a single capability name; vector options carry a list. Same scalar/vector
// dispatch as PluginResolver::find_option_for_capability.
if (const auto* string_option = dynamic_cast<const ConfigOptionString*>(opt))
append_ref(string_option->value);
else if (const auto* vector_option = dynamic_cast<const ConfigOptionVectorBase*>(opt))
for (const std::string& val : vector_option->vserialize())
append_ref(val);
}
std::vector<std::string> ConfigBase::collect_plugin_manifest() const
{
std::vector<std::string> refs;
if (!resolve_capability_fn)
return refs;
// Each plugin-backed option (ConfigOptionDef::is_plugin_backed) contributes its resolved
// reference(s) via save_plugin_collection, which appends in order and skips duplicates, so no
// second de-duplication pass is needed here.
for (const std::string& opt_key : this->keys())
if (const ConfigOption* opt = this->option(opt_key))
this->save_plugin_collection(opt_key, opt, refs);
return refs;
}
void ConfigBase::update_plugin_manifest()
{
// Writes the derived manifest back into this config's "plugins" option (save_to_json writes the
// same manifest into a JSON document instead), so an in-memory backend config carries a resolved
// manifest even when the source preset was never serialized (picked-but-unsaved). Without a
// resolver (CLI/headless) leave whatever manifest was loaded from disk untouched.
if (!resolve_capability_fn)
return;
if (auto* manifest = this->option<ConfigOptionStrings>("plugins", true))
manifest->values = this->collect_plugin_manifest();
}
DynamicConfig::DynamicConfig(const ConfigBase& rhs, const t_config_option_keys& keys)

View File

@@ -2444,10 +2444,13 @@ public:
// "serialized" - vector valued option is entered in a single edit field. Values are separated by a semicolon.
// "show_value" - even if enum_values / enum_labels are set, still display the value, not the enum label.
std::string gui_flags;
// Optional plugin type used by GUIType::plugin_picker for filtering plugins.
// Capability type of a plugin-backed option, e.g. "slicing-pipeline" / "printer-connection"
// (empty for ordinary options). GUIType::plugin_picker filters the plugin list by it, and it
// resolves the option's "plugins" manifest reference; see is_plugin_backed().
std::string plugin_type;
// Indicate whether the option support plugin.
bool support_plugin { false };
// Whether this option holds plugin capability name(s) that feed the "plugins" manifest -- true
// iff it declares a plugin_type. Setting plugin_type is the only step needed to add one.
bool is_plugin_backed() const { return !plugin_type.empty(); }
// Label of the GUI input field.
// In case the GUI input fields are grouped in some views, the label defines a short label of a grouped value,
// while full_label contains a label of a stand-alone field.
@@ -2761,6 +2764,13 @@ public:
//BBS: add json support
void save_to_json(const std::string &file, const std::string &name, const std::string &from, const std::string &version) const;
// Rebuild the in-memory "plugins" manifest (the "name;uuid;capability" references the plugin
// dispatchers consume) from the plugin-backed options via the registered resolver. save_to_json()
// derives the same manifest, but only when a preset is written to disk; a config assembled in
// memory for the backend (PresetBundle::full_config -> Print::apply) must refresh it here or a
// picked-but-unsaved plugin never resolves at slice/export time. No-op without a resolver.
void update_plugin_manifest();
// Set all the nullable values to nils.
void null_nullables();
@@ -2770,6 +2780,11 @@ private:
// Set a configuration value from a string.
bool set_deserialize_raw(const t_config_option_key& opt_key_src, const std::string& value, ConfigSubstitutionContext& substitutions, bool append);
void save_plugin_collection(const std::string& opt_key, const ConfigOption* opt, std::vector<std::string>& plugin_refs) const;
// Collect the de-duplicated "name;uuid;capability" plugin references derived from this config's
// plugin-backed options via the resolver. Shared by save_to_json (serializes them into the JSON
// manifest) and update_plugin_manifest (writes them back into the "plugins" option). Order is
// preserved and empties are dropped; returns empty without a resolver (CLI/headless).
std::vector<std::string> collect_plugin_manifest() const;
static std::function<std::string(std::string, std::string)> resolve_capability_fn;
};

View File

@@ -1192,7 +1192,7 @@ static std::vector<std::string> s_Preset_print_options{
"min_feature_size",
"min_bead_width",
"post_process",
"post_process_plugin",
"slicing_pipeline_plugin",
"plugins",
"process_change_extrusion_role_gcode",
"min_length_factor",

View File

@@ -50,6 +50,8 @@ using namespace nlohmann;
namespace Slic3r {
Print::SlicingPipelineHookFn Print::s_slicing_pipeline_hook_fn = nullptr;
template class PrintState<PrintStep, psCount>;
template class PrintState<PrintObjectStep, posCount>;
@@ -123,8 +125,8 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
"printing_by_object_gcode",
"filament_end_gcode",
"post_process",
"post_process_plugin",
// "plugins" is the manifest backing post_process_plugin; like it, it only affects G-code export.
// "plugins" is the derived manifest backing the plugin-picker options; on its own it only
// affects G-code export. The specific option (e.g. slicing_pipeline_plugin) drives any re-slice.
"plugins",
"extruder_clearance_height_to_rod",
"extruder_clearance_height_to_lid",
@@ -277,7 +279,8 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
|| opt_key == "wipe_tower_rotation_angle") {
steps.emplace_back(psSkirtBrim);
} else if (
opt_key == "initial_layer_print_height"
opt_key == "slicing_pipeline_plugin"
|| opt_key == "initial_layer_print_height"
|| opt_key == "nozzle_diameter"
|| opt_key == "filament_shrink"
|| opt_key == "filament_shrinkage_compensation_z"
@@ -2201,6 +2204,11 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
if (time_cost_with_cache)
*time_cost_with_cache = 0;
{
const auto* sp = this->config().option<ConfigOptionStrings>("slicing_pipeline_plugin");
m_pipeline_plugin_active = s_slicing_pipeline_hook_fn && sp && !sp->values.empty();
}
name_tbb_thread_pool_threads_set_locale();
//compute the PrintObject with the same geometries
@@ -2310,20 +2318,47 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": total object counts %1% in current print, need to slice %2%")%m_objects.size()%need_slicing_objects.size();
BOOST_LOG_TRIVIAL(info) << "Starting the slicing process." << log_memory_info();
if (!use_cache) {
// Fire the SlicingPipeline hook for `obj` iff it just (re)computed `pstep` this pass.
auto hook_after = [this](PrintObject* obj, bool was_done, PrintObjectStep pstep, SlicingPipelineStepPlugin sstep) {
if (m_pipeline_plugin_active && !was_done && obj->is_step_done(pstep))
run_pipeline_hook(sstep, obj);
};
// SlicingPipeline: dedicated slice loop so the Slice boundary is hookable before perimeters.
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
obj->make_perimeters();
}
else {
if (obj->set_started(posSlice))
obj->set_done(posSlice);
if (obj->set_started(posPerimeters))
obj->set_done(posPerimeters);
const bool was_done = obj->is_step_done(posSlice);
obj->slice();
hook_after(obj, was_done, posSlice, SlicingPipelineStepPlugin::posSlice);
// G3: re-snapshot each layer's raw_slices AFTER the Slice hook ran, so the
// plugin's mutation becomes the untyped baseline. Without this, a later
// perimeter-only re-run (make_perimeters -> restore_untyped_slices) reverts
// slices to the PRE-hook geometry while posSlice stays cached (the hook does
// not re-fire), silently un-applying the mutation; raw_slices consumers
// (sharp-tail support, ToolOrdering) also read this backup directly. Gated on
// an active plugin AND a genuine (re)slice, so the inactive path is untouched
// and re-backing-up an unmutated layer is a harmless identical copy.
if (m_pipeline_plugin_active && !was_done && obj->is_step_done(posSlice))
for (Layer *layer : obj->layers())
layer->backup_untyped_slices();
} else {
if (obj->set_started(posSlice)) obj->set_done(posSlice); // shared/duplicate — no hook
}
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posPerimeters);
obj->make_perimeters(); // slice() inside is a no-op: posSlice already DONE
hook_after(obj, was_done, posPerimeters, SlicingPipelineStepPlugin::posPerimeters);
} else {
if (obj->set_started(posPerimeters)) obj->set_done(posPerimeters);
}
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posEstimateCurledExtrusions);
obj->estimate_curled_extrusions();
hook_after(obj, was_done, posEstimateCurledExtrusions, SlicingPipelineStepPlugin::posEstimateCurledExtrusions);
}
else {
if (obj->set_started(posEstimateCurledExtrusions))
@@ -2332,7 +2367,17 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
// G4: split prepare_infill (fill-surface prep) from infill (make_fills) so a
// plugin can mutate fill surfaces at the PrepareInfill seam and have make_fills
// consume them (unlike the Infill seam, which fires after the fills are already
// built). infill() re-invokes prepare_infill() as a no-op once posPrepareInfill
// is DONE, so this is a mechanical split mirroring the slice/perimeters loop.
const bool prepare_was_done = obj->is_step_done(posPrepareInfill);
obj->prepare_infill();
hook_after(obj, prepare_was_done, posPrepareInfill, SlicingPipelineStepPlugin::posPrepareInfill);
const bool was_done = obj->is_step_done(posInfill);
obj->infill();
hook_after(obj, was_done, posInfill, SlicingPipelineStepPlugin::posInfill);
}
else {
if (obj->set_started(posPrepareInfill))
@@ -2343,7 +2388,9 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posIroning);
obj->ironing();
hook_after(obj, was_done, posIroning, SlicingPipelineStepPlugin::posIroning);
}
else {
if (obj->set_started(posIroning))
@@ -2355,13 +2402,22 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
for (PrintObject *obj : m_objects) {
bool need_contouring = need_slicing_objects.count(obj) != 0 && obj->need_z_contouring();
if (need_contouring) {
const bool was_done = obj->is_step_done(posContouring);
obj->contour_z();
hook_after(obj, was_done, posContouring, SlicingPipelineStepPlugin::posContouring);
} else {
if (obj->set_started(posContouring))
obj->set_done(posContouring);
}
}
// SlicingPipeline: support runs in the parallel block below; the hook must fire in a
// sequential loop afterward. Snapshot per-object done-state just before the parallel_for.
std::vector<char> sup_was_done(m_objects.size(), 1);
if (m_pipeline_plugin_active)
for (size_t i = 0; i < m_objects.size(); ++i)
sup_was_done[i] = m_objects[i]->is_step_done(posSupportMaterial) ? 1 : 0;
tbb::parallel_for(tbb::blocked_range<int>(0, int(m_objects.size())),
[this, need_slicing_objects](const tbb::blocked_range<int>& range) {
for (int i = range.begin(); i < range.end(); i++) {
@@ -2377,9 +2433,17 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
);
if (m_pipeline_plugin_active)
for (size_t i = 0; i < m_objects.size(); ++i)
if (need_slicing_objects.count(m_objects[i]) != 0 && !sup_was_done[i]
&& m_objects[i]->is_step_done(posSupportMaterial))
run_pipeline_hook(SlicingPipelineStepPlugin::posSupportMaterial, m_objects[i]);
for (PrintObject* obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posDetectOverhangsForLift);
obj->detect_overhangs_for_lift();
hook_after(obj, was_done, posDetectOverhangsForLift, SlicingPipelineStepPlugin::posDetectOverhangsForLift);
}
else {
if (obj->set_started(posDetectOverhangsForLift))
@@ -2456,6 +2520,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
this->set_done(psWipeTower);
if (m_pipeline_plugin_active) run_pipeline_hook(SlicingPipelineStepPlugin::psWipeTower, nullptr);
}
if (this->has_wipe_tower()) {
@@ -2581,6 +2646,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
this->finalize_first_layer_convex_hull();
this->set_done(psSkirtBrim);
if (m_pipeline_plugin_active) run_pipeline_hook(SlicingPipelineStepPlugin::psSkirtBrim, nullptr);
if (time_cost_with_cache) {
end_time = (long long)Slic3r::Utils::get_current_time_utc();
@@ -2591,7 +2657,13 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
for (PrintObject *obj : m_objects) {
if (((!use_cache)&&(need_slicing_objects.count(obj) != 0))
|| (use_cache &&(re_slicing_objects.count(obj) != 0))){
const bool was_done = obj->is_step_done(posSimplifyPath);
obj->simplify_extrusion_path();
// Unlike every other seam (all inside the `if (!use_cache)` block above), this loop is
// shared with the use_cache path (re_slicing_objects), so `!use_cache` must be checked
// explicitly here to keep hooks from ever firing on cache-loaded (plugin-final) objects.
if (!use_cache && m_pipeline_plugin_active && !was_done && obj->is_step_done(posSimplifyPath))
run_pipeline_hook(SlicingPipelineStepPlugin::posSimplifyPath, obj);
}
else {
if (obj->set_started(posSimplifyPath))

View File

@@ -99,6 +99,14 @@ enum PrintObjectStep {
posCount,
};
enum class SlicingPipelineStepPlugin {
posSlice, posPerimeters, posEstimateCurledExtrusions, posPrepareInfill, posInfill, posIroning, posContouring,
posSupportMaterial, posDetectOverhangsForLift, posSimplifyPath, psWipeTower, psSkirtBrim,
// Fires from the GUI G-code export/post-process seam (PostProcessor.cpp), NOT from Print::process().
// At this step the plugin edits the exported G-code file in place; see the binding for the full contract.
psGCodePostProcess
};
// A PrintRegion object represents a group of volumes to print
// sharing the same config (including the same assigned extruder(s))
class PrintRegion
@@ -891,6 +899,11 @@ private: // Prevents erroneous use by other classes.
typedef std::pair<PrintObject *, bool> PrintObjectInfo;
public:
using SlicingPipelineHookFn = std::function<void(Print&, const PrintObject*, SlicingPipelineStepPlugin)>;
// Cross-layer injection (mirrors ConfigBase::set_resolve_capability_fn): the GUI/plugin
// layer registers a dispatcher; libslic3r stays free of any plugin/Python dependency.
static void set_slicing_pipeline_hook_fn(SlicingPipelineHookFn fn) { s_slicing_pipeline_hook_fn = std::move(fn); }
Print() = default;
virtual ~Print() { this->clear(); }
@@ -1147,6 +1160,13 @@ private:
// Islands of objects and their supports extruded at the 1st layer.
Polygons first_layer_islands() const;
static SlicingPipelineHookFn s_slicing_pipeline_hook_fn;
bool m_pipeline_plugin_active { false };
void run_pipeline_hook(SlicingPipelineStepPlugin step, const PrintObject* object) {
if (m_pipeline_plugin_active && s_slicing_pipeline_hook_fn)
s_slicing_pipeline_hook_fn(*this, object, step);
}
PrintConfig m_config;
PrintObjectConfig m_default_object_config;
PrintRegionConfig m_default_region_config;

View File

@@ -828,7 +828,10 @@ void PrintConfigDef::init_common_params()
def->tooltip = L("Select the network agent implementation for printer communication.");
def->mode = comAdvanced;
def->cli = ConfigOptionDef::nocli;
def->support_plugin = true;
// Plugin-backed like the pickers, but edited via a dedicated Choice widget rather than a
// plugin_picker field. plugin_type marks it plugin-backed and names its capability type, so its
// "plugins" manifest reference is derived generically (see ConfigOptionDef::is_plugin_backed).
def->plugin_type = "printer-connection";
def->set_default_value(new ConfigOptionString(""));
def = this->add("print_host", coString);
@@ -5111,14 +5114,12 @@ void PrintConfigDef::init_fff_params()
def->mode = comDevelop;
def->set_default_value(new ConfigOptionStrings());
def = this->add("post_process_plugin", coStrings);
def->label = L("Post-processing Plugin");
def->tooltip = L("Select a Python plugin to process the output G-code. "
"Plugins are loaded from the orca_plugins directory in your data folder. "
"The plugin will receive the G-code file path and can modify it in place.");
def = this->add("slicing_pipeline_plugin", coStrings);
def->label = L("Slicing Pipeline Plugin");
def->tooltip = L("Python plugin(s) invoked at each slicing pipeline step to read and modify intermediate slicing data, "
"including a final G-code post-processing step. Research/experimental.");
def->gui_type = ConfigOptionDef::GUIType::plugin_picker;
def->plugin_type = "post-processing";
def->support_plugin = true;
def->plugin_type = "slicing-pipeline";
def->full_width = true;
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionStrings());

View File

@@ -1570,7 +1570,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE(
((ConfigOptionBool, ooze_prevention))
((ConfigOptionString, filename_format))
((ConfigOptionStrings, post_process))
((ConfigOptionStrings, post_process_plugin))
((ConfigOptionStrings, slicing_pipeline_plugin))
((ConfigOptionString, printer_model))
((ConfigOptionFloat, resolution))
((ConfigOptionFloats, retraction_minimum_travel))

View File

@@ -109,6 +109,9 @@ set(SLIC3R_GUI_SOURCES
GUI/Downloader.hpp
GUI/DownloadProgressDialog.cpp
GUI/DownloadProgressDialog.hpp
GUI/PluginSource.hpp
GUI/PluginSort.hpp
GUI/PluginStatus.hpp
GUI/PluginPickerDialog.cpp
GUI/PluginPickerDialog.hpp
GUI/PluginsDialog.cpp
@@ -593,10 +596,13 @@ set(SLIC3R_GUI_SOURCES
plugin/PythonPluginBridge.hpp
plugin/PythonPluginInterface.hpp
plugin/PyPluginPackage.hpp
plugin/PluginBindingUtils.hpp
plugin/PluginHostApi.cpp
plugin/PluginHostApi.hpp
plugin/PluginHostUi.cpp
plugin/PluginHostUi.hpp
plugin/PluginHostSlicing.cpp
plugin/PluginHostSlicing.hpp
plugin/CloudPluginService.cpp
plugin/CloudPluginService.hpp
plugin/PluginFsUtils.cpp
@@ -612,15 +618,15 @@ set(SLIC3R_GUI_SOURCES
plugin/PluginAuditManager.hpp
plugin/PluginResolver.cpp
plugin/PluginResolver.hpp
plugin/pluginTypes/gcode/GCodePluginCapability.hpp
plugin/pluginTypes/gcode/GCodePluginCapability.cpp
plugin/pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp
plugin/pluginTypes/script/ScriptPluginCapability.hpp
plugin/pluginTypes/script/ScriptPluginCapability.cpp
plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp
pchheader.cpp
pchheader.hpp
Utils/ASCIIFolding.cpp

View File

@@ -81,6 +81,7 @@
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/PluginHostUi.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "GUI.hpp"
#include "GUI_Utils.hpp"
@@ -3122,6 +3123,75 @@ bool GUI_App::on_init_inner()
return identity + ';' + descriptor.cloud_uuid() + ';' + cap_name;
});
// Orca: register the slicing-pipeline plugin dispatcher (mirrors set_resolve_capability_fn: the
// GUI/plugin layer supplies the Python bridge so libslic3r stays free of any plugin dependency).
// Print::process() fires this hook at each pipeline seam on the slicing worker thread; here we run
// the picker-selected SlicingPipeline capabilities. Per capability we acquire the GIL, honor
// cancellation, and convert a plugin failure into a (non-critical) SlicingError so it surfaces as a
// slicing-error notification rather than the fatal-crash dialog.
Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print& print, const Slic3r::PrintObject* object, Slic3r::SlicingPipelineStepPlugin step) {
const auto* caps = print.config().option<ConfigOptionStrings>("slicing_pipeline_plugin");
// `plugins` is a dynamic-only manifest key (not a static PrintConfig member), so it
// must be read from the full/dynamic config -- reading it off print.config() (the
// static PrintConfig) always yields nullptr and skips every capability. Mirrors the
// post-process path (PostProcessor.cpp, via BackgroundSlicingProcess::full_print_config()).
const auto* plugs = print.full_print_config().option<ConfigOptionStrings>("plugins");
if (caps == nullptr || caps->values.empty())
return;
Slic3r::execute_capabilities_from_refs<Slic3r::SlicingPipelinePluginCapability>(
*caps, plugs, Slic3r::PluginCapabilityType::SlicingPipeline,
[&](std::shared_ptr<Slic3r::SlicingPipelinePluginCapability> cap, const Slic3r::PluginCapabilityRef& ref) {
Slic3r::ExecutionResult r;
try {
// GIL is acquired per capability (not once for the whole dispatch) so it
// is released between capabilities.
PythonGILState gil;
// throw_if_canceled() is protected on PrintBase; canceled() is the public
// equivalent check (same cancel flag), so honor cancellation via it.
if (print.canceled())
throw Slic3r::CanceledException();
Slic3r::SlicingPipelineContext ctx;
ctx.orca_version = SoftFever_VERSION;
ctx.step = step;
ctx.print = &print;
ctx.object = object;
// G5: hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params
// (same plugin_key the capability was resolved by, so it always matches).
const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid;
ctx.params = Slic3r::PluginManager::instance().get_loader().get_plugin_settings(plugin_key);
r = cap->execute(ctx);
} catch (const Slic3r::CanceledException&) {
throw; // cancellation must reach process(), never become a slicing error
} catch (const std::exception& ex) {
// A Python raise reaches here as pybind11::error_already_set; surface it as a
// (non-critical) slicing error instead of a crash.
throw Slic3r::SlicingError(std::string("Slicing pipeline plugin '") +
ref.capability_name + "' error: " + ex.what());
}
if (r.status == Slic3r::PluginResult::FatalError)
throw Slic3r::SlicingError(std::string("Slicing pipeline plugin '") +
ref.capability_name + "' error: " + r.message);
// G8: log a non-empty success/skipped message instead of dropping it. This is
// log-only by design: every pipeline hook fires AFTER set_done() (see Print.cpp),
// so the Print-level m_step_active is -1 here. Calling active_step_add_warning()
// would then index m_state[-1] (out-of-bounds; the guarding assert is compiled
// out in Release), so it must NOT be called from a pipeline hook.
if (!r.message.empty()) {
static const char* const kStepNames[] = {
"posSlice", "posPerimeters", "posEstimateCurledExtrusions", "posPrepareInfill", "posInfill",
"posIroning", "posContouring", "posSupportMaterial", "posDetectOverhangsForLift",
"posSimplifyPath", "psWipeTower", "psSkirtBrim", "psGCodePostProcess"
}; // order must match Slic3r::SlicingPipelineStepPlugin
const char* step_name = static_cast<size_t>(step) < sizeof(kStepNames) / sizeof(kStepNames[0])
? kStepNames[static_cast<int>(step)] : "Unknown";
BOOST_LOG_TRIVIAL(info) << "Slicing pipeline plugin '" << ref.capability_name
<< "' [" << step_name << "]: " << r.message;
}
});
});
// Set cloud plugin directory from previous session so cloud-installed
// plugins are discovered even before the network agent is ready.
const std::string preset_folder = app_config->get("preset_folder");

View File

@@ -0,0 +1,210 @@
#pragma once
#include "PluginSource.hpp"
#include "PluginStatus.hpp"
#include "libslic3r/Semver.hpp"
#include <algorithm>
#include <cctype>
#include <string>
#include <string_view>
#include <vector>
namespace Slic3r::GUI
{
enum class PluginSortKey
{
Status,
Name,
Source,
Version,
// why: neutral "no column selected" state - clearing a header sort returns here and the
// list falls to compare_plugin_base_order only. Header UI reaches it via the asc/desc/clear cycle.
None
};
enum class PluginSortOrder
{
Asc,
Desc
};
inline std::string to_string(PluginSortKey sort_key)
{
switch (sort_key)
{
case PluginSortKey::Status: return "status";
case PluginSortKey::Name: return "name";
case PluginSortKey::Source: return "source";
case PluginSortKey::Version: return "version";
case PluginSortKey::None: return "none";
}
return "status";
}
inline std::string to_string(PluginSortOrder sort_order)
{
return sort_order == PluginSortOrder::Desc ? "desc" : "asc";
}
inline PluginSortKey plugin_sort_key_from_string(const std::string& sort_key, PluginSortKey fallback)
{
if (sort_key == "status")
return PluginSortKey::Status;
if (sort_key == "name")
return PluginSortKey::Name;
if (sort_key == "source")
return PluginSortKey::Source;
if (sort_key == "version")
return PluginSortKey::Version;
if (sort_key == "none")
return PluginSortKey::None;
return fallback;
}
inline PluginSortOrder plugin_sort_order_from_string(const std::string& sort_order, PluginSortOrder fallback)
{
if (sort_order == "asc")
return PluginSortOrder::Asc;
if (sort_order == "desc")
return PluginSortOrder::Desc;
return fallback;
}
// Natural, case-insensitive ASCII compare returning -1 / 0 / +1. Digit runs compare by
// numeric value; other chars compare lowercased; on a prefix tie the shorter string is less.
// e.g. "item2" < "item10" (2 < 10, not '2' > '1')
// "Camera" == "camera" (case ignored)
// "app" < "apple" (prefix is shorter)
// "1" < "01" (equal value, fewer leading zeros wins the tie)
// note: ASCII only - no locale/Unicode; accented or non-Latin names fall back to byte order.
inline int compare_ascii_case_insensitive_natural(const std::string& lhs, const std::string& rhs)
{
std::size_t li = 0;
std::size_t ri = 0;
while (li < lhs.size() && ri < rhs.size())
{
const unsigned char lc = static_cast<unsigned char>(lhs[li]);
const unsigned char rc = static_cast<unsigned char>(rhs[ri]);
if (std::isdigit(lc) && std::isdigit(rc))
{
const std::size_t lhs_digit_begin = li;
const std::size_t rhs_digit_begin = ri;
while (li < lhs.size() && std::isdigit(static_cast<unsigned char>(lhs[li])))
++li;
while (ri < rhs.size() && std::isdigit(static_cast<unsigned char>(rhs[ri])))
++ri;
const std::string_view lhs_run(lhs.data() + lhs_digit_begin, li - lhs_digit_begin);
const std::string_view rhs_run(rhs.data() + rhs_digit_begin, ri - rhs_digit_begin);
// why: digit runs compare numerically; leading zeros only break exact ties ("1" < "01").
const std::string_view lhs_num = lhs_run.substr(std::min(lhs_run.find_first_not_of('0'), lhs_run.size()));
const std::string_view rhs_num = rhs_run.substr(std::min(rhs_run.find_first_not_of('0'), rhs_run.size()));
if (lhs_num.size() != rhs_num.size())
return lhs_num.size() < rhs_num.size() ? -1 : 1;
if (const int cmp = lhs_num.compare(rhs_num); cmp != 0)
return cmp;
// note: fewer-leading-zeros-first is our convention, not an industry standard (impls
// diverge here); it only matters as a deterministic total order for unstable std::sort.
if (lhs_run.size() != rhs_run.size())
return lhs_run.size() < rhs_run.size() ? -1 : 1;
continue;
}
const int lower_lhs = std::tolower(lc);
const int lower_rhs = std::tolower(rc);
if (lower_lhs != lower_rhs)
return lower_lhs < lower_rhs ? -1 : 1;
++li;
++ri;
}
if (li == lhs.size() && ri == rhs.size())
return 0;
return li == lhs.size() ? -1 : 1;
}
// Neutral baseline order: the whole order when no column is sorted, and the tie-breaker under
// every primary sort key. Name-first so the default view is intuitively alphabetical:
// name, then source, then status, then type, with plugin_key as the final deterministic tie.
// e.g. with no column sorted the list reads A..Z by name.
template <class PluginItem>
int compare_plugin_base_order(const PluginItem& lhs, const PluginItem& rhs)
{
if (const int cmp = compare_ascii_case_insensitive_natural(lhs.display_name, rhs.display_name); cmp != 0)
return cmp;
if (const int cmp = static_cast<int>(lhs.source) - static_cast<int>(rhs.source); cmp != 0)
return cmp;
if (const int cmp = static_cast<int>(lhs.status) - static_cast<int>(rhs.status); cmp != 0)
return cmp;
if (const int cmp = lhs.type_key.compare(rhs.type_key); cmp != 0)
return cmp;
return lhs.plugin_key.compare(rhs.plugin_key);
}
// Compares two version strings returning -1 / 0 / +1. Uses Slic3r::Semver (the same parser the
// plugin catalog's update-available check uses); on unparseable input falls back to the natural
// compare so the order stays deterministic.
// e.g. "1.2.0" < "1.10.0" (numeric), "1.0.0-rc1" < "1.0.0" (semver prerelease rule).
inline int compare_plugin_version(const std::string& lhs, const std::string& rhs)
{
const auto lhs_semver = Semver::parse(lhs);
const auto rhs_semver = Semver::parse(rhs);
if (lhs_semver && rhs_semver)
{
if (*lhs_semver < *rhs_semver) return -1;
if (*rhs_semver < *lhs_semver) return 1;
return 0;
}
return compare_ascii_case_insensitive_natural(lhs, rhs);
}
// Compares two items by the chosen primary key, returning -1 / 0 / +1. Status and Source
// rank by enum ordinal (the declared dialog priority); Name uses the natural compare above.
// e.g. Status: an enabled item (lower ordinal) sorts before a disabled one.
// Name: "Plugin 2" sorts before "Plugin 10".
template <class PluginItem>
int compare_plugin_sort_key(const PluginItem& lhs, const PluginItem& rhs, PluginSortKey sort_key)
{
switch (sort_key)
{
case PluginSortKey::Status:
// why: PluginStatus/PluginSource declare the dialog sort priority as their ordinal order.
return static_cast<int>(lhs.status) - static_cast<int>(rhs.status);
case PluginSortKey::Name:
return compare_ascii_case_insensitive_natural(lhs.display_name, rhs.display_name);
case PluginSortKey::Source:
return static_cast<int>(lhs.source) - static_cast<int>(rhs.source);
case PluginSortKey::Version:
return compare_plugin_version(lhs.sort_version, rhs.sort_version);
case PluginSortKey::None:
// why: no primary key - every pair ties here so sort_plugin_items_for_dialog falls
// straight to the ascending base order (direction is irrelevant for the baseline).
return 0;
}
return 0;
}
// Sorts the dialog list in place by primary key + direction. Ties always fall back to the
// ascending base order, so the result is deterministic regardless of the primary direction.
// e.g. sort_key=Name, order=Desc -> names Z..A, but equal names keep the stable base order.
template <class PluginItem>
void sort_plugin_items_for_dialog(std::vector<PluginItem>& items, PluginSortKey sort_key,
PluginSortOrder sort_order)
{
std::sort(items.begin(), items.end(),
[sort_key, sort_order](const PluginItem& lhs, const PluginItem& rhs)
{
if (const int cmp = compare_plugin_sort_key(lhs, rhs, sort_key); cmp != 0)
return sort_order == PluginSortOrder::Asc ? cmp < 0 : cmp > 0;
// why: ties fall back to ascending base order regardless of the primary direction.
return compare_plugin_base_order(lhs, rhs) < 0;
});
}
} // namespace Slic3r::GUI

View File

@@ -0,0 +1,29 @@
#pragma once
#include <string>
namespace Slic3r
{
namespace GUI
{
enum class PluginSource
{
// IMPORTANT: ordinal order is the Plugins dialog Source sort priority.
Mine,
Subscribed,
Local
};
inline std::string to_string(PluginSource source)
{
switch (source)
{
case PluginSource::Mine: return "mine";
case PluginSource::Subscribed: return "subscribed";
case PluginSource::Local: return "local";
}
return "local";
}
}
} // namespace Slic3r::GUI

View File

@@ -0,0 +1,31 @@
#pragma once
#include <string>
namespace Slic3r
{
namespace GUI
{
enum class PluginStatus
{
// IMPORTANT: ordinal order is the Plugins dialog Status sort priority.
Activated,
Error,
Inactive,
Loading
};
inline std::string to_string(PluginStatus status)
{
switch (status)
{
case PluginStatus::Activated: return "Activated";
case PluginStatus::Error: return "Error";
case PluginStatus::Inactive: return "Inactive";
case PluginStatus::Loading: return "Loading";
}
return "Inactive";
}
}
} // namespace Slic3r::GUI

View File

@@ -25,7 +25,6 @@
#include <boost/log/trivial.hpp>
#include <algorithm>
#include <cctype>
#include <memory>
#include <mutex>
#include <vector>
@@ -90,6 +89,7 @@ struct PluginDialogItem
std::string version;
std::string installed_version;
std::string latest_version;
std::string sort_version; // Version shown in the row (installed if installed, else latest); used by the Version sort.
std::string type_label;
std::string type_key;
std::string sharing_token;
@@ -103,7 +103,7 @@ struct PluginDialogItem
PluginUpdateStatus update_status = PluginUpdateStatus::Normal;
std::string error_text;
bool has_error = false;
bool loaded = false;
bool is_loaded = false;
bool loading = false;
// Installation and capability flags
@@ -187,27 +187,62 @@ void refresh_plugin_catalog_blocking(bool fetch_cloud)
}
}
std::string to_string(PluginSource source)
std::string to_string(PluginUpdateStatus status);
nlohmann::json build_context_actions_payload(const PluginAvailableActions& available_actions);
nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item)
{
switch (source) {
case PluginSource::Local: return "local";
case PluginSource::Mine: return "mine";
case PluginSource::Subscribed: return "subscribed";
nlohmann::json payload_item;
payload_item["plugin_key"] = dialog_item.plugin_key;
payload_item["plugin_id"] = dialog_item.plugin_id;
payload_item["name"] = dialog_item.display_name;
payload_item["description"] = dialog_item.description;
payload_item["author"] = dialog_item.author;
payload_item["version"] = dialog_item.version;
payload_item["type"] = dialog_item.type_label;
payload_item["type_key"] = dialog_item.type_key;
payload_item["types"] = dialog_item.type_labels;
nlohmann::json caps = nlohmann::json::array();
for (const PluginCapabilityView& capability : dialog_item.capabilities) {
nlohmann::json c;
c["name"] = capability.name;
c["type"] = capability.type_label;
c["type_key"] = capability.type_key;
c["enabled"] = capability.enabled;
c["can_toggle"] = capability.can_toggle;
c["can_run"] = capability.can_run;
caps.push_back(std::move(c));
}
payload_item["capabilities"] = std::move(caps);
return "local";
}
std::string to_string(PluginStatus status)
{
switch (status) {
case PluginStatus::Inactive: return "Inactive";
case PluginStatus::Error: return "Error";
case PluginStatus::Loading: return "Loading";
case PluginStatus::Activated: return "Activated";
nlohmann::json changelog = nlohmann::json::array();
for (const PluginChangelogView& entry : dialog_item.changelog) {
nlohmann::json c;
c["version"] = entry.version;
c["changelog"] = entry.changelog;
c["created_time"] = entry.created_time;
changelog.push_back(std::move(c));
}
payload_item["changelog"] = std::move(changelog);
return "Inactive";
payload_item["label"] = dialog_item.display_name;
payload_item["source"] = to_string(dialog_item.source);
payload_item["status"] = to_string(dialog_item.status);
payload_item["error"] = dialog_item.error_text;
payload_item["update_status"] = to_string(dialog_item.update_status);
payload_item["unauthorized"] = dialog_item.unauthorized;
payload_item["context_actions"] = build_context_actions_payload(dialog_item.available_actions);
payload_item["update_available"] = dialog_item.update_status == PluginUpdateStatus::UpdateAvailable;
payload_item["can_toggle"] = dialog_item.available_actions.can_toggle;
payload_item["has_script_capability"] = dialog_item.has_script_capability;
payload_item["can_run_script"] = dialog_item.can_run_script;
payload_item["sharing_token"] = dialog_item.sharing_token;
payload_item["thumbnail_url"] = dialog_item.thumbnail_url;
payload_item["installed"] = dialog_item.has_local_package;
payload_item["installed_version"] = dialog_item.installed_version;
payload_item["latest_version"] = dialog_item.latest_version;
return payload_item;
}
std::string to_string(PluginUpdateStatus status)
@@ -285,6 +320,9 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
(descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version) :
std::string{};
item.latest_version = descriptor.latest_available_version();
// why: sort by the same version the row displays (GetDisplayVersion in index.js) - installed when
// installed, otherwise latest - so the Version sort matches what the user sees.
item.sort_version = item.installed_version.empty() ? item.latest_version : item.installed_version;
item.type_label = descriptor.type_label();
item.type_key = plugin_capability_type_to_string(descriptor.primary_capability_type());
// "types" is the display-only compatibility list. Cloud plugins show the raw labels the
@@ -311,9 +349,9 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
item.has_local_package = descriptor.has_local_package();
item.unauthorized = descriptor.is_unauthorized();
item.has_script_capability = descriptor.has_capability_type(Slic3r::PluginCapabilityType::Script);
item.loaded = loader.is_plugin_loaded(descriptor.plugin_key);
item.is_loaded = loader.is_plugin_loaded(descriptor.plugin_key);
item.loading = loader.is_plugin_load_in_progress(descriptor.plugin_key);
if (item.loaded) {
if (item.is_loaded) {
for (const auto& cap : loader.get_loaded_plugin_capabilities(descriptor.plugin_key)) {
if (cap) {
item.capabilities.push_back({cap->name, plugin_capability_type_display_name(cap->type),
@@ -342,7 +380,7 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
item.status = PluginStatus::Loading;
else if (item.has_error)
item.status = PluginStatus::Error;
else if (item.loaded)
else if (item.is_loaded)
item.status = PluginStatus::Activated;
else
item.status = PluginStatus::Inactive;
@@ -352,7 +390,7 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
[](const PluginCapabilityView& capability) {
return capability.type_key == "script" && capability.enabled;
});
item.can_run_script = descriptor.is_metadata_valid() && !descriptor.has_error() && item.has_script_capability && item.loaded &&
item.can_run_script = descriptor.is_metadata_valid() && !descriptor.has_error() && item.has_script_capability && item.is_loaded &&
!item.loading && has_enabled_script;
for (PluginCapabilityView& capability : item.capabilities) {
capability.can_run = item.can_run_script && capability.type_key == "script" && capability.enabled;
@@ -478,6 +516,8 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload)
open_plugin_on_cloud(payload.value("sharing_token", ""));
} else if (command == "open_plugin_hub") {
open_plugin_hub();
} else if (command == "set_plugin_sort") {
set_plugin_sort(payload.value("sort_key", ""), payload.value("sort_order", ""));
} else if (command == "set_plugin_install_action") {
const std::string action = payload.value("action", "");
if (action == "explore" || action == "install-local")
@@ -487,92 +527,41 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload)
void PluginsDialog::send_plugins() { call_web_handler(build_plugins_payload()); }
void PluginsDialog::set_plugin_sort(const std::string& sort_key, const std::string& sort_order)
{
m_plugin_sort_key = plugin_sort_key_from_string(sort_key, m_plugin_sort_key);
m_plugin_sort_order = plugin_sort_order_from_string(sort_order, m_plugin_sort_order);
send_plugins();
}
nlohmann::json PluginsDialog::build_plugins_payload() const
{
nlohmann::json response;
response["command"] = "list_plugins";
response["install_action"] = s_selected_plugin_install_action;
response["sort_key"] = to_string(m_plugin_sort_key);
response["sort_order"] = to_string(m_plugin_sort_order);
response["data"] = nlohmann::json::array();
auto append_plugin = [&response](const PluginDescriptor& row) {
const PluginDialogItem dialog_item = build_plugin_dialog_item(row);
nlohmann::json payload_item;
payload_item["plugin_key"] = dialog_item.plugin_key;
payload_item["plugin_id"] = dialog_item.plugin_id;
payload_item["name"] = row.name;
payload_item["description"] = dialog_item.description;
payload_item["author"] = dialog_item.author;
payload_item["version"] = dialog_item.version;
payload_item["installed_version"] = dialog_item.installed_version;
payload_item["latest_version"] = dialog_item.latest_version;
payload_item["installed"] = dialog_item.has_local_package;
payload_item["type"] = dialog_item.type_label;
payload_item["type_key"] = dialog_item.type_key;
payload_item["types"] = dialog_item.type_labels;
nlohmann::json caps = nlohmann::json::array();
for (const PluginCapabilityView& capability : dialog_item.capabilities) {
nlohmann::json c;
c["name"] = capability.name;
c["type"] = capability.type_label;
c["type_key"] = capability.type_key;
c["enabled"] = capability.enabled;
c["can_toggle"] = capability.can_toggle;
c["can_run"] = capability.can_run;
caps.push_back(std::move(c));
}
payload_item["capabilities"] = std::move(caps);
nlohmann::json changelog = nlohmann::json::array();
for (const PluginChangelogView& entry : dialog_item.changelog) {
nlohmann::json c;
c["version"] = entry.version;
c["changelog"] = entry.changelog;
c["created_time"] = entry.created_time;
changelog.push_back(std::move(c));
}
payload_item["changelog"] = std::move(changelog);
payload_item["label"] = dialog_item.display_name;
payload_item["source"] = to_string(dialog_item.source);
payload_item["status"] = to_string(dialog_item.status);
payload_item["error"] = dialog_item.error_text;
payload_item["update_status"] = to_string(dialog_item.update_status);
payload_item["unauthorized"] = dialog_item.unauthorized;
payload_item["context_actions"] = build_context_actions_payload(dialog_item.available_actions);
payload_item["update_available"] = dialog_item.update_status == PluginUpdateStatus::UpdateAvailable;
payload_item["can_toggle"] = dialog_item.available_actions.can_toggle;
payload_item["has_script_capability"] = dialog_item.has_script_capability;
payload_item["can_run_script"] = dialog_item.can_run_script;
payload_item["sharing_token"] = dialog_item.sharing_token;
payload_item["thumbnail_url"] = dialog_item.thumbnail_url;
response["data"].push_back(std::move(payload_item));
};
const auto& catalog = PluginManager::instance().get_catalog();
const auto valid = catalog.get_all_plugin_descriptors();
const auto invalid = catalog.get_invalid_plugins();
BOOST_LOG_TRIVIAL(info) << "Prepared " << valid.size() + invalid.size() << " plugin rows for Plugins dialog";
std::vector<PluginDialogItem> items;
items.reserve(valid.size() + invalid.size());
for (const PluginDescriptor& row : valid)
append_plugin(row);
items.push_back(build_plugin_dialog_item(row));
for (const PluginDescriptor& row : invalid)
append_plugin(row);
items.push_back(build_plugin_dialog_item(row));
auto sort_value = [](const nlohmann::json& payload_item, const char* key) { return payload_item.value(key, std::string{}); };
// In-place sort
sort_plugin_items_for_dialog(items, m_plugin_sort_key, m_plugin_sort_order);
std::sort(response["data"].begin(), response["data"].end(), [&sort_value](const nlohmann::json& lhs, const nlohmann::json& rhs) {
const std::string lhs_source = sort_value(lhs, "source");
const std::string rhs_source = sort_value(rhs, "source");
if (lhs_source != rhs_source)
return lhs_source < rhs_source;
const std::string lhs_type = sort_value(lhs, "type_key");
const std::string rhs_type = sort_value(rhs, "type_key");
if (lhs_type != rhs_type)
return lhs_type < rhs_type;
return sort_value(lhs, "name") < sort_value(rhs, "name");
});
for (const PluginDialogItem& item : items)
response["data"].push_back(build_plugin_payload_item(item));
return response;
}
@@ -1007,9 +996,9 @@ void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::
// ModelObject*/ModelVolume*/ModelInstance* aliases into host data and can mint ObjectIDs,
// which libslic3r requires on the main thread (ObjectID.hpp's non-atomic s_last_id). Running
// here makes those reads/instantiations legal and means nothing mutates the model underneath
// a run. The trade-off is that a slow execute() freezes the UI: the contract (see
// plugin_development.md) is to keep execute() quick and offload heavy work to the plugin's own
// threading.Thread. orca.host.ui calls already no-op their main-thread marshaling here.
// a run. The trade-off is that a slow execute() freezes the UI, so the contract is to keep
// execute() quick and offload heavy work to the plugin's own threading.Thread. orca.host.ui
// calls already no-op their main-thread marshaling here.
{
wxBusyCursor busy;
try {
@@ -1274,7 +1263,7 @@ void PluginsDialog::delete_mine_local_and_cloud_plugin(const std::string& plugin
// delete_mine_local_and_cloud_plugin already updated the in-memory catalog
// (finalize_cloud_plugin_removal removes the row and, when a local package existed,
// re-syncs the cloud list itself), so a UI refresh is sufficient here an extra
// re-syncs the cloud list itself), so a UI refresh is sufficient here - an extra
// clearing rescan + cloud fetch would be redundant.
send_plugins();
show_status(wxString::Format(_L("Deleted \"%s\"."), plugin_name), "success");

View File

@@ -2,6 +2,9 @@
#define slic3r_PluginsDialog_hpp_
#include "Widgets/WebViewHostDialog.hpp"
#include "PluginSource.hpp"
#include "PluginStatus.hpp"
#include "PluginSort.hpp"
#include "slic3r/plugin/PluginDescriptor.hpp"
#include <exception>
@@ -27,21 +30,6 @@ enum class PluginCapabilityType;
namespace GUI {
enum class PluginSource
{
Local,
Mine,
Subscribed
};
enum class PluginStatus
{
Inactive,
Error,
Loading,
Activated
};
class PluginsDialog : public Slic3r::GUI::WebViewHostDialog
{
public:
@@ -63,6 +51,7 @@ private:
void on_script_message(const nlohmann::json& payload) override;
void send_plugins();
void set_plugin_sort(const std::string& sort_key, const std::string& sort_order);
nlohmann::json build_plugins_payload() const;
bool get_descriptor(const std::string& plugin_key, Slic3r::PluginDescriptor& descriptor) const;
@@ -221,6 +210,8 @@ private:
}
std::function<void()> m_open_terminal_dlg_fn;
PluginSortKey m_plugin_sort_key = PluginSortKey::None;
PluginSortOrder m_plugin_sort_order = PluginSortOrder::Asc;
// Serializes run_script_plugin. With main-thread execution a plugin's orca.host.ui modal
// (message/show_dialog) or the result message box pumps a nested event loop, which could

View File

@@ -9,7 +9,7 @@
// file lives in the GUI layer (libslic3r must not depend on pybind11 / PluginManager).
#include "libslic3r/Config.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
#include <boost/algorithm/string.hpp>
@@ -241,27 +241,39 @@ void gcode_add_line_number(const std::string& path, const DynamicPrintConfig& co
fs.close();
}
// Run the configured post-processing plugins on `gcode_path` in place. Plugins are executed in-process
// through the embedded Python interpreter. Throws Slic3r::RuntimeError on any failure; the caller is
// responsible for removing the working copy (see run_post_process_scripts' catch block).
// Entries are bare capability names; the top-level plugins manifest carries the full plugin refs.
// Run the configured slicing-pipeline plugins on `gcode_path` in place, at their Step.psGCodePostProcess
// seam. This is the same capability that runs at the geometry seams inside Print::process(); here it is
// dispatched a final time on the exported G-code, so a plugin can edit slices AND the final G-code from
// one class. Plugins are executed in-process through the embedded Python interpreter. Throws
// Slic3r::RuntimeError on any failure; the caller removes the working copy (see run_post_process_scripts'
// catch block). Entries are bare capability names; the top-level plugins manifest carries the full refs.
// A geometry-only plugin simply returns success here (it filters on ctx.step), so it costs nothing beyond
// one no-op call, but note any configured pipeline plugin still engages this post-process path (i.e. the
// non-BBL ".pp" working copy) even if it does no G-code work.
static void run_post_process_plugins(const ConfigOptionStrings& capabilities,
const ConfigOptionStrings* plugins,
const std::string& gcode_path,
const std::string& host,
const std::string& output_name)
const std::string& output_name,
const DynamicPrintConfig& config)
{
// Let plugins observe the (possibly script-updated) target file name, mirroring the script env.
boost::nowide::setenv("SLIC3R_PP_OUTPUT_NAME", output_name.c_str(), 1);
const boost::filesystem::path gcode_file(gcode_path);
auto execute_fn = [&](std::shared_ptr<GCodePluginCapability> cap, const PluginCapabilityRef& ref) {
GCodePluginContext ctx;
auto execute_fn = [&](std::shared_ptr<SlicingPipelinePluginCapability> cap, const PluginCapabilityRef& ref) {
SlicingPipelineContext ctx;
ctx.orca_version = SoftFever_VERSION;
ctx.step = SlicingPipelineStepPlugin::psGCodePostProcess;
ctx.gcode_path = gcode_path;
ctx.host = host;
ctx.output_name = output_name;
ctx.full_config = &config; // no live Print here; config_value() reads this
// Hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params (same plugin_key the
// capability was resolved by), mirroring the in-pipeline dispatcher in GUI_App.cpp.
const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid;
ctx.params = PluginManager::instance().get_loader().get_plugin_settings(plugin_key);
ExecutionResult exec_result;
try {
@@ -298,11 +310,12 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities,
BOOST_LOG_TRIVIAL(info) << "Post-processing plugin " << ref.capability_name << " completed successfully";
};
execute_capabilities_from_refs<GCodePluginCapability>(capabilities, plugins, PluginCapabilityType::PostProcessing, execute_fn);
execute_capabilities_from_refs<SlicingPipelinePluginCapability>(capabilities, plugins, PluginCapabilityType::SlicingPipeline, execute_fn);
}
// Run post-processing scripts ("post_process") and/or post-processing plugins ("post_process_plugin")
// if defined. Both run on the same working copy of the G-code (the ".pp" temp when make_copy), so a
// Run post-processing scripts ("post_process") and/or the slicing-pipeline plugins' psGCodePostProcess
// step ("slicing_pipeline_plugin") if defined. Both run on the same working copy of the G-code (the
// ".pp" temp when make_copy), so a
// plugin never opens the original file the G-code viewer keeps memory-mapped (a writable open of the
// mapped file fails on Windows with a sharing violation).
// Returns true if a script or plugin was executed.
@@ -317,11 +330,13 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities,
bool run_post_process_scripts(
std::string& src_path, bool make_copy, const std::string& host, std::string& output_name, const DynamicPrintConfig& config)
{
// post_process / post_process_plugin are absent in SLA mode, hence the null checks.
const auto* post_process = config.opt<ConfigOptionStrings>("post_process");
const auto* post_process_plugin = config.opt<ConfigOptionStrings>("post_process_plugin");
const bool have_scripts = post_process != nullptr && !post_process->values.empty();
const bool have_plugins = post_process_plugin != nullptr && !post_process_plugin->values.empty();
// post_process / slicing_pipeline_plugin are absent in SLA mode, hence the null checks. G-code
// post-processing is now the psGCodePostProcess step of the slicing-pipeline plugin, so the same
// slicing_pipeline_plugin option drives both the geometry seams and this final G-code seam.
const auto* post_process = config.opt<ConfigOptionStrings>("post_process");
const auto* slicing_pipeline_plugin = config.opt<ConfigOptionStrings>("slicing_pipeline_plugin");
const bool have_scripts = post_process != nullptr && !post_process->values.empty();
const bool have_plugins = slicing_pipeline_plugin != nullptr && !slicing_pipeline_plugin->values.empty();
if (!have_scripts && !have_plugins)
return false;
@@ -469,7 +484,7 @@ bool run_post_process_scripts(
// Run plugins after the scripts so they observe any output_name the scripts produced. A thrown
// exception is handled by the catch below, which removes the temp copy.
if (have_plugins) {
run_post_process_plugins(*post_process_plugin, config.opt<ConfigOptionStrings>("plugins"), path, host, output_name);
run_post_process_plugins(*slicing_pipeline_plugin, config.opt<ConfigOptionStrings>("plugins"), path, host, output_name, config);
}
} catch (...) {
remove_output_name_file();

View File

@@ -9,9 +9,10 @@
namespace Slic3r {
// Run post-processing scripts (the "post_process" option) and/or post-processing plugins (the
// "post_process_plugin" option) if defined. Lives in the GUI layer because plugins are executed
// through the embedded-Python PluginManager, which libslic3r must not depend on.
// Run post-processing scripts (the "post_process" option) and/or the slicing-pipeline plugins'
// Step.psGCodePostProcess seam (the "slicing_pipeline_plugin" option) if defined. Lives in the GUI
// layer because plugins are executed through the embedded-Python PluginManager, which libslic3r must
// not depend on.
// Returns true if a script or plugin was executed.
// Returns false if neither a post-processing script nor plugin was defined.
// Throws an exception on error.

View File

@@ -1790,6 +1790,13 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value)
return;
}
// Keep this preset's "plugins" manifest in sync when a plugin picker changes, so the edited preset
// always carries resolved "name;uuid;capability" references that full_config() and save_to_json()
// then pass downstream as-is -- no separate rebuild anywhere else.
if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key);
opt_def && opt_def->is_plugin_backed())
m_config->update_plugin_manifest();
if (opt_key == "gcode_flavor" && m_type == Preset::TYPE_PRINTER) {
if (auto printer_tab = dynamic_cast<TabPrinter*>(this))
printer_tab->on_gcode_flavor_changed();
@@ -3073,9 +3080,9 @@ void TabPrint::build()
option.opt.height = 15;
optgroup->append_single_option_line(option, "others_settings_post_processing_scripts");
optgroup = page->new_optgroup(L("Post-processing Plugin"), L"param_gcode", 0);
optgroup = page->new_optgroup(L("Slicing Pipeline Plugin"), L"param_gcode", 0);
optgroup->hide_labels();
option = optgroup->get_option("post_process_plugin");
option = optgroup->get_option("slicing_pipeline_plugin");
option.opt.full_width = true;
optgroup->append_single_option_line(option, "others_settings_plugin_picker");

View File

@@ -0,0 +1,106 @@
#pragma once
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include "libslic3r/Config.hpp" // ConfigBase
#include "libslic3r/Point.hpp" // Point/Point3 packing asserts, Vec3d, Transform3d
#include <string>
#include <utility>
#include <vector>
namespace Slic3r {
// Point/Point3 must be tightly packed for zero-copy views. coord_t = int64_t.
static_assert(sizeof(Point) == 2 * sizeof(coord_t), "Point must be 2 packed coord_t");
static_assert(sizeof(Point3) == 3 * sizeof(coord_t), "Point3 must be 3 packed coord_t");
// Run a builder that constructs numpy objects, translating the "numpy missing"
// ImportError into an actionable message (plugins must declare numpy as a dep).
template<typename Builder>
pybind11::object with_numpy(Builder&& build)
{
namespace py = pybind11;
try {
return std::forward<Builder>(build)();
} catch (py::error_already_set& err) {
if (err.matches(PyExc_ImportError))
throw py::import_error("numpy is required to access geometry/mesh arrays; "
"add dependencies = [\"numpy\"] to your plugin metadata");
throw;
}
}
// Zero-copy, read-only (rows, N) numpy view over `data`, whose lifetime is tied
// to `base` (the array's base object). T is the element scalar (coord_t = int64
// for slicing coords, float for mesh vertices). rows == 0 / null data yields a
// fresh empty (0, N) array with no base.
template<typename T, int N>
pybind11::array make_readonly_rows(pybind11::handle base, const T* data, pybind11::ssize_t rows)
{
namespace py = pybind11;
if (rows == 0 || data == nullptr) {
py::array_t<T> empty(std::vector<py::ssize_t>{ 0, (py::ssize_t) N });
// Keep behavior-preserving: the pre-refactor helper returned read-only
// arrays on every path, so mark the fresh empty array read-only too.
empty.attr("setflags")(py::arg("write") = false);
return std::move(empty);
}
py::array_t<T> arr(
{ rows, (py::ssize_t) N },
{ (py::ssize_t)(N * sizeof(T)), (py::ssize_t) sizeof(T) },
data, base);
// A base-carrying array is writable by default in pybind11; force read-only.
arr.attr("setflags")(py::arg("write") = false);
return std::move(arr);
}
// Zero-copy, WRITABLE (rows, N) numpy view over `data`, lifetime tied to `base`.
// Twin of make_readonly_rows: a base-carrying pybind array is writable by default,
// so we simply do not clear the write flag. Writing through the view mutates the
// underlying C++ buffer in place. rows == 0 / null data yields a fresh empty (0, N)
// array (writable, no base).
template<typename T, int N>
pybind11::array make_writable_rows(pybind11::handle base, T* data, pybind11::ssize_t rows)
{
namespace py = pybind11;
if (rows == 0 || data == nullptr)
return py::array_t<T>(std::vector<py::ssize_t>{ 0, (py::ssize_t) N });
return py::array_t<T>(
{ rows, (py::ssize_t) N },
{ (py::ssize_t)(N * sizeof(T)), (py::ssize_t) sizeof(T) },
data, base);
}
// Serialize one config key to a Python string, or None if the key is absent.
// Works on any ConfigBase (resolved DynamicPrintConfig snapshots,
// PrintObjectConfig, PrintRegionConfig, preset configs).
inline pybind11::object config_value_or_none(const ConfigBase& config, const std::string& key)
{
if (!config.has(key))
return pybind11::none();
return pybind11::cast(config.opt_serialize(key));
}
// Plugins receive 3D vectors as plain Python tuples (x, y, z) so the API stays
// Pythonic and free of an Eigen/numpy runtime dependency.
inline pybind11::tuple vec3_to_tuple(const Vec3d& v)
{
return pybind11::make_tuple(v.x(), v.y(), v.z());
}
// 4x4 row-major float64 copy of an affine transform. Eigen stores column-major,
// so fill element-wise to produce correct C-order data. Requires numpy.
inline pybind11::object mat4_to_numpy(const Transform3d& transform)
{
namespace py = pybind11;
return with_numpy([&] {
py::array_t<double> array({ py::ssize_t(4), py::ssize_t(4) });
auto view = array.mutable_unchecked<2>();
const auto& matrix = transform.matrix();
for (int i = 0; i < 4; ++i)
for (int j = 0; j < 4; ++j)
view(i, j) = matrix(i, j);
return py::object(std::move(array));
});
}
} // namespace Slic3r

View File

@@ -4,6 +4,7 @@
#include <algorithm>
#include <cctype>
#include <map>
#include <optional>
#include <string>
#include <utility>
@@ -61,6 +62,7 @@ struct PluginDescriptor
std::string entry_path; // Full path to the installed plugin entry file
std::string entry_package; // Import package/module used for package-based loading
std::vector<std::string> dependencies; // Python dependency requirements declared by plugin package metadata
std::map<std::string, std::string> settings; // G5: [tool.orcaslicer.plugin.settings] table -> per-plugin params (ctx.params)
std::vector<PluginChangelog> changelog; // Cloud release changelog, sorted newest-first when available.
std::string error; // Blocking error message. Non-empty means the plugin is in an error state.

View File

@@ -1,5 +1,7 @@
#include "PluginHostApi.hpp"
#include "PluginHostUi.hpp"
#include "PluginHostSlicing.hpp"
#include "PluginBindingUtils.hpp"
#include <libslic3r/BoundingBox.hpp>
#include <libslic3r/Model.hpp>
@@ -46,20 +48,6 @@ PresetBundle* current_preset_bundle()
return preset_bundle;
}
py::object config_value_or_none(const DynamicPrintConfig& config, const std::string& key)
{
if (!config.has(key))
return py::none();
return py::cast(config.opt_serialize(key));
}
// Plugins receive 3D vectors as plain Python tuples (x, y, z) so the API stays
// Pythonic and free of an Eigen/numpy runtime dependency.
py::tuple vec3_to_tuple(const Vec3d& v)
{
return py::make_tuple(v.x(), v.y(), v.z());
}
// Build a BoundingBoxf3 from precomputed (float) triangle-mesh stats min/max.
BoundingBoxf3 bbox_from_stats(const TriangleMeshStats& stats)
{
@@ -86,59 +74,20 @@ struct HostTriangleMesh
const indexed_triangle_set& its() const { return mesh->its; }
};
// Run a builder that constructs numpy objects, translating the "numpy missing"
// ImportError into an actionable message (plugins must declare numpy as a dep).
template<typename Builder>
py::object with_numpy(Builder&& build)
{
try {
return std::forward<Builder>(build)();
} catch (py::error_already_set& err) {
if (err.matches(PyExc_ImportError))
throw py::import_error("numpy is required to access mesh arrays/matrices; "
"add dependencies = [\"numpy\"] to your plugin metadata");
throw;
}
}
// Read-only, zero-copy (rows, 3) numpy view over a packed T[rows][3] buffer.
// The array owns a capsule that pins `mesh` alive for the view's lifetime.
// The array's base is a capsule owning a strong ref to `mesh`, so the view
// stays valid even if the volume's mesh is later replaced on the main thread.
template<typename T>
py::array make_readonly_rows3(const std::shared_ptr<const TriangleMesh>& mesh,
const T* data, py::ssize_t rows)
{
if (rows == 0 || data == nullptr)
return py::array_t<T>(std::vector<py::ssize_t>{0, 3});
return py::array_t<T>(std::vector<py::ssize_t>{ 0, 3 });
auto* owner = new std::shared_ptr<const TriangleMesh>(mesh);
py::capsule base(owner, [](void* p) {
delete reinterpret_cast<std::shared_ptr<const TriangleMesh>*>(p);
});
py::array_t<T> array(
{ rows, py::ssize_t(3) },
{ py::ssize_t(3 * sizeof(T)), py::ssize_t(sizeof(T)) },
data,
base);
// A capsule-based array is writable by default in pybind11; the underlying
// mesh is const, so force the view read-only.
array.attr("setflags")(py::arg("write") = false);
return array;
}
// 4x4 row-major float64 copy of an affine transform. Eigen stores column-major,
// so fill element-wise to produce correct C-order data.
py::object mat4_to_numpy(const Transform3d& transform)
{
return with_numpy([&] {
py::array_t<double> array({ py::ssize_t(4), py::ssize_t(4) });
auto view = array.mutable_unchecked<2>();
const auto& matrix = transform.matrix();
for (int i = 0; i < 4; ++i)
for (int j = 0; j < 4; ++j)
view(i, j) = matrix(i, j);
return py::object(std::move(array));
});
return make_readonly_rows<T, 3>(base, data, rows);
}
py::list current_filament_presets(PresetBundle& bundle)
@@ -530,6 +479,9 @@ void PluginHostApi::RegisterBindings(pybind11::module_& module)
// UI: native dialogs and interactive HTML windows for plugins.
PluginHostUi::RegisterBindings(host);
// Slicing print-graph data model (Print, Layer, Surface, ...).
PluginHostSlicing::RegisterBindings(host);
}
} // namespace Slic3r

View File

@@ -0,0 +1,532 @@
#include "PluginHostSlicing.hpp"
#include "PluginBindingUtils.hpp"
#include "libslic3r/libslic3r.h" // unscale<>, scale_
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp" // offset/offset_ex/union_ex/diff_ex/intersection_ex
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Surface.hpp"
#include "libslic3r/SurfaceCollection.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include "libslic3r/Layer.hpp" // LayerRegion, Layer, SupportLayer
#include "libslic3r/Print.hpp" // PrintRegion, PrintObject, Print
#include <pybind11/stl.h>
#include <memory>
#include <vector>
namespace py = pybind11;
namespace Slic3r {
namespace {
// --- Input path: Python geometry -> C++ ExPolygon/Surface, with validation. ---------------
// The mutators take scaled integer coords (the same units the read views hand out). A Python
// raise here surfaces as ValueError (pybind translates) so malformed input is rejected up
// front rather than silently corrupting the slicing graph.
// One (N,2) int64 ndarray -> Polygon. Rejects wrong dtype/shape and degenerate (<3 pt) rings.
// Float / NaN / inf are rejected implicitly: only a signed-integer, 8-byte (coord_t==int64)
// dtype is accepted, and integer arrays cannot hold NaN/inf.
static Polygon parse_polygon(py::handle h, const char* who)
{
if (!py::isinstance<py::array>(h))
throw py::value_error(std::string(who) + ": each contour/hole must be an (N,2) int64 ndarray");
py::array a = py::reinterpret_borrow<py::array>(h);
if (a.dtype().kind() != 'i' || a.itemsize() != (py::ssize_t) sizeof(coord_t))
throw py::value_error(std::string(who) + ": polygon coordinates must be int64 (scaled coords)");
if (a.ndim() != 2 || a.shape(1) != 2)
throw py::value_error(std::string(who) + ": each polygon array must have shape (N,2)");
if (a.shape(0) < 3)
throw py::value_error(std::string(who) + ": a polygon needs at least 3 points");
// dtype already validated as int64; forcecast here only guarantees a C-contiguous buffer.
auto arr = py::array_t<coord_t, py::array::c_style | py::array::forcecast>::ensure(a);
if (!arr)
throw py::value_error(std::string(who) + ": could not read polygon as a contiguous int64 array");
auto r = arr.unchecked<2>();
Polygon poly;
poly.points.reserve((size_t) arr.shape(0));
for (py::ssize_t i = 0; i < arr.shape(0); ++i)
poly.points.emplace_back((coord_t) r(i, 0), (coord_t) r(i, 1));
return poly;
}
// Accept a bound orca.host.Polygon (copied) or an (N,2) int64 ndarray. Used by the ExPolygon
// binding, whose constructor/contour-setter/set_holes must accept the Polygon it itself hands
// out (e.g. `ExPolygon(some_polygon_ref)`) in addition to the ndarray-only parse_polygon() path.
static Polygon as_polygon(py::handle h, const char* who)
{
if (py::isinstance<Polygon>(h))
return h.cast<Polygon>();
return parse_polygon(h, who);
}
// Flatten an extrusion graph into a list of leaf ExtrusionPath* while walking the
// ORIGINAL Print-owned tree (never a temporary copy): the returned pointers stay
// valid for the execute(ctx) lifetime pinned by `owner`, so points() can hand out
// zero-copy views into path->polyline.points.
//
// This is deliberately NOT ExtrusionEntityCollection::flatten(): flatten() only
// unwraps nested collections (is_collection() is true solely for collections) and
// returns them by value, so it would (a) dangle if we viewed into the copy and
// (b) leave ExtrusionLoop/ExtrusionMultiPath intact — dropping every perimeter
// loop, since dynamic_cast<ExtrusionPath*> fails on a loop. We descend into
// loops/multipaths here to reach their contained paths.
static void collect_extrusion_paths(const ExtrusionEntity* ee, std::vector<const ExtrusionPath*>& out)
{
if (ee == nullptr)
return;
if (const auto* coll = dynamic_cast<const ExtrusionEntityCollection*>(ee)) {
for (const ExtrusionEntity* child : coll->entities)
collect_extrusion_paths(child, out);
} else if (const auto* loop = dynamic_cast<const ExtrusionLoop*>(ee)) {
for (const ExtrusionPath& p : loop->paths)
out.push_back(&p);
} else if (const auto* mp = dynamic_cast<const ExtrusionMultiPath*>(ee)) {
for (const ExtrusionPath& p : mp->paths)
out.push_back(&p);
} else if (const auto* path = dynamic_cast<const ExtrusionPath*>(ee)) {
// Catches ExtrusionPath and its subclasses (Sloped/Contoured/Oriented) last,
// after the composite types above have been ruled out.
out.push_back(path);
}
}
// Rebuild a layer's per-island bbox cache from lslices — the same inline pattern
// every C++ call site uses (PrintObjectSlice.cpp, Print.cpp, TreeSupport.cpp); no
// libslic3r helper exists to reuse.
static void refresh_lslices_bboxes(Layer& l)
{
l.lslices_bboxes.clear();
l.lslices_bboxes.reserve(l.lslices.size());
for (const ExPolygon& island : l.lslices)
l.lslices_bboxes.emplace_back(get_extents(island));
}
} // namespace
void PluginHostSlicing::RegisterBindings(py::module_& host)
{
// ------------------------------------------------------------------
// Slicing print-graph data model — raw bindings of the classes the C++
// pipeline itself uses, same nodelete/reference style as the Model and
// Preset graphs above.
//
// LIFETIME (C++ semantics, the one rule of this API): every object handed
// out below is a non-owning reference into the live slicing graph owned by
// the Print. References — and every numpy view they hand out — are valid
// only while the plugin hook (execute(ctx)) runs, and a container-replacing
// mutator (SurfaceCollection.set / append / clear, Polygon.set_points / append,
// ExPolygon.set_holes) invalidates previously obtained references into that
// container, exactly as std::vector operations invalidate C++ iterators. Do
// not stash references or arrays across execute() calls; copy what you need.
// ------------------------------------------------------------------
py::enum_<SurfaceType>(host, "SurfaceType")
.value("stTop", stTop)
.value("stBottom", stBottom)
.value("stBottomBridge", stBottomBridge)
.value("stInternalAfterExternalBridge", stInternalAfterExternalBridge)
.value("stInternal", stInternal)
.value("stInternalSolid", stInternalSolid)
.value("stInternalBridge", stInternalBridge)
.value("stSecondInternalBridge", stSecondInternalBridge)
.value("stInternalVoid", stInternalVoid)
.value("stPerimeter", stPerimeter)
.value("stCount", stCount)
.export_values();
// Point: a constructible value type (default holder, so Python-owned instances
// are freed). Returned-by-reference from Polygon.points, it aliases the buffer;
// x()/y() are Eigen lvalues, so the properties are read/write. p+q / p-q go
// through Eigen expression templates, wrapped back into a Point.
py::class_<Point>(host, "Point")
.def(py::init([](coord_t x, coord_t y) { return Point(x, y); }), py::arg("x"), py::arg("y"))
.def_property("x", [](const Point& p) { return p.x(); },
[](Point& p, coord_t v) { p.x() = v; })
.def_property("y", [](const Point& p) { return p.y(); },
[](Point& p, coord_t v) { p.y() = v; })
.def("__add__", [](const Point& a, const Point& b) { return Point(a + b); }, py::is_operator())
.def("__sub__", [](const Point& a, const Point& b) { return Point(a - b); }, py::is_operator())
.def("__mul__", [](const Point& a, double s) { return Point(a.x() * s, a.y() * s); }, py::is_operator())
.def("__repr__", [](const Point& p) {
return "orca.host.Point(" + std::to_string(p.x()) + ", " + std::to_string(p.y()) + ")";
});
py::class_<Polygon>(host, "Polygon")
.def(py::init<>())
.def("size", [](const Polygon& p) { return p.points.size(); })
.def("is_valid", [](const Polygon& p) { return p.is_valid(); })
.def("is_counter_clockwise", [](const Polygon& p) { return p.is_counter_clockwise(); })
.def("is_clockwise", [](const Polygon& p) { return p.is_clockwise(); })
.def("make_counter_clockwise", [](Polygon& p) { return p.make_counter_clockwise(); },
"Reorient to CCW in place. Returns True if it reversed the winding.")
.def("make_clockwise", [](Polygon& p) { return p.make_clockwise(); })
.def("area", [](const Polygon& p) { return p.area(); })
.def("centroid", [](const Polygon& p) { return p.centroid(); })
.def("contains", [](const Polygon& p, const Point& pt) { return p.contains(pt); }, py::arg("point"))
.def("translate", [](Polygon& p, double x, double y) { p.translate(x, y); }, py::arg("x"), py::arg("y"))
.def("rotate", [](Polygon& p, double angle) { p.rotate(angle); }, py::arg("angle"))
.def("rotate", [](Polygon& p, double angle, const Point& c) { p.rotate(angle, c); },
py::arg("angle"), py::arg("center"))
.def("douglas_peucker", [](Polygon& p, double tol) { p.douglas_peucker(tol); }, py::arg("tolerance"))
.def("simplify", [](const Polygon& p, double tol) { return p.simplify(tol); }, py::arg("tolerance"),
"Return simplified geometry as a list of Polygon (may split into several).")
.def("offset", [](const Polygon& p, coord_t delta) { return offset(p, (float) delta); }, py::arg("delta"),
"Clipper offset by `delta` scaled units (negative shrinks). Returns [Polygon].")
// --- Point-object idiom: references into the buffer (in-place element edit). ---
.def_property_readonly("points", [](py::object self) {
Polygon& p = self.cast<Polygon&>();
py::list out;
for (Point& pt : p.points)
out.append(py::cast(&pt, py::return_value_policy::reference_internal, self));
return out;
}, "Vertices as [Point] references into this polygon. Editing a Point mutates the "
"buffer in place. Structural changes (count) go through set_points/append, which "
"invalidate previously returned Point refs and array views (C++ vector semantics).")
.def("append", [](Polygon& p, const Point& pt) { p.points.push_back(pt); }, py::arg("point"),
"Append a vertex. Structural change (count): invalidates previously returned "
"Point refs and array views into this polygon (C++ vector semantics).")
// --- numpy idiom: writable zero-copy (N,2) view (bulk affine edits). ---
.def("as_array", [](py::object self) {
Polygon& p = self.cast<Polygon&>();
return with_numpy([&] {
return py::object(make_writable_rows<coord_t, 2>(
self, p.points.empty() ? nullptr : p.points.front().data(),
(py::ssize_t) p.points.size()));
});
}, "Vertices as a WRITABLE int64 (N,2) numpy view in scaled coords, aliasing the "
"buffer. Count-preserving in-place edits only; valid during execute(ctx). Requires numpy.")
.def("set_points", [](Polygon& p, py::handle src) { p = parse_polygon(src, "Polygon.set_points"); },
py::arg("points"),
"Replace all vertices from an (N,2) int64 ndarray (scaled coords). Count-changing; "
"invalidates prior Point refs and array views. Raises ValueError on malformed input.");
// ExPolygon: default holder (Python-owned instances are freed) so plugins can construct
// their own geometry, not just navigate the live slicing graph. contour/holes accessors
// still use reference_internal, so refs into a graph-owned ExPolygon stay non-owning views
// tied to that owner's lifetime, same as Polygon/Surface above.
py::class_<ExPolygon>(host, "ExPolygon")
.def(py::init([](py::handle contour, py::handle holes) {
// Accept bound Polygons or (N,2) ndarrays for both contour and each hole.
ExPolygon ex;
ex.contour = as_polygon(contour, "ExPolygon.contour");
if (!holes.is_none()) {
if (!py::isinstance<py::sequence>(holes) || py::isinstance<py::str>(holes))
throw py::value_error("ExPolygon: holes must be a list of Polygon or (N,2) ndarrays");
for (py::handle h : py::reinterpret_borrow<py::sequence>(holes)) {
Polygon hole = as_polygon(h, "ExPolygon.hole");
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
ex.contour.make_counter_clockwise();
return ex;
}), py::arg("contour"), py::arg("holes") = py::none(),
"Construct from a Polygon/ndarray contour and optional list of hole Polygons/ndarrays. "
"Orientation is normalized (contour CCW, holes CW).")
.def_property("contour",
[](ExPolygon& e) -> Polygon& { return e.contour; },
[](ExPolygon& e, py::handle v) { e.contour = as_polygon(v, "ExPolygon.contour"); },
py::return_value_policy::reference_internal,
"Outer contour (CCW). Read returns a live Polygon ref; assign a Polygon/ndarray to replace it.")
.def_property_readonly("holes", [](py::object self) {
ExPolygon& e = self.cast<ExPolygon&>();
py::list out;
for (Polygon& h : e.holes)
out.append(py::cast(&h, py::return_value_policy::reference_internal, self));
return out;
}, "Hole contours (CW) as [Polygon] references (in-place editable). set_holes replaces them.")
.def("set_holes", [](ExPolygon& e, py::handle holes) {
ExPolygon tmp;
if (!py::isinstance<py::sequence>(holes) || py::isinstance<py::str>(holes))
throw py::value_error("set_holes: expected a list of Polygon or (N,2) ndarrays");
for (py::handle h : py::reinterpret_borrow<py::sequence>(holes)) {
Polygon hole = as_polygon(h, "ExPolygon.set_holes");
hole.make_clockwise();
tmp.holes.emplace_back(std::move(hole));
}
e.holes = std::move(tmp.holes);
}, py::arg("holes"), "Replace all holes. Invalidates prior hole refs (C++ vector semantics).")
.def("translate", [](ExPolygon& e, double x, double y) { e.translate(x, y); }, py::arg("x"), py::arg("y"))
.def("rotate", [](ExPolygon& e, double a) { e.rotate(a); }, py::arg("angle"))
.def("rotate", [](ExPolygon& e, double a, const Point& c) { e.rotate(a, c); },
py::arg("angle"), py::arg("center"))
.def("scale", [](ExPolygon& e, double f) { e.scale(f); }, py::arg("factor"))
.def("douglas_peucker", [](ExPolygon& e, double t) { e.douglas_peucker(t); }, py::arg("tolerance"))
.def("area", [](const ExPolygon& e) { return e.area(); })
.def("is_valid", [](const ExPolygon& e) { return e.is_valid(); })
.def("contains", [](const ExPolygon& e, const Point& p) { return e.contains(p); }, py::arg("point"))
.def("num_contours", [](const ExPolygon& e) { return e.num_contours(); })
.def("simplify", [](const ExPolygon& e, double t) { return e.simplify(t); }, py::arg("tolerance"),
"Return simplified geometry as [ExPolygon].")
.def("offset", [](const ExPolygon& e, coord_t delta) { return offset_ex(e, (float) delta); },
py::arg("delta"), "Clipper offset by `delta` scaled units (negative shrinks). Returns [ExPolygon].")
.def("union_ex", [](const ExPolygon& a, const ExPolygon& b) {
return union_ex(ExPolygons{ a, b });
}, py::arg("other"), "Union with another ExPolygon. Returns [ExPolygon].")
.def("diff_ex", [](const ExPolygon& a, const ExPolygon& b) {
return diff_ex(ExPolygons{ a }, ExPolygons{ b });
}, py::arg("other"), "This minus `other`. Returns [ExPolygon].")
.def("intersection_ex", [](const ExPolygon& a, const ExPolygon& b) {
return intersection_ex(ExPolygons{ a }, ExPolygons{ b });
}, py::arg("other"), "Intersection with `other`. Returns [ExPolygon].");
// Surface: default holder (Python-owned instances are freed), so plugins can construct
// their own Surface(surface_type, expolygon) — not just navigate the live slicing graph.
// expolygon is a reference_internal property, same idiom as Polygon/ExPolygon above.
py::class_<Surface>(host, "Surface")
.def(py::init([](SurfaceType t, const ExPolygon& e) { return Surface(t, e); }),
py::arg("surface_type"), py::arg("expolygon"))
.def(py::init([](SurfaceType t) { return Surface(t); }), py::arg("surface_type"))
.def_readwrite("surface_type", &Surface::surface_type,
"This surface's SurfaceType. Assigning reclassifies it in place (geometry unchanged).")
.def_readwrite("thickness", &Surface::thickness)
.def_readwrite("bridge_angle", &Surface::bridge_angle)
.def_readwrite("extra_perimeters", &Surface::extra_perimeters)
.def_property("expolygon",
[](Surface& s) -> ExPolygon& { return s.expolygon; },
[](Surface& s, const ExPolygon& e) { s.expolygon = e; },
py::return_value_policy::reference_internal,
"This surface's geometry. Read returns a live ExPolygon ref; assign to replace it.")
.def("area", [](const Surface& s) { return s.area(); })
.def("is_top", [](const Surface& s) { return s.is_top(); })
.def("is_bottom", [](const Surface& s) { return s.is_bottom(); })
.def("is_bridge", [](const Surface& s) { return s.is_bridge(); })
.def("is_internal", [](const Surface& s) { return s.is_internal(); })
.def("is_external", [](const Surface& s) { return s.is_external(); })
.def("is_solid", [](const Surface& s) { return s.is_solid(); });
// SurfaceCollection: kept on py::nodelete — it is only ever a reference into the live
// slicing graph (LayerRegion::slices/fill_surfaces), never constructed by a plugin.
py::class_<SurfaceCollection, std::unique_ptr<SurfaceCollection, py::nodelete>>(host, "SurfaceCollection")
.def("size", [](const SurfaceCollection& c) { return c.surfaces.size(); })
.def("empty", [](const SurfaceCollection& c) { return c.empty(); })
.def("clear", [](SurfaceCollection& c) { c.clear(); })
.def("has", [](const SurfaceCollection& c, SurfaceType t) { return c.has(t); }, py::arg("surface_type"))
.def("set_type", [](SurfaceCollection& c, SurfaceType t) { c.set_type(t); }, py::arg("surface_type"))
.def("set", [](SurfaceCollection& c, const std::vector<ExPolygon>& src, SurfaceType t) { c.set(src, t); },
py::arg("expolygons"), py::arg("surface_type"),
"Replace all surfaces from a list of ExPolygon, all tagged `surface_type`. "
"This is the faithful replacement for the retired set_slices().")
.def("set", [](SurfaceCollection& c, const std::vector<Surface>& src) { c.set(src); },
py::arg("surfaces"), "Replace all surfaces from a list of Surface (types preserved per surface).")
.def("append", [](SurfaceCollection& c, const std::vector<ExPolygon>& src, SurfaceType t) { c.append(src, t); },
py::arg("expolygons"), py::arg("surface_type"))
.def("filter_by_type", [](py::object self, SurfaceType t) {
SurfaceCollection& c = self.cast<SurfaceCollection&>();
py::list out;
// SurfacesPtr (SurfaceCollection::filter_by_type's return type) is
// std::vector<const Surface*> (see Surface.hpp); the brief's note describing it
// as std::vector<Surface*> does not match the header, so this iterates by const
// pointer (py::cast accepts `const itype*` directly, see cast.h cast(const itype*)).
for (const Surface* s : c.filter_by_type(t))
out.append(py::cast(s, py::return_value_policy::reference_internal, self));
return out;
}, py::arg("surface_type"), "Surfaces of a given type as [Surface] refs. Invalidated by "
"set()/append()/clear() on this collection (C++ vector semantics), same as .surfaces.")
.def_property_readonly("surfaces", [](py::object self) {
SurfaceCollection& c = self.cast<SurfaceCollection&>();
py::list out;
for (Surface& s : c.surfaces)
out.append(py::cast(&s, py::return_value_policy::reference_internal, self));
return out;
}, "Surfaces as [Surface] references into the live collection. Invalidated by "
"set()/append()/clear() on this collection (C++ vector semantics).");
// --- Extrusion tree (read-only in v1). Registered polymorphically: when a returned
// ExtrusionEntity*'s dynamic type IS one of the classes registered below, pybind
// hands the plugin that concrete type, so plugins walk the same tree shape C++ does.
// When the dynamic type is NOT registered (e.g. ExtrusionLoopSloped, produced with
// scarf seams), pybind falls back to the STATIC type at the cast site -- so such a
// `.entities` child surfaces as a bare ExtrusionEntity (only .role is available).
// flatten_paths() (a dynamic_cast walk) still yields proper ExtrusionPath leaves and
// is the robust way to extract toolpaths.
py::class_<ExtrusionEntity, std::unique_ptr<ExtrusionEntity, py::nodelete>>(host, "ExtrusionEntity")
.def_property_readonly("role", [](const ExtrusionEntity& e) {
return ExtrusionEntity::role_to_string(e.role());
}, "Extrusion role as a human-readable string (e.g. \"Outer wall\", \"Sparse infill\").");
py::class_<ExtrusionPath, ExtrusionEntity, std::unique_ptr<ExtrusionPath, py::nodelete>>(host, "ExtrusionPath")
.def("points", [](py::object self) {
const ExtrusionPath& p = self.cast<const ExtrusionPath&>();
const Points3& pts = p.polyline.points;
return with_numpy([&] {
return py::object(make_readonly_rows<coord_t, 3>(
self, pts.empty() ? nullptr : pts.front().data(), (py::ssize_t) pts.size()));
});
}, "Path vertices as a read-only int64 (N,3) numpy view in scaled coords "
"(the polyline is natively 3D on this branch). Requires numpy.")
.def_readonly("width", &ExtrusionPath::width)
.def_readonly("height", &ExtrusionPath::height)
.def_readonly("mm3_per_mm", &ExtrusionPath::mm3_per_mm);
py::class_<ExtrusionLoop, ExtrusionEntity, std::unique_ptr<ExtrusionLoop, py::nodelete>>(host, "ExtrusionLoop")
.def_property_readonly("paths", [](py::object self) {
ExtrusionLoop& l = self.cast<ExtrusionLoop&>();
py::list out;
for (ExtrusionPath& p : l.paths)
out.append(py::cast(&p, py::return_value_policy::reference_internal, self));
return out;
}, "The loop's constituent paths as [ExtrusionPath].");
py::class_<ExtrusionMultiPath, ExtrusionEntity, std::unique_ptr<ExtrusionMultiPath, py::nodelete>>(host, "ExtrusionMultiPath")
.def_property_readonly("paths", [](py::object self) {
ExtrusionMultiPath& m = self.cast<ExtrusionMultiPath&>();
py::list out;
for (ExtrusionPath& p : m.paths)
out.append(py::cast(&p, py::return_value_policy::reference_internal, self));
return out;
}, "The multipath's constituent paths as [ExtrusionPath].");
py::class_<ExtrusionEntityCollection, ExtrusionEntity,
std::unique_ptr<ExtrusionEntityCollection, py::nodelete>>(host, "ExtrusionEntityCollection")
.def("size", [](const ExtrusionEntityCollection& c) { return c.entities.size(); })
.def_property_readonly("entities", [](py::object self) {
ExtrusionEntityCollection& c = self.cast<ExtrusionEntityCollection&>();
py::list out;
for (ExtrusionEntity* e : c.entities)
out.append(py::cast(e, py::return_value_policy::reference_internal, self));
return out;
}, "Child entities. Each is handed to you as its concrete type only when that type "
"is registered; a child whose concrete type is unregistered (e.g. a scarf-seam "
"ExtrusionLoopSloped) surfaces as a bare ExtrusionEntity exposing only .role. Use "
"flatten_paths() to robustly reach every ExtrusionPath leaf.")
.def("flatten_paths", [](py::object self) {
const ExtrusionEntityCollection& c = self.cast<const ExtrusionEntityCollection&>();
std::vector<const ExtrusionPath*> paths;
collect_extrusion_paths(&c, paths);
py::list out;
for (const ExtrusionPath* p : paths)
out.append(py::cast(const_cast<ExtrusionPath*>(p),
py::return_value_policy::reference_internal, self));
return out;
}, "Every leaf ExtrusionPath under this tree (collections recursed into, "
"loops/multipaths decomposed).");
py::class_<PrintRegion, std::unique_ptr<PrintRegion, py::nodelete>>(host, "PrintRegion")
.def("config_keys", [](const PrintRegion& r) { return r.config().keys(); })
.def("config_value", [](const PrintRegion& r, const std::string& key) {
return config_value_or_none(r.config(), key);
}, py::arg("key"),
"Serialized value of this region's resolved config option, or None if absent.");
auto layer_region = py::class_<LayerRegion, std::unique_ptr<LayerRegion, py::nodelete>>(host, "LayerRegion");
layer_region
.def_readonly("slices", &LayerRegion::slices,
"Sliced, typed surfaces (SurfaceCollection). Edit in place, or replace with "
"slices.set(expolygons, surface_type). At Step.posSlice this is the primary mutation "
"target; the split slice loop runs make_perimeters() afterward so edits cascade downstream.")
.def_readonly("fill_surfaces", &LayerRegion::fill_surfaces,
"Surfaces prepared for infill (SurfaceCollection). Edit in place or via fill_surfaces.set(...).")
.def_readonly("perimeters", &LayerRegion::perimeters,
"Perimeter toolpaths (ExtrusionEntityCollection, read-only in v1).")
.def_readonly("fills", &LayerRegion::fills,
"Infill toolpaths (ExtrusionEntityCollection, read-only in v1).")
.def("layer", [](LayerRegion& r) -> py::object {
Layer* l = r.layer();
if (l == nullptr)
return py::none();
return py::cast(l, py::return_value_policy::reference);
}, "Owning Layer, or None.")
.def("region", [](LayerRegion& r) -> const PrintRegion& { return r.region(); },
py::return_value_policy::reference,
"This region's PrintRegion (resolved per-region settings).")
.def("config_value", [](const LayerRegion& r, const std::string& key) {
return config_value_or_none(r.region().config(), key);
}, py::arg("key"),
"Serialized value of this region's resolved config option, or None if absent.");
auto layer = py::class_<Layer, std::unique_ptr<Layer, py::nodelete>>(host, "Layer");
layer
.def_readonly("print_z", &Layer::print_z)
.def_readonly("slice_z", &Layer::slice_z)
.def_readonly("height", &Layer::height)
.def_property_readonly("upper_layer", [](Layer& l) -> py::object {
if (l.upper_layer == nullptr) return py::none();
return py::cast(l.upper_layer, py::return_value_policy::reference);
}, "The layer above, or None (graph navigation, like C++).")
.def_property_readonly("lower_layer", [](Layer& l) -> py::object {
if (l.lower_layer == nullptr) return py::none();
return py::cast(l.lower_layer, py::return_value_policy::reference);
}, "The layer below, or None.")
.def("regions", [](py::object self) {
Layer& l = self.cast<Layer&>();
py::list out;
for (LayerRegion* r : l.regions())
out.append(py::cast(r, py::return_value_policy::reference_internal, self));
return out;
}, "Per-region data as [LayerRegion].")
.def("make_slices", [](Layer& l) {
l.make_slices();
refresh_lslices_bboxes(l);
}, "Re-derive lslices (merged islands) from the region slices and refresh the bbox "
"cache — the C++ invariant-maintenance call after in-place slice edits.")
.def("lslices", [](py::object self) {
Layer& l = self.cast<Layer&>();
py::list out;
for (ExPolygon& e : l.lslices)
out.append(py::cast(&e, py::return_value_policy::reference_internal, self));
return out;
}, "Merged per-layer islands as [ExPolygon] refs (in-place editable). Derived from the "
"region slices; call make_slices() to re-derive after edits. Invalidated by make_slices().");
py::class_<PrintObject, std::unique_ptr<PrintObject, py::nodelete>>(host, "PrintObject")
.def("id", [](const PrintObject& o) { return o.id().id; },
"Stable numeric object id (ObjectBase::id()).")
.def("layers", [](py::object self) {
PrintObject& o = self.cast<PrintObject&>();
py::list out;
for (Layer* l : o.layers())
out.append(py::cast(l, py::return_value_policy::reference_internal, self));
return out;
}, "Object layers, bottom-up, as [Layer].")
.def("support_layers", [](py::object self) {
PrintObject& o = self.cast<PrintObject&>();
py::list out;
for (SupportLayer* sl : o.support_layers())
out.append(py::cast(static_cast<Layer*>(sl),
py::return_value_policy::reference_internal, self));
return out;
}, "Support layers as [Layer] (support-specific fields are not exposed in v1).")
.def("model_object", [](PrintObject& o) -> py::object {
// The Print's model SNAPSHOT (worker-thread stable), reusing the
// orca.host.ModelObject bindings — mesh access for slicing plugins.
// o is non-const here, so model_object() already returns a non-const ModelObject*.
return py::cast(o.model_object(), py::return_value_policy::reference);
}, "The source orca.host.ModelObject from the Print's own model snapshot.")
.def("bounding_box", [](const PrintObject& o) {
const BoundingBox bb = o.bounding_box();
return py::make_tuple(bb.min.x(), bb.min.y(), bb.max.x(), bb.max.y());
}, "Object XY bounding box in scaled coords as (min_x, min_y, max_x, max_y). The "
"sliced polygons live in this same frame, so its midpoint is the footprint center.")
.def("trafo", [](const PrintObject& o) { return mat4_to_numpy(o.trafo()); },
"Object-to-print 4x4 float64 affine matrix (copy). Requires numpy.")
.def("config_keys", [](const PrintObject& o) { return o.config().keys(); })
.def("config_value", [](const PrintObject& o, const std::string& key) {
return config_value_or_none(o.config(), key);
}, py::arg("key"),
"Serialized value of a resolved per-object config option, or None if absent.");
py::class_<Print, std::unique_ptr<Print, py::nodelete>>(host, "Print")
.def("objects", [](py::object self) {
Print& p = self.cast<Print&>();
py::list out;
for (PrintObject* o : p.objects())
out.append(py::cast(o, py::return_value_policy::reference_internal, self));
return out;
}, "The print's objects as [PrintObject].")
.def("model", [](Print& p) -> Model& { return const_cast<Model&>(p.model()); },
py::return_value_policy::reference_internal,
"The Print's own Model snapshot (worker-thread stable). Inside slicing "
"hooks use THIS — never orca.host.model(), which is the live GUI model "
"owned by another thread.")
.def("config_keys", [](const Print& p) { return p.full_print_config().keys(); })
.def("config_value", [](const Print& p, const std::string& key) {
return config_value_or_none(p.full_print_config(), key);
}, py::arg("key"),
"Serialized value of the resolved (full) print config for this slice, or None.")
.def("canceled", [](const Print& p) { return p.canceled(); },
"True once cancellation was requested (prefer ctx.cancelled()).");
}
} // namespace Slic3r

View File

@@ -0,0 +1,16 @@
#pragma once
#include <pybind11/pybind11.h>
namespace Slic3r {
// Registers the slicing print-graph data model (Print, PrintObject, Layer,
// LayerRegion, Surface, ExPolygon, extrusions, ...) into the `orca.host`
// submodule, in the same raw-class style as PluginHostApi's Model/Preset
// graph. Called from PluginHostApi::RegisterBindings.
class PluginHostSlicing
{
public:
static void RegisterBindings(pybind11::module_& host);
};
} // namespace Slic3r

View File

@@ -261,6 +261,13 @@ std::shared_ptr<LoadedPluginCapability> PluginLoader::get_plugin_capability_by_n
return nullptr;
}
std::map<std::string, std::string> PluginLoader::get_plugin_settings(const std::string& plugin_key) const
{
std::lock_guard<std::mutex> lock(m_mutex);
const auto it = m_plugins.find(plugin_key);
return it != m_plugins.end() ? it->second.descriptor.settings : std::map<std::string, std::string>{};
}
std::vector<std::shared_ptr<LoadedPluginCapability>> PluginLoader::get_loaded_plugin_capabilities(const std::string& plugin_key) const
{
std::lock_guard<std::mutex> lock(m_mutex);
@@ -603,7 +610,6 @@ bool PluginLoader::unload_plugin(const std::string& plugin_key, PluginCapability
if (!torn_down_types.insert(cap_type).second)
continue;
switch (cap_type) {
case PluginCapabilityType::PostProcessing: break;
case PluginCapabilityType::PrinterConnection: NetworkAgentFactory::deregister_python_plugin(plugin_key); break;
default: break;
}

View File

@@ -104,6 +104,8 @@ public:
std::chrono::milliseconds timeout,
std::string& error) const;
std::vector<PluginDescriptor> get_all_loaded_plugin_descriptors() const;
// G5: the plugin's [tool.orcaslicer.plugin.settings] table (empty if the plugin is unknown).
std::map<std::string, std::string> get_plugin_settings(const std::string& plugin_key) const;
// Package descriptor accessor; returns nullptr when the package is not loaded.

View File

@@ -102,10 +102,14 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
{
PluginManager& plugin_mgr = PluginManager::instance();
// Log prefix derived from the capability type so each capability family (Post-processing,
// Slicing Pipeline, ...) tags its dispatch diagnostics with its own display name.
const std::string tag = plugin_capability_type_display_name(type);
const bool has_any = std::any_of(capabilities.values.begin(), capabilities.values.end(),
[](const std::string& s) { return !s.empty(); });
if (has_any && !plugin_mgr.get_loader().wait_for_all_plugin_loads(std::chrono::seconds(10))) {
BOOST_LOG_TRIVIAL(warning) << "Post-process: timed out waiting for plugin loads; unresolved capabilities will be skipped";
BOOST_LOG_TRIVIAL(warning) << tag << ": timed out waiting for plugin loads; unresolved capabilities will be skipped";
}
for (const std::string& capability : capabilities.values) {
@@ -127,7 +131,7 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
}
if (!ref) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: no plugin reference found for capability '" << capability << "'; skipping";
BOOST_LOG_TRIVIAL(warning) << tag << ": no plugin reference found for capability '" << capability << "'; skipping";
continue;
}
@@ -136,19 +140,19 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
cap = plugin_mgr.get_loader().get_plugin_capability_by_name(plugin_key, type, cap_name);
if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: no loaded capability '" << cap_name
BOOST_LOG_TRIVIAL(warning) << tag << ": no loaded capability '" << cap_name
<< "' for plugin '" << plugin_key << "'; skipping";
continue;
}
if (!cap->enabled) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: capability '" << cap_name
BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name
<< "' for plugin '" << plugin_key << "' is disabled; skipping";
continue;
}
auto plugin_capability = std::dynamic_pointer_cast<T>(cap->instance);
if (!plugin_capability) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: capability '" << cap_name
BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name
<< "' (plugin_key=" << cap->plugin_key
<< ") is not a " << plugin_capability_type_to_string(type) << "; skipping";
continue;

View File

@@ -35,9 +35,9 @@ std::string find_option_for_capability(Preset::Type type, const Preset& preset,
if (type != Preset::TYPE_PRINT && type != Preset::TYPE_PRINTER && type != Preset::TYPE_FILAMENT)
return {};
// Plugin-bearing options opt in via ConfigOptionDef::support_plugin, so scan the preset's
// definition rather than maintaining a hardcoded per-type field list. A typed preset's config
// only contains keys for its own type, so this naturally stays scoped to `type`.
// Plugin-bearing options opt in via ConfigOptionDef::is_plugin_backed (a non-empty plugin_type),
// so scan the preset's definition rather than maintaining a hardcoded per-type field list. A typed
// preset's config only contains keys for its own type, so this naturally stays scoped to `type`.
const ConfigDef* def = preset.config.def();
if (def == nullptr)
return {};
@@ -48,7 +48,7 @@ std::string find_option_for_capability(Preset::Type type, const Preset& preset,
for (const std::string& field : preset.config.keys()) {
const ConfigOptionDef* opt_def = def->get(field);
if (opt_def == nullptr || !opt_def->support_plugin)
if (opt_def == nullptr || !opt_def->is_plugin_backed())
continue;
const ConfigOption* option = preset.config.option(field);

View File

@@ -128,7 +128,7 @@ bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::stri
}
// TOML section parsing states.
enum class TomlSection { Root, OrcaPlugin, InDepsArray };
enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray };
// Strip a quoted string value: "foo" → foo, 'foo' → foo.
// Returns the unquoted value or the input unchanged if not quoted.
@@ -187,6 +187,7 @@ bool parse_pep723_toml(const std::string& toml_content,
std::string& out_description,
std::string& out_author,
std::string& out_version,
std::map<std::string, std::string>& out_settings,
std::string& error)
{
out_deps.clear();
@@ -195,6 +196,7 @@ bool parse_pep723_toml(const std::string& toml_content,
out_description.clear();
out_author.clear();
out_version.clear();
out_settings.clear();
TomlSection section = TomlSection::Root;
@@ -218,6 +220,8 @@ bool parse_pep723_toml(const std::string& toml_content,
if (trimmed[0] == '[') {
if (trimmed == "[tool.orcaslicer.plugin]") {
section = TomlSection::OrcaPlugin;
} else if (trimmed == "[tool.orcaslicer.plugin.settings]") {
section = TomlSection::OrcaPluginSettings; // G5: per-plugin params table
} else {
section = TomlSection::Root; // Unknown section — skip.
}
@@ -270,6 +274,10 @@ bool parse_pep723_toml(const std::string& toml_content,
else if (key == "description") out_description = unquote_toml_string(val);
else if (key == "author") out_author = unquote_toml_string(val);
else if (key == "version") out_version = unquote_toml_string(val);
} else if (section == TomlSection::OrcaPluginSettings) {
// G5: collect every key as a string; the plugin parses (int/float/...) what it needs.
if (!key.empty())
out_settings[key] = unquote_toml_string(val);
}
}
@@ -673,6 +681,7 @@ bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginD
pep_desc,
pep_author,
pep_version,
descriptor.settings,
pep723_error)) {
error = "Failed to parse PEP 723 metadata: " + pep723_error;
return false;

View File

@@ -13,9 +13,9 @@
#include "PluginHostApi.hpp"
#include "PyPluginPackage.hpp"
#include "PyPluginTrampoline.hpp"
#include "pluginTypes/gcode/GCodePluginCapability.hpp"
#include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp"
#include "pluginTypes/script/ScriptPluginCapability.hpp"
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
namespace py = pybind11;
@@ -286,7 +286,6 @@ void bind_python_api(pybind11::module_& m)
m.doc() = "OrcaSlicer plugin API";
auto pluginTypes = py::enum_<PluginCapabilityType>(m, "PluginType", "Available plugin capability groups")
.value("PostProcessing", PluginCapabilityType::PostProcessing)
.value("PrinterConnection", PluginCapabilityType::PrinterConnection)
.value("Automation", PluginCapabilityType::Automation)
.value("Analysis", PluginCapabilityType::Analysis)
@@ -294,6 +293,7 @@ void bind_python_api(pybind11::module_& m)
.value("Exporter", PluginCapabilityType::Exporter)
.value("Visualization", PluginCapabilityType::Visualization)
.value("Script", PluginCapabilityType::Script)
.value("SlicingPipeline", PluginCapabilityType::SlicingPipeline)
.value("Unknown", PluginCapabilityType::Unknown)
.export_values();
@@ -334,9 +334,9 @@ void bind_python_api(pybind11::module_& m)
BOOST_LOG_TRIVIAL(debug) << "Registering embedded Python plugin type bindings";
// Make sure you register your bindings here
GCodePluginCapability::RegisterBindings(m, pluginTypes);
PrinterAgentPluginCapability::RegisterBindings(m, pluginTypes);
ScriptPluginCapability::RegisterBindings(m, pluginTypes);
SlicingPipelinePluginCapability::RegisterBindings(m, pluginTypes);
PluginHostApi::RegisterBindings(m);
BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings";

View File

@@ -10,12 +10,11 @@
namespace Slic3r {
enum class PluginCapabilityType { PostProcessing = 0, PrinterConnection, Automation, Analysis, Importer, Exporter, Visualization, Script, Unknown };
enum class PluginCapabilityType { PrinterConnection = 0, Automation, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown };
inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
{
switch (type) {
case PluginCapabilityType::PostProcessing: return "post-processing";
case PluginCapabilityType::PrinterConnection: return "printer-connection";
case PluginCapabilityType::Automation: return "automation";
case PluginCapabilityType::Analysis: return "analysis";
@@ -23,6 +22,7 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
case PluginCapabilityType::Exporter: return "exporter";
case PluginCapabilityType::Visualization: return "visualization";
case PluginCapabilityType::Script: return "script";
case PluginCapabilityType::SlicingPipeline: return "slicing-pipeline";
default: return "unknown";
}
}
@@ -30,7 +30,6 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
inline std::string plugin_capability_type_display_name(PluginCapabilityType type)
{
switch (type) {
case PluginCapabilityType::PostProcessing: return "Post-processing";
case PluginCapabilityType::PrinterConnection: return "Printer connection";
case PluginCapabilityType::Automation: return "Automation";
case PluginCapabilityType::Analysis: return "Analysis";
@@ -38,6 +37,7 @@ inline std::string plugin_capability_type_display_name(PluginCapabilityType type
case PluginCapabilityType::Exporter: return "Exporter";
case PluginCapabilityType::Visualization: return "Visualization";
case PluginCapabilityType::Script: return "Script";
case PluginCapabilityType::SlicingPipeline: return "Slicing Pipeline";
default: return "Unknown";
}
}
@@ -51,8 +51,6 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view
lowered.push_back(to_lower(ch));
}
if (lowered == "post-processing")
return PluginCapabilityType::PostProcessing;
if (lowered == "printer-connection")
return PluginCapabilityType::PrinterConnection;
if (lowered == "automation")
@@ -67,6 +65,8 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view
return PluginCapabilityType::Visualization;
if (lowered == "script")
return PluginCapabilityType::Script;
if (lowered == "slicing-pipeline")
return PluginCapabilityType::SlicingPipeline;
return PluginCapabilityType::Unknown;
}

View File

@@ -1,30 +0,0 @@
#include "GCodePluginCapability.hpp"
#include "GCodePluginCapabilityTrampoline.hpp"
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
namespace py = pybind11;
namespace Slic3r {
void GCodePluginCapability::RegisterBindings(pybind11::module_& module, pybind11::enum_<PluginCapabilityType>& pluginTypes)
{
(void) pluginTypes;
auto gcode = module.def_submodule("gcode", "G-code API");
py::class_<GCodePluginContext, PluginContext>(gcode, "GCodePluginContext", "Context shared with G-code plugins")
.def(py::init<>())
.def_readwrite("gcode_path", &GCodePluginContext::gcode_path)
.def_readwrite("host", &GCodePluginContext::host)
.def_readwrite("output_name", &GCodePluginContext::output_name);
py::class_<GCodePluginCapability, PluginCapabilityInterface, PyGCodePluginCapabilityTrampoline, std::shared_ptr<GCodePluginCapability>>(gcode, "GCodePluginCapabilityBase")
.def(py::init<>())
.def("get_type", &GCodePluginCapability::get_type)
.def("execute", &GCodePluginCapability::execute);
}
} // namespace Slic3r

View File

@@ -1,27 +0,0 @@
#ifndef slic3r_GCodePluginCapability_hpp_
#define slic3r_GCodePluginCapability_hpp_
#include "../../PythonPluginInterface.hpp"
namespace Slic3r {
struct GCodePluginContext : public PluginContext {
std::string gcode_path;
std::string host;
std::string output_name;
};
class GCodePluginCapability : public PluginCapabilityInterface
{
public:
PluginCapabilityType get_type() const override { return PluginCapabilityType::PostProcessing; }
virtual ExecutionResult execute(const GCodePluginContext& ctx) = 0;
static void RegisterBindings(pybind11::module_ &module,
pybind11::enum_<PluginCapabilityType> &pluginTypes);
};
} // namespace Slic3r
#endif /* slic3r_GCodePluginCapability_hpp_ */

View File

@@ -1,35 +0,0 @@
#ifndef slic3r_GCodePluginCapabilityTrampoline_hpp_
#define slic3r_GCodePluginCapabilityTrampoline_hpp_
#include <filesystem>
#include "../../PyPluginTrampoline.hpp"
#include "../../PluginAuditManager.hpp"
#include "GCodePluginCapability.hpp"
namespace Slic3r {
class PyGCodePluginCapabilityTrampoline : public PyPluginCommonTrampoline<GCodePluginCapability>
{
public:
using PyPluginCommonTrampoline<GCodePluginCapability>::PyPluginCommonTrampoline;
ExecutionResult execute(const GCodePluginContext& ctx) override
{
ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[&] {
// G-code post-processing plugins may also write into the folder holding the
// current temp G-code file, in addition to the globally-allowed data_dir().
// The setup callback runs AFTER the context is constructed so the scoped root
// is not cleared by ScopedPluginAuditContext's constructor.
if (!ctx.gcode_path.empty())
::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root(
std::filesystem::path(ctx.gcode_path).parent_path());
},
PYBIND11_OVERRIDE_PURE, ExecutionResult, GCodePluginCapability, execute, ctx);
}
};
} // namespace Slic3r
#endif

View File

@@ -0,0 +1,99 @@
#include "SlicingPipelinePluginCapability.hpp"
#include "SlicingPipelinePluginCapabilityTrampoline.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp" // config_value_or_none
#include "libslic3r/libslic3r.h" // unscale<>, live SCALING_FACTOR
#include <pybind11/stl.h> // std::map<std::string,std::string> -> dict for ctx.params
namespace py = pybind11;
namespace Slic3r {
bool SlicingPipelineContext::cancelled() const { return print && print->canceled(); }
void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::enum_<PluginCapabilityType>& pluginTypes) {
(void) pluginTypes; // matches gcode/script/printerAgent; Step is a fresh enum below.
auto slicing = module.def_submodule("slicing", "Slicing pipeline API (research/experimental).");
py::enum_<SlicingPipelineStepPlugin>(slicing, "Step")
.value("posSlice", SlicingPipelineStepPlugin::posSlice)
.value("posPerimeters", SlicingPipelineStepPlugin::posPerimeters)
.value("posEstimateCurledExtrusions", SlicingPipelineStepPlugin::posEstimateCurledExtrusions)
.value("posPrepareInfill", SlicingPipelineStepPlugin::posPrepareInfill) // after prepare_infill, before make_fills: editing fill_surfaces here CASCADES
.value("posInfill", SlicingPipelineStepPlugin::posInfill) // after make_fills: editing fill_surfaces here does NOT regenerate fills (v1)
.value("posIroning", SlicingPipelineStepPlugin::posIroning)
.value("posContouring", SlicingPipelineStepPlugin::posContouring)
.value("posSupportMaterial", SlicingPipelineStepPlugin::posSupportMaterial)
.value("posDetectOverhangsForLift", SlicingPipelineStepPlugin::posDetectOverhangsForLift)
.value("posSimplifyPath", SlicingPipelineStepPlugin::posSimplifyPath) // covers all simplify sub-steps
.value("psWipeTower", SlicingPipelineStepPlugin::psWipeTower)
.value("psSkirtBrim", SlicingPipelineStepPlugin::psSkirtBrim)
// Post-process seam: fires in the GUI export path AFTER the classic post_process scripts, on the
// exported G-code file. Unlike every step above it is NOT fired by Print::process(): ctx.print and
// ctx.object are None; instead ctx.gcode_path / ctx.host / ctx.output_name are set and the plugin
// edits the file at ctx.gcode_path IN PLACE. May fire more than once per slice (file export and/or
// upload each fire once, on separate working copies) and its output is not reflected in the G-code
// preview (the viewer maps the pre-post-process file). ctx.config_value()/ctx.params still work.
.value("psGCodePostProcess", SlicingPipelineStepPlugin::psGCodePostProcess)
.export_values();
// The read-graph data model (Surface / ExPolygon / the extrusion tree / LayerRegion /
// Layer / PrintObject / Print) and the 2D-geometry mutators live in orca.host, registered
// by PluginHostSlicing.cpp. orca.slicing is workflow-only: Step, unscale, the context, and
// the capability base. See PluginHostSlicing.cpp for the mandatory reference-lifetime rule.
// Scaled integer coordinate -> millimeters. Reads the live SCALING_FACTOR at call
// time (1e-6 normal, 1e-5 for beds > 2147mm), so it is never cached.
slicing.def("unscale", [](coord_t v) { return unscale<double>(v); }, py::arg("coord"),
"Convert a scaled integer coordinate to millimeters (reads the live SCALING_FACTOR).");
py::class_<SlicingPipelineContext>(slicing, "SlicingPipelineContext")
.def_readonly("orca_version", &SlicingPipelineContext::orca_version)
.def_readonly("step", &SlicingPipelineContext::step)
.def_readonly("params", &SlicingPipelineContext::params,
"G5: read-only dict of this plugin's [tool.orcaslicer.plugin.settings] values "
"(string->string). Parse the values you need, e.g. float(ctx.params['rate']).")
.def_readonly("gcode_path", &SlicingPipelineContext::gcode_path,
"Path to the working G-code file, set ONLY at Step.psGCodePostProcess. Edit it in "
"place; empty at every other step.")
.def_readonly("host", &SlicingPipelineContext::host,
"Target host at Step.psGCodePostProcess (\"File\", \"OctoPrint\", ...); empty otherwise.")
.def_readonly("output_name", &SlicingPipelineContext::output_name,
"Final output G-code name at Step.psGCodePostProcess (mirrors SLIC3R_PP_OUTPUT_NAME); "
"empty otherwise.")
.def_property_readonly("print", [](const SlicingPipelineContext& ctx) -> py::object {
if (ctx.print == nullptr)
return py::none();
return py::cast(ctx.print, py::return_value_policy::reference);
}, "The orca.host.Print being sliced — the raw slicing graph, exactly what the "
"C++ pipeline walks. Valid only during the execute(ctx) call. For mesh access "
"use ctx.print.model() (the Print's snapshot), never orca.host.model().")
.def_property_readonly("object", [](const SlicingPipelineContext& ctx) -> py::object {
if (ctx.object == nullptr)
return py::none();
// The hook signature hands objects out as const; they are genuinely mutable
// (owned by the Print) — the same const_cast the old view mutators used,
// done once here at the graph entry point.
return py::cast(const_cast<PrintObject*>(ctx.object), py::return_value_policy::reference);
}, "orca.host.PrintObject for object-scoped steps, or None for print-wide steps. "
"Valid only during the execute(ctx) call.")
.def("config_value", [](const SlicingPipelineContext& ctx, const std::string& key) -> py::object {
// In-pipeline steps read the live Print's full config; at psGCodePostProcess (print == null)
// fall back to the config the export path handed in.
if (ctx.print != nullptr)
return config_value_or_none(ctx.print->full_print_config(), key);
if (ctx.full_config != nullptr)
return config_value_or_none(*ctx.full_config, key);
return py::none();
}, py::arg("key"),
"G2: serialized value of a resolved (full) print config option for this slice, or "
"None if absent. Shorthand for ctx.print.config_value(key).")
.def("cancelled", &SlicingPipelineContext::cancelled);
py::class_<SlicingPipelinePluginCapability, PluginCapabilityInterface,
PySlicingPipelinePluginCapabilityTrampoline,
std::shared_ptr<SlicingPipelinePluginCapability>>(slicing, "SlicingPipelineCapabilityBase")
.def(py::init<>())
.def("get_type", &SlicingPipelinePluginCapability::get_type)
.def("execute", &SlicingPipelinePluginCapability::execute);
}
} // namespace Slic3r

View File

@@ -0,0 +1,45 @@
#pragma once
#include "slic3r/plugin/PythonPluginInterface.hpp"
#include "libslic3r/Print.hpp" // SlicingPipelineStepPlugin, Print, PrintObject
#include <pybind11/pybind11.h>
#include <map>
#include <string>
namespace Slic3r {
// Workflow context handed to SlicingPipeline plugins. ctx.print / ctx.object
// are RAW references into the live slicing graph — the same objects the C++
// pipeline mutates. The data-model bindings and the mandatory lifetime rule
// (valid only during execute(ctx); mutators invalidate references into replaced
// containers, like std::vector iterators) live in
// src/slic3r/plugin/PluginHostSlicing.cpp.
struct SlicingPipelineContext {
std::string orca_version;
SlicingPipelineStepPlugin step { SlicingPipelineStepPlugin::posSlice };
Print* print { nullptr }; // present for in-pipeline steps; null at psGCodePostProcess
const PrintObject* object { nullptr }; // null for print-wide steps and psGCodePostProcess
// G5: read-only per-plugin settings, populated by the dispatcher from the
// plugin's [tool.orcaslicer.plugin.settings] PEP-723 table. Exposed as
// ctx.params (dict of string->string).
std::map<std::string, std::string> params;
// Populated ONLY at Step.psGCodePostProcess (the GUI G-code export/post-process seam,
// PostProcessor.cpp). gcode_path is the working G-code file on disk that the plugin edits
// in place; host is the target ("File", "OctoPrint", ...); output_name mirrors
// SLIC3R_PP_OUTPUT_NAME. Empty at every other step.
std::string gcode_path;
std::string host;
std::string output_name;
// C++-only config fallback for psGCodePostProcess (no live Print graph there): config_value()
// reads it when `print` is null. Not exposed to Python directly. Never dereferenced elsewhere.
const DynamicPrintConfig* full_config { nullptr };
bool cancelled() const; // -> print->canceled() (false when print is null)
};
class SlicingPipelinePluginCapability : public PluginCapabilityInterface {
public:
PluginCapabilityType get_type() const override { return PluginCapabilityType::SlicingPipeline; }
virtual ExecutionResult execute(SlicingPipelineContext& ctx) = 0;
static void RegisterBindings(pybind11::module_& module, pybind11::enum_<PluginCapabilityType>& pluginTypes);
};
} // namespace Slic3r

View File

@@ -0,0 +1,29 @@
#pragma once
#include "SlicingPipelinePluginCapability.hpp"
#include "slic3r/plugin/PyPluginTrampoline.hpp"
#include "slic3r/plugin/PluginAuditManager.hpp"
#include <filesystem>
namespace Slic3r {
class PySlicingPipelinePluginCapabilityTrampoline : public PyPluginCommonTrampoline<SlicingPipelinePluginCapability> {
public:
using PyPluginCommonTrampoline<SlicingPipelinePluginCapability>::PyPluginCommonTrampoline;
ExecutionResult execute(SlicingPipelineContext& ctx) override {
ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[&]{
// At Step.psGCodePostProcess the plugin edits the exported G-code file, which lives
// outside data_dir() (a temp/output folder), so writing to it would otherwise be
// blocked by the audit sandbox. Grant that folder as a scoped allowed root, mirroring
// the former G-code post-processing trampoline. The setup callback runs AFTER the
// audit context is constructed, so the scoped root is not cleared by its constructor.
// Empty at every other step, so no extra access is granted to the geometry hooks.
if (!ctx.gcode_path.empty())
::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root(
std::filesystem::path(ctx.gcode_path).parent_path());
},
PYBIND11_OVERRIDE_PURE,
ExecutionResult, SlicingPipelinePluginCapability, execute, ctx);
}
};
} // namespace Slic3r

View File

@@ -13,6 +13,7 @@ add_executable(${_TEST_NAME}_tests
test_printgcode.cpp
test_printobject.cpp
test_skirt_brim.cpp
test_slicing_pipeline_hook.cpp
test_support_material.cpp
test_trianglemesh.cpp
)

View File

@@ -0,0 +1,472 @@
#include <catch2/catch_test_macros.hpp>
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_pipeline]") {
DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config();
const ConfigOptionStrings* opt = cfg.option<ConfigOptionStrings>("slicing_pipeline_plugin");
REQUIRE(opt != nullptr);
CHECK(opt->values.empty());
const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin");
REQUIRE(def != nullptr);
CHECK(def->plugin_type == "slicing-pipeline");
CHECK(def->is_plugin_backed());
CHECK(def->gui_type == ConfigOptionDef::GUIType::plugin_picker);
}
#include "libslic3r/Print.hpp"
TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; });
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); // reset — must be legal
CHECK(calls == 0);
}
#include "test_data.hpp"
#include <vector>
#include <algorithm>
using namespace Slic3r::Test;
TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slicing_pipeline]") {
struct Call { const Slic3r::PrintObject* obj; Slic3r::SlicingPipelineStepPlugin step; };
std::vector<Call> calls;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ calls.push_back({o, s}); });
Slic3r::Print print; Slic3r::Model model;
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
using S = Slic3r::SlicingPipelineStepPlugin;
auto count = [&](S s){ return std::count_if(calls.begin(), calls.end(), [&](const Call& c){ return c.step == s; }); };
CHECK(count(S::posSlice) == 1);
CHECK(count(S::posPerimeters) == 1);
CHECK(count(S::posPrepareInfill) == 1); // G4: the prepare-infill seam fires once per object
CHECK(count(S::posInfill) == 1);
CHECK(count(S::psWipeTower) == 1);
CHECK(count(S::psSkirtBrim) == 1);
// psGCodePostProcess fires from the GUI export path, never from process():
CHECK(count(S::psGCodePostProcess) == 0);
// print-wide steps carry a null object:
for (const auto& c : calls)
if (c.step == S::psWipeTower || c.step == S::psSkirtBrim) CHECK(c.obj == nullptr);
// Slice must fire before Perimeters for the same object:
auto idx = [&](S s){ for (size_t i=0;i<calls.size();++i) if (calls[i].step==s) return (int)i; return -1; };
CHECK(idx(S::posSlice) < idx(S::posPerimeters));
CHECK(idx(S::posPerimeters) < idx(S::posPrepareInfill)); // G4: prepare-infill fires after perimeters...
CHECK(idx(S::posPrepareInfill) < idx(S::posInfill)); // ...and before the fills are built
}
#include <sstream>
#include <cmath>
// Exported G-code carries a few nondeterministic comment lines unrelated to toolpaths: a
// wall-clock timestamp ("; generated by ..."), ObjectID-derived ids (from a process-global
// counter never reset between runs), and a config-dump line naming the selected plugin (an
// active run records it, the absent baseline does not). Strip exactly those lines so a raw
// byte-compare isolates the real motion/extrusion output; every other byte is still compared.
static std::string strip_nondeterministic_gcode_lines(const std::string& gcode) {
std::string out; out.reserve(gcode.size());
std::istringstream in(gcode);
std::string line;
while (std::getline(in, line)) {
if (line.compare(0, 15, "; generated by ") == 0) continue; // wall-clock timestamp
if (line.compare(0, 18, "; model label id: ") == 0) continue; // ObjectID-derived
// "; [stop] printing object <name> id:N copy M" / "... unique label id: N" (ObjectID-derived):
if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue;
if (line.find("slicing_pipeline_plugin") != std::string::npos) continue; // config-dump plugin name
out += line; out += '\n';
}
return out;
}
TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset)", "[slicing_pipeline]") {
// Three configurations must all normalize to the same G-code:
// (activate=false, hook=none) baseline -- feature entirely absent.
// (activate=false, hook=noop) hook registered but option empty -> gated off, never fires.
// (activate=true, hook=noop) hook ACTIVE and firing at every pipeline seam, mutating
// nothing. This is the real backward-compat claim: an active
// but non-mutating hook must not perturb the output.
auto run = [](bool activate, bool set_noop_hook) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
// Activating requires BOTH a non-empty option and a registered hook (see Print::apply).
if (activate)
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (set_noop_hook)
Slic3r::Print::set_slicing_pipeline_hook_fn([](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){});
else
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
std::string g = Slic3r::Test::gcode(print);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return g;
};
// Compare only machine-meaningful output (see strip_nondeterministic_gcode_lines): every
// motion/extrusion byte is still compared, so this proves the inactive hook -- and the
// active-but-non-mutating hook -- leave the real toolpath byte-identical.
const std::string baseline = strip_nondeterministic_gcode_lines(run(false, false)); // feature absent
CHECK(strip_nondeterministic_gcode_lines(run(false, true)) == baseline); // gated off: hook never fires
CHECK(strip_nondeterministic_gcode_lines(run(true, true)) == baseline); // active no-op hook fires everywhere, mutates nothing
}
// Fix 4(a): gating negative path. With the option EMPTY the plugin is inactive, so a
// registered hook must NOT fire even once across a full slice (m_pipeline_plugin_active
// stays false in Print::apply). Distinct from the byte-identical test above: this asserts
// the gate directly by counting invocations rather than comparing output.
TEST_CASE("Empty option: registered hook is gated off and never fires", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; });
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
// option left EMPTY -> inactive regardless of the registered hook.
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
CHECK(calls == 0);
}
// Fix 4(b): duplicate-skip gating. Two ModelObjects that share one mesh_ptr are detected as
// identical by Print::process()'s is_print_object_the_same(); the second becomes a shared
// (duplicate) object and is NOT re-sliced, so the Slice hook must fire exactly once even
// though there are two print objects. The clone shares mesh_ptr and copies the volume
// transformation/config (ModelVolume copy ctor), which the equality check requires.
TEST_CASE("Duplicate objects share a slice: Slice hook fires exactly once", "[slicing_pipeline]") {
int slice_calls = 0, perim_calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin s){
if (s == Slic3r::SlicingPipelineStepPlugin::posSlice) ++slice_calls;
if (s == Slic3r::SlicingPipelineStepPlugin::posPerimeters) ++perim_calls;
});
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
// init_print builds one arranged, on-bed cube object (o1).
init_print({TestMesh::cube_20x20x20}, print, model, config);
Slic3r::ModelObject* o1 = model.objects.front();
// Model::add_object(const ModelObject&) force-sets object extruder=1 on the clone; give o1
// the same so the two objects' configs match (is_print_object_the_same compares config).
if (!o1->config.has("extruder"))
o1->config.set_key_value("extruder", new Slic3r::ConfigOptionInt(1));
// Clone o1: shares mesh_ptr and copies the volume transformation + config (genuine duplicate).
Slic3r::ModelObject* o2 = model.add_object(*o1);
// Shift the clone in X so validate() sees no collision (20mm cubes -> 40mm centres = 20mm gap).
for (Slic3r::ModelInstance* inst : o2->instances)
inst->set_offset(inst->get_offset() + Slic3r::Vec3d(40.0, 0.0, 0.0));
print.apply(model, config);
print.validate();
print.set_status_silent();
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
REQUIRE(print.objects().size() == 2); // two print objects present...
CHECK(slice_calls == 1); // ...but the duplicate is skipped -> one slice
CHECK(perim_calls == 1); // and one perimeters pass (the sliced object)
}
#include "libslic3r/Layer.hpp" // Layer, LayerRegion (full defs for the cascade hook)
#include "libslic3r/ClipperUtils.hpp" // offset_ex
// The correctness heart of the mutation feature. A C++ hook insets every
// region's `slices` at the Slice boundary (via SurfaceCollection::set with offset
// polygons); because make_perimeters() derives fill_surfaces from slices AFTER the
// Slice hook fires (see Print::process's split slice loop), the downstream
// fill_surfaces area must shrink relative to a baseline (un-inset) run. This proves
// the mutation cascade end-to-end using the same C++ APIs the Python mutators wrap.
TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing_pipeline]") {
auto fill_area = [](bool inset) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (inset) Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) sf.expolygon = offset_ex(sf.expolygon, -scale_(1.0)).front();
r->slices.set(std::move(in));
}
});
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
double a = 0; for (auto* l : print.objects().front()->layers()) for (auto* r : l->regions()) for (auto& s : r->fill_surfaces.surfaces) a += s.expolygon.area();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return a;
};
CHECK(fill_area(true) < fill_area(false));
}
TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pipeline]") {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
REQUIRE(print.objects().front()->is_step_done(posSlice));
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
print.apply(model, config);
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
}
#include <catch2/matchers/catch_matchers_floating_point.hpp>
// §3.6 (Twistify design): Twistify's effect is a similarity transform (rotate + uniform
// scale) applied to slices at Step.posSlice. This C++ analogue rotates every region's slices a
// fixed 45 deg about the object's base-footprint center -- the same seam and cascade that
// Twistify.py drives through the slices.set() + Layer::make_slices() path. Two end-to-end invariants after
// process() confirm the approach:
// (1) a pure rotation is a similarity with scale 1, so total fill area is preserved, and
// (2) the mutation genuinely cascaded into make_perimeters' fill_surfaces -- a 20mm square
// rotated 45 deg becomes a diamond whose bbox is ~sqrt(2)x wider (it did not stay
// axis-aligned), proving downstream geometry was rebuilt from the twisted slices.
TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox rotated)", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
struct Measure { double area; double width; double height; };
auto measure = [](bool rotate) -> Measure {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (rotate) Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
auto* obj = const_cast<Slic3r::PrintObject*>(o);
// Twist axis = center of the first sliced layer's footprint (Twistify's anchor).
coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false;
for (Slic3r::Layer* l : obj->layers()) {
for (Slic3r::LayerRegion* r : l->regions())
for (const Slic3r::Surface& sf : r->slices.surfaces)
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
if (!seeded) { nx=xx=p.x(); ny=xy=p.y(); seeded=true; }
else { nx=std::min(nx,p.x()); xx=std::max(xx,p.x());
ny=std::min(ny,p.y()); xy=std::max(xy,p.y()); }
}
if (seeded) break;
}
const double cx = 0.5*((double)nx+(double)xx), cy = 0.5*((double)ny+(double)xy);
const double ct = 0.7071067811865476, st = 0.7071067811865476; // cos/sin 45 deg
auto rot = [&](const Slic3r::Point& p) {
const double dx = (double)p.x()-cx, dy = (double)p.y()-cy;
return Slic3r::Point((coord_t)std::llround(dx*ct - dy*st + cx),
(coord_t)std::llround(dx*st + dy*ct + cy));
};
for (Slic3r::Layer* l : obj->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) {
for (auto& pt : sf.expolygon.contour.points) pt = rot(pt);
for (auto& h : sf.expolygon.holes)
for (auto& pt : h.points) pt = rot(pt);
}
r->slices.set(std::move(in));
}
});
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
double area = 0;
coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false;
for (auto* l : print.objects().front()->layers())
for (auto* r : l->regions())
for (auto& sf : r->fill_surfaces.surfaces) {
area += sf.expolygon.area();
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
if (!seeded) { nx=xx=p.x(); ny=xy=p.y(); seeded=true; }
else { nx=std::min(nx,p.x()); xx=std::max(xx,p.x());
ny=std::min(ny,p.y()); xy=std::max(xy,p.y()); }
}
}
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return { area, (double)(xx-nx), (double)(xy-ny) };
};
const Measure base = measure(false);
const Measure rot = measure(true);
// (1) A pure rotation preserves area (similarity, scale 1): fills add up to the same area.
CHECK_THAT(rot.area, WithinRel(base.area, 0.05));
// (2) The rotation cascaded downstream: the square's fill bbox grew toward the sqrt(2)
// diagonal (diamond) instead of staying axis-aligned.
CHECK(rot.width > 1.3 * base.width);
CHECK(rot.width < 1.5 * base.width);
CHECK(rot.height > 1.3 * base.height);
CHECK(rot.height < 1.5 * base.height);
}
// §3.6 (Twistify design): Twistify skips exact-identity layers entirely, but every transformed
// layer invokes the slices.set() write-back + make_perimeters re-run. This proves that write path
// is lossless for already-normalized (CCW contour / CW hole) input -- an active hook that
// re-sets every region's slices to their CURRENT geometry (the identity similarity transform)
// produces output byte-identical to an active hook that mutates nothing. Both runs are active
// (same config dump); the only difference is whether the write path ran, so equality isolates it.
TEST_CASE("Identity round-trip through set_slices is byte-identical", "[slicing_pipeline]") {
auto run = [](bool roundtrip) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs
Slic3r::Print::set_slicing_pipeline_hook_fn(
[roundtrip](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (!roundtrip || s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces; // copy current (already-normalized) geometry
r->slices.set(std::move(in)); // write back unchanged: identity transform
}
});
init_print({TestMesh::cube_20x20x20}, print, model, config);
std::string g = Slic3r::Test::gcode(print);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return g;
};
CHECK(strip_nondeterministic_gcode_lines(run(true)) == strip_nondeterministic_gcode_lines(run(false)));
}
#include "libslic3r/ExtrusionEntityCollection.hpp" // count fill paths in the G4 cascade test
// Total leaf ExtrusionPath count under an extrusion (sub)tree (collections recursed into).
static size_t count_leaf_paths(const Slic3r::ExtrusionEntity* ee) {
if (ee == nullptr) return 0;
if (const auto* coll = dynamic_cast<const Slic3r::ExtrusionEntityCollection*>(ee)) {
size_t n = 0;
for (const Slic3r::ExtrusionEntity* e : coll->entities) n += count_leaf_paths(e);
return n;
}
return 1;
}
// Width (scaled) of the object-wide bounding box over every region's sliced contour.
static double outer_slices_width(const Slic3r::Print& print) {
coord_t min_x = 0, max_x = 0; bool seeded = false;
for (auto* l : print.objects().front()->layers())
for (auto* r : l->regions())
for (const Slic3r::Surface& sf : r->slices.surfaces)
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
if (!seeded) { min_x = max_x = p.x(); seeded = true; }
else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); }
}
return (double)(max_x - min_x);
}
// G3: after the Slice hook mutates slices, raw_slices must be re-snapshotted so the mutation
// becomes the untyped baseline. make_perimeters() restores untyped slices from raw_slices on
// any perimeter re-run; invoking that restore directly must reproduce the mutation, not revert
// to the pre-hook geometry (which is what happened before this fix).
TEST_CASE("G3: raw_slices captures post-hook geometry so a perimeter re-run keeps the mutation", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) {
Slic3r::ExPolygons e = offset_ex(sf.expolygon, -scale_(1.0));
if (!e.empty()) sf.expolygon = e.front();
}
r->slices.set(std::move(in));
}
});
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
const double w_mutated = outer_slices_width(print); // inset applied at the Slice hook
// The same restore make_perimeters() runs on a perimeter-only re-slice. With G3 the post-hook
// backup reproduces the inset; without it this reverts to the wider original outline.
for (Slic3r::Layer* l : print.objects().front()->layers())
l->restore_untyped_slices();
const double w_restored = outer_slices_width(print);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
CHECK_THAT(w_restored, WithinRel(w_mutated, 0.02)); // mutation survived the restore
}
// G4: a plugin can mutate fill_surfaces at the new PrepareInfill seam and have make_fills consume
// them, whereas the pre-existing Infill seam fires after the fills are already built (v1 limit).
// All three runs register a hook (active path) so the comparison isolates only the mutation.
TEST_CASE("G4: fill_surfaces mutation cascades at PrepareInfill but not at Infill", "[slicing_pipeline]") {
auto fill_paths = [](bool shrink, Slic3r::SlicingPipelineStepPlugin at) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
Slic3r::Print::set_slicing_pipeline_hook_fn(
[shrink, at](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (!shrink || s != at || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->fill_surfaces.surfaces, out;
for (const Slic3r::Surface& sf : in)
for (const Slic3r::ExPolygon& e : offset_ex(sf.expolygon, -scale_(3.0))) {
Slic3r::Surface s2 = sf; s2.expolygon = e; out.push_back(std::move(s2));
}
r->fill_surfaces.set(std::move(out));
}
});
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
size_t n = 0;
for (auto* l : print.objects().front()->layers())
for (auto* r : l->regions())
n += count_leaf_paths(&r->fills);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return n;
};
using S = Slic3r::SlicingPipelineStepPlugin;
const size_t base = fill_paths(false, S::posPrepareInfill); // active hook, no mutation
CHECK(base > 0);
CHECK(fill_paths(true, S::posPrepareInfill) < base); // mutation before make_fills cascades
CHECK(fill_paths(true, S::posInfill) == base); // mutation after make_fills is a no-op (v1)
}
// G1: lslices (the layer's merged islands) are built once in slice() and never rebuilt by
// make_perimeters, so mutating region slices leaves them stale. The slices.set() + Layer::make_slices()
// path re-derives them; this C++ analogue proves the mechanism -- without the
// refresh the islands keep the original 20mm footprint, with it they track the 18mm inset.
TEST_CASE("G1: refreshing lslices after a slice mutation makes islands track the geometry", "[slicing_pipeline]") {
auto lslices_width = [](bool refresh) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
Slic3r::Print::set_slicing_pipeline_hook_fn(
[refresh](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers()) {
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) {
Slic3r::ExPolygons e = offset_ex(sf.expolygon, -scale_(1.0));
if (!e.empty()) sf.expolygon = e.front();
}
r->slices.set(std::move(in));
}
if (refresh) // the load-bearing half of the slices.set() + Layer::make_slices() path
l->make_slices();
}
});
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
coord_t min_x = 0, max_x = 0; bool seeded = false;
for (auto* l : print.objects().front()->layers())
for (const Slic3r::ExPolygon& island : l->lslices)
for (const Slic3r::Point& p : island.contour.points) {
if (!seeded) { min_x = max_x = p.x(); seeded = true; }
else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); }
}
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return (double)(max_x - min_x);
};
using Catch::Matchers::WithinRel;
const double stale = lslices_width(false); // islands keep the original ~20 mm footprint
const double fresh = lslices_width(true); // islands track the ~18 mm inset region slices
CHECK(fresh < stale);
CHECK_THAT(stale, WithinRel((double) scale_(20.0), 0.05)); // stale islands = original outline
CHECK_THAT(fresh, WithinRel((double) scale_(18.0), 0.05)); // refreshed islands = inset outline
}

View File

@@ -410,14 +410,14 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
namespace fs = boost::filesystem;
const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca_plugins_%%%%-%%%%.json");
const std::vector<std::string> refs = {
"local_plugin;;post_process",
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;post_process"
"local_plugin;;inset",
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset"
};
std::unique_ptr<DynamicPrintConfig> config_ptr(
DynamicPrintConfig::new_from_defaults_keys({"post_process_plugin"}));
DynamicPrintConfig::new_from_defaults_keys({"slicing_pipeline_plugin"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("post_process_plugin", true)->values = refs;
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = refs;
config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0");
nlohmann::json j;
@@ -425,7 +425,7 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
boost::nowide::ifstream ifs(tmp.string());
ifs >> j;
}
REQUIRE(j["post_process_plugin"] == nlohmann::json(refs));
REQUIRE(j["slicing_pipeline_plugin"] == nlohmann::json(refs));
CHECK_FALSE(j.contains("plugins"));
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
@@ -434,7 +434,7 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
std::string reason;
REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0);
CHECK(reason.empty());
CHECK(reloaded.option<ConfigOptionStrings>("post_process_plugin")->values == refs);
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
fs::remove(tmp);
}
@@ -446,17 +446,17 @@ TEST_CASE("plugin capability references survive string-map serialization", "[Con
};
DynamicPrintConfig original = DynamicPrintConfig::full_print_config();
original.option<ConfigOptionStrings>("post_process_plugin", true)->values = refs;
original.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = refs;
std::map<std::string, std::string> serialized{
{"post_process_plugin", original.option<ConfigOptionStrings>("post_process_plugin")->serialize()}
{"slicing_pipeline_plugin", original.option<ConfigOptionStrings>("slicing_pipeline_plugin")->serialize()}
};
CHECK(serialized["post_process_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos);
CHECK(serialized["slicing_pipeline_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos);
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
reloaded.load_string_map(serialized, ForwardCompatibilitySubstitutionRule::Disable);
CHECK(reloaded.option<ConfigOptionStrings>("post_process_plugin")->values == refs);
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
}
TEST_CASE("parse_capability_ref parses local and cloud references", "[Config][plugin]") {
@@ -483,3 +483,55 @@ TEST_CASE("parse_capability_ref rejects malformed input", "[Config][plugin]") {
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;;").has_value());
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid;").has_value());
}
namespace {
// Installs a stub capability resolver that echoes the capability type into the reference, so tests
// can assert each plugin-backed option resolved with its own ConfigOptionDef::plugin_type. Resets
// the global resolver on teardown -- tests run in random order and other cases assert the
// no-resolver behavior (an absent "plugins" manifest).
struct PluginResolverFixture {
PluginResolverFixture() {
ConfigBase::set_resolve_capability_fn([](const std::string& name, const std::string& type) {
return name.empty() ? std::string() : name + ";;" + type;
});
}
~PluginResolverFixture() { ConfigBase::set_resolve_capability_fn(nullptr); }
};
} // namespace
TEST_CASE_METHOD(PluginResolverFixture,
"update_plugin_manifest derives references generically from plugin-backed options",
"[Config][plugins]") {
// Both scalar (printer_agent) and vector (slicing_pipeline_plugin) options opt in via a non-empty
// ConfigOptionDef::plugin_type (is_plugin_backed) and are resolved with it -- there is no hardcoded
// per-option switch. printer_agent in particular relies on its plugin_type metadata being wired up
// (it is edited via a dedicated widget, not the plugin_picker).
std::unique_ptr<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
{"slicing_pipeline_plugin", "printer_agent"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"sp"};
config.option<ConfigOptionString>("printer_agent", true)->value = "agent";
config.update_plugin_manifest();
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
using Catch::Matchers::VectorContains;
REQUIRE_THAT(manifest, VectorContains(std::string("sp;;slicing-pipeline")));
REQUIRE_THAT(manifest, VectorContains(std::string("agent;;printer-connection")));
CHECK(manifest.size() == 2);
}
TEST_CASE_METHOD(PluginResolverFixture,
"update_plugin_manifest de-duplicates references and skips unset options",
"[Config][plugins]") {
std::unique_ptr<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
{"slicing_pipeline_plugin", "printer_agent"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"x", "x"}; // duplicate
// printer_agent stays at its default empty value -> contributes nothing to the manifest.
config.update_plugin_manifest();
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
CHECK(manifest == std::vector<std::string>{"x;;slicing-pipeline"});
}

View File

@@ -4,6 +4,8 @@ add_executable(${_TEST_NAME}_tests
test_plugin_host_api.cpp
test_plugin_capability_identifier.cpp
test_plugin_install.cpp
test_slicing_pipeline_bindings.cpp
test_plugin_sort.cpp
)
if (MSVC)

View File

@@ -0,0 +1,38 @@
#pragma once
// Shared embedded-interpreter bootstrap for slic3rutils tests that need a live Python
// interpreter (test_plugin_host_api.cpp, test_slicing_pipeline_bindings.cpp, ...).
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <slic3r/plugin/PythonPluginBridge.hpp>
namespace {
void ensure_python_initialized()
{
// Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter:
// `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing
// it needs no bundled stdlib/sys.path, and the deterministic assertions are
// independent of the host's Python. PythonInterpreter::initialize() expects the
// bundled Python home laid out next to the app bundle (lib/python3.12/encodings),
// which is not deployed beside the test binary, so using it here would fail to find
// a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime.
if (!Py_IsInitialized()) {
static pybind11::scoped_interpreter interpreter;
(void) interpreter;
}
}
pybind11::module_ import_orca_module()
{
ensure_python_initialized();
// Force PythonPluginBridge.cpp into the test binary so the embedded
// PYBIND11_EMBEDDED_MODULE(orca, ...) registration is available.
(void) Slic3r::PythonPluginBridge::instance();
return pybind11::module_::import("orca");
}
} // namespace

View File

@@ -8,9 +8,9 @@ using Slic3r::PluginCapabilityIdentifier;
using Slic3r::PluginCapabilityType;
TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][identifier]") {
PluginCapabilityIdentifier a{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"};
PluginCapabilityIdentifier b{PluginCapabilityType::PostProcessing, "Cleanup", "b.py"};
PluginCapabilityIdentifier a2{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"};
PluginCapabilityIdentifier a{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"};
PluginCapabilityIdentifier b{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"};
PluginCapabilityIdentifier a2{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"};
CHECK(a == a2);
CHECK_FALSE(a == b); // same (type,name), different plugin_key -> distinct
@@ -18,8 +18,8 @@ TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][i
TEST_CASE("PluginCapabilityIdentifier is usable as a hash-map key", "[plugin][identifier]") {
std::unordered_map<PluginCapabilityIdentifier, int> m;
m[{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}] = 1;
m[{PluginCapabilityType::PostProcessing, "Cleanup", "b.py"}] = 2; // no collision
m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}] = 1;
m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"}] = 2; // no collision
CHECK(m.size() == 2);
CHECK(m.at({PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}) == 1);
CHECK(m.at({PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}) == 1);
}

View File

@@ -5,6 +5,8 @@
#include <libslic3r/TriangleMesh.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include "python_test_support.hpp"
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
@@ -14,30 +16,8 @@ namespace py = pybind11;
namespace {
void ensure_python_initialized()
{
// Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter:
// `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing
// it needs no bundled stdlib/sys.path, and the deterministic assertions are
// independent of the host's Python. PythonInterpreter::initialize() expects the
// bundled Python home laid out next to the app bundle (lib/python3.12/encodings),
// which is not deployed beside the test binary, so using it here would fail to find
// a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime.
if (!Py_IsInitialized()) {
static py::scoped_interpreter interpreter;
(void) interpreter;
}
}
py::module_ import_orca_module()
{
ensure_python_initialized();
// Force PythonPluginBridge.cpp into the test binary so the embedded
// PYBIND11_EMBEDDED_MODULE(orca, ...) registration is available.
(void) Slic3r::PythonPluginBridge::instance();
return py::module_::import("orca");
}
// import_orca_module() lives in python_test_support.hpp (shared with
// test_slicing_pipeline_bindings.cpp).
bool has_attr(const py::handle& object, const char* name)
{

View File

@@ -146,3 +146,38 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
read_install_state(plugin_dir, scanned);
CHECK(scanned.installed_version == "1.2.0");
}
TEST_CASE("install_plugin parses [tool.orcaslicer.plugin.settings] into descriptor.settings (G5)", "[PluginInstall]")
{
ScopedDataDir data_dir_guard("g5-settings");
// A PEP-723 header with a per-plugin settings sub-table. Values stay strings; the plugin
// parses what it needs (ctx.params). This is the source Twistify reads its knobs from.
const std::string contents =
"# /// script\n"
"# requires-python = \">=3.12\"\n"
"#\n"
"# [tool.orcaslicer.plugin]\n"
"# name = \"Settings Plugin\"\n"
"# type = \"slicing-pipeline\"\n"
"#\n"
"# [tool.orcaslicer.plugin.settings]\n"
"# twist_deg_per_mm = \"1.5\"\n"
"# taper_per_mm = \"-0.004\"\n"
"# ///\n"
"print('ok')\n";
const fs::path py = write_py_file(data_dir_guard.dir / "src", "settings.py", contents);
PluginLoader loader; // non-cloud
PluginDescriptor descriptor;
std::string error;
const bool installed = loader.install_plugin(py, descriptor, error);
REQUIRE(installed);
CHECK(error.empty());
REQUIRE(descriptor.settings.count("twist_deg_per_mm") == 1);
CHECK(descriptor.settings.at("twist_deg_per_mm") == "1.5");
CHECK(descriptor.settings.at("taper_per_mm") == "-0.004");
// Identity keys are NOT captured as settings (they belong to [tool.orcaslicer.plugin]).
CHECK(descriptor.settings.count("name") == 0);
}

View File

@@ -0,0 +1,189 @@
#include <catch2/catch_all.hpp>
#include <slic3r/GUI/PluginSort.hpp>
#include <string>
#include <vector>
using Slic3r::GUI::compare_ascii_case_insensitive_natural;
using Slic3r::GUI::PluginSortKey;
using Slic3r::GUI::PluginSortOrder;
using Slic3r::GUI::PluginSource;
using Slic3r::GUI::PluginStatus;
using Slic3r::GUI::plugin_sort_key_from_string;
using Slic3r::GUI::plugin_sort_order_from_string;
using Slic3r::GUI::sort_plugin_items_for_dialog;
namespace {
struct SortFixtureItem
{
std::string plugin_key;
PluginSource source;
PluginStatus status;
std::string type_key;
std::string display_name;
std::string sort_version;
};
std::vector<std::string> keys(const std::vector<SortFixtureItem>& items)
{
std::vector<std::string> result;
result.reserve(items.size());
for (const SortFixtureItem& item : items)
result.push_back(item.plugin_key);
return result;
}
} // namespace
TEST_CASE("plugin dialog status sort uses requested priority and base-order ties", "[plugin][sort]")
{
std::vector<SortFixtureItem> items = {
{"local_inactive", PluginSource::Local, PluginStatus::Inactive, "script", "Local Inactive"},
{"mine_error", PluginSource::Mine, PluginStatus::Error, "script", "Mine Error"},
{"mine_activated", PluginSource::Mine, PluginStatus::Activated, "script", "Mine Activated"},
{"local_activated", PluginSource::Local, PluginStatus::Activated, "script", "Local Activated"},
{"subscribed_loading", PluginSource::Subscribed, PluginStatus::Loading, "script", "Subscribed Loading"},
};
sort_plugin_items_for_dialog(items, PluginSortKey::Status, PluginSortOrder::Asc);
// why: local_activated and mine_activated tie on Status, so base order breaks the tie by name
// (case-insensitive) - "Local Activated" before "Mine Activated".
const std::vector<std::string> expected = {
"local_activated",
"mine_activated",
"mine_error",
"local_inactive",
"subscribed_loading",
};
CHECK(keys(items) == expected);
sort_plugin_items_for_dialog(items, PluginSortKey::Status, PluginSortOrder::Desc);
// why: Desc reverses the status ordinal, but the Activated tie still resolves by ascending
// base order (name: "Local Activated" before "Mine Activated") - direction only flips the key.
const std::vector<std::string> desc_expected = {
"subscribed_loading",
"local_inactive",
"mine_error",
"local_activated",
"mine_activated",
};
CHECK(keys(items) == desc_expected);
}
TEST_CASE("plugin dialog source sort uses enum priority", "[plugin][sort]")
{
std::vector<SortFixtureItem> items = {
{"local", PluginSource::Local, PluginStatus::Activated, "script", "Local"},
{"mine", PluginSource::Mine, PluginStatus::Activated, "script", "Mine"},
{"subscribed", PluginSource::Subscribed, PluginStatus::Activated, "script", "Subscribed"},
};
sort_plugin_items_for_dialog(items, PluginSortKey::Source, PluginSortOrder::Asc);
const std::vector<std::string> asc_expected = {"mine", "subscribed", "local"};
CHECK(keys(items) == asc_expected);
sort_plugin_items_for_dialog(items, PluginSortKey::Source, PluginSortOrder::Desc);
const std::vector<std::string> desc_expected = {"local", "subscribed", "mine"};
CHECK(keys(items) == desc_expected);
}
TEST_CASE("plugin dialog version sort is semver-aware with base-order ties", "[plugin][sort]")
{
std::vector<SortFixtureItem> items = {
{"v_1_2_0", PluginSource::Local, PluginStatus::Activated, "script", "B", "1.2.0"},
{"v_1_10_0", PluginSource::Local, PluginStatus::Activated, "script", "A", "1.10.0"},
{"v_0_9_3", PluginSource::Local, PluginStatus::Activated, "script", "C", "0.9.3"},
};
sort_plugin_items_for_dialog(items, PluginSortKey::Version, PluginSortOrder::Asc);
// why: semver numeric compare - 1.10.0 > 1.2.0 (not lexical "1.10" < "1.2"), so ascending is
// 0.9.3 < 1.2.0 < 1.10.0.
const std::vector<std::string> asc_expected = {"v_0_9_3", "v_1_2_0", "v_1_10_0"};
CHECK(keys(items) == asc_expected);
sort_plugin_items_for_dialog(items, PluginSortKey::Version, PluginSortOrder::Desc);
const std::vector<std::string> desc_expected = {"v_1_10_0", "v_1_2_0", "v_0_9_3"};
CHECK(keys(items) == desc_expected);
}
TEST_CASE("plugin dialog name sort is case-insensitive and numeric-aware", "[plugin][sort]")
{
std::vector<SortFixtureItem> items = {
{"rig10", PluginSource::Local, PluginStatus::Activated, "script", "Rig 10"},
{"ada_lower", PluginSource::Local, PluginStatus::Activated, "script", "ada"},
{"rig2", PluginSource::Local, PluginStatus::Activated, "script", "Rig 2"},
{"ada_upper", PluginSource::Local, PluginStatus::Activated, "script", "Ada"},
};
sort_plugin_items_for_dialog(items, PluginSortKey::Name, PluginSortOrder::Asc);
// why: "Ada"/"ada" tie on the case-insensitive name (primary AND base name level), so the tie
// falls through source/status/type to plugin_key: "ada_lower" before "ada_upper".
const std::vector<std::string> expected = {"ada_lower", "ada_upper", "rig2", "rig10"};
CHECK(keys(items) == expected);
sort_plugin_items_for_dialog(items, PluginSortKey::Name, PluginSortOrder::Desc);
// why: names reverse ("Rig 10" before "Rig 2"), but "Ada"/"ada" tie on the case-insensitive
// key and keep ascending base order, which resolves by plugin_key ("ada_lower" < "ada_upper").
const std::vector<std::string> desc_expected = {"rig10", "rig2", "ada_lower", "ada_upper"};
CHECK(keys(items) == desc_expected);
}
TEST_CASE("natural compare handles digits, case, prefixes and leading zeros", "[plugin][sort]")
{
// numeric runs compare by value, not lexically
CHECK(compare_ascii_case_insensitive_natural("item2", "item10") < 0);
CHECK(compare_ascii_case_insensitive_natural("item10", "item2") > 0);
CHECK(compare_ascii_case_insensitive_natural("2", "10") < 0);
// case is ignored on the primary comparison
CHECK(compare_ascii_case_insensitive_natural("Camera", "camera") == 0);
// a prefix is less than the longer string it prefixes
CHECK(compare_ascii_case_insensitive_natural("app", "apple") < 0);
CHECK(compare_ascii_case_insensitive_natural("apple", "app") > 0);
// equal numeric value: fewer leading zeros wins the tie
CHECK(compare_ascii_case_insensitive_natural("1", "01") < 0);
CHECK(compare_ascii_case_insensitive_natural("01", "1") > 0);
// reflexivity and empty-string boundaries
CHECK(compare_ascii_case_insensitive_natural("plugin", "plugin") == 0);
CHECK(compare_ascii_case_insensitive_natural("", "") == 0);
CHECK(compare_ascii_case_insensitive_natural("", "a") < 0);
}
TEST_CASE("plugin dialog None sort key falls to ascending base order in both directions", "[plugin][sort]")
{
std::vector<SortFixtureItem> items = {
{"z_mine", PluginSource::Mine, PluginStatus::Activated, "script", "Zebra"},
{"a_local", PluginSource::Local, PluginStatus::Activated, "script", "Apple"},
{"m_sub", PluginSource::Subscribed, PluginStatus::Error, "script", "Mango"},
};
// why: no primary key -> pure name-first base order (Apple < Mango < Zebra). A source-first
// baseline would instead give {z_mine, m_sub, a_local}, so this pins the name-first order.
const std::vector<std::string> base_expected = {"a_local", "m_sub", "z_mine"};
sort_plugin_items_for_dialog(items, PluginSortKey::None, PluginSortOrder::Asc);
CHECK(keys(items) == base_expected);
// why: None has no direction - Desc must not reverse the baseline.
sort_plugin_items_for_dialog(items, PluginSortKey::None, PluginSortOrder::Desc);
CHECK(keys(items) == base_expected);
}
TEST_CASE("plugin dialog sort request parsing keeps previous state on invalid values", "[plugin][sort]")
{
CHECK(plugin_sort_key_from_string("source", PluginSortKey::Status) == PluginSortKey::Source);
CHECK(plugin_sort_key_from_string("none", PluginSortKey::Status) == PluginSortKey::None);
CHECK(plugin_sort_key_from_string("missing", PluginSortKey::Name) == PluginSortKey::Name);
CHECK(plugin_sort_order_from_string("desc", PluginSortOrder::Asc) == PluginSortOrder::Desc);
CHECK(plugin_sort_order_from_string("down", PluginSortOrder::Asc) == PluginSortOrder::Asc);
}

View File

@@ -0,0 +1,682 @@
#include <catch2/catch_test_macros.hpp>
#include "slic3r/plugin/PythonPluginInterface.hpp"
using namespace Slic3r;
TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pipeline]") {
CHECK(plugin_capability_type_to_string(PluginCapabilityType::SlicingPipeline) == "slicing-pipeline");
CHECK(plugin_capability_type_display_name(PluginCapabilityType::SlicingPipeline) == "Slicing Pipeline");
CHECK(plugin_capability_type_from_string("slicing-pipeline") == PluginCapabilityType::SlicingPipeline);
CHECK(plugin_capability_type_from_string("SLICING-PIPELINE") == PluginCapabilityType::SlicingPipeline);
CHECK(plugin_capability_type_from_string("nope") == PluginCapabilityType::Unknown);
}
#include "python_test_support.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Surface.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <pybind11/embed.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
TEST_CASE("make_readonly_rows builds a read-only (N,2) int64 view", "[slicing_pipeline]") {
ensure_python_initialized(); // helper already used by test_plugin_host_api.cpp
py::gil_scoped_acquire gil;
// make_readonly_rows() constructs a py::array_t, which requires numpy to be
// importable in the embedded interpreter. The unit-test interpreter ships no
// site-packages (same condition test_plugin_host_api.cpp's TriangleMesh numpy
// test guards against), so skip the array-backed assertions when numpy is
// unavailable there rather than fail on an environment quirk.
bool have_numpy = false;
try {
py::module_::import("numpy");
have_numpy = true;
} catch (const py::error_already_set&) {
have_numpy = false;
}
if (!have_numpy) {
SKIP("numpy unavailable in unit-test interpreter");
}
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
py::capsule keepalive(&pts, [](void*){});
py::array a = Slic3r::make_readonly_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
CHECK(a.dtype().kind() == 'i');
CHECK(a.itemsize() == 8); // int64
CHECK(a.shape(0) == 2);
CHECK(a.shape(1) == 2);
CHECK_FALSE(a.writeable());
auto r = a.unchecked<coord_t, 2>();
CHECK(r(0,0) == 10); CHECK(r(1,1) == 40);
}
TEST_CASE("make_writable_rows builds a writable (N,2) int64 view that aliases the buffer", "[slicing_pipeline]") {
ensure_python_initialized();
py::gil_scoped_acquire gil;
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
py::capsule keepalive(&pts, [](void*){});
py::array a = Slic3r::make_writable_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
CHECK(a.writeable());
// Writing through the view mutates the C++ buffer (zero-copy alias).
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(99));
CHECK(pts.front().x() == 99);
}
TEST_CASE("orca.slicing module: Step enum, context, and a Python capability can execute", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module(); // forces PythonPluginBridge::instance() (see test_plugin_host_api.cpp:32-40)
py::gil_scoped_acquire gil;
py::module_ orca = py::module_::import("orca");
REQUIRE(py::hasattr(orca, "slicing"));
py::object slicing = orca.attr("slicing");
CHECK(py::hasattr(slicing, "Step"));
CHECK(py::hasattr(slicing.attr("Step"), "posSlice"));
CHECK(py::hasattr(slicing.attr("Step"), "psGCodePostProcess"));
CHECK(py::hasattr(slicing, "SlicingPipelineContext"));
CHECK(py::hasattr(slicing, "SlicingPipelineCapabilityBase"));
// A trivial Python subclass whose execute() reports success, invoked via the C++ trampoline.
py::exec(R"(
import orca
class Probe(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): return "probe"
def execute(self, ctx): return orca.ExecutionResult.success("ok")
_probe = Probe()
)");
// (Full C++ trampoline invocation with a real context is exercised elsewhere.)
}
TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view classes are gone", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::module_ orca = py::module_::import("orca");
py::object slicing = orca.attr("slicing");
// Context surface: raw graph entry points + workflow accessors.
for (const char* name : { "print", "object", "params", "config_value", "cancelled",
"orca_version", "step" })
CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), name));
// The wrapper layer is gone.
for (const char* legacy : { "ExPolygonView", "SurfaceView", "LayerRegionView",
"LayerView", "PrintObjectView", "PathData", "SurfaceType" })
CHECK_FALSE(py::hasattr(slicing, legacy));
// unscale() stays in orca.slicing and reads the live SCALING_FACTOR.
const coord_t scaled10 = (coord_t) scale_(10.0);
double mm = slicing.attr("unscale")(scaled10).cast<double>();
CHECK_THAT(mm, WithinRel(10.0, 1e-9));
// A default context casts print/object to None (no dangling wrapper).
Slic3r::SlicingPipelineContext ctx;
py::object pyctx = py::cast(&ctx, py::return_value_policy::reference);
CHECK(pyctx.attr("print").is_none());
CHECK(pyctx.attr("object").is_none());
}
#include "libslic3r/PrintConfig.hpp" // DynamicPrintConfig for the psGCodePostProcess context
#include <boost/filesystem.hpp>
#include <boost/nowide/fstream.hpp>
#include <sstream>
// psGCodePostProcess is the merged post-processing seam: no live Print (print/object are None), the
// plugin edits the file at ctx.gcode_path in place, and ctx.config_value() falls back to the config
// the export path handed in. Exercising the real bindings by calling the Python execute() directly
// (not the C++ audit trampoline) keeps this a pure binding-surface test.
TEST_CASE("orca.slicing psGCodePostProcess context: file edit in place + config fallback", "[slicing_pipeline]") {
namespace fs = boost::filesystem;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
const fs::path gpath = fs::temp_directory_path() / fs::unique_path("orca_pp_%%%%-%%%%.gcode");
{
boost::nowide::ofstream ofs(gpath.string());
ofs << "; header\nG1 X0 Y0\n";
}
// Config the plugin reads back through ctx.config_value() (there is no live Print at this step).
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("layer_height", new Slic3r::ConfigOptionFloat(0.2));
Slic3r::SlicingPipelineContext ctx;
ctx.orca_version = "test";
ctx.step = Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess;
ctx.gcode_path = gpath.string();
ctx.host = "File";
ctx.output_name = "final.gcode";
ctx.full_config = &config; // print stays null
py::object pyctx = py::cast(&ctx, py::return_value_policy::reference);
CHECK(pyctx.attr("gcode_path").cast<std::string>() == gpath.string());
CHECK(pyctx.attr("host").cast<std::string>() == "File");
CHECK(pyctx.attr("output_name").cast<std::string>() == "final.gcode");
CHECK(pyctx.attr("print").is_none());
CHECK(pyctx.attr("object").is_none());
CHECK(pyctx.attr("step").cast<Slic3r::SlicingPipelineStepPlugin>()
== Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess);
CHECK_FALSE(pyctx.attr("cancelled")().cast<bool>()); // null print -> not cancelled
// config_value() resolves from full_config when print is null; unknown keys are None.
CHECK_FALSE(pyctx.attr("config_value")("layer_height").is_none());
CHECK(pyctx.attr("config_value")("this_key_does_not_exist").is_none());
// A Python capability edits the file in place through ctx.gcode_path. Calling execute() directly
// in Python dispatches to the Python method (no C++ trampoline), so this needs no audit context.
py::module_ main = py::module_::import("__main__");
main.attr("_pp_ctx") = pyctx;
py::exec(R"(
import orca
class Stamp(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): return "stamp"
def execute(self, ctx):
assert ctx.step == orca.slicing.Step.psGCodePostProcess
assert ctx.print is None and ctx.object is None
with open(ctx.gcode_path, "a") as f:
f.write("; stamped by " + ctx.host + "\n")
return orca.ExecutionResult.success("ok")
_pp_result = Stamp().execute(_pp_ctx)
)");
CHECK(main.attr("_pp_result").attr("message").cast<std::string>() == std::string("ok"));
std::string contents;
{
boost::nowide::ifstream ifs(gpath.string());
std::stringstream ss; ss << ifs.rdbuf(); contents = ss.str();
}
CHECK(contents.find("; stamped by File") != std::string::npos);
fs::remove(gpath);
}
// ---------------------------------------------------------------------------
// Toolpath helpers for the raw-graph tests.
//
// LayerRegion's ctor is protected (constructed only by Layer/PrintObject). A
// trivial derived struct lets a unit test build one with null layer/region
// pointers — the extrusion accessors only read the public `perimeters`/`fills`
// collections, never the layer/region back-pointers.
// ---------------------------------------------------------------------------
namespace {
struct TestLayerRegion : Slic3r::LayerRegion {
TestLayerRegion() : Slic3r::LayerRegion(nullptr, nullptr) {}
};
// Build a realistic nested perimeters collection into `region.perimeters`:
// perimeters (outer) -> inner collection -> [ ExtrusionLoop(pathA), ExtrusionPath(pathB) ]
// This exercises both the recursive descent through nested collections and the
// decomposition of an ExtrusionLoop into its contained ExtrusionPath (flatten()
// does NOT decompose loops, hence the hand-rolled recursive walk).
static void build_nested_perimeters(TestLayerRegion& region) {
using namespace Slic3r;
ExtrusionPath pathA(erExternalPerimeter); // -> "Outer wall"
pathA.mm3_per_mm = 0.05; pathA.width = 0.45f; pathA.height = 0.20f;
pathA.polyline.points = { Point3(0, 0, 0), Point3(10, 0, 0), Point3(10, 10, 0) };
ExtrusionPath pathB(erInternalInfill); // -> "Sparse infill"
pathB.mm3_per_mm = 0.03; pathB.width = 0.40f; pathB.height = 0.20f;
pathB.polyline.points = { Point3(1, 1, 0), Point3(2, 1, 0), Point3(2, 2, 0) };
ExtrusionEntityCollection inner;
inner.append(ExtrusionLoop(pathA)); // clone_move
inner.append(pathB); // clone
region.perimeters.append(inner); // nested (deep clone)
}
} // namespace
// ---------------------------------------------------------------------------
// Raw Print-graph data model (orca.host) — replaces the *View wrapper API.
// LIFETIME: raw bindings follow C++ semantics — references into the slicing
// graph are valid during execute(ctx) and invalidated by container-replacing
// mutators, exactly like std::vector iterators.
// ---------------------------------------------------------------------------
TEST_CASE("orca.host leaf geometry: Surface/ExPolygon/Polygon raw bindings", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
for (const char* name : { "SurfaceType", "Polygon", "ExPolygon", "Surface", "SurfaceCollection" })
CHECK(py::hasattr(host, name));
// SurfaceType enum values round-trip to the C++ enumerators (moved from orca.slicing).
py::object ST = host.attr("SurfaceType");
CHECK(ST.attr("stTop").cast<Slic3r::SurfaceType>() == Slic3r::stTop);
CHECK(ST.attr("stInternalSolid").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
CHECK(ST.attr("stPerimeter").cast<Slic3r::SurfaceType>() == Slic3r::stPerimeter);
// Raw Surface: scalar reads + WRITABLE surface_type (replaces SurfaceView.set_type).
Slic3r::Surface surf(Slic3r::stInternalSolid);
surf.thickness = 0.4;
surf.bridge_angle = -1.0;
surf.extra_perimeters = 2;
py::object sv = py::cast(&surf, py::return_value_policy::reference);
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
CHECK_THAT(sv.attr("thickness").cast<double>(), WithinRel(0.4, 1e-9));
CHECK_THAT(sv.attr("bridge_angle").cast<double>(), WithinAbs(-1.0, 1e-12));
CHECK(sv.attr("extra_perimeters").cast<int>() == 2);
sv.attr("surface_type") = host.attr("SurfaceType").attr("stTop");
CHECK(surf.surface_type == Slic3r::stTop); // C++ side reflects the assignment
// ExPolygon navigation without numpy: contour is a Polygon, holes an empty list.
py::object exv = sv.attr("expolygon");
CHECK(py::hasattr(exv, "contour"));
CHECK(exv.attr("holes").cast<py::list>().size() == 0);
CHECK(exv.attr("contour").attr("size")().cast<size_t>() == 0);
}
TEST_CASE("orca.host Surface/SurfaceCollection: construct, writable members, set()", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
py::object ST = host.attr("SurfaceType");
const coord_t s = (coord_t) scale_(10.0);
// Build an ExPolygon (Point idiom) and a Surface from it.
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
py::object ex = host.attr("ExPolygon")(P);
py::object surf = host.attr("Surface")(ST.attr("stTop"), ex);
CHECK(surf.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stTop);
CHECK(surf.attr("is_top")().cast<bool>());
CHECK_THAT(surf.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
surf.attr("thickness") = py::float_(0.3);
CHECK_THAT(surf.attr("thickness").cast<double>(), WithinRel(0.3, 1e-9));
// SurfaceCollection.set(expolys, type) — the faithful replacement for set_slices' body.
Slic3r::SurfaceCollection coll;
py::object cv = py::cast(&coll, py::return_value_policy::reference);
py::list expolys; expolys.append(ex);
cv.attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(coll.surfaces.size() == 1);
CHECK(coll.surfaces.front().surface_type == Slic3r::stInternalSolid);
CHECK(cv.attr("has")(ST.attr("stInternalSolid")).cast<bool>());
cv.attr("clear")();
CHECK(coll.surfaces.empty());
}
TEST_CASE("orca.host Point: construct, read/write coords, arithmetic", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
REQUIRE(py::hasattr(host, "Point"));
py::object p = host.attr("Point")(3, 4);
CHECK(p.attr("x").cast<coord_t>() == 3);
CHECK(p.attr("y").cast<coord_t>() == 4);
p.attr("x") = py::int_(7);
CHECK(p.attr("x").cast<coord_t>() == 7);
py::object q = host.attr("Point")(1, 2);
py::object sum = p.attr("__add__")(q);
CHECK(sum.attr("x").cast<coord_t>() == 8);
CHECK(sum.attr("y").cast<coord_t>() == 6);
// __mul__ must scale as a double, not truncate to int64 before multiplying.
py::object h = host.attr("Point")(10, 20).attr("__mul__")(py::float_(0.5));
CHECK(h.attr("x").cast<coord_t>() == 5);
CHECK(h.attr("y").cast<coord_t>() == 10);
}
TEST_CASE("orca.host Polygon: writable as_array aliases buffer; Point refs; set_points; offset", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
Slic3r::Polygon poly;
poly.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0), Slic3r::Point(s, s), Slic3r::Point(0, s) };
py::object pv = py::cast(&poly, py::return_value_policy::reference);
// Non-array surface works without numpy.
CHECK(pv.attr("size")().cast<size_t>() == 4);
CHECK(pv.attr("is_counter_clockwise")().cast<bool>());
CHECK_THAT(pv.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
// Point-object idiom: editing a returned Point ref mutates the buffer in place.
py::list pts = pv.attr("points").cast<py::list>();
REQUIRE(pts.size() == 4);
pts[0].attr("x") = py::int_(5);
CHECK(poly.points[0].x() == 5);
poly.points[0].x() = 0; // restore
// offset() returns new geometry (ClipperUtils bound as a method).
py::list shrunk = pv.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
CHECK(shrunk.size() >= 1);
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable: array-backed assertions skipped");
py::module_ np = py::module_::import("numpy");
py::array a = pv.attr("as_array")().cast<py::array>();
CHECK(a.dtype().kind() == 'i');
CHECK(a.itemsize() == 8);
CHECK(a.shape(0) == 4);
CHECK(a.shape(1) == 2);
CHECK(a.writeable()); // writable now
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(123));
CHECK(poly.points[0].x() == 123); // in-place bulk edit
// set_points replaces contents (count-changing).
py::object i64 = np.attr("int64");
py::list rows;
rows.append(py::make_tuple(0, 0)); rows.append(py::make_tuple(s, 0)); rows.append(py::make_tuple(s, s));
pv.attr("set_points")(np.attr("array")(rows, py::arg("dtype") = i64));
CHECK(poly.points.size() == 3);
}
TEST_CASE("orca.host ExPolygon: construct, writable contour/holes, transforms, boolean ops", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
// Construct from Polygon objects (Point idiom, no numpy).
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
py::object ex = host.attr("ExPolygon")(P);
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
CHECK(ex.attr("num_contours")().cast<size_t>() == 1);
CHECK(ex.attr("contour").attr("size")().cast<size_t>() == 4);
// In-place transform mutates the geometry.
ex.attr("translate")(py::float_(1000.0), py::float_(0.0));
// Boolean op returns new geometry: A minus a smaller inset of A is a non-empty ring set.
py::list inset = ex.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
REQUIRE(inset.size() >= 1);
py::list ring = ex.attr("diff_ex")(inset[0]).cast<py::list>();
CHECK(ring.size() >= 1);
}
namespace {
// Nested collection: outer -> inner -> [ ExtrusionLoop(pathA), ExtrusionPath(pathB) ].
// Exercises polymorphic downcast of .entities and loop decomposition in flatten_paths().
static Slic3r::ExtrusionEntityCollection build_nested_collection() {
using namespace Slic3r;
ExtrusionPath pathA(erExternalPerimeter); // -> "Outer wall"
pathA.mm3_per_mm = 0.05; pathA.width = 0.45f; pathA.height = 0.20f;
pathA.polyline.points = { Point3(0, 0, 0), Point3(10, 0, 0), Point3(10, 10, 0) };
ExtrusionPath pathB(erInternalInfill); // -> "Sparse infill"
pathB.mm3_per_mm = 0.03; pathB.width = 0.40f; pathB.height = 0.20f;
pathB.polyline.points = { Point3(1, 1, 0), Point3(2, 1, 0), Point3(2, 2, 0) };
ExtrusionEntityCollection inner;
inner.append(ExtrusionLoop(pathA));
inner.append(pathB);
ExtrusionEntityCollection outer;
outer.append(inner);
return outer;
}
} // namespace
TEST_CASE("orca.host extrusion tree: polymorphic entities + flatten_paths", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
for (const char* name : { "ExtrusionEntity", "ExtrusionPath", "ExtrusionLoop",
"ExtrusionMultiPath", "ExtrusionEntityCollection", "PrintRegion" })
CHECK(py::hasattr(host, name));
Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
py::object coll = py::cast(&outer, py::return_value_policy::reference);
// .entities downcasts: the single child is a collection; ITS children are a loop + a path.
py::list kids = coll.attr("entities").cast<py::list>();
REQUIRE(kids.size() == 1);
py::list inner_kids = kids[0].attr("entities").cast<py::list>();
REQUIRE(inner_kids.size() == 2);
CHECK(py::hasattr(inner_kids[0], "paths")); // ExtrusionLoop binding
CHECK(py::hasattr(inner_kids[1], "width")); // ExtrusionPath binding
// flatten_paths: loop decomposed, scalars readable.
py::list ps = coll.attr("flatten_paths")().cast<py::list>();
REQUIRE(ps.size() == 2);
CHECK(ps[0].attr("role").cast<std::string>() == "Outer wall");
CHECK_THAT(ps[0].attr("width").cast<double>(), WithinRel(0.45, 1e-6));
CHECK_THAT(ps[0].attr("mm3_per_mm").cast<double>(), WithinRel(0.05, 1e-9));
CHECK(ps[1].attr("role").cast<std::string>() == "Sparse infill");
}
TEST_CASE("orca.host ExtrusionPath.points() is a read-only (N,3) int64 view", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
py::object coll = py::cast(&outer, py::return_value_policy::reference);
py::list ps = coll.attr("flatten_paths")().cast<py::list>();
REQUIRE(ps.size() == 2);
py::array pts = ps[1].attr("points")().cast<py::array>(); // pathB: (1,1,0),(2,1,0),(2,2,0)
CHECK(pts.dtype().kind() == 'i');
CHECK(pts.itemsize() == 8);
CHECK(pts.shape(0) == 3);
CHECK(pts.shape(1) == 3);
CHECK_FALSE(pts.writeable());
auto r = pts.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(r(0, 0) == 1); CHECK(r(1, 0) == 2); CHECK(r(2, 1) == 2);
}
// ---------------------------------------------------------------------------
// Raw Print-graph spine (orca.host): LayerRegion / Layer / PrintObject / Print,
// read side. LayerRegion/Layer ctors are protected (friend class PrintObject),
// so the tests use tiny derived structs -- the pattern TestLayerRegion above
// already establishes; TestLayer is its Layer counterpart.
// ---------------------------------------------------------------------------
namespace {
struct TestLayer : Slic3r::Layer {
// id=0, no owning PrintObject, height/print_z/slice_z suitable for assertions.
TestLayer() : Slic3r::Layer(0, nullptr, 0.2, 0.45, 0.35) {}
};
} // namespace
TEST_CASE("orca.host graph classes: LayerRegion/Layer raw traversal; Print/PrintObject registered", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
for (const char* name : { "LayerRegion", "Layer", "PrintObject", "Print" })
CHECK(py::hasattr(host, name));
// Members needing a live Print are verified by registration only (slic3rutils
// cannot build a Print; the fff_print C++ suite covers live-graph behavior).
for (const char* name : { "layers", "support_layers", "model_object", "id",
"bounding_box", "trafo", "config_value", "config_keys" })
CHECK(py::hasattr(host.attr("PrintObject"), name));
for (const char* name : { "objects", "model", "config_value", "config_keys", "canceled" })
CHECK(py::hasattr(host.attr("Print"), name));
// Raw LayerRegion traversal over a hand-built region.
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
build_nested_perimeters(region); // helper defined earlier in this file
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
CHECK(lr.attr("slices").attr("size")().cast<size_t>() == 1);
CHECK(lr.attr("slices").attr("surfaces").cast<py::list>().size() == 1);
CHECK(lr.attr("perimeters").attr("flatten_paths")().cast<py::list>().size() == 2);
CHECK(lr.attr("fills").attr("size")().cast<size_t>() == 0);
CHECK(lr.attr("layer")().is_none()); // hand-built region has no owning layer
// Raw Layer scalars + empty traversals on a hand-built layer.
TestLayer layer;
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer),
py::return_value_policy::reference);
CHECK_THAT(ly.attr("print_z").cast<double>(), WithinRel(0.45, 1e-9));
CHECK_THAT(ly.attr("slice_z").cast<double>(), WithinRel(0.35, 1e-9));
CHECK_THAT(ly.attr("height").cast<double>(), WithinRel(0.2, 1e-9));
CHECK(ly.attr("regions")().cast<py::list>().size() == 0);
CHECK(ly.attr("lslices")().cast<py::list>().size() == 0);
CHECK(ly.attr("upper_layer").is_none());
CHECK(ly.attr("lower_layer").is_none());
}
TEST_CASE("orca.host: plugin-only mutators are gone; class-API editing works", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
// The three plugin-only mutators were removed in the raw-API realignment.
CHECK_FALSE(py::hasattr(host.attr("LayerRegion"), "set_slices"));
CHECK_FALSE(py::hasattr(host.attr("LayerRegion"), "set_fill_surfaces"));
CHECK_FALSE(py::hasattr(host.attr("Layer"), "set_lslices"));
// The faithful surface is present.
CHECK(py::hasattr(host.attr("SurfaceCollection"), "set"));
CHECK(py::hasattr(host.attr("Layer"), "make_slices"));
// clear() via the collection on a hand-built region (null owning layer is null-safe).
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::return_value_policy::reference);
lr.attr("slices").attr("clear")();
CHECK(region.slices.surfaces.empty());
}
TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_slices", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
py::object host = py::module_::import("orca").attr("host");
py::module_ np = py::module_::import("numpy");
py::object i64 = np.attr("int64");
py::object ST = host.attr("SurfaceType");
const coord_t s = (coord_t) scale_(10.0);
auto arr = [&](std::initializer_list<std::pair<coord_t,coord_t>> pts) {
py::list rows; for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second));
return np.attr("array")(rows, py::arg("dtype") = i64);
};
// Build an ExPolygon from a CW ndarray; the ctor normalizes to CCW.
py::object ex = host.attr("ExPolygon")(arr({ {0,0}, {0,s}, {s,s}, {s,0} }));
CHECK(ex.attr("contour").attr("is_counter_clockwise")().cast<bool>());
TestLayerRegion region;
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::return_value_policy::reference);
py::list expolys; expolys.append(ex);
lr.attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(region.slices.surfaces.size() == 1);
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.surface_type == Slic3r::stInternalSolid);
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9));
// Read geometry back through the class API.
py::array c = lr.attr("slices").attr("surfaces").cast<py::list>()[0]
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
CHECK(c.shape(0) == 4);
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
TestLayer layer;
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
ly.attr("make_slices")();
CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
}
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
auto make_square = [&]() {
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
return host.attr("ExPolygon")(P);
};
const double area0 = (double) s * (double) s;
// rotate about the square's center preserves area
py::object ex = make_square();
py::object center = host.attr("Point")(s / 2, s / 2);
ex.attr("rotate")(py::float_(1.5707963267948966), center); // pi/2
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
// uniform scale by 2 quadruples area (scale is about the origin)
py::object ex2 = make_square();
ex2.attr("scale")(py::float_(2.0));
CHECK_THAT(ex2.attr("area")().cast<double>(), WithinRel(4.0 * area0, 1e-6));
// translate preserves area
py::object ex3 = make_square();
ex3.attr("translate")(py::float_(1000.0), py::float_(-500.0));
CHECK_THAT(ex3.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
// SurfaceCollection.append accumulates surfaces of a second type (the sample write-back path)
Slic3r::SurfaceCollection coll;
py::object cv = py::cast(&coll, py::return_value_policy::reference);
py::list g1; g1.append(make_square());
cv.attr("set")(g1, host.attr("SurfaceType").attr("stInternalSolid"));
py::list g2; g2.append(make_square());
cv.attr("append")(g2, host.attr("SurfaceType").attr("stTop"));
REQUIRE(coll.surfaces.size() == 2);
CHECK(coll.surfaces[0].surface_type == Slic3r::stInternalSolid);
CHECK(coll.surfaces[1].surface_type == Slic3r::stTop);
}
TEST_CASE("orca.host: in-place edit of surface.expolygon through a live collection persists to C++", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
const coord_t s = (coord_t) scale_(10.0);
// Live LayerRegion holding one surface (a 10mm square at the origin).
TestLayerRegion region;
Slic3r::ExPolygon sq;
sq.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
Slic3r::Point(s, s), Slic3r::Point(0, s) };
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal, sq));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
// Twistify's path: get the Surface through the live collection, mutate its expolygon in place.
py::object surf = lr.attr("slices").attr("surfaces").cast<py::list>()[0];
surf.attr("expolygon").attr("translate")(py::float_(1000.0), py::float_(0.0));
// The C++-side surface geometry reflects the Python in-place edit (proves the live ref).
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.expolygon.contour.points[0].x() == 1000); // was 0
CHECK(out.expolygon.contour.points[0].y() == 0);
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // translate preserves area
}