mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-11 11:26:34 +03:00
Compare commits
7 Commits
feat/plugi
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed9c97283b | ||
|
|
23a32e763e | ||
|
|
1354d04778 | ||
|
|
06910bc891 | ||
|
|
773ad7943e | ||
|
|
aafcccc83c | ||
|
|
b0bacdd00b |
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
138
resources/web/dialog/PluginsDialog/plugin-search.css
Normal file
138
resources/web/dialog/PluginsDialog/plugin-search.css
Normal 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);
|
||||
}
|
||||
156
resources/web/dialog/PluginsDialog/plugin-search.js
Normal file
156
resources/web/dialog/PluginsDialog/plugin-search.js
Normal 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);
|
||||
42
resources/web/dialog/PluginsDialog/plugin-sort.css
Normal file
42
resources/web/dialog/PluginsDialog/plugin-sort.css
Normal 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);
|
||||
}
|
||||
67
resources/web/dialog/PluginsDialog/plugin-sort.js
Normal file
67
resources/web/dialog/PluginsDialog/plugin-sort.js
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
74
sandboxes/orca_gcode_stamp_plugin_any.py
Normal file
74
sandboxes/orca_gcode_stamp_plugin_any.py
Normal 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)
|
||||
83
sandboxes/orca_inset_plugin_any.py
Normal file
83
sandboxes/orca_inset_plugin_any.py
Normal 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)
|
||||
146
sandboxes/orca_twistify_plugin_example_any.py
Normal file
146
sandboxes/orca_twistify_plugin_example_any.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
210
src/slic3r/GUI/PluginSort.hpp
Normal file
210
src/slic3r/GUI/PluginSort.hpp
Normal 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
|
||||
29
src/slic3r/GUI/PluginSource.hpp
Normal file
29
src/slic3r/GUI/PluginSource.hpp
Normal 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
|
||||
31
src/slic3r/GUI/PluginStatus.hpp
Normal file
31
src/slic3r/GUI/PluginStatus.hpp
Normal 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
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
106
src/slic3r/plugin/PluginBindingUtils.hpp
Normal file
106
src/slic3r/plugin/PluginBindingUtils.hpp
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
532
src/slic3r/plugin/PluginHostSlicing.cpp
Normal file
532
src/slic3r/plugin/PluginHostSlicing.cpp
Normal 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
|
||||
16
src/slic3r/plugin/PluginHostSlicing.hpp
Normal file
16
src/slic3r/plugin/PluginHostSlicing.hpp
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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_ */
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
472
tests/fff_print/test_slicing_pipeline_hook.cpp
Normal file
472
tests/fff_print/test_slicing_pipeline_hook.cpp
Normal 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
|
||||
}
|
||||
@@ -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"});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
38
tests/slic3rutils/python_test_support.hpp
Normal file
38
tests/slic3rutils/python_test_support.hpp
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
189
tests/slic3rutils/test_plugin_sort.cpp
Normal file
189
tests/slic3rutils/test_plugin_sort.cpp
Normal 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);
|
||||
}
|
||||
682
tests/slic3rutils/test_slicing_pipeline_bindings.cpp
Normal file
682
tests/slic3rutils/test_slicing_pipeline_bindings.cpp
Normal 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*>(®ion),
|
||||
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*>(®ion), 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*>(®ion), 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*>(®ion),
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user