Compare commits

..
Author SHA1 Message Date
peachismomo 73059233f6 fix: Allow plugins to read the embedded Python stdlib 2026-10-20 03:26:10 +08:00
SoftFever ddaf9ba5bc Stop re-asking for plugin permissions granted in an earlier session 2026-10-20 00:01:02 +08:00
Ian Bassi c5cfe40a1f Update translations: fill empty entries in all languages and fix color picker strings (#16652) 2026-10-19 10:19:06 -03:00
SoftFever e5b6cbf091 Let plugins make HTTPS requests on macOS (#16638)
* Let the bundled Python verify HTTPS certificates on macOS

* Restore SSL_CERT_FILE after the bundled Python override test
2026-10-19 20:06:14 +08:00
Alexandre Folle de Menezes 3b9b871ba6 Standardize the spelling of "G-code", "SVG", "GitHub" and "IP" (#16618) 2026-10-19 09:01:39 -03:00
Surfoo cb2260f057 i18n(fr): update translations (#16591) 2026-10-19 09:00:32 -03:00
Alexandre Folle de Menezes 3fcbb2adf9 Translate color names to pt-BR (#16585) 2026-10-19 08:59:35 -03:00
Sergei 252bbbf9f5 Make the exclude-object outline tolerance a user setting (#16503)
* Make the exclude-object outline tolerance configurable

Signed-off-by: Sergei Rozhkov <zmod@zmod.link>

* fix(config): move exclude_object_tolerance to developer mode

Signed-off-by: Sergei Rozhkov <zmod@zmod.link>

* fix(config): move exclude_object_tolerance to expert mode

Signed-off-by: Sergei Rozhkov <zmod@zmod.link>

* feat(ui): hide exclude_object_tolerance for non-Klipper firmwares

mend

Signed-off-by: Sergei Rozhkov <zmod@zmod.link>

* Test that a larger exclude object tolerance writes fewer outline points

Signed-off-by: Sergei Rozhkov <zmod@zmod.link>

---------

Signed-off-by: Sergei Rozhkov <zmod@zmod.link>
2026-10-19 08:17:20 -03:00
Lam Wei Lun 4c2ae6d202 Keep Auto Drop off for projects saved by 2.4.x (#16633)
2.4.0 to 2.4.2 wrote auto_drop on each build item, and 2.4.x objects
carry no auto_drop attribute in model_settings.config. The importer only
read the object attribute and treated a missing one as on, so floating
objects were dropped to the bed on load and the flag was rewritten as on
on the next save.

Record object ids with an item auto_drop="0" and use that as the fallback
when the object has no attribute. The attribute's presence is now tracked
so the fallback applies only when it is absent. Any item set to off turns
auto drop off for the object, since the flag is shared by all instances.
2026-10-19 16:08:59 +08:00
HanifKoh ac022b56ad Wait for the Close Frame in the Moonraker Status Stream Test (#16635)
The fake printer counts the agent's close frame in its read handler, but
Beast answers the close before that read completes, so stop() can return
and the test can read the counter before it is incremented. On a loaded
runner the check saw 0.

Wait for the counter like the other fake-printer counters do, then keep
the exact-count check.
2026-10-19 16:06:41 +08:00
HanifKoh d3699dd9f6 Keep Python Bytecode out of the Bundled Python Home (#16550)
* Keep Python Bytecode out of the Bundled Python Home

The bundled stdlib ships .pyc files that are stale against their sources:
they are compiled when the Python deps are installed, and the copy into the
app does not keep the sources' mtimes. CPython then recompiles every module
it imports and writes the result next to the source. A drag-installed macOS
app rewrites files inside its own bundle on first launch, so its signature
no longer verifies, and read-only installs (AppImage, Flatpak, Program Files)
recompile the same modules on every launch.

Point pycache_prefix at <data_dir>/python/pycache so the interpreter reads
and writes bytecode there. The bundle is never written, the cache survives
between launches, and the data directory is already a plugin read/write root.

* Drop Cached Bytecode Through the Interpreter in the Module Release Test
2026-10-19 13:48:53 +08:00
HanifKoh a315a96c5c Show "No Available Printers" Instead of an Empty Printer Dropdown (#16611)
* Show "No Available Printers" Instead of an Empty Printer Dropdown

A ComboBox with no items opened a one-row-high popup that DropDown never
paints, leaving a blank strip with stale pixels under the box. Don't open
the list when it is empty, and label the printer box in the print and
send-to-printer dialogs when no printer is available.

* Add "No Available Printers" to the Translation Template
2026-10-19 13:30:36 +08:00
HanifKoh 24cf1c6bcc Fix Crash Selecting a User Printer Whose Parent Preset Is Not Loaded (#16579)
A user preset can inherit a system preset from a vendor that is not
installed. get_preset_base() returns null for it, and
get_selected_preset_base() dereferenced that unconditionally, so picking
the printer crashed while building the shared profiles notification URL.
Fall back to the selected preset itself. The create printer and create
filament dialogs had the same null dereference on such presets.
2026-10-19 13:29:26 +08:00
Kris Austin d16ca97380 Snapmaker U1: set the top cover purifier mode and match Snapmaker's start G-code (#16590)
Port the U1 top cover purifier mode and the related start G-code
changes from Snapmaker's Orca (Snapmaker/OrcaSlicer#504 and
Snapmaker/OrcaSlicer#1005, as of 8931178229).

The start G-code sends SET_PURIFIER_MODE for strong cooling, weak
cooling or keep warm, picked from the lowest softening temperature of
the filaments used through min_vitrification_temperature, with
Snapmaker's thresholds and parameters. It also pre-extrudes the first
tool, and turns the part fan off for probing and back on for nozzle
cleaning.

U1 filament presets use Snapmaker's softening temperatures. U1 presets
that have no U1 version in Snapmaker's Orca use the value Snapmaker
gives that material on the U1.
2026-10-18 21:35:32 -05:00
Kris Austin da4d766d46 Fix Moonraker status stream hanging app exit and missing dead printers (#16604)
The status stream read the websocket synchronously. Beast's
expires_after() only applies to async operations, so when a printer
stopped sending, ws.read() blocked forever, and stop_status_stream()
joined that thread when the app closed. On Windows the stuck process
also froze the next launch through the shared WebView2 browser.

Run the whole stream on one io_context owned by the stream thread, and
have stop_status_stream() post an abort to it. A stop on an open
websocket sends a close frame and waits up to 1 s for the answer.
Keep-alive pings detect a dead printer, and the stream reconnects until
stopped, with the backoff capped at 32 s. When a quiet printer still
answers pings, its last status is sent again. The stream starts under
connect_mutex after a staleness check, and a stop from the stream's own
callback no longer joins its own thread.

The Moonraker tests move into test_moonraker_printer_agent.cpp.
2026-10-18 19:33:58 -05:00
Kris Austin 06665d0558 Post a readable diff of changed custom G-code on profile PRs (#16605)
A profile stores each custom G-code setting as one JSON string, so GitHub
shows a G-code edit as one long changed line and reviewers cannot see
what changed.

Add a gcode-diff command to orca_profile_tool.py. For each changed
profile file it decodes, on both sides, every *_gcode and multiline
string option PrintConfig.cpp defines, the options Tab.cpp edits as
code, and any string value that spans several lines. It prints one
collapsed unified diff per setting as markdown. Files with the same diff
for a setting share one section. Trailing spaces are removed, file names
cannot mention GitHub users, and the output is cut to whole sections to
fit a GitHub comment.

Check profiles runs it between the PR's merge commit and its first
parent and adds the result to its artifact, with a line naming the PR
commit. Post profile check comment posts it under its own marker. It
keeps the comment while the diff is unchanged, edits it in place when
only the commit changed, replaces it when the diff changes, and deletes
it when the PR no longer changes any G-code.

The posting job still runs no PR code. It now posts only when the run
checked the head of the PR the artifact names, which covers the
validation comment too.
2026-10-18 13:33:53 -05:00
Kris Austin 0f59cd5d05 Let Global shortcuts run while typing, except keys that edit text (#16558)
#16505 skipped every Global shortcut while ImGui had the keyboard, so
Ctrl+S, Ctrl+R and the others did nothing while the cursor was in the
text tool's box. Skip only the chords that edit text, and only while a
text field (a wx text entry, a spin control, a web view, or an active
ImGui InputText) has the focus. Skip every Global shortcut while an
ImGui modal is open on the focused canvas.

KeyChord::edits_text() defines the text-editing chords. The Global page
of the shortcuts dialog no longer says these shortcuts apply while
typing.
2026-10-18 13:32:41 -05:00
yw4z c1fb801ff3 Linux: Show display backend of app on Troubleshoot dialog (#16615)
* init

* Update TroubleshootDialog.cpp

* fix build
2026-10-18 22:45:30 +08:00
Lam Wei Lun 1ee0b730a1 Linux: use WebKit's shared-memory transport in hypervisor guests
Web views in VirtualBox (and other virtual GPUs) fail on WebKitGTK >= 2.44
because the guest GL stack cannot back the hardware DMABUF renderer. Setting
WEBKIT_DMABUF_RENDERER_FORCE_SHM=1 fixes it and keeps accelerated compositing,
confirmed in VirtualBox on WebKitGTK 2.54.

Detect a hypervisor guest from the cpuinfo hypervisor flag before GTK init and
set the variable non-replacing, so a user-exported value still wins. Bare metal
is unchanged. The skill references are updated to match.
2026-10-18 04:43:45 +08:00
Kiss Lorand 72ba7a2b35 Fix incorrect layer number in G-code conflict warning (#16588)
Fix layer number in G-code conflict warning

Correct the comparison used to look up a layer by Z height.

The reversed comparison could return the total layer count instead of
the layer corresponding to the reported conflict height.
2026-10-17 23:04:50 +03:00
Downtime 8160f64cae Skip the LAN reconnect after a print to the selected printer (#16271)
After a print, `Plater::print_job_finished` selects the target printer again. When that printer was already the selected LAN printer, `set_selected_machine` disconnected, reset and reconnected it for no reason. Skip the call in that case. Selecting the printer by hand still reconnects it, and cloud printers are unchanged.
2026-10-17 13:50:38 -05:00
123 changed files with 23107 additions and 12770 deletions

No files matched your search

+4 -4
View File
@@ -1,6 +1,6 @@
--- ---
name: orca-translations name: orca-translations
description: Use when updating, filling, reviewing or fixing OrcaSlicer translations in localization/i18n — running scripts/run_gettext --full, translating empty or new msgids in the .po catalogs, AI translation passes and their "# AI Translated" / "# Needs human review" comments, plural forms, msgctxt homonyms, and keeping catalogs consistent with the localization glossary (OrcaSlicer_WIKI localization_glossary.tsv), including adding new glossary terms. Also use for msgfmt or run_gettext failures, placeholder or line-break mismatches, and reviewing a translation PR against the glossary. description: Use when updating, filling, reviewing or fixing OrcaSlicer translations in localization/i18n — running scripts/run_gettext --full, translating empty or new msgids in the .po catalogs, AI translation passes and their "# AI Translated" / "# Needs human review" comments, plural forms, msgctxt homonyms, and keeping catalogs consistent with the localization glossary (OrcaSlicer_WIKI developer_reference/glossary), including adding new glossary terms. Also use for msgfmt or run_gettext failures, placeholder or line-break mismatches, and reviewing a translation PR against the glossary.
--- ---
# OrcaSlicer translations # OrcaSlicer translations
@@ -9,7 +9,7 @@ The UI strings live in gettext catalogs: `localization/i18n/OrcaSlicer.pot` is t
`localization/i18n/<lang>/OrcaSlicer_<lang>.po` holds each language. The `en` catalog is empty on `localization/i18n/<lang>/OrcaSlicer_<lang>.po` holds each language. The `en` catalog is empty on
purpose, so skip it. Recurring terms are fixed by the purpose, so skip it. Recurring terms are fixed by the
[localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/localization_glossary.md), [localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/localization_glossary.md),
whose source is `developer_reference/localization_glossary.tsv` in the **OrcaSlicer_WIKI** repository. whose source is the per-language files in `developer_reference/glossary/` (`terms.tsv`, `languages.txt` and one `<lang>.tsv`) in the **OrcaSlicer_WIKI** repository.
A translation pass therefore touches two repositories: the catalogs here, and the glossary there. A translation pass therefore touches two repositories: the catalogs here, and the glossary there.
All catalog editing goes through [`scripts/po_tool.py`](scripts/po_tool.py) (standard library only). All catalog editing goes through [`scripts/po_tool.py`](scripts/po_tool.py) (standard library only).
@@ -153,7 +153,7 @@ Report:
- anything left empty, and why; - anything left empty, and why;
- the glossary rows added or filled. - the glossary rows added or filled.
The catalogs (and `OrcaSlicer.pot`) and the glossary (`localization_glossary.tsv` and `.md`) are The catalogs (and `OrcaSlicer.pot`) and the glossary (`developer_reference/glossary/*.tsv` and `localization_glossary.md`) are
separate changes in separate repositories. The wiki change should land with, or before, the catalog separate changes in separate repositories. The wiki change should land with, or before, the catalog
change. Commit or open PRs only when asked. change. Commit or open PRs only when asked.
@@ -210,7 +210,7 @@ with the translators, followed by the catalogs.
When this skill and the checkouts disagree, the checkouts win: When this skill and the checkouts disagree, the checkouts win:
- `scripts/run_gettext.sh` / `.bat` for extraction and merging; - `scripts/run_gettext.sh` / `.bat` for extraction and merging;
- `localization/i18n/list.txt` for the scanned sources; - `localization/i18n/list.txt` for the scanned sources;
- the wiki's `localization_guide.md` and `localization_glossary.md` / `.tsv` for the rules and terms; - the wiki's `localization_guide.md` and `localization_glossary.md` and `glossary/*.tsv` for the rules and terms;
- `AGENTS.md` (Localization & translations) for the repository's conventions. - `AGENTS.md` (Localization & translations) for the repository's conventions.
## Editing this skill ## Editing this skill
@@ -28,13 +28,14 @@ import sys
import tempfile import tempfile
import time import time
import unicodedata import unicodedata
import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
REPO = Path(__file__).resolve().parents[4] REPO = Path(__file__).resolve().parents[4]
I18N = REPO / "localization" / "i18n" I18N = REPO / "localization" / "i18n"
SKIP_LANGS = {"en"} # the English catalog is intentionally empty SKIP_LANGS = {"en"} # the English catalog is intentionally empty
GLOSSARY_TSV = Path("developer_reference") / "localization_glossary.tsv" GLOSSARY_DIR = Path("developer_reference") / "glossary" # terms.tsv, languages.txt and one <lang>.tsv per language
GLOSSARY_MD = Path("developer_reference") / "localization_glossary.md" GLOSSARY_MD = Path("developer_reference") / "localization_glossary.md"
MISSING = {"", "—"} MISSING = {"", "—"}
RAW_URL = "https://raw.githubusercontent.com/OrcaSlicer/OrcaSlicer_WIKI/main/" RAW_URL = "https://raw.githubusercontent.com/OrcaSlicer/OrcaSlicer_WIKI/main/"
@@ -252,30 +253,40 @@ def all_langs():
def find_wiki(arg): def find_wiki(arg):
for cand in [arg, os.environ.get("ORCA_WIKI_DIR"), REPO.parent / "OrcaSlicer_WIKI", REPO.parent / "OrcaSlicer.wiki"]: for cand in [arg, os.environ.get("ORCA_WIKI_DIR"), REPO.parent / "OrcaSlicer_WIKI", REPO.parent / "OrcaSlicer.wiki"]:
if cand and (Path(cand) / GLOSSARY_TSV).exists(): if cand and (Path(cand) / GLOSSARY_DIR / "terms.tsv").exists():
return Path(cand) return Path(cand)
return None return None
def _download(rel): def _download(rel, required=True):
cache = Path(tempfile.gettempdir()) / ("orca_glossary_" + rel.name) cache = Path(tempfile.gettempdir()) / "orca_glossary" / rel
if not cache.exists() or time.time() - cache.stat().st_mtime > 3600: if not cache.exists() or time.time() - cache.stat().st_mtime > 3600:
with urllib.request.urlopen(RAW_URL + rel.as_posix(), timeout=30) as r: try:
cache.write_bytes(r.read()) with urllib.request.urlopen(RAW_URL + rel.as_posix(), timeout=30) as r:
data = r.read()
except urllib.error.HTTPError:
if required:
raise
return None
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_bytes(data)
return cache return cache
class Glossary: class Glossary:
def __init__(self, wiki_arg=None, quiet=False): """The glossary: rows of [English, Description, <one translation per language loaded>]."""
def __init__(self, wiki_arg=None, quiet=False, langs=None):
self.wiki = find_wiki(wiki_arg) self.wiki = find_wiki(wiki_arg)
self.langs = langs # only these languages' files are read (and downloaded); None means all
if self.wiki: if self.wiki:
self.tsv, self.md = self.wiki / GLOSSARY_TSV, self.wiki / GLOSSARY_MD self.dir, self.md = self.wiki / GLOSSARY_DIR, self.wiki / GLOSSARY_MD
if not quiet: if not quiet:
self.report_clone_state() self.report_clone_state()
else: else:
if not quiet: if not quiet:
print("note: no OrcaSlicer_WIKI clone found; using the published glossary read-only", file=sys.stderr) print("note: no OrcaSlicer_WIKI clone found; using the published glossary read-only", file=sys.stderr)
self.tsv, self.md = _download(GLOSSARY_TSV), _download(GLOSSARY_MD) self.dir, self.md = _download(GLOSSARY_DIR / "terms.tsv").parent, _download(GLOSSARY_MD)
self.load() self.load()
def report_clone_state(self): def report_clone_state(self):
@@ -290,11 +301,23 @@ class Glossary:
if behind and behind != "0": if behind and behind != "0":
print(f"note: the wiki clone is {behind} commits behind origin/main (as of its last fetch)", file=sys.stderr) print(f"note: the wiki clone is {behind} commits behind origin/main (as of its last fetch)", file=sys.stderr)
def _read(self, name, required=True):
path = self.dir / name if self.wiki else _download(GLOSSARY_DIR / name, required)
return path.read_text(encoding="utf-8-sig").splitlines() if path and path.exists() else None
def load(self): def load(self):
rows = [l.split("\t") for l in self.tsv.read_text(encoding="utf-8-sig").splitlines() if l.strip()] terms = [l.split(" ") for l in self._read("terms.tsv")[1:] if l.strip()]
self.header = rows[0] langs = [l.strip() for l in self._read("languages.txt") if l.strip()]
# "—" marks a language with no established rendering yet langs = [l for l in langs if self.langs is None or l in self.langs]
self.rows = [[c if c.strip() not in MISSING else "" for c in r] + [""] * (len(rows[0]) - len(r)) for r in rows[1:]] self.header = ["English", "Description"] + langs
self.rows = [[r[0], r[1] if len(r) > 1 else ""] + [""] * len(langs) for r in terms]
index = {r[0]: r for r in self.rows}
for col, lang in enumerate(langs, start=2):
for line in (self._read(f"{lang}.tsv", required=False) or [])[1:]:
cells = line.split(" ")
# "—" marks a language with no established rendering yet
if len(cells) > 2 and cells[0] in index and cells[2].strip() not in MISSING:
index[cells[0]][col] = cells[2].strip()
self.matchers = [] self.matchers = []
for row in self.rows: for row in self.rows:
base = re.sub(r"\s*\([^)]*\)\s*$", "", row[0]).strip() base = re.sub(r"\s*\([^)]*\)\s*$", "", row[0]).strip()
@@ -494,7 +517,7 @@ def similar_entries(c, e, pool, cutoff=0.72):
def cmd_pending(a): def cmd_pending(a):
c = Catalog(catalog_path(a.lang)) c = Catalog(catalog_path(a.lang))
g = Glossary(a.wiki) g = Glossary(a.wiki, langs=[a.lang])
todo = [e for e in c.active() if needs_translation(e, c.nplurals, a.include_fuzzy)] todo = [e for e in c.active() if needs_translation(e, c.nplurals, a.include_fuzzy)]
batch = todo[a.offset:a.offset + a.limit] if a.limit else todo[a.offset:] batch = todo[a.offset:a.offset + a.limit] if a.limit else todo[a.offset:]
pool = [(x.msgid, x.msgstrs()[0], x.obsolete) for x in c.entries pool = [(x.msgid, x.msgstrs()[0], x.obsolete) for x in c.entries
@@ -533,7 +556,7 @@ def cmd_pending(a):
def cmd_apply(a): def cmd_apply(a):
c = Catalog(catalog_path(a.lang)) c = Catalog(catalog_path(a.lang))
g = None if a.no_glossary else Glossary(a.wiki, quiet=True) g = None if a.no_glossary else Glossary(a.wiki, quiet=True, langs=[a.lang])
data = json.loads(Path(a.input).read_text(encoding="utf-8")) data = json.loads(Path(a.input).read_text(encoding="utf-8"))
items = data["entries"] if isinstance(data, dict) else data items = data["entries"] if isinstance(data, dict) else data
idx = c.index() idx = c.index()
@@ -588,7 +611,7 @@ def find_msgfmt():
def cmd_check(a): def cmd_check(a):
g = None if a.no_glossary else Glossary(a.wiki, quiet=True) g = None if a.no_glossary else Glossary(a.wiki, quiet=True, langs=a.lang or None)
msgfmt = find_msgfmt() msgfmt = find_msgfmt()
status = 0 status = 0
for lang in a.lang or all_langs(): for lang in a.lang or all_langs():
@@ -646,7 +669,7 @@ def cmd_lookup(a):
def cmd_glossary(a): def cmd_glossary(a):
g = Glossary(a.wiki) g = Glossary(a.wiki, langs=[a.lang])
col = g.column(a.lang) col = g.column(a.lang)
if col is None: if col is None:
print(f"'{a.lang}' has no glossary column yet; add one with glossary-set --set {a.lang}=...") print(f"'{a.lang}' has no glossary column yet; add one with glossary-set --set {a.lang}=...")
@@ -733,6 +756,17 @@ def cmd_candidates(a):
print(f"{cnt:7} {langs:6} {text}{' [label]' if k in labels else ''} e.g. {ex[:70]}{'…' if len(ex) > 70 else ''}") print(f"{cnt:7} {langs:6} {text}{' [label]' if k in labels else ''} e.g. {ex[:70]}{'…' if len(ex) > 70 else ''}")
def _edit_tsv(path, edit):
"""Apply edit(lines) to a glossary TSV, keeping its BOM and line endings."""
data = path.read_bytes()
bom = data.startswith(b"\xef\xbb\xbf")
text = data.decode("utf-8-sig")
nl = "\r\n" if "\r\n" in text else "\n"
lines = text.splitlines()
edit(lines)
path.write_bytes((b"\xef\xbb\xbf" if bom else b"") + (nl.join(lines) + nl).encode("utf-8"))
def cmd_glossary_set(a): def cmd_glossary_set(a):
wiki = find_wiki(a.wiki) wiki = find_wiki(a.wiki)
if not wiki: if not wiki:
@@ -741,8 +775,21 @@ def cmd_glossary_set(a):
f" {a.term}: " + ", ".join(a.set) + "\n" f" {a.term}: " + ", ".join(a.set) + "\n"
"Clone https://github.com/OrcaSlicer/OrcaSlicer_WIKI next to this repository, or pass --wiki.", file=sys.stderr) "Clone https://github.com/OrcaSlicer/OrcaSlicer_WIKI next to this repository, or pass --wiki.", file=sys.stderr)
return 2 return 2
tsv = wiki / GLOSSARY_TSV gdir = wiki / GLOSSARY_DIR
lock = tsv.with_suffix(".lock") gen = wiki / "generate_glossary.py"
if not gen.exists():
sys.exit(f"{gen} not found")
run_gen = lambda *args: subprocess.run([sys.executable, str(gen), *args], cwd=wiki, capture_output=True, text=True)
langs = [l.strip() for l in (gdir / "languages.txt").read_text(encoding="utf-8-sig").splitlines() if l.strip()]
values = {}
for s in a.set:
lang, _, val = s.partition("=")
if lang not in langs:
sys.exit(f"no glossary language '{lang}' (add it to {GLOSSARY_DIR.as_posix()}/languages.txt)")
if "\t" in val or "\n" in val:
sys.exit("values cannot contain tabs or line breaks")
values[lang] = val.strip()
lock = gdir / ".lock"
for _ in range(100): for _ in range(100):
try: try:
fd = os.open(lock, os.O_CREAT | os.O_EXCL) fd = os.open(lock, os.O_CREAT | os.O_EXCL)
@@ -752,50 +799,51 @@ def cmd_glossary_set(a):
else: else:
sys.exit(f"could not lock {lock}; remove it if no other run is active") sys.exit(f"could not lock {lock}; remove it if no other run is active")
try: try:
data = tsv.read_bytes() terms = (gdir / "terms.tsv").read_text(encoding="utf-8-sig").splitlines()
bom = data.startswith(b"\xef\xbb\xbf") new = not any(l.split("\t")[0] == a.term for l in terms[1:])
text = data.decode("utf-8-sig") if new and not a.description:
nl = "\r\n" if "\r\n" in text else "\n" sys.exit("a new term needs --description (one sentence saying what it is)")
lines = text.splitlines() # an established translation is only replaced on request
header = lines[0].split("\t") conflicts = []
values = {} for lang, v in values.items():
for s in a.set: for line in (gdir / f"{lang}.tsv").read_text(encoding="utf-8-sig").splitlines()[1:]:
lang, _, val = s.partition("=") cells = line.split("\t")
if lang not in header: if cells[0] == a.term and len(cells) > 2 and cells[2].strip() not in MISSING and cells[2] != v:
sys.exit(f"no glossary column '{lang}'") conflicts.append(f"{lang}: '{cells[2]}' -> '{v}'")
if "\t" in val or "\n" in val:
sys.exit("values cannot contain tabs or line breaks")
values[header.index(lang)] = val.strip()
row_i = next((i for i, l in enumerate(lines[1:], 1) if l.split("\t")[0] == a.term), None)
new = row_i is None
if new:
if not a.description:
sys.exit("a new term needs --description (one sentence saying what it is)")
row = [a.term, a.description] + [""] * (len(header) - 2)
lines.append("")
row_i = len(lines) - 1
else:
row = lines[row_i].split("\t")
row += [""] * (len(header) - len(row))
if a.description:
row[1] = a.description
conflicts = [f"{header[i]}: '{row[i]}' -> '{v}'" for i, v in values.items() if row[i].strip() not in MISSING and row[i] != v]
if conflicts and not a.replace: if conflicts and not a.replace:
sys.exit("established translations differ (pass --replace, and update the catalogs to match):\n " + "\n ".join(conflicts)) sys.exit("established translations differ (pass --replace, and update the catalogs to match):\n " + "\n ".join(conflicts))
for i, v in values.items():
row[i] = v def edit_terms(lines):
lines[row_i] = "\t".join(row) for i, l in enumerate(lines):
tsv.write_bytes((b"\xef\xbb\xbf" if bom else b"") + (nl.join(lines) + nl).encode("utf-8")) if i and l.split("\t")[0] == a.term:
if a.description:
lines[i] = f"{a.term}\t{a.description}"
return
lines.append(f"{a.term}\t{a.description}")
if new or a.description:
_edit_tsv(gdir / "terms.tsv", edit_terms)
r = run_gen("--sync") # copies the term and description into every language file
if r.returncode:
sys.exit((r.stdout + r.stderr).strip())
def setter(lang, v):
def edit(lines):
for i, l in enumerate(lines):
cells = l.split("\t")
if i and cells[0] == a.term:
lines[i] = "\t".join(cells[:2] + [v])
return
sys.exit(f"{lang}.tsv has no row for '{a.term}'")
return edit
for lang, v in values.items():
_edit_tsv(gdir / f"{lang}.tsv", setter(lang, v))
finally: finally:
os.close(fd) os.close(fd)
os.unlink(lock) os.unlink(lock)
print(f"glossary: {'added' if new else 'updated'} '{a.term}'") print(f"glossary: {'added' if new else 'updated'} '{a.term}'")
gen = wiki / "generate_glossary.py" r = run_gen("--update")
if gen.exists(): print((r.stdout + r.stderr).strip())
r = subprocess.run([sys.executable, str(gen), "--update"], cwd=wiki, capture_output=True, text=True) return r.returncode
print((r.stdout + r.stderr).strip())
return r.returncode
return 0
def main(): def main():
@@ -574,7 +574,8 @@ DMABUF workaround and then branches on `GDK_BACKEND` (an `x11` prefix means the
paths, when `/proc/driver/nvidia/version` exists and the linked WebKitGTK honours the switch (>= 2.44, paths, when `/proc/driver/nvidia/version` exists and the linked WebKitGTK honours the switch (>= 2.44,
`WebView::SupportsForceSharedMemory()`), it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WebView::SupportsForceSharedMemory()`), it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and
`WEBKIT_FORCE_DMABUF_RENDERER=1` (non-replacing): the proprietary driver cannot back WebKit's hardware `WEBKIT_FORCE_DMABUF_RENDERER=1` (non-replacing): the proprietary driver cannot back WebKit's hardware
DMABUF renderer. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY` DMABUF renderer. On a hypervisor guest (`/proc/cpuinfo` `hypervisor` flag) and WebKitGTK >= 2.44 it sets
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` alone, since virtual GPUs cannot back the hardware path either. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
is set, and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` (non-replacing) inside that branch when the linked is set, and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` (non-replacing) inside that branch when the linked
WebKitGTK is older than 2.42: a forced X11 backend in a Wayland session is the only default-path case WebKitGTK is older than 2.42: a forced X11 backend in a Wayland session is the only default-path case
that runs through XWayland, which `DISPLAY` and `WAYLAND_DISPLAY` alone do not tell you (a native that runs through XWayland, which `DISPLAY` and `WAYLAND_DISPLAY` alone do not tell you (a native
@@ -568,13 +568,18 @@ vue-resize workaround script and sets a persistent SQLite cookie store on the vi
## WebKitGTK on Linux sessions ## WebKitGTK on Linux sessions
**Mechanism.** `CLI::run` (`src/OrcaSlicer.cpp`, under `#ifdef __WXGTK__`, before GTK initialises) **Mechanism.** `CLI::run` (`src/OrcaSlicer.cpp`, under `#ifdef __WXGTK__`, before GTK initialises)
sets the NVIDIA DMABUF workaround and then branches on `GDK_BACKEND`: sets the DMABUF workarounds and then branches on `GDK_BACKEND`:
- **Both paths**, before the split: when `/proc/driver/nvidia/version` exists and the linked WebKitGTK - **Both paths**, before the split: when `/proc/driver/nvidia/version` exists and the linked WebKitGTK
honours the switch (>= 2.44, `WebView::SupportsForceSharedMemory()`), it sets honours the switch (>= 2.44, `WebView::SupportsForceSharedMemory()`), it sets
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_FORCE_DMABUF_RENDERER=1` non-replacing. The `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_FORCE_DMABUF_RENDERER=1` non-replacing. The
proprietary driver cannot back WebKit's hardware DMABUF renderer, so the shared-memory transport keeps proprietary driver cannot back WebKit's hardware DMABUF renderer, so the shared-memory transport keeps
accelerated compositing instead of dropping it; `WEBKIT_FORCE_DMABUF_RENDERER` clears the accelerated compositing instead of dropping it; `WEBKIT_FORCE_DMABUF_RENDERER` clears the
Debian/Ubuntu/openSUSE `isNVIDIA()` early return, which otherwise returns before the FORCE_SHM check. Debian/Ubuntu/openSUSE `isNVIDIA()` early return, which otherwise returns before the FORCE_SHM check.
- **Hypervisor guests**, same position: when `is_hypervisor_guest()` (the `hypervisor` flag in
`/proc/cpuinfo`) and WebKitGTK >= 2.44, it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` non-replacing.
Virtual GPUs (VirtualBox VMSVGA, VMware, QEMU) cannot back the hardware DMABUF path either, and
FORCE_SHM alone was confirmed to fix web views in VirtualBox; the `WEBKIT_FORCE_DMABUF_RENDERER` pair
is not needed without the Debian `isNVIDIA()` patch. The flag is x86-only, so ARM guests are not covered.
- **Default path** (`GDK_BACKEND` unset or not starting with `x11`): if wx lacks EGL (`!wxHAS_EGL`) and - **Default path** (`GDK_BACKEND` unset or not starting with `x11`): if wx lacks EGL (`!wxHAS_EGL`) and
`WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`, and in that same branch it sets `WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`, and in that same branch it sets
`WEBKIT_DISABLE_COMPOSITING_MODE=1` non-replacing when the linked WebKitGTK predates 2.42 `WEBKIT_DISABLE_COMPOSITING_MODE=1` non-replacing when the linked WebKitGTK predates 2.42
@@ -590,9 +595,9 @@ Runtime backend detection elsewhere uses Orca's `is_running_on_wayland()` / `is_
(see `references/platforms.md`), not these environment variables. (see `references/platforms.md`), not these environment variables.
**Pitfall.** **Pitfall.**
- **Rule:** On NVIDIA reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before - **Rule:** On NVIDIA and hypervisor guests reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
`WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver cannot back WebKit's hardware `WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver and virtual GPUs cannot back WebKit's
DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only hardware DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
skips the hardware path, while disabling compositing drops it. Threaded scrolling is a separate, skips the hardware path, while disabling compositing drops it. Threaded scrolling is a separate,
release-dependent default (off in 2.44/2.45, on from 2.46). `WEBKIT_DISABLE_DMABUF_RENDERER` is not a release-dependent default (off in 2.44/2.45, on from 2.46). `WEBKIT_DISABLE_DMABUF_RENDERER` is not a
substitute — it is read first, leaves the transport set empty (no compositing from 2.44 on, where the substitute — it is read first, leaves the transport set empty (no compositing from 2.44 on, where the
+25
View File
@@ -232,6 +232,31 @@ jobs:
mkdir -p ${{ runner.temp }}/profile-check-results mkdir -p ${{ runner.temp }}/profile-check-results
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
# Builds a readable diff of the custom G-code the PR changes, which GitHub shows as
# one long line per setting, for check_profiles_comment.yml to post.
- name: Diff custom G-code
if: ${{ always() && github.event_name == 'pull_request' }}
continue-on-error: true
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git fetch --no-tags --depth=2 origin "$(git rev-parse HEAD)"
results=${{ runner.temp }}/profile-check-results
diff=${{ runner.temp }}/gcode_diff.md
# HEAD is the PR's merge commit, so HEAD^1 is the base it merges onto.
python3 ./scripts/orca_profile_tool.py gcode-diff --base HEAD^1 --head HEAD > "$diff"
if [ -s "$diff" ]; then
{
echo "<!-- profile-gcode-diff-comment -->"
cat "$diff"
echo ""
# The comment workflow finds this line as the only one starting with <sub>.
echo "<sub>For commit ${PR_HEAD_SHA} of this PR.</sub>"
} > "$results/gcode_diff.md"
fi
# Tells the comment workflow that a missing gcode_diff.md means no G-code changed.
touch "$results/gcode_diff_done.txt"
- name: Prepare comment artifact - name: Prepare comment artifact
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }} if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
run: | run: |
+58 -3
View File
@@ -38,6 +38,7 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }} GH_REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: | run: |
if [ ! -f pr_number.txt ]; then if [ ! -f pr_number.txt ]; then
echo "No pr_number.txt in artifact, skipping." echo "No pr_number.txt in artifact, skipping."
@@ -49,18 +50,72 @@ jobs:
echo "Invalid PR number: $PR_NUMBER" echo "Invalid PR number: $PR_NUMBER"
exit 1 exit 1
fi fi
# PR code wrote the artifact, so only a run for the named PR's current head may post.
if [ -z "$HEAD_SHA" ]; then
echo "The triggering run names no head commit."
exit 1
fi
if ! PR_HEAD=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq .head.sha); then
echo "Cannot look up PR #${PR_NUMBER}."
exit 1
fi
if [ "$HEAD_SHA" != "$PR_HEAD" ]; then
echo "${HEAD_SHA} is not the head of PR #${PR_NUMBER}; skipping."
exit 0
fi
# Lists every bot comment on the PR, one JSON object per line.
COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
--jq '.[] | select(.user.login == "github-actions[bot]") | {id, body} | @json')
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading. # Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
OLD_IDS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ OLD_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
--jq '.[] | select(.user.login == "github-actions[bot]") | select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
for comment_id in $OLD_IDS; do for comment_id in $OLD_IDS; do
echo "Deleting outdated profile-validation comment ${comment_id}" echo "Deleting outdated profile-validation comment ${comment_id}"
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
done done
# Post a new comment only when validation failed (pr_comment.md present). # Post a new comment only when validation failed (pr_comment.md present).
# A failed post fails the job only after the G-code diff is handled.
failed=0
if [ -f pr_comment.md ]; then if [ -f pr_comment.md ]; then
gh pr comment "$PR_NUMBER" --body-file pr_comment.md gh pr comment "$PR_NUMBER" --body-file pr_comment.md || failed=1
else else
echo "Validation succeeded; cleaned up prior comments without posting." echo "Validation succeeded; cleaned up prior comments without posting."
fi fi
# Makes the G-code diff comment match gcode_diff.md, or deletes it if there is none.
update_gcode_diff_comment() {
if [ ! -f gcode_diff_done.txt ]; then
echo "The G-code diff step did not finish; leaving any G-code diff comment as it is."
return 0
fi
GCODE_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select(.body | startswith("<!-- profile-gcode-diff-comment -->")) | .id')
if [ -f gcode_diff.md ] && [ "$(wc -c < gcode_diff.md)" -gt 65536 ]; then
echo "gcode_diff.md is over GitHub's comment limit; not posting it."
rm gcode_diff.md
fi
if [ -f gcode_diff.md ] && [ "$(echo "$GCODE_IDS" | wc -w)" -eq 1 ]; then
OLD_BODY=$(printf '%s\n' "$COMMENTS" | jq -r --argjson id "$GCODE_IDS" 'select(.id == $id) | .body' | tr -d '\r')
NEW_BODY=$(tr -d '\r' < gcode_diff.md)
if [ "$OLD_BODY" = "$NEW_BODY" ]; then
echo "G-code diff unchanged; keeping comment ${GCODE_IDS}."
return 0
fi
# Edits in place when only the <sub> line differs, since edits notify nobody.
if [ "$(grep -v '^<sub>' <<< "$OLD_BODY")" = "$(grep -v '^<sub>' <<< "$NEW_BODY")" ]; then
echo "G-code diff unchanged at a new commit; updating comment ${GCODE_IDS}."
gh api -X PATCH "repos/${GH_REPO}/issues/comments/${GCODE_IDS}" -F body=@gcode_diff.md > /dev/null
return 0
fi
fi
for comment_id in $GCODE_IDS; do
echo "Deleting outdated G-code diff comment ${comment_id}"
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
done
if [ -f gcode_diff.md ]; then
gh pr comment "$PR_NUMBER" --body-file gcode_diff.md
fi
}
update_gcode_diff_comment
exit "$failed"
+1 -1
View File
@@ -91,7 +91,7 @@ See `.claude/skills/orca-translations/references/source-audit.md` for the full c
### Terminology ### Terminology
- Use the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/localization_glossary.md) as the source of truth for recurring terms, so the same English term is always rendered the same way within a language, and terms that must stay in English (brand/product names, acronyms, materials, file formats, G-code tokens, macros/variables/identifiers) are not translated. - Use the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/localization_glossary.md) as the source of truth for recurring terms, so the same English term is always rendered the same way within a language, and terms that must stay in English (brand/product names, acronyms, materials, file formats, G-code tokens, macros/variables/identifiers) are not translated.
- If a term's established translation changes, update both the affected `.po` files and the glossary (`localization_glossary.tsv`, then regenerate) so they stay in sync. - If a term's established translation changes, update both the affected `.po` files and the glossary (`developer_reference/glossary/<lang>.tsv` in the wiki, then regenerate) so they stay in sync.
- Translate the *meaning*, not the words. Check what the string actually controls before translating it — English reuses one word for different things. `Flow ratio` (multiplier), `Flow Rate` (throughput) and `Flow Dynamics` (pressure compensation) are three different terms; `extruder` may mean the toolhead, the feeder motor, or the nozzle depending on the string. - Translate the *meaning*, not the words. Check what the string actually controls before translating it — English reuses one word for different things. `Flow ratio` (multiplier), `Flow Rate` (throughput) and `Flow Dynamics` (pressure compensation) are three different terms; `extruder` may mean the toolhead, the feeder motor, or the nozzle depending on the string.
- Reuse one template per recurring message shape (`Failed to connect to …`, `Are you sure you want to …?`), even where the English wording varies. - Reuse one template per recurring message shape (`Failed to connect to …`, `Are you sure you want to …?`), even where the English wording varies.
+17 -7
View File
@@ -16,6 +16,8 @@ else()
endif() endif()
endif() endif()
set(_openssl_dir ${DESTDIR})
if(WIN32) if(WIN32)
set(_openssl_msvc_env CC=cl CXX=cl RC=rc CL=/FS) set(_openssl_msvc_env CC=cl CXX=cl RC=rc CL=/FS)
# OpenSSL's perl Configure honors the CC environment variable, but the # OpenSSL's perl Configure honors the CC environment variable, but the
@@ -43,6 +45,14 @@ if(WIN32)
else() else()
if(APPLE) if(APPLE)
set(_conf_cmd export MACOSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET} && ./Configure -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}) set(_conf_cmd export MACOSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET} && ./Configure -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET})
# OPENSSLDIR is only where OpenSSL looks at runtime for cert.pem, certs/
# and openssl.cnf; install_sw installs nothing there. Point it at the
# system CA bundle, the one libcurl already uses here, so the bundled
# Python's ssl module can verify HTTPS. Elsewhere it stays the prefix:
# Windows Python reads the system certificate store, and on Linux, where
# the bundle's location differs by distro, CurlGlobalInit points
# SSL_CERT_FILE at it at startup.
set(_openssl_dir /etc/ssl)
else() else()
# A static library that is embedded into a shared object must not export # A static library that is embedded into a shared object must not export
# its symbols. On Linux the running process also loads the system OpenSSL # its symbols. On Linux the running process also loads the system OpenSSL
@@ -81,7 +91,7 @@ ExternalProject_Add(dep_OpenSSL
# URL_HASH SHA256=8c776993154652d0bb393f506d850b811517c8bd8d24b1008aef57fbe55d3f31 # URL_HASH SHA256=8c776993154652d0bb393f506d850b811517c8bd8d24b1008aef57fbe55d3f31
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/OpenSSL DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/OpenSSL
CONFIGURE_COMMAND ${_conf_cmd} ${_cross_arch} CONFIGURE_COMMAND ${_conf_cmd} ${_cross_arch}
"--openssldir=${DESTDIR}" "--openssldir=${_openssl_dir}"
"--prefix=${DESTDIR}" "--prefix=${DESTDIR}"
# OpenSSL's linux-x86_64 target sets multilib=64, so it installs to # OpenSSL's linux-x86_64 target sets multilib=64, so it installs to
# <prefix>/lib64 while every other dep uses <prefix>/lib. CPython's # <prefix>/lib64 while every other dep uses <prefix>/lib. CPython's
@@ -115,12 +125,12 @@ ExternalProject_Add_Step(dep_OpenSSL install_cmake_files
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
) )
if (NOT WIN32 AND NOT APPLE) if (NOT WIN32)
# OpenSSL's object rules do not depend on CFLAGS, so reconfiguring it (for # OpenSSL's object rules do not depend on the configured flags or paths, so
# example to add -fvisibility=hidden) relinks the archives from stale # reconfiguring it (a new compiler flag, a different --openssldir) relinks
# objects instead of recompiling them, and the change silently has no # the archives from stale objects instead of recompiling them, and the
# effect. Drop the objects whenever this recipe changes so the next build # change silently has no effect. Drop the objects whenever this recipe
# actually recompiles them. # changes so the next build actually recompiles them.
ExternalProject_Get_Property(dep_OpenSSL SOURCE_DIR) ExternalProject_Get_Property(dep_OpenSSL SOURCE_DIR)
ExternalProject_Add_Step(dep_OpenSSL clean_objects ExternalProject_Add_Step(dep_OpenSSL clean_objects
DEPENDEES configure DEPENDEES configure
+5 -4
View File
@@ -365,10 +365,11 @@ if(TARGET dep_ZLIB)
add_dependencies(dep_python3 dep_ZLIB) add_dependencies(dep_python3 dep_ZLIB)
endif() endif()
if (NOT WIN32 AND NOT APPLE) if (NOT WIN32)
# CPython's Makefile rules for _ssl and _hashlib depend only on their own # _ssl and _hashlib embed the static OpenSSL built in deps/ (Windows uses
# sources, not on the OpenSSL archives, so a rebuilt OpenSSL does not make # CPython's prebuilt OpenSSL instead). Their Makefile rules depend only on
# them relink and they keep the previous symbols. On an incremental tree, # their own sources, not on the OpenSSL archives, so a rebuilt OpenSSL does
# not make them relink and they keep the previous one. On an incremental tree,
# drop the built modules and relink them against the current OpenSSL; a # drop the built modules and relink them against the current OpenSSL; a
# fresh build is left alone (its PGO target builds them). "make" alone is a # fresh build is left alone (its PGO target builds them). "make" alone is a
# no-op once PGO has run, so sharedmods is invoked explicitly. # no-op once PGO has run, so sharedmods is invoked explicitly.
+7
View File
@@ -53,6 +53,13 @@ Space counts as typing. The speed dial's default is the one bare Space, and
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons, `MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
combo boxes), so it opens the dial from the canvases and the tab strip only. combo boxes), so it opens the dial from the canvases and the tab strip only.
`MainFrame::handle_global_shortcut` skips a chord that `KeyChord::edits_text()` reports
while a text field has the focus (a wx text entry, spin control, web view or ImGui
InputText), and skips every chord while an ImGui modal is open, as a wx modal dialog
would. `ImGuiWrapper::owns_key()` reads the active InputText and the popup stack
directly, since `WantCaptureKeyboard` lags a frame, and is called only when the focus is on
a visible canvas from `OpenGLManager::create_wxglcanvas()`.
## Which event a chord matches ## Which event a chord matches
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
-252
View File
@@ -1,252 +0,0 @@
# Preset Plugin API — High Level Design
## Purpose and scope
The preset plugin API lets Python plugins update the presets OrcaSlicer is using,
so users do not have to perform every routine setting change by hand. It covers
process, printer, and filament presets. All three use one staged update lifecycle;
the way a target is selected is specific to the preset type.
The API edits Orca's managed edited preset, not a detached copy or a saved preset
behind the UI's back. It does not save to disk. Saving, discarding, and transferring
unsaved edits remain under Orca's existing preset workflow.
The API does not create or delete presets or provide off-tab editing. For filament,
choosing a different `preset_name` as the update target also selects that preset for
the requested slot, following the normal UI flow. A standalone slot-assignment or
preset-fork operation is outside this API's contract.
## Preset and slot model
Each preset collection maintains a managed edited copy for its selected preset.
Settings tabs apply changes to that copy and own the normal dirty-state, validation,
save/discard/transfer, and downstream refresh behavior. Plugin updates must use the
same path so that the settings tab, dirty indicators, and effective print
configuration observe a consistent state.
Filament selection adds a slot-to-preset mapping. `PresetBundle::filaments` is the
catalog, while `PresetBundle::filament_presets` maps each slot to a preset name.
Several slots may refer to the same preset. A filament update edits that shared
preset, so all slots referring to it are affected. Slot numbers exposed by the API
are 1-based, matching the UI.
## API shape
Each preset API exposes `begin_update()`, which selects and binds one target, and
returns a transaction. The transaction stages setting changes using `set()` and
applies them only on explicit `commit()`.
```python
# Filament is targeted by its 1-based slot. Omitting preset_name keeps the
# preset currently assigned to the slot; providing it selects that preset for
# the slot before staging updates.
tx = orca.host.preset.filament.begin_update(
slot_number=2,
preset_name="Generic PLA"
)
tx.set("filament_flow_ratio", "0.98")
tx.set("filament_retract_length", "0.8")
result = tx.commit()
# Process and printer default to their active presets. A caller can request a
# different preset that is selectable in the corresponding UI dropdown.
tx = orca.host.preset.process.begin_update(
preset_name="0.20mm Standard"
)
tx.set("layer_height", "0.2")
tx.commit()
```
The example is the API contract, and the Python bridge provides it. `begin_update()`
is synchronous: it blocks the background Script worker until the target selection and
any unsaved-edit dialog settle, then returns the bound transaction. Orca's UI thread
stays responsive because the Script capability runs on its own worker. `set()` and
`commit()` run synchronously on that same worker. The Script worker and its optional
coroutine loop are described in
[python-plugin-async-bridge.md](python-plugin-async-bridge.md).
### Target resolution
- **Filament:** `slot_number` identifies the slot. If `preset_name` is omitted,
the transaction targets the preset currently assigned to that slot. If supplied,
it requests that preset for the slot and selects it through the normal UI flow;
the name must be among the presets offered by that slot's dropdown, with one
exception: the preset currently assigned to the slot stays targetable even when the
slot's dropdown hides it (for example an incompatible-but-selected preset). Selecting
a different preset changes the slot-to-preset mapping. In both cases the transaction
edits the shared preset, not an isolated slot override.
- **Process and printer:** omitting `preset_name` targets the currently active
preset. Supplying `preset_name` requests the preset by the name shown in its
dropdown. It must be one of the choices the dropdown currently offers, rather
than merely any preset present in the collection. This is particularly important
for process presets, whose selectable choices can depend on the current context.
- A transaction targets exactly one preset. Multi-preset transactions are not
supported: applying them would require switching among targets while the
application has only one managed edited copy per collection.
Targets are resolved through Orca's normal preset-selection model. The API should
not accept raw preset pointers or filesystem paths as target identities. A missing,
unavailable, or ambiguous target fails explicitly instead of silently selecting a
different preset.
## Selection and user interaction
`begin_update()` resolves its target through shared binding helpers
(`bind_preset_target` for process/printer, `bind_filament_target` for filament) and
performs the selection only when the requested target differs from the active target;
otherwise it returns a transaction without switching. The helpers use Orca's normal
preset selection flow, `Tab::select_preset()`, including updating a filament slot
assignment when requested. If the normal selection flow requires user input, it opens
the relevant existing dialog(s), including unsaved-edit choices such as save, discard,
or transfer. The call remains blocked on the Script worker until those interactions
finish; it returns the bound transaction, or raises on a cancellation, without
applying staged settings. A canceled selection does not change the requested target or
filament slot assignment, but it does not roll back decisions the user already accepted
in an earlier dialog. The helpers then confirm that the requested preset actually
became the edited preset; a successful selection request alone is not sufficient. There
is no separate `switch_preset` plugin API.
If the requested target is already active, `begin_update()` returns without a
selection prompt, apart from the UI-thread dispatch needed to bind the transaction.
If a different preset is requested, Orca selects it through the normal UI flow. For
filament, this also updates the requested slot's assignment when `preset_name`
differs from its current assignment. When the current preset has unsaved changes,
the Script worker stays blocked until the user chooses among Orca's existing options,
such as transfer, discard, or cancel.
The API never suppresses that warning, chooses an option for the user, or switches
away from dirty edits silently. Cancel aborts the operation before any staged target
changes are applied. Process and printer updates follow this rule too when the
plugin requests a different `preset_name`; they normally return immediately when
the active preset is the target.
## Staging and commit semantics
`set(key, value)` stages a serialized config value and does not mutate the preset.
The transaction is the boundary for a related group of changes, avoiding
intermediate partially-updated states. `commit()` runs synchronously on the background
Script worker. At commit, the API validates keys and values against the target preset
type and applies the batch through `Tab::apply_preset_config_values`, the existing tab
and settings-group update path. Existing setting-specific validation, correction, and
callbacks remain authoritative; plugin updates do not create a parallel setter with
different behavior.
A successful commit updates dirty state and downstream configuration/Plater state
once for the batch. The edited preset remains unsaved; persistence continues through
Orca's normal save action. A validation or commit failure restores the edited preset's
config to its pre-batch state and recomputes the state derived from it (compatibility
marks, the preset selector, dependent tabs, the Plater and the printer agent), so no
staged setting is left partially applied. That guarantee is limited to the edited
preset and its config-derived state: an `on_change` callback is a GUI method, not a pure
function of the config, so the transaction does not reverse a preset or slot selection
made while resolving the target, a dialog decision the user already accepted, an undo
snapshot, a dependent preset's rewritten config, or anything already sent to a device.
Exact all-or-nothing across those external effects is not achievable while the
authoritative GUI callbacks run; the boundary is documented on
`Tab::apply_preset_config_values`. A filament slot assignment made as part of target
selection is likewise not a staged setting: after the user accepts the selection flow it
may remain changed even if a later commit fails, and the API does not switch back or
reverse the user's selection or any accepted transfer/discard decision for the previous
preset.
## Outcomes and consistency
The API distinguishes target resolution failures, invalid setting/value, user
cancellation, stale filament-slot mapping, and commit failure. A result identifies
the target preset, whether preset selection changed, and (for filament) whether the
slot assignment changed and which slots share the edited preset. A canceled
selection applies neither the requested slot assignment nor staged target changes;
a failed commit applies none of the staged target settings.
Filament slot assignments can change between `begin_update()` and `commit()`. The API
detects that and rejects a stale operation instead of silently editing the new
slot assignment. It must not hold raw preset pointers across reloads. Conflicting
duplicate writes to one setting in a transaction are rejected rather than resolved
by incidental call order.
The existing one-setting filament call,
`orca.host.preset.filament.update(slot_number, key, value)`, remains available as a
compatibility shorthand. It shares the staged API's target resolution, selection,
validation, and commit path, and it is synchronous like `begin_update()`: both block
the background Script worker while any selection dialog settles.
## Design principles
- **Reduce routine interaction, preserve agency.** Clean switches should be
seamless; unsaved user work must still receive the normal warning and decision.
- **One source of truth.** UI and plugins share the managed edited copy and setting
update path, including validation and callbacks.
- **The UI workflow is authoritative.** The API must enter through the same
selection and setting-change workflows as the UI, not reproduce their logic or
write config values directly. This keeps existing and future side effects active:
`on_change` handling for mutually exclusive, invalid, or out-of-range settings;
compatibility and dependent-preset updates; and printer-agent changes when the
selected printer changes.
- **Explicit target and explicit commit.** Plugins know which preset they edit and
when staged changes become visible.
- **No hidden persistence.** Plugin edits remain unsaved until the user saves them
through Orca.
- **Shared means shared.** Editing a filament preset through one slot affects every
slot assigned to it. Choosing another preset for a slot is explicit in
`preset_name`; slot-specific divergence would require a separate fork operation.
- **Consistent lifecycle, type-specific targeting.** Process, printer, and
filament share transaction and error semantics while respecting their existing
selection models.
## Implementation in the current codebase
The transaction API is implemented. The current plugin surface exposes `begin_update()`,
`PresetTransaction` with `set()`/`commit()`, and the per-setting `update()` compatibility
shorthand. For filament, `update_filament_slot()` resolves the preset already assigned
to a slot, selects it through `Tab::select_preset()`, and applies one setting through
`Tab::apply_preset_config_values()`; it does not change the slot assignment.
`begin_update()` and `update()` are synchronous blocking calls
(`begin_preset_update` / `begin_filament_update`). They marshal the target binding to
the UI thread and block the background Script worker until the selection settles, so
the wait never blocks the UI thread. They are valid only on that worker, never from the
UI thread or a slicing pipeline hook.
`Tab::select_preset()` is the basis for switching to process, printer, or filament
presets and for invoking the normal unsaved-edit flow. The flow is type-specific: a
process selection can trigger compatibility handling for filaments, and a printer
selection can involve technology changes and dependent preset compatibility. It may
therefore show more than one dialog. Cancellation of a later step does not necessarily
undo decisions the user already accepted in an earlier dialog. Target resolution
preserves those side effects and then verifies the resulting edited target.
Filament `preset_name` is an additional integration on top of `Tab::select_preset()`:
selecting a different name for a slot also updates the slot-to-preset mapping and its
dependent UI/configuration state, mirroring the filament branch of the Plater's normal
selection path (`PresetBundle::set_filament_preset()` plus the project/selection refresh,
the dynamic filament list, the conditional support-filament flushing recalc, the slot
combo refresh and its AMS sync badge). A mixed-color virtual slot is not targetable.
Process and printer dropdown choices are constrained by visibility and compatibility,
and compatibility may depend on the active context. Target resolution checks the tab
dropdown's actual items before the selection flow, so a target the user cannot pick
fails without raising that flow's unsaved-edit prompt; a name two entries carry is
rejected as ambiguous. A filament `preset_name` resolves against the requested slot's
own sidebar selector rather than the tab's, so that slot's compatibility filter is the
one that applies. A name the slot's dropdown does not show is rejected unless it names
that slot's current assignment (a hidden or incompatible-but-selected preset), which
stays targetable.
`commit()` applies its batch through `Tab::apply_preset_config_values`, which parses and
validates the whole batch before touching the edited preset and restores the edited
preset and its config-derived state on failure (see "Staging and commit semantics" for
the limit of that rollback). Since `begin_update()` performs target selection before the
caller stages values, invalid staged values can still follow a user-visible selection
prompt; that is a consequence of this API shape and remains explicit in its behavior.
## Integration boundary
The plugin API coordinates target resolution, preset selection, staged values, and
the settings commit path. It does not own or duplicate preset dirty-state logic,
selection prompts, setting validation, or persistence. Target changes flow through
the same UI selection path so dependent behavior—such as selecting the printer
agent—still runs. Setting changes flow through the same UI change path so `on_change`
and related validation/correction behavior still runs. The integration bridges UI
prompts to the Python operation through the blocking `begin_update()` and reuses
the normal Plater slot-selection behavior for explicit filament targets, and commits a
batch without losing existing per-setting behavior.
-351
View File
@@ -1,351 +0,0 @@
# Python plugin async bridge — High Level Design
## Purpose and scope
The embedded Python plugin runtime runs most capability calls synchronously: a C++ call site acquires
the GIL and the capability's Python method runs to completion on the calling thread. The staged preset
update (`begin_update()` in [preset-plugin-api.md](preset-plugin-api.md)) is also synchronous, but
its target selection can open unsaved-edit dialogs. It therefore blocks the Script worker, not the UI
thread, and returns only when the selection settles.
This document describes the Script capability's background execution path, including the optional
per-invocation asyncio loop for an `async def execute`, and the blocking UI marshalling that host calls
use from that worker. The bridge is deliberately narrow: only the Script capability gains a
background/async execution path. Every other capability — including the SlicingPipeline hooks — stays
synchronous on its caller's thread.
## Execution baseline
### How a capability call runs
A capability instance is a Python object owned through `Slic3r::PluginInstanceHandle`
(`src/slic3r/plugin/PythonPluginBridge.cpp`) and handed to the rest of the program as a
`std::shared_ptr<Slic3r::PluginCapabilityInterface>`. Its identity (`name()`, `type()`,
`audit_plugin_key()`) is resolved once at materialization and cached in C++
(`PluginCapabilityInterface` in `src/slic3r/plugin/PythonPluginInterface.hpp`).
Every C++→Python call crosses a trampoline method. `PyPluginCommonTrampoline` and
`PyPluginInterfaceTrampoline` (`src/slic3r/plugin/PyPluginTrampoline.hpp`), and the per-type subclass
`PyScriptPluginCapabilityTrampoline`
(`src/slic3r/plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp`), wrap the call in the
`ORCA_PY_OVERRIDE_AUDITED` macro. For one synchronous call that macro, in order:
1. constructs a `PluginCapabilityInterface::RefCounter`, which `increment()`s the capability's
reference count for the call's duration;
2. constructs a `PythonGILState`, which first acquires a `PythonRuntimeLease` and then
`PyGILState_Ensure()`;
3. opens a `ScopedPluginAuditContext(audit_plugin_key(), name())`;
4. dispatches into Python via `PYBIND11_OVERRIDE`/`PYBIND11_OVERRIDE_PURE`;
and unwinds those guards when the call returns.
`PythonGILState` and `PythonRuntimeLease` live in `src/slic3r/plugin/PythonInterpreter.hpp`. A lease is
a `shared_lock` on `PythonInterpreter::m_runtime_mutex`; `PythonInterpreter::shutdown()` takes the
exclusive side, so a thread that holds a lease can safely touch Python objects, and interpreter
finalization cannot run underneath it. `ScopedPluginAuditContext`
(`src/slic3r/plugin/PluginAuditManager.hpp`) stores the previous thread-local plugin/capability pair
and scoped filesystem roots, sets this call's, and restores them on destruction; the audit hook then
attributes every filesystem/socket/process event to the plugin currently executing on that thread.
### The Script capability
`Slic3r::ScriptPluginCapability` (`src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp`)
declares one pure virtual method, `ExecutionResult execute()`. The async runtime invokes it through
`invoke_script_execute` (`src/slic3r/plugin/PyPluginTrampoline.hpp`), which replicates the audited
trampoline call (ref counter, audit scope, traceback-preserving rethrow) but returns a
`ScriptExecuteInvocation` instead of forcing a cast:
- a plain `def execute` runs to completion in `invoke_script_execute` and yields
`ScriptExecuteInvocation::result`;
- an `async def execute` (or any override that returns a coroutine, detected with
`inspect.iscoroutine`) yields the coroutine object in `ScriptExecuteInvocation::awaitable` without
running it, so the runtime can drive it.
`ScriptExecuteInvocation` is defined in `PyPluginTrampoline.hpp`; `py_is_coroutine` is the detection
helper.
### UI marshalling
`PluginHostUi::run_on_ui_thread` (`src/slic3r/plugin/host/PluginHostUi.cpp`) forwards to the
file-local `run_on_ui_blocking`:
- if the caller is already on the wx main thread, it runs the callable inline after a
`py::gil_scoped_release`;
- otherwise it sets up a `std::promise`/`std::future`, releases the GIL, posts the callable with
`GUI::wxGetApp().CallAfter`, and blocks on `future.get()`.
That is a blocking handoff: the caller's stack is parked on a thread while the UI thread runs the
callable. It is the correct primitive for the synchronous `update()`/`commit()` path and for
`reload_local_bundle`/`list_bundle_ids`, but it cannot be used by an operation that must suspend.
### Slicing hooks
`Print::set_slicing_pipeline_hook_fn` is installed by `src/slic3r/plugin/PluginHooks.cpp`. The hook
runs on the slicing worker thread and dispatches to `SlicingPipelinePluginCapability::execute(ctx)`
per capability, acquiring a `PythonGILState` per capability. `SlicingPipelineContext`
(`SlicingPipelinePluginCapability.hpp`) holds raw pointers into the live slicing graph
(`ctx.print`, `ctx.object`) that are valid only for the duration of `execute(ctx)`. The UI thread can
be blocked waiting on the slicing worker (`BackgroundSlicingProcess::stop()` parks it), so a
marshalled UI call from a hook can deadlock; `PluginHostSlicing.cpp`, `SlicingPipelinePluginCapability.hpp`
and `PluginHostUi.hpp` all document this.
## Script-only async execution
### Per-invocation worker and loop
`ScriptAsyncRuntime` (declared in `src/slic3r/plugin/PluginManager.hpp`, implemented in
`PluginManager.cpp`) drives every Script invocation off the caller's thread. Its `start()` creates a
`Run` and launches a detached worker thread that runs `Impl::run_invocation`. There is one worker
thread per invocation, so no invocation ever blocks the UI thread and no invocation is queued behind
another plugin's.
`run_invocation` runs detection and execution on the worker:
- it re-checks `cancel_requested` before anything (a cancellation that arrived before the run
started is honored without running the plugin);
- it calls `invoke_script_execute`; a plain `def execute` result is used directly, and an `async def`
coroutine is driven on a loop created for that invocation:
`asyncio.new_event_loop()` → `asyncio.set_event_loop(loop)` → `loop.create_task(coroutine)` →
`loop.run_until_complete(task)` → `loop.close()`, then `asyncio.set_event_loop(None)`.
The loop is per invocation, not global, and no loop outlives its invocation. The loop for the
calling thread is published in the thread-local `tl_script_loop` and exposed through
`ScriptAsyncRuntime::current_loop()` (GIL held). A host call such as `begin_update()` does not suspend
the loop: it blocks the worker thread while the UI settles, then returns a value the coroutine uses
directly.
`PluginManager::launch_script_capability` is the entry point: it resolves the capability, hands it to
`ScriptAsyncRuntime::start`, and sets `ScriptLaunchResult::launched_async`. The completion callback
runs on the GUI thread (or inline when there is no wx app), and `launch_script_capability` records a
run error through `set_plugin_error` before invoking the caller's `ScriptCompletionFn`.
`PluginManager::run_script_capability` is kept as a compatibility wrapper: it waits for the
completion callback, pumping `wxYield()` when it is itself on the GUI thread, and returns the
`ExecutionResult`.
### Capability, runtime, GIL and audit lifetime across a suspension
For the whole invocation — including an `async def` suspended at an `await` — `run_invocation` holds:
- a `PluginCapabilityInterface::RefCounter` per invocation and a `shared_ptr` to the capability in the
`Run`, so the capability and its module stay alive across awaits and unload cannot drop them;
- a `PythonRuntimeLease` (acquired before the GIL). `PythonInterpreter::shutdown()` cannot finalize
the interpreter while any invocation worker holds a lease;
- the GIL, acquired with `pybind11::gil_scoped_acquire` on the worker;
- a `ScopedPluginAuditContext(audit_plugin_key(), name())`, so filesystem/socket/process events
raised by the coroutine are attributed to the plugin.
The capability reference is released inside the GIL scope (`run->capability.reset()`), because
dropping the pybind holder from a non-Python thread would `DECREF` without the GIL. The loop is
closed and `tl_script_loop` cleared before the worker exits.
A consequence of running Script on a non-main thread: the audit permission prompt is fail-closed off
the main thread. `request_permission` in `src/slic3r/plugin/PluginAuditManager.cpp` posts the dialog
with `CallAfter` and denies the current event, persisting the grant when the user accepts, so a
Script sees a denial on the first attempt and can succeed on a later one. This matches the existing
behavior for the plugin-load worker and the slicing worker.
### Blocking UI continuation
`PluginHostUi::run_on_ui_thread` (in `src/slic3r/plugin/host/PluginHostUi.cpp`) is the primitive host
calls use from the Script worker. It forwards to `run_on_ui_blocking`:
- if the caller is already on the wx main thread, it runs the callable inline after a
`py::gil_scoped_release`;
- otherwise it sets up a `std::promise`/`std::future`, releases the GIL, posts the callable with
`GUI::wxGetApp().CallAfter`, and blocks on `future.get()`.
Because the Script capability owns a worker thread, blocking there does not freeze the UI. The same
primitive is used by `begin_update()`, `set()`/`commit()`, `reload_local_bundle()` and
`list_bundle_ids()`.
### Synchronous `begin_update()`
`begin_preset_update` and `begin_filament_update` (`src/slic3r/plugin/host/PluginHostPresets.cpp`) run
on the Script worker with the GIL held and block it:
1. the binding enters through `PluginHostUi::run_on_ui_thread`, which fails with a `RuntimeError`
before posting when there is no wx application;
2. the UI thread runs the target binding with the GIL released — `bind_preset_target` for
process/printer, `bind_filament_target` for filament — including `Tab::select_preset()` and any
unsaved-edit prompt;
3. the worker resumes with the name-based `PresetTransaction`, and nothing is staged until the
caller's later `set()`/`commit()`.
An unavailable target is rejected before selection and changes no preset. A selection the user
declines raises `RuntimeError` and creates no transaction; decisions already accepted in an earlier
dialog are not rolled back. No GUI or preset pointer outlives the call: the transaction holds only
copied category/name/slot values.
`set()` stages a serialized value without mutating the preset, and `commit()` applies the batch
through `Tab::apply_preset_config_values` on the UI thread. Both are synchronous and, per their
bindings, valid only on the background Script worker.
### Cancellation
`ScriptAsyncRuntime::cancel_plugin(plugin_key)` sets `cancel_requested` on every active run for the
plugin and, when a loop is live (`has_loop`), cancels the task with
`loop.call_soon_threadsafe(task.cancel)` while holding the GIL. A synchronous `def execute`
invocation has no loop, so only the flag is set (checked before the run starts).
A task cancellation cannot interrupt a blocking `begin_update()` native call: the worker is inside
that call, so the loop does not run the scheduled `task.cancel()` until the selection settles and the
call returns. The transaction is therefore delivered, and any accepted UI selection stands; the
cancellation is observed at the coroutine's next suspension point. A user-declined selection is an
ordinary `RuntimeError`, not `asyncio.CancelledError`.
A plugin's own `asyncio.CancelledError` is treated as a cancellation only when `cancel_requested` was
set; otherwise it stays a genuine error (see `run_invocation`), so a plugin that cancels its own task
does not masquerade as an unload cancellation.
### Unload and shutdown drain
Unloading or shutting down must guarantee that no plugin Python code runs after its module is
unloaded, and that a blocking UI selection cannot mutate presets after teardown.
- `PluginManager::cancel_and_wait_for_capabilities` calls `on_cancelled()` first (so the plugin can
observe state while the run is intact), then `ScriptAsyncRuntime::cancel_plugin`, then drains.
The drain is **unbounded while a Script invocation worker is outstanding** — its loop-exit
condition is `all_released && !invocation_active`. `has_active_run()` distinguishes a run that was
cancelled before it started from one whose worker is still live, and a worker blocked in
`begin_update()` keeps the run active until its UI callback returns. On the GUI thread the drain
pumps `wxYield()` so a modal selection can settle instead of being starved by the blocking wait.
Only the non-Script path keeps the historical bounded 2-second grace: when no invocation is active,
the drain stops waiting for capability references at the grace deadline and logs any remaining
ones.
- `PluginManager::shutdown` detaches the libslic3r hooks (`plugin_hooks::uninstall`), stops new
loads, waits for in-flight loads, unloads every plugin (draining the async runs above), then calls
`ScriptAsyncRuntime::instance().shutdown()`, which refuses new runs and waits without a deadline
for every invocation worker to finish — nothing is torn down by force. `ScriptAsyncRuntime::reset()`
re-arms the runtime so a later `initialize()` in the same process can start runs again.
Cancellation triggered by unload or shutdown is a separate, expected completion, not a plugin
failure. `run_invocation` marks a run whose `cancel_requested` was set as `completion.cancelled` and
clears any error, whatever the coroutine body did. `launch_script_capability` records a plugin error
only when `!completion.cancelled && !completion.error.empty()`; `run_script_capability` reports an
empty error for a cancelled completion; and the two GUI launchers treat `cancelled` as neither error
nor success (the speed dial stays silent, the Plugins dialog updates no status). A plugin's own
`CancelledError` is not counted as this expected termination unless `cancel_requested` is set, so a
self-cancelled plugin still records its failure.
## Supported and unsupported capabilities
| Capability | Async support | Reason |
| --- | --- | --- |
| Script | `def execute` and `async def execute` both run on the per-invocation worker | The runtime owns the worker and, for a coroutine, a per-invocation loop; nothing about a Script invocation borrows C++ state. |
| SlicingPipeline | Synchronous only | `execute(ctx)` borrows `Print`/`PrintObject` for the callback's duration, and the UI thread can be blocked on the slicing worker. `PluginHooks.cpp` rejects an async override: `cap->execute()` casts its Python result to `ExecutionResult`, and a coroutine object cannot be cast, so it surfaces as a `SlicingError` rather than being scheduled. |
| PrinterConnection, Pages, Analysis, Importer, Exporter, Visualization, ResourceTexture | Unchanged | Not part of this bridge; they keep their existing synchronous contract. |
## Deadlock constraints
- **Never block the UI thread on plugin work.** Every Script launch path goes through
`launch_script_capability`; the speed-dial `ActionRegistry` action and the Plugins dialog launcher
return immediately and report the outcome later (see Examples).
- **Never call `orca.host.ui.*` from a slicing hook.** The slicing worker runs while the UI thread
can be parked on it.
- **Marshal host UI work from the Script worker only.** `PluginHostUi::run_on_ui_thread` blocks the
calling worker while the UI thread runs the callback; calling it from the UI thread itself would
deadlock the marshalling path.
- **No pointer crosses a call boundary.** `begin_update()` returns copied category/name/slot values;
the `SlicingPipelineContext` graph is valid only within one synchronous `execute(ctx)`.
## File and symbol map
Script execution runtime:
- `src/slic3r/plugin/PluginManager.hpp` / `.cpp` — `ScriptAsyncRuntime` (`instance`, `start`,
`cancel_plugin`, `has_active_run`, `shutdown`, `reset`, `current_loop`), `ScriptCompletion`,
`ScriptCompletionFn`, `ScriptLaunchResult`, `launch_script_capability`, `run_script_capability`.
- `src/slic3r/plugin/PyPluginTrampoline.hpp` — `invoke_script_execute`, `ScriptExecuteInvocation`,
`py_is_coroutine`.
- `src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp` / `ScriptPluginCapabilityTrampoline.hpp`
— `ScriptPluginCapability::execute`, `PyScriptPluginCapabilityTrampoline`.
Lifetime and GIL primitives:
- `src/slic3r/plugin/PythonInterpreter.hpp` — `PythonInterpreter`, `PythonRuntimeLease`,
`PythonGILState`.
- `src/slic3r/plugin/PythonPluginInterface.hpp` — `PluginCapabilityInterface`, `RefCounter`,
`increment`/`decrement`/`ref_count`.
- `src/slic3r/plugin/PluginAuditManager.hpp` / `.cpp` — `ScopedPluginAuditContext`,
`request_filesystem_read_permissions`, `request_permission` (fail-closed off the main thread).
UI marshalling:
- `src/slic3r/plugin/host/PluginHostUi.hpp` / `.cpp` — `PluginHostUi::run_on_ui_thread`, the
file-local `run_on_ui_blocking`, and `close_windows_for_plugin`.
- `GUI::wxGetApp().CallAfter` — the post-to-UI-thread primitive.
Preset targets:
- `src/slic3r/plugin/host/PluginHostPresets.cpp` — `PresetEditor`, `PresetTransaction`,
`bind_preset_target`, `bind_filament_target`, `begin_preset_update`, `begin_filament_update`,
`SelectionCancelled`, `resolve_combo_preset`/`resolve_preset_in_combo`.
- `src/slic3r/GUI/Tab.hpp` / `.cpp` — `Tab::select_preset`, `Tab::apply_preset_config_values`,
`Tab::commit_preset_config_value`.
Launch paths:
- `src/slic3r/GUI/ActionRegistry.cpp` — `PluginScriptAction::run`, `notify_script_outcome`.
- `src/slic3r/GUI/PluginsDialog.cpp` — `PluginsDialog::run_script_plugin_capability`.
Slicing (synchronous only):
- `src/slic3r/plugin/PluginHooks.cpp` — `install_slicing_pipeline_hook`.
- `src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp` —
`SlicingPipelineContext`, `execute`.
Unload/shutdown:
- `src/slic3r/plugin/PluginManager.cpp` — `unload_plugin`, `unload_all_plugins`,
`cancel_and_wait_for_capabilities`, `shutdown`.
## Examples
An `async def execute` that updates a preset. `execute` runs on the background Script worker with its
own asyncio loop; `begin_update()` blocks that worker until any selection / unsaved-edit dialog is
answered, without blocking the UI thread; `set()`/`commit()` then run synchronously on the same
worker. This is the shape used by `sandboxes/orca_preset_transaction_demo.py`:
```python
import asyncio
import orca
class AutoTune(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Auto tune"
async def execute(self):
# begin_update() blocks this worker until the user answers any
# selection / unsaved-edit dialog. The UI thread stays responsive.
tx = orca.host.preset.process.begin_update(preset_name="0.20mm Standard")
tx.set("layer_height", "0.2")
tx.commit()
return orca.ExecutionResult.success("process preset updated")
```
The window-driven demo shows the same pattern where the plugin's own UI must hand work to the worker:
`on_message` fires on the UI thread and only enqueues onto the worker loop with
`loop.call_soon_threadsafe`; `execute` consumes that queue and calls `begin_update()` per command.
Sequence for one `begin_update(...)`:
```text
Script worker (owned, per invocation) UI thread
loop.run_until_complete(task)
begin_update() ───────────────────────► CallAfter(run_on_ui_blocking)
(blocked; not suspended) bind_*_target →
Tab::select_preset() … dialog
settles
promise set; worker resumes with PresetTransaction
set(); commit() (UI thread for the batch)
coroutine resumes at its next await
execute() returns ExecutionResult
```
A plain `def execute` needs no loop and runs to completion on the same worker; both kinds are
launched with `launch_script_capability`, so the caller (UI thread) never blocks. A launch failure is
reported inline; a started run reports its outcome through the completion callback — the speed dial
shows "started" and pushes a completion notification, and the Plugins dialog updates its status when
`finish` runs.
+58 -21
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-15 17:40+0800\n" "POT-Creation-Date: 2026-10-19 09:48-0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -1637,16 +1637,16 @@ msgstr ""
msgid "Stroke of shape (%1%) contains unsupported: %2%." msgid "Stroke of shape (%1%) contains unsupported: %2%."
msgstr "" msgstr ""
msgid "Not valid state please report reproduction steps on github" msgid "Not valid state please report reproduction steps on GitHub"
msgstr "" msgstr ""
msgid "No embossed file" msgid "No embossed file"
msgstr "" msgstr ""
msgid "Missing svg file in embossed shape" msgid "Missing SVG file in embossed shape"
msgstr "" msgstr ""
msgid "Missing data of svg file" msgid "Missing data of SVG file"
msgstr "" msgstr ""
msgid "Face the camera" msgid "Face the camera"
@@ -4579,7 +4579,7 @@ msgstr ""
msgid "Unknown C++ exception." msgid "Unknown C++ exception."
msgstr "" msgstr ""
msgid "Processing G-Code from previous file…" msgid "Processing G-code from previous file…"
msgstr "" msgstr ""
msgid "Slicing complete" msgid "Slicing complete"
@@ -6049,7 +6049,7 @@ msgstr ""
msgid "Size:" msgid "Size:"
msgstr "" msgstr ""
#, possible-boost-format #, possible-c-format, possible-boost-format
msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)."
msgstr "" msgstr ""
@@ -10657,6 +10657,9 @@ msgstr ""
msgid "Send Failed" msgid "Send Failed"
msgstr "" msgstr ""
msgid "No available printers"
msgstr ""
msgid "The name length exceeds the limit." msgid "The name length exceeds the limit."
msgstr "" msgstr ""
@@ -12315,7 +12318,7 @@ msgstr ""
msgid "Left mouse" msgid "Left mouse"
msgstr "" msgstr ""
msgid "Available anywhere in the window, even while typing in a text field." msgid "Available anywhere in the window."
msgstr "" msgstr ""
msgid "Run favorite 1 to 10" msgid "Run favorite 1 to 10"
@@ -12412,9 +12415,6 @@ msgstr ""
msgid "The default %s is assigned to %s. Reassign it to %s?" msgid "The default %s is assigned to %s. Reassign it to %s?"
msgstr "" msgstr ""
msgid "Global shortcuts also apply while typing."
msgstr ""
msgid "A key that types a character cannot be a global shortcut." msgid "A key that types a character cannot be a global shortcut."
msgstr "" msgstr ""
@@ -15645,6 +15645,12 @@ msgstr ""
msgid "Enable this option to add EXCLUDE OBJECT command in G-code." msgid "Enable this option to add EXCLUDE OBJECT command in G-code."
msgstr "" msgstr ""
msgid "Outline tolerance"
msgstr ""
msgid "Maximum deviation of the object outline written to EXCLUDE_OBJECT_DEFINE from the original outline. Larger values give fewer points, which helps firmware that stalls while parsing G-code with many objects. 0 keeps every point."
msgstr ""
msgid "Verbose G-code" msgid "Verbose G-code"
msgstr "" msgstr ""
@@ -20540,7 +20546,7 @@ msgid "test %s start..."
msgstr "" msgstr ""
#, possible-c-format, possible-boost-format #, possible-c-format, possible-boost-format
msgid "test %s ip resolved = %s" msgid "test %s IP resolved = %s"
msgstr "" msgstr ""
#, possible-c-format, possible-boost-format #, possible-c-format, possible-boost-format
@@ -26492,17 +26498,18 @@ msgstr ""
msgid "Basic Colors" msgid "Basic Colors"
msgstr "" msgstr ""
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
msgid "Orca Palette" msgid "Orca Palette"
msgstr "" msgstr ""
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor. #. TRN: Keep the standard/brand name "RAL" unchanged.
msgid "RAL Classic" msgid "RAL Classic"
msgstr "" msgstr ""
#. TRN: Keep the standard/brand name "RAL" unchanged.
msgid "Gradients" msgid "Gradients"
msgstr "" msgstr ""
msgctxt "Color picker"
msgid "Solid" msgid "Solid"
msgstr "" msgstr ""
@@ -26877,7 +26884,7 @@ msgstr ""
msgid "Medium violet red" msgid "Medium violet red"
msgstr "" msgstr ""
msgid "PaleV violet red" msgid "Pale violet red"
msgstr "" msgstr ""
msgid "Deep pink" msgid "Deep pink"
@@ -27039,7 +27046,7 @@ msgstr ""
msgid "Shallow sky" msgid "Shallow sky"
msgstr "" msgstr ""
msgid "Electric bel" msgid "Electric blue"
msgstr "" msgstr ""
msgid "Dolphin" msgid "Dolphin"
@@ -27108,7 +27115,7 @@ msgstr ""
msgid "Maize yellow" msgid "Maize yellow"
msgstr "" msgstr ""
msgid "Dalmatian yellow" msgid "Daffodil yellow"
msgstr "" msgstr ""
msgid "Brown beige" msgid "Brown beige"
@@ -27369,7 +27376,7 @@ msgstr ""
msgid "Turquoise blue" msgid "Turquoise blue"
msgstr "" msgstr ""
msgid "Capsanthin violet" msgid "Capri blue"
msgstr "" msgstr ""
msgid "Ocean blue" msgid "Ocean blue"
@@ -27483,7 +27490,7 @@ msgstr ""
msgid "Silver grey" msgid "Silver grey"
msgstr "" msgstr ""
msgid "Olivestone" msgid "Olive grey"
msgstr "" msgstr ""
msgid "Moss grey" msgid "Moss grey"
@@ -28071,10 +28078,19 @@ msgstr ""
msgid "Built-in" msgid "Built-in"
msgstr "" msgstr ""
msgid "My textures"
msgstr ""
msgid "No textures found." msgid "No textures found."
msgstr "" msgstr ""
msgid "My textures" msgid "Texture libraries"
msgstr ""
msgid "Explore more texture libraries"
msgstr ""
msgid "Opens OrcaCloud in the Home page to browse for plugins."
msgstr "" msgstr ""
msgid "Import your own image as a height map. It is converted to the format the slicer bakes from and saved to your personal texture folder, kept separate from the textures shipped with OrcaSlicer." msgid "Import your own image as a height map. It is converted to the format the slicer bakes from and saved to your personal texture folder, kept separate from the textures shipped with OrcaSlicer."
@@ -30127,6 +30143,9 @@ msgstr ""
msgid "Slicing Pipeline" msgid "Slicing Pipeline"
msgstr "" msgstr ""
msgid "Resource Texture"
msgstr ""
msgid "The plugin's configuration UI was empty. Showing the default editor." msgid "The plugin's configuration UI was empty. Showing the default editor."
msgstr "" msgstr ""
@@ -30242,6 +30261,24 @@ msgstr ""
msgid "Plugin directory does not contain a .py or .whl entry file" msgid "Plugin directory does not contain a .py or .whl entry file"
msgstr "" msgstr ""
msgid "Resource texture directory is not set"
msgstr ""
msgid "Resource texture directory could not be resolved"
msgstr ""
msgid "Resource texture path is not a directory"
msgstr ""
msgid "Plugin folder could not be resolved"
msgstr ""
msgid "Resource texture directory is outside the plugin folder"
msgstr ""
msgid "Resource texture directory could not be read"
msgstr ""
#, possible-boost-format #, possible-boost-format
msgid "Slicing pipeline plugin '%1%' error: %2%" msgid "Slicing pipeline plugin '%1%' error: %2%"
msgstr "" msgstr ""
@@ -30598,7 +30635,7 @@ msgstr ""
#. TRN %1% is the application name. #. TRN %1% is the application name.
#, possible-boost-format #, possible-boost-format
msgid "Not a gcode file generated by %1%." msgid "Not a G-code file generated by %1%."
msgstr "" msgstr ""
#. TRN %1% is a tag in the G-code file that must not be translated. #. TRN %1% is a tag in the G-code file that must not be translated.
@@ -30686,7 +30723,7 @@ msgstr ""
msgid "Failed making a temporary copy of G-code file %1% before running a post-processing script: %2%" msgid "Failed making a temporary copy of G-code file %1% before running a post-processing script: %2%"
msgstr "" msgstr ""
msgid "Post-processor can't find exported gcode file" msgid "Post-processor can't find exported G-code file"
msgstr "" msgstr ""
#, possible-boost-format #, possible-boost-format
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+58 -21
View File
@@ -3,7 +3,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Orca Slicer\n" "Project-Id-Version: Orca Slicer\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-15 17:40+0800\n" "POT-Creation-Date: 2026-10-19 09:48-0300\n"
"PO-Revision-Date: 2026-06-17 15:44-0300\n" "PO-Revision-Date: 2026-06-17 15:44-0300\n"
"Last-Translator: Alexandre Folle de Menezes\n" "Last-Translator: Alexandre Folle de Menezes\n"
"Language-Team: \n" "Language-Team: \n"
@@ -1633,16 +1633,16 @@ msgstr ""
msgid "Stroke of shape (%1%) contains unsupported: %2%." msgid "Stroke of shape (%1%) contains unsupported: %2%."
msgstr "" msgstr ""
msgid "Not valid state please report reproduction steps on github" msgid "Not valid state please report reproduction steps on GitHub"
msgstr "" msgstr ""
msgid "No embossed file" msgid "No embossed file"
msgstr "" msgstr ""
msgid "Missing svg file in embossed shape" msgid "Missing SVG file in embossed shape"
msgstr "" msgstr ""
msgid "Missing data of svg file" msgid "Missing data of SVG file"
msgstr "" msgstr ""
msgid "Face the camera" msgid "Face the camera"
@@ -4575,7 +4575,7 @@ msgstr ""
msgid "Unknown C++ exception." msgid "Unknown C++ exception."
msgstr "" msgstr ""
msgid "Processing G-Code from previous file…" msgid "Processing G-code from previous file…"
msgstr "" msgstr ""
msgid "Slicing complete" msgid "Slicing complete"
@@ -6045,7 +6045,7 @@ msgstr ""
msgid "Size:" msgid "Size:"
msgstr "" msgstr ""
#, boost-format #, c-format, boost-format
msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)."
msgstr "" msgstr ""
@@ -10653,6 +10653,9 @@ msgstr ""
msgid "Send Failed" msgid "Send Failed"
msgstr "" msgstr ""
msgid "No available printers"
msgstr ""
msgid "The name length exceeds the limit." msgid "The name length exceeds the limit."
msgstr "" msgstr ""
@@ -12311,7 +12314,7 @@ msgstr ""
msgid "Left mouse" msgid "Left mouse"
msgstr "" msgstr ""
msgid "Available anywhere in the window, even while typing in a text field." msgid "Available anywhere in the window."
msgstr "" msgstr ""
msgid "Run favorite 1 to 10" msgid "Run favorite 1 to 10"
@@ -12408,9 +12411,6 @@ msgstr ""
msgid "The default %s is assigned to %s. Reassign it to %s?" msgid "The default %s is assigned to %s. Reassign it to %s?"
msgstr "" msgstr ""
msgid "Global shortcuts also apply while typing."
msgstr ""
msgid "A key that types a character cannot be a global shortcut." msgid "A key that types a character cannot be a global shortcut."
msgstr "" msgstr ""
@@ -15641,6 +15641,12 @@ msgstr ""
msgid "Enable this option to add EXCLUDE OBJECT command in G-code." msgid "Enable this option to add EXCLUDE OBJECT command in G-code."
msgstr "" msgstr ""
msgid "Outline tolerance"
msgstr ""
msgid "Maximum deviation of the object outline written to EXCLUDE_OBJECT_DEFINE from the original outline. Larger values give fewer points, which helps firmware that stalls while parsing G-code with many objects. 0 keeps every point."
msgstr ""
msgid "Verbose G-code" msgid "Verbose G-code"
msgstr "" msgstr ""
@@ -20536,7 +20542,7 @@ msgid "test %s start..."
msgstr "" msgstr ""
#, c-format, boost-format #, c-format, boost-format
msgid "test %s ip resolved = %s" msgid "test %s IP resolved = %s"
msgstr "" msgstr ""
#, c-format, boost-format #, c-format, boost-format
@@ -26488,17 +26494,18 @@ msgstr ""
msgid "Basic Colors" msgid "Basic Colors"
msgstr "" msgstr ""
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
msgid "Orca Palette" msgid "Orca Palette"
msgstr "" msgstr ""
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor. #. TRN: Keep the standard/brand name "RAL" unchanged.
msgid "RAL Classic" msgid "RAL Classic"
msgstr "" msgstr ""
#. TRN: Keep the standard/brand name "RAL" unchanged.
msgid "Gradients" msgid "Gradients"
msgstr "" msgstr ""
msgctxt "Color picker"
msgid "Solid" msgid "Solid"
msgstr "" msgstr ""
@@ -26873,7 +26880,7 @@ msgstr ""
msgid "Medium violet red" msgid "Medium violet red"
msgstr "" msgstr ""
msgid "PaleV violet red" msgid "Pale violet red"
msgstr "" msgstr ""
msgid "Deep pink" msgid "Deep pink"
@@ -27035,7 +27042,7 @@ msgstr ""
msgid "Shallow sky" msgid "Shallow sky"
msgstr "" msgstr ""
msgid "Electric bel" msgid "Electric blue"
msgstr "" msgstr ""
msgid "Dolphin" msgid "Dolphin"
@@ -27104,7 +27111,7 @@ msgstr ""
msgid "Maize yellow" msgid "Maize yellow"
msgstr "" msgstr ""
msgid "Dalmatian yellow" msgid "Daffodil yellow"
msgstr "" msgstr ""
msgid "Brown beige" msgid "Brown beige"
@@ -27365,7 +27372,7 @@ msgstr ""
msgid "Turquoise blue" msgid "Turquoise blue"
msgstr "" msgstr ""
msgid "Capsanthin violet" msgid "Capri blue"
msgstr "" msgstr ""
msgid "Ocean blue" msgid "Ocean blue"
@@ -27479,7 +27486,7 @@ msgstr ""
msgid "Silver grey" msgid "Silver grey"
msgstr "" msgstr ""
msgid "Olivestone" msgid "Olive grey"
msgstr "" msgstr ""
msgid "Moss grey" msgid "Moss grey"
@@ -28067,10 +28074,19 @@ msgstr ""
msgid "Built-in" msgid "Built-in"
msgstr "" msgstr ""
msgid "My textures"
msgstr ""
msgid "No textures found." msgid "No textures found."
msgstr "" msgstr ""
msgid "My textures" msgid "Texture libraries"
msgstr ""
msgid "Explore more texture libraries"
msgstr ""
msgid "Opens OrcaCloud in the Home page to browse for plugins."
msgstr "" msgstr ""
msgid "Import your own image as a height map. It is converted to the format the slicer bakes from and saved to your personal texture folder, kept separate from the textures shipped with OrcaSlicer." msgid "Import your own image as a height map. It is converted to the format the slicer bakes from and saved to your personal texture folder, kept separate from the textures shipped with OrcaSlicer."
@@ -30123,6 +30139,9 @@ msgstr ""
msgid "Slicing Pipeline" msgid "Slicing Pipeline"
msgstr "" msgstr ""
msgid "Resource Texture"
msgstr ""
msgid "The plugin's configuration UI was empty. Showing the default editor." msgid "The plugin's configuration UI was empty. Showing the default editor."
msgstr "" msgstr ""
@@ -30238,6 +30257,24 @@ msgstr ""
msgid "Plugin directory does not contain a .py or .whl entry file" msgid "Plugin directory does not contain a .py or .whl entry file"
msgstr "" msgstr ""
msgid "Resource texture directory is not set"
msgstr ""
msgid "Resource texture directory could not be resolved"
msgstr ""
msgid "Resource texture path is not a directory"
msgstr ""
msgid "Plugin folder could not be resolved"
msgstr ""
msgid "Resource texture directory is outside the plugin folder"
msgstr ""
msgid "Resource texture directory could not be read"
msgstr ""
#, boost-format #, boost-format
msgid "Slicing pipeline plugin '%1%' error: %2%" msgid "Slicing pipeline plugin '%1%' error: %2%"
msgstr "" msgstr ""
@@ -30594,7 +30631,7 @@ msgstr ""
#. TRN %1% is the application name. #. TRN %1% is the application name.
#, boost-format #, boost-format
msgid "Not a gcode file generated by %1%." msgid "Not a G-code file generated by %1%."
msgstr "" msgstr ""
#. TRN %1% is a tag in the G-code file that must not be translated. #. TRN %1% is a tag in the G-code file that must not be translated.
@@ -30682,7 +30719,7 @@ msgstr ""
msgid "Failed making a temporary copy of G-code file %1% before running a post-processing script: %2%" msgid "Failed making a temporary copy of G-code file %1% before running a post-processing script: %2%"
msgstr "" msgstr ""
msgid "Post-processor can't find exported gcode file" msgid "Post-processor can't find exported G-code file"
msgstr "" msgstr ""
#, boost-format #, boost-format
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+110 -47
View File
@@ -6,7 +6,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Slic3rPE\n" "Project-Id-Version: Slic3rPE\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-15 17:40+0800\n" "POT-Creation-Date: 2026-10-19 09:48-0300\n"
"PO-Revision-Date: 2026-06-11 12:37-0300\n" "PO-Revision-Date: 2026-06-11 12:37-0300\n"
"Last-Translator: Handle <mail@bysb.net>\n" "Last-Translator: Handle <mail@bysb.net>\n"
"Language-Team: \n" "Language-Team: \n"
@@ -1751,7 +1751,7 @@ msgid "Stroke of shape (%1%) contains unsupported: %2%."
msgstr "形状(%1%)的描边包含不受支持的内容:%2%。" msgstr "形状(%1%)的描边包含不受支持的内容:%2%。"
# AI Translated # AI Translated
msgid "Not valid state please report reproduction steps on github" msgid "Not valid state please report reproduction steps on GitHub"
msgstr "状态无效,请在 GitHub 上报告复现步骤" msgstr "状态无效,请在 GitHub 上报告复现步骤"
# AI Translated # AI Translated
@@ -1759,11 +1759,11 @@ msgid "No embossed file"
msgstr "没有浮雕文件" msgstr "没有浮雕文件"
# AI Translated # AI Translated
msgid "Missing svg file in embossed shape" msgid "Missing SVG file in embossed shape"
msgstr "浮雕形状中缺少 SVG 文件" msgstr "浮雕形状中缺少 SVG 文件"
# AI Translated # AI Translated
msgid "Missing data of svg file" msgid "Missing data of SVG file"
msgstr "缺少 SVG 文件数据" msgstr "缺少 SVG 文件数据"
# AI Translated # AI Translated
@@ -5008,7 +5008,7 @@ msgid "Unknown C++ exception."
msgstr "未知的 C++ 异常。" msgstr "未知的 C++ 异常。"
# AI Translated # AI Translated
msgid "Processing G-Code from previous file…" msgid "Processing G-code from previous file…"
msgstr "正在处理之前文件的 G-code…" msgstr "正在处理之前文件的 G-code…"
msgid "Slicing complete" msgid "Slicing complete"
@@ -6699,7 +6699,7 @@ msgstr "体积:"
msgid "Size:" msgid "Size:"
msgstr "尺寸:" msgstr "尺寸:"
#, boost-format #, c-format, boost-format
msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)."
msgstr "发现G-code路径在层%d,高度为%.2lf mm处有冲突。请将有冲突的对象分离得更远(%s <-> %s)。" msgstr "发现G-code路径在层%d,高度为%.2lf mm处有冲突。请将有冲突的对象分离得更远(%s <-> %s)。"
@@ -12013,6 +12013,10 @@ msgstr "上传文件到打印机存储失败,请重试。"
msgid "Send Failed" msgid "Send Failed"
msgstr "发送失败" msgstr "发送失败"
# AI Translated
msgid "No available printers"
msgstr "没有可用的打印机"
msgid "The name length exceeds the limit." msgid "The name length exceeds the limit."
msgstr "名称长度超过限制。" msgstr "名称长度超过限制。"
@@ -13922,8 +13926,8 @@ msgid "Left mouse"
msgstr "鼠标左键" msgstr "鼠标左键"
# AI Translated # AI Translated
msgid "Available anywhere in the window, even while typing in a text field." msgid "Available anywhere in the window."
msgstr "在窗口任意位置可用,即使正在文本框中输入。" msgstr "在窗口任意位置可用。"
# AI Translated # AI Translated
msgid "Run favorite 1 to 10" msgid "Run favorite 1 to 10"
@@ -14039,10 +14043,6 @@ msgstr "%s 已分配给 %s。是否将其重新分配给 %s?"
msgid "The default %s is assigned to %s. Reassign it to %s?" msgid "The default %s is assigned to %s. Reassign it to %s?"
msgstr "默认的 %s 已分配给 %s。是否将其重新分配给 %s?" msgstr "默认的 %s 已分配给 %s。是否将其重新分配给 %s?"
# AI Translated
msgid "Global shortcuts also apply while typing."
msgstr "全局快捷键在输入文字时同样生效。"
# AI Translated # AI Translated
msgid "A key that types a character cannot be a global shortcut." msgid "A key that types a character cannot be a global shortcut."
msgstr "会输入字符的按键不能用作全局快捷键。" msgstr "会输入字符的按键不能用作全局快捷键。"
@@ -18086,6 +18086,14 @@ msgstr "对象排除"
msgid "Enable this option to add EXCLUDE OBJECT command in G-code." msgid "Enable this option to add EXCLUDE OBJECT command in G-code."
msgstr "启用此选项以在G-code中添加EXCLUDE OBJECT命令。" msgstr "启用此选项以在G-code中添加EXCLUDE OBJECT命令。"
# AI Translated
msgid "Outline tolerance"
msgstr "轮廓线公差"
# AI Translated
msgid "Maximum deviation of the object outline written to EXCLUDE_OBJECT_DEFINE from the original outline. Larger values give fewer points, which helps firmware that stalls while parsing G-code with many objects. 0 keeps every point."
msgstr "写入 EXCLUDE_OBJECT_DEFINE 的对象轮廓线与原始轮廓线之间的最大偏差。数值越大,点数越少,有助于避免固件在解析包含大量对象的 G-code 时卡顿。0 表示保留所有点。"
msgid "Verbose G-code" msgid "Verbose G-code"
msgstr "注释G-code" msgstr "注释G-code"
@@ -24004,7 +24012,7 @@ msgstr "开始测试 %s..."
# AI Translated # AI Translated
#, c-format, boost-format #, c-format, boost-format
msgid "test %s ip resolved = %s" msgid "test %s IP resolved = %s"
msgstr "测试 %s:IP 解析结果 = %s" msgstr "测试 %s:IP 解析结果 = %s"
# AI Translated # AI Translated
@@ -31498,20 +31506,21 @@ msgid "Basic Colors"
msgstr "基本颜色" msgstr "基本颜色"
# AI Translated # AI Translated
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
msgid "Orca Palette" msgid "Orca Palette"
msgstr "Orca 调色板" msgstr "Orca 调色板"
# AI Translated # AI Translated
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor. #. TRN: Keep the standard/brand name "RAL" unchanged.
msgid "RAL Classic" msgid "RAL Classic"
msgstr "RAL 经典色" msgstr "RAL 经典色"
# AI Translated # AI Translated
#. TRN: Keep the standard/brand name "RAL" unchanged.
msgid "Gradients" msgid "Gradients"
msgstr "渐变预设" msgstr "渐变预设"
# AI Translated # AI Translated
msgctxt "Color picker"
msgid "Solid" msgid "Solid"
msgstr "纯色" msgstr "纯色"
@@ -32010,7 +32019,7 @@ msgid "Medium violet red"
msgstr "中紫罗兰红" msgstr "中紫罗兰红"
# AI Translated # AI Translated
msgid "PaleV violet red" msgid "Pale violet red"
msgstr "浅紫罗兰红" msgstr "浅紫罗兰红"
# AI Translated # AI Translated
@@ -32226,8 +32235,7 @@ msgid "Shallow sky"
msgstr "浅空" msgstr "浅空"
# AI Translated # AI Translated
# Needs human review: The English “Electric bel” looks like a typo (Electric blue? Electric eel?); the color is #1E90FF, so 电光蓝 was chosen. Recheck once the source name is fixed. msgid "Electric blue"
msgid "Electric bel"
msgstr "电光蓝" msgstr "电光蓝"
# AI Translated # AI Translated
@@ -32319,9 +32327,8 @@ msgid "Maize yellow"
msgstr "玉米黄" msgstr "玉米黄"
# AI Translated # AI Translated
# Needs human review: RAL 1007 is “Daffodil yellow” (水仙黄); the English name in the palette looks wrong. Recheck once the source name is fixed. msgid "Daffodil yellow"
msgid "Dalmatian yellow" msgstr "水仙黄"
msgstr "达尔马提亚黄"
# AI Translated # AI Translated
msgid "Brown beige" msgid "Brown beige"
@@ -32668,8 +32675,8 @@ msgid "Turquoise blue"
msgstr "绿松石蓝" msgstr "绿松石蓝"
# AI Translated # AI Translated
msgid "Capsanthin violet" msgid "Capri blue"
msgstr "辣椒红素紫" msgstr "卡普里蓝"
# AI Translated # AI Translated
msgid "Ocean blue" msgid "Ocean blue"
@@ -32820,8 +32827,8 @@ msgid "Silver grey"
msgstr "银灰色" msgstr "银灰色"
# AI Translated # AI Translated
msgid "Olivestone" msgid "Olive grey"
msgstr "橄榄石色" msgstr "橄榄灰"
# AI Translated # AI Translated
msgid "Moss grey" msgid "Moss grey"
@@ -33597,13 +33604,25 @@ msgstr "从“我的纹理”中移除"
msgid "Built-in" msgid "Built-in"
msgstr "内置" msgstr "内置"
# AI Translated
msgid "My textures"
msgstr "我的纹理"
# AI Translated # AI Translated
msgid "No textures found." msgid "No textures found."
msgstr "未找到纹理。" msgstr "未找到纹理。"
# AI Translated # AI Translated
msgid "My textures" msgid "Texture libraries"
msgstr "我的纹理" msgstr "纹理库"
# AI Translated
msgid "Explore more texture libraries"
msgstr "探索更多纹理库"
# AI Translated
msgid "Opens OrcaCloud in the Home page to browse for plugins."
msgstr "在主页中打开 OrcaCloud,浏览插件。"
# AI Translated # AI Translated
msgid "Import your own image as a height map. It is converted to the format the slicer bakes from and saved to your personal texture folder, kept separate from the textures shipped with OrcaSlicer." msgid "Import your own image as a height map. It is converted to the format the slicer bakes from and saved to your personal texture folder, kept separate from the textures shipped with OrcaSlicer."
@@ -36285,6 +36304,10 @@ msgstr "脚本"
msgid "Slicing Pipeline" msgid "Slicing Pipeline"
msgstr "切片流水线" msgstr "切片流水线"
# AI Translated
msgid "Resource Texture"
msgstr "资源纹理"
# AI Translated # AI Translated
msgid "The plugin's configuration UI was empty. Showing the default editor." msgid "The plugin's configuration UI was empty. Showing the default editor."
msgstr "插件的配置界面为空。正在显示默认编辑器。" msgstr "插件的配置界面为空。正在显示默认编辑器。"
@@ -36433,6 +36456,30 @@ msgstr "插件目录同时包含 .py 和 .whl 入口文件"
msgid "Plugin directory does not contain a .py or .whl entry file" msgid "Plugin directory does not contain a .py or .whl entry file"
msgstr "插件目录不包含 .py 或 .whl 入口文件" msgstr "插件目录不包含 .py 或 .whl 入口文件"
# AI Translated
msgid "Resource texture directory is not set"
msgstr "未设置资源纹理目录"
# AI Translated
msgid "Resource texture directory could not be resolved"
msgstr "无法解析资源纹理目录"
# AI Translated
msgid "Resource texture path is not a directory"
msgstr "资源纹理路径不是目录"
# AI Translated
msgid "Plugin folder could not be resolved"
msgstr "无法解析插件文件夹"
# AI Translated
msgid "Resource texture directory is outside the plugin folder"
msgstr "资源纹理目录位于插件文件夹之外"
# AI Translated
msgid "Resource texture directory could not be read"
msgstr "无法读取资源纹理目录"
# AI Translated # AI Translated
#, boost-format #, boost-format
msgid "Slicing pipeline plugin '%1%' error: %2%" msgid "Slicing pipeline plugin '%1%' error: %2%"
@@ -36891,7 +36938,7 @@ msgstr "加载配置文件“%1%”失败:%2%"
# AI Translated # AI Translated
#. TRN %1% is the application name. #. TRN %1% is the application name.
#, boost-format #, boost-format
msgid "Not a gcode file generated by %1%." msgid "Not a G-code file generated by %1%."
msgstr "不是由 %1% 生成的 G-code 文件。" msgstr "不是由 %1% 生成的 G-code 文件。"
# AI Translated # AI Translated
@@ -37011,7 +37058,7 @@ msgid "Failed making a temporary copy of G-code file %1% before running a post-p
msgstr "运行后处理脚本之前,无法创建 G-code 文件 %1% 的临时副本:%2%" msgstr "运行后处理脚本之前,无法创建 G-code 文件 %1% 的临时副本:%2%"
# AI Translated # AI Translated
msgid "Post-processor can't find exported gcode file" msgid "Post-processor can't find exported G-code file"
msgstr "后处理器找不到导出的 G-code 文件" msgstr "后处理器找不到导出的 G-code 文件"
# AI Translated # AI Translated
@@ -37400,6 +37447,40 @@ msgstr ""
"避免翘曲\n" "避免翘曲\n"
"您知道吗?打印ABS这类易翘曲材料时,适当提高热床温度可以降低翘曲的概率。" "您知道吗?打印ABS这类易翘曲材料时,适当提高热床温度可以降低翘曲的概率。"
# AI Translated
# Needs human review: RAL 1007 is “Daffodil yellow” (水仙黄); the English name in the palette looks wrong. Recheck once the source name is fixed.
#~ msgid "Dalmatian yellow"
#~ msgstr "达尔马提亚黄"
# AI Translated
#~ msgid "Capsanthin violet"
#~ msgstr "辣椒红素紫"
# AI Translated
#~ msgid "Olivestone"
#~ msgstr "橄榄石色"
# AI Translated
#~ msgid "Available anywhere in the window, even while typing in a text field."
#~ msgstr "在窗口任意位置可用,即使正在文本框中输入。"
# AI Translated
#~ msgid "Global shortcuts also apply while typing."
#~ msgstr "全局快捷键在输入文字时同样生效。"
# AI Translated
#~ msgid "Solid"
#~ msgstr "纯色"
# AI Translated
#~ msgid "PaleV violet red"
#~ msgstr "浅紫罗兰红"
# AI Translated
# Needs human review: The English “Electric bel” looks like a typo (Electric blue? Electric eel?); the color is #1E90FF, so 电光蓝 was chosen. Recheck once the source name is fixed.
#~ msgid "Electric bel"
#~ msgstr "电光蓝"
#~ msgid "Please choose the filament color" #~ msgid "Please choose the filament color"
#~ msgstr "请选择耗材丝颜色" #~ msgstr "请选择耗材丝颜色"
@@ -37865,9 +37946,6 @@ msgstr ""
#~ msgid "no configured tool" #~ msgid "no configured tool"
#~ msgstr "未配置的工具头" #~ msgstr "未配置的工具头"
#~ msgid "Add end G-Code when finishing the entire print."
#~ msgstr "所有打印结束时的结尾G-code"
#~ msgid "Maximum X speed" #~ msgid "Maximum X speed"
#~ msgstr "X最大速度" #~ msgstr "X最大速度"
@@ -37884,26 +37962,11 @@ msgstr ""
#~ msgid "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." #~ msgid "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."
#~ msgstr "在每个切片流水线步骤中调用的 Python 插件,用于读取和修改中间切片数据,包括最终的 G-code 后处理步骤。研究/实验性。" #~ msgstr "在每个切片流水线步骤中调用的 Python 插件,用于读取和修改中间切片数据,包括最终的 G-code 后处理步骤。研究/实验性。"
# AI Translated
#~ msgid "When enabled, the slicer emits g-code with the primary zone's contents centered on bed origin (0,0). The printer firmware is responsible for placing copies/mirrors at each physical zone position. Enable only on printers whose firmware applies its own zone offsets in copy/mirror mode (e.g. RepRapFirmware IDEX duplication mode). Has no effect in Primary mode."
#~ msgstr "启用后,切片软件输出的 G-code 会以热床原点 (0,0) 为中心放置主区域的内容,由打印机固件负责把复制件/镜像件放到各个物理区域。仅当打印机固件在复制/镜像模式下会自行应用区域偏移时才启用(例如 RepRapFirmware 的 IDEX 复制模式)。主模式下无效。"
# AI Translated # AI Translated
# Needs human review: "pem-inversion defaults": confirm pem is the physical extruder map and that the default is its inverse. # Needs human review: "pem-inversion defaults": confirm pem is the physical extruder map and that the default is its inverse.
#~ msgid "Per-plate override mapping physical heads to filament slots (1-based). Empty means use pem-inversion defaults." #~ msgid "Per-plate override mapping physical heads to filament slots (1-based). Empty means use pem-inversion defaults."
#~ msgstr "按盘单独设置物理工具头到耗材丝槽位的映射(从 1 开始编号),覆盖默认映射。留空则使用由物理挤出机映射(pem)反推的默认值。" #~ msgstr "按盘单独设置物理工具头到耗材丝槽位的映射(从 1 开始编号),覆盖默认映射。留空则使用由物理挤出机映射(pem)反推的默认值。"
# AI Translated
#~ msgid "Axis the mesh is rotated about before slicing. This is the belt printer's tilt: an isometric (no distortion) rotation that also drives bed rendering and support gravity tilt, and that the g-code back-transform inverts before the machine-frame shear/scale and remap. X is the typical gantry tilt (belt travels along Y)."
#~ msgstr "切片前模型网格的旋转轴。这一旋转即传送带打印机的倾斜:它是保距(无畸变)旋转,同时用于热床渲染和支撑的重力倾斜;生成 G-code 时,逆变换会先还原这一旋转,再进行机器坐标系剪切/缩放和重映射。X 是典型的龙门倾斜轴(传送带沿 Y 方向移动)。"
# AI Translated
#~ msgid "Expert override: set the machine-frame (g-code shear/scale) tilt angle independently of the pre-slice rotation angle. When disabled, the machine-frame transform is derived from the belt tilt angle, so a single angle drives both stages. Enable only to compensate for a machine whose physical gantry tilt differs from the slicing rotation."
#~ msgstr "专家选项:单独设置机器坐标系(G-code 剪切/缩放)的倾斜角度,与切片前的旋转角度互不影响。禁用时,机器坐标系变换由传送带倾斜角度推导,两个阶段使用同一个角度。仅当机器实际的龙门倾角与切片旋转角度不一致时,才需启用此项进行补偿。"
#~ msgid "The maximum deviation allowed when reducing the resolution for the 'Maximum wall resolution' setting. If you increase this, the print will be less accurate, but the G-Code will be smaller. 'Maximum wall deviation' limits 'Maximum wall resolution', so if the two conflict, 'Maximum wall deviation' takes precedence."
#~ msgstr "在为“最大墙体分辨率”设置降低分辨率时所允许的最大偏差。如果增大此值,打印精度会降低,但 G-code 会更小。“最大墙体偏差”会限制“最大墙体分辨率”,因此当两者冲突时,以“最大墙体偏差”为准。"
#~ msgid " not in range " #~ msgid " not in range "
#~ msgstr " 不在合理范围内" #~ msgstr " 不在合理范围内"
+111 -48
View File
@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Orca Slicer\n" "Project-Id-Version: Orca Slicer\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-15 17:40+0800\n" "POT-Creation-Date: 2026-10-19 09:48-0300\n"
"PO-Revision-Date: 2025-11-28 13:48-0600\n" "PO-Revision-Date: 2025-11-28 13:48-0600\n"
"Last-Translator: tntchn <15895303+tntchn@users.noreply.github.com>\n" "Last-Translator: tntchn <15895303+tntchn@users.noreply.github.com>\n"
"Language-Team: \n" "Language-Team: \n"
@@ -1826,7 +1826,7 @@ msgid "Stroke of shape (%1%) contains unsupported: %2%."
msgstr "形狀(%1%)的筆劃包含不支援的內容:%2%。" msgstr "形狀(%1%)的筆劃包含不支援的內容:%2%。"
# AI Translated # AI Translated
msgid "Not valid state please report reproduction steps on github" msgid "Not valid state please report reproduction steps on GitHub"
msgstr "狀態無效,請在 GitHub 上回報重現步驟" msgstr "狀態無效,請在 GitHub 上回報重現步驟"
# AI Translated # AI Translated
@@ -1834,11 +1834,11 @@ msgid "No embossed file"
msgstr "沒有浮雕檔案" msgstr "沒有浮雕檔案"
# AI Translated # AI Translated
msgid "Missing svg file in embossed shape" msgid "Missing SVG file in embossed shape"
msgstr "浮雕形狀中缺少 SVG 檔案" msgstr "浮雕形狀中缺少 SVG 檔案"
# AI Translated # AI Translated
msgid "Missing data of svg file" msgid "Missing data of SVG file"
msgstr "缺少 SVG 檔案的資料" msgstr "缺少 SVG 檔案的資料"
msgid "Face the camera" msgid "Face the camera"
@@ -5219,7 +5219,7 @@ msgid "Unknown C++ exception."
msgstr "未知的 C++ 例外。" msgstr "未知的 C++ 例外。"
# AI Translated # AI Translated
msgid "Processing G-Code from previous file…" msgid "Processing G-code from previous file…"
msgstr "正在處理先前檔案的 G-code…" msgstr "正在處理先前檔案的 G-code…"
msgid "Slicing complete" msgid "Slicing complete"
@@ -6912,7 +6912,7 @@ msgid "Size:"
msgstr "尺寸:" msgstr "尺寸:"
# AI Translated # AI Translated
#, boost-format #, c-format, boost-format
msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)."
msgstr "在第 %d 層(Z = %.2lf mm)發現 G-code 路徑衝突。請將有衝突的物件分開得更遠(%s <-> %s)。" msgstr "在第 %d 層(Z = %.2lf mm)發現 G-code 路徑衝突。請將有衝突的物件分開得更遠(%s <-> %s)。"
@@ -12298,6 +12298,10 @@ msgstr "無法將檔案上傳至列印裝置的儲存空間,請重試。"
msgid "Send Failed" msgid "Send Failed"
msgstr "傳送失敗" msgstr "傳送失敗"
# AI Translated
msgid "No available printers"
msgstr "沒有可用的列印裝置"
msgid "The name length exceeds the limit." msgid "The name length exceeds the limit."
msgstr "名稱長度超過限制。" msgstr "名稱長度超過限制。"
@@ -14251,8 +14255,8 @@ msgid "Left mouse"
msgstr "滑鼠左鍵" msgstr "滑鼠左鍵"
# AI Translated # AI Translated
msgid "Available anywhere in the window, even while typing in a text field." msgid "Available anywhere in the window."
msgstr "在視窗中任何位置皆可使用,即使正在文字欄位中輸入也可以。" msgstr "在視窗中任何位置皆可使用。"
# AI Translated # AI Translated
msgid "Run favorite 1 to 10" msgid "Run favorite 1 to 10"
@@ -14372,10 +14376,6 @@ msgstr "%s 已指派給 %s。要改為指派給 %s 嗎?"
msgid "The default %s is assigned to %s. Reassign it to %s?" msgid "The default %s is assigned to %s. Reassign it to %s?"
msgstr "預設的 %s 已指派給 %s。要改為指派給 %s 嗎?" msgstr "預設的 %s 已指派給 %s。要改為指派給 %s 嗎?"
# AI Translated
msgid "Global shortcuts also apply while typing."
msgstr "全域快速鍵在輸入文字時也會生效。"
# AI Translated # AI Translated
msgid "A key that types a character cannot be a global shortcut." msgid "A key that types a character cannot be a global shortcut."
msgstr "會輸入字元的按鍵不能作為全域快速鍵。" msgstr "會輸入字元的按鍵不能作為全域快速鍵。"
@@ -18513,6 +18513,14 @@ msgstr "物件排除"
msgid "Enable this option to add EXCLUDE OBJECT command in G-code." msgid "Enable this option to add EXCLUDE OBJECT command in G-code."
msgstr "啟用此選項以在 G-code 中加入 EXCLUDE OBJECT 指令。" msgstr "啟用此選項以在 G-code 中加入 EXCLUDE OBJECT 指令。"
# AI Translated
msgid "Outline tolerance"
msgstr "輪廓線公差"
# AI Translated
msgid "Maximum deviation of the object outline written to EXCLUDE_OBJECT_DEFINE from the original outline. Larger values give fewer points, which helps firmware that stalls while parsing G-code with many objects. 0 keeps every point."
msgstr "寫入 EXCLUDE_OBJECT_DEFINE 的物件輪廓線與原始輪廓線之間的最大偏差。數值越大,點數越少,有助於避免韌體在解析包含大量物件的 G-code 時卡住。0 表示保留所有點。"
msgid "Verbose G-code" msgid "Verbose G-code"
msgstr "詳細 G-code" msgstr "詳細 G-code"
@@ -24597,7 +24605,7 @@ msgstr "測試 %s 開始..."
# AI Translated # AI Translated
#, c-format, boost-format #, c-format, boost-format
msgid "test %s ip resolved = %s" msgid "test %s IP resolved = %s"
msgstr "測試 %s IP 解析結果 = %s" msgstr "測試 %s IP 解析結果 = %s"
# AI Translated # AI Translated
@@ -32124,21 +32132,22 @@ msgid "Basic Colors"
msgstr "基本顏色" msgstr "基本顏色"
# AI Translated # AI Translated
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
msgid "Orca Palette" msgid "Orca Palette"
msgstr "Orca 調色盤" msgstr "Orca 調色盤"
# AI Translated # AI Translated
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor. #. TRN: Keep the standard/brand name "RAL" unchanged.
msgid "RAL Classic" msgid "RAL Classic"
msgstr "RAL 經典色" msgstr "RAL 經典色"
# AI Translated # AI Translated
# Needs human review: Palette dropdown entry for the gradient presets (next to 基本顏色 / Orca 調色盤 / RAL 經典色); 漸層配色 keeps it apart from the Solid/Gradient switch label 漸層. Check it reads right in the dropdown. # Needs human review: Palette dropdown entry for the gradient presets (next to 基本顏色 / Orca 調色盤 / RAL 經典色); 漸層配色 keeps it apart from the Solid/Gradient switch label 漸層. Check it reads right in the dropdown.
#. TRN: Keep the standard/brand name "RAL" unchanged.
msgid "Gradients" msgid "Gradients"
msgstr "漸層配色" msgstr "漸層配色"
# AI Translated # AI Translated
msgctxt "Color picker"
msgid "Solid" msgid "Solid"
msgstr "純色" msgstr "純色"
@@ -32637,7 +32646,7 @@ msgid "Medium violet red"
msgstr "中紫羅蘭紅" msgstr "中紫羅蘭紅"
# AI Translated # AI Translated
msgid "PaleV violet red" msgid "Pale violet red"
msgstr "淡紫羅蘭紅" msgstr "淡紫羅蘭紅"
# AI Translated # AI Translated
@@ -32853,8 +32862,7 @@ msgid "Shallow sky"
msgstr "淺色天空" msgstr "淺色天空"
# AI Translated # AI Translated
# Needs human review: Orca palette: the English is a typo for "Electric blue" (hex #1E90FF); translated as 電光藍. Revisit if the source keeps a different name. msgid "Electric blue"
msgid "Electric bel"
msgstr "電光藍" msgstr "電光藍"
# AI Translated # AI Translated
@@ -32946,9 +32954,8 @@ msgid "Maize yellow"
msgstr "玉米黃" msgstr "玉米黃"
# AI Translated # AI Translated
# Needs human review: RAL 1007 in the RAL Classic list: the English name looks wrong (1007 is Daffodil yellow, 水仙黃); translated literally. Revisit once the source name is fixed. msgid "Daffodil yellow"
msgid "Dalmatian yellow" msgstr "水仙黃"
msgstr "達爾馬提亞黃"
# AI Translated # AI Translated
msgid "Brown beige" msgid "Brown beige"
@@ -33296,9 +33303,8 @@ msgid "Turquoise blue"
msgstr "綠松石藍" msgstr "綠松石藍"
# AI Translated # AI Translated
# Needs human review: RAL 5019 in the RAL Classic list: the English name is not a RAL Classic name (5019 is Capri blue); translated literally. Revisit once the source name is fixed. msgid "Capri blue"
msgid "Capsanthin violet" msgstr "卡布里藍"
msgstr "辣椒紅素紫"
# AI Translated # AI Translated
msgid "Ocean blue" msgid "Ocean blue"
@@ -33449,8 +33455,8 @@ msgid "Silver grey"
msgstr "銀灰" msgstr "銀灰"
# AI Translated # AI Translated
msgid "Olivestone" msgid "Olive grey"
msgstr "橄欖石色" msgstr "橄欖灰"
# AI Translated # AI Translated
msgid "Moss grey" msgid "Moss grey"
@@ -34226,13 +34232,25 @@ msgstr "從「我的紋理」移除"
msgid "Built-in" msgid "Built-in"
msgstr "內建" msgstr "內建"
# AI Translated
msgid "My textures"
msgstr "我的紋理"
# AI Translated # AI Translated
msgid "No textures found." msgid "No textures found."
msgstr "找不到紋理。" msgstr "找不到紋理。"
# AI Translated # AI Translated
msgid "My textures" msgid "Texture libraries"
msgstr "我的紋理" msgstr "紋理庫"
# AI Translated
msgid "Explore more texture libraries"
msgstr "探索更多紋理庫"
# AI Translated
msgid "Opens OrcaCloud in the Home page to browse for plugins."
msgstr "在首頁中開啟 OrcaCloud,瀏覽外掛。"
# AI Translated # AI Translated
msgid "Import your own image as a height map. It is converted to the format the slicer bakes from and saved to your personal texture folder, kept separate from the textures shipped with OrcaSlicer." msgid "Import your own image as a height map. It is converted to the format the slicer bakes from and saved to your personal texture folder, kept separate from the textures shipped with OrcaSlicer."
@@ -36928,6 +36946,10 @@ msgstr "指令碼"
msgid "Slicing Pipeline" msgid "Slicing Pipeline"
msgstr "切片管線" msgstr "切片管線"
# AI Translated
msgid "Resource Texture"
msgstr "資源紋理"
# AI Translated # AI Translated
msgid "The plugin's configuration UI was empty. Showing the default editor." msgid "The plugin's configuration UI was empty. Showing the default editor."
msgstr "外掛的設定介面為空。將顯示預設編輯器。" msgstr "外掛的設定介面為空。將顯示預設編輯器。"
@@ -37075,6 +37097,30 @@ msgstr "外掛目錄同時包含 .py 和 .whl 進入點檔案"
msgid "Plugin directory does not contain a .py or .whl entry file" msgid "Plugin directory does not contain a .py or .whl entry file"
msgstr "外掛目錄不包含 .py 或 .whl 進入點檔案" msgstr "外掛目錄不包含 .py 或 .whl 進入點檔案"
# AI Translated
msgid "Resource texture directory is not set"
msgstr "未設定資源紋理目錄"
# AI Translated
msgid "Resource texture directory could not be resolved"
msgstr "無法解析資源紋理目錄"
# AI Translated
msgid "Resource texture path is not a directory"
msgstr "資源紋理路徑不是目錄"
# AI Translated
msgid "Plugin folder could not be resolved"
msgstr "無法解析外掛資料夾"
# AI Translated
msgid "Resource texture directory is outside the plugin folder"
msgstr "資源紋理目錄位於外掛資料夾之外"
# AI Translated
msgid "Resource texture directory could not be read"
msgstr "無法讀取資源紋理目錄"
# AI Translated # AI Translated
#, boost-format #, boost-format
msgid "Slicing pipeline plugin '%1%' error: %2%" msgid "Slicing pipeline plugin '%1%' error: %2%"
@@ -37533,7 +37579,7 @@ msgstr "載入設定檔「%1%」失敗:%2%"
# AI Translated # AI Translated
#. TRN %1% is the application name. #. TRN %1% is the application name.
#, boost-format #, boost-format
msgid "Not a gcode file generated by %1%." msgid "Not a G-code file generated by %1%."
msgstr "不是由 %1% 產生的 G-code 檔案。" msgstr "不是由 %1% 產生的 G-code 檔案。"
# AI Translated # AI Translated
@@ -37653,7 +37699,7 @@ msgid "Failed making a temporary copy of G-code file %1% before running a post-p
msgstr "執行後處理腳本前,無法建立 G-code 檔案 %1% 的暫存副本:%2%" msgstr "執行後處理腳本前,無法建立 G-code 檔案 %1% 的暫存副本:%2%"
# AI Translated # AI Translated
msgid "Post-processor can't find exported gcode file" msgid "Post-processor can't find exported G-code file"
msgstr "後處理程式找不到匯出的 G-code 檔案" msgstr "後處理程式找不到匯出的 G-code 檔案"
# AI Translated # AI Translated
@@ -38049,6 +38095,41 @@ msgstr ""
"避免翹曲\n" "避免翹曲\n"
"您知道嗎?當列印容易翹曲的材料(如 ABS)時,適當提高熱床溫度可以降低翹曲的機率。" "您知道嗎?當列印容易翹曲的材料(如 ABS)時,適當提高熱床溫度可以降低翹曲的機率。"
# AI Translated
# Needs human review: RAL 1007 in the RAL Classic list: the English name looks wrong (1007 is Daffodil yellow, 水仙黃); translated literally. Revisit once the source name is fixed.
#~ msgid "Dalmatian yellow"
#~ msgstr "達爾馬提亞黃"
# AI Translated
# Needs human review: RAL 5019 in the RAL Classic list: the English name is not a RAL Classic name (5019 is Capri blue); translated literally. Revisit once the source name is fixed.
#~ msgid "Capsanthin violet"
#~ msgstr "辣椒紅素紫"
# AI Translated
#~ msgid "Olivestone"
#~ msgstr "橄欖石色"
# AI Translated
#~ msgid "Available anywhere in the window, even while typing in a text field."
#~ msgstr "在視窗中任何位置皆可使用,即使正在文字欄位中輸入也可以。"
# AI Translated
#~ msgid "Global shortcuts also apply while typing."
#~ msgstr "全域快速鍵在輸入文字時也會生效。"
# AI Translated
#~ msgid "Solid"
#~ msgstr "純色"
# AI Translated
#~ msgid "PaleV violet red"
#~ msgstr "淡紫羅蘭紅"
# AI Translated
# Needs human review: Orca palette: the English is a typo for "Electric blue" (hex #1E90FF); translated as 電光藍. Revisit if the source keeps a different name.
#~ msgid "Electric bel"
#~ msgstr "電光藍"
#~ msgid "Please choose the filament color" #~ msgid "Please choose the filament color"
#~ msgstr "請選擇線材顏色" #~ msgstr "請選擇線材顏色"
@@ -38513,9 +38594,6 @@ msgstr ""
#~ msgid "no configured tool" #~ msgid "no configured tool"
#~ msgstr "未設定的工具頭" #~ msgstr "未設定的工具頭"
#~ msgid "Add end G-Code when finishing the entire print."
#~ msgstr "所有列印結束時的結尾 G-code"
#~ msgid "Maximum X speed" #~ msgid "Maximum X speed"
#~ msgstr "X 最大速度" #~ msgstr "X 最大速度"
@@ -38532,25 +38610,10 @@ msgstr ""
#~ msgid "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." #~ msgid "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."
#~ msgstr "在每個切片管線步驟呼叫的 Python 外掛,用於讀取與修改中介切片資料,包括最終的 G-code 後處理步驟。研究/實驗性質。" #~ msgstr "在每個切片管線步驟呼叫的 Python 外掛,用於讀取與修改中介切片資料,包括最終的 G-code 後處理步驟。研究/實驗性質。"
# AI Translated
#~ msgid "When enabled, the slicer emits g-code with the primary zone's contents centered on bed origin (0,0). The printer firmware is responsible for placing copies/mirrors at each physical zone position. Enable only on printers whose firmware applies its own zone offsets in copy/mirror mode (e.g. RepRapFirmware IDEX duplication mode). Has no effect in Primary mode."
#~ msgstr "啟用後,切片軟體輸出的 G-code 會將主區域的內容以列印板原點 (0,0) 為中心放置,並由列印裝置韌體負責將複製件/鏡像件放到各個實體區域位置。僅在韌體會於複製/鏡像模式下自行套用區域偏移的列印裝置上啟用(例如 RepRapFirmware 的 IDEX 複製模式)。在主模式下無效。"
# AI Translated # AI Translated
#~ msgid "Per-plate override mapping physical heads to filament slots (1-based). Empty means use pem-inversion defaults." #~ msgid "Per-plate override mapping physical heads to filament slots (1-based). Empty means use pem-inversion defaults."
#~ msgstr "依列印板覆寫實體工具頭到線材槽位的映射(從 1 開始編號)。留空表示使用由 pem 反推的預設映射。" #~ msgstr "依列印板覆寫實體工具頭到線材槽位的映射(從 1 開始編號)。留空表示使用由 pem 反推的預設映射。"
# AI Translated
#~ msgid "Axis the mesh is rotated about before slicing. This is the belt printer's tilt: an isometric (no distortion) rotation that also drives bed rendering and support gravity tilt, and that the g-code back-transform inverts before the machine-frame shear/scale and remap. X is the typical gantry tilt (belt travels along Y)."
#~ msgstr "切片前網格旋轉所繞的軸。這就是輸送帶列印裝置的傾斜:一種等距(無變形)旋轉,同時用於列印板渲染與支撐的重力傾斜;G-code 反向轉換會先還原此旋轉,再進行機器座標系的剪切/縮放與重新映射。X 是典型的龍門傾斜軸(輸送帶沿 Y 方向移動)。"
# AI Translated
#~ msgid "Expert override: set the machine-frame (g-code shear/scale) tilt angle independently of the pre-slice rotation angle. When disabled, the machine-frame transform is derived from the belt tilt angle, so a single angle drives both stages. Enable only to compensate for a machine whose physical gantry tilt differs from the slicing rotation."
#~ msgstr "專家覆寫選項:獨立於切片前的旋轉角度,單獨設定機器座標系(G-code 剪切/縮放)的傾斜角度。停用時,機器座標系變換由輸送帶傾斜角度推導,兩個階段使用同一個角度。僅在機器實際的龍門傾角與切片旋轉角度不同時,才啟用此選項進行補償。"
#~ msgid "The maximum deviation allowed when reducing the resolution for the 'Maximum wall resolution' setting. If you increase this, the print will be less accurate, but the G-Code will be smaller. 'Maximum wall deviation' limits 'Maximum wall resolution', so if the two conflict, 'Maximum wall deviation' takes precedence."
#~ msgstr "「最大牆解析度」設定在降低解析度時所允許的最大偏差。若增加此值,列印會較不精確,但 G-Code 會更小。「最大牆偏差」會限制「最大牆解析度」,因此若兩者衝突,將以「最大牆偏差」為準。"
#~ msgid " not in range " #~ msgid " not in range "
#~ msgstr " 不在合理的區間" #~ msgstr " 不在合理的區間"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "Snapmaker", "name": "Snapmaker",
"version": "02.04.00.17", "version": "02.04.00.18",
"force_update": "0", "force_update": "0",
"description": "Snapmaker configurations", "description": "Snapmaker configurations",
"machine_model_list": [ "machine_model_list": [
@@ -49,5 +49,8 @@
], ],
"pressure_advance": [ "pressure_advance": [
"0.02" "0.02"
],
"temperature_vitrification": [
"100"
] ]
} }
@@ -46,5 +46,8 @@
], ],
"fan_min_speed": [ "fan_min_speed": [
"0" "0"
],
"temperature_vitrification": [
"100"
] ]
} }
@@ -45,7 +45,7 @@
"250" "250"
], ],
"temperature_vitrification": [ "temperature_vitrification": [
"178" "70"
], ],
"fan_max_speed": [ "fan_max_speed": [
"20" "20"
@@ -4,5 +4,8 @@
"inherits": "fdm_filament_petg", "inherits": "fdm_filament_petg",
"from": "system", "from": "system",
"filament_id": "OFW8lqnb", "filament_id": "OFW8lqnb",
"instantiation": "false" "instantiation": "false",
"temperature_vitrification": [
"70"
]
} }
@@ -60,7 +60,7 @@
"250" "250"
], ],
"temperature_vitrification": [ "temperature_vitrification": [
"178" "70"
], ],
"fan_max_speed": [ "fan_max_speed": [
"20" "20"
@@ -34,5 +34,8 @@
], ],
"nozzle_temperature": [ "nozzle_temperature": [
"220" "220"
],
"temperature_vitrification": [
"45"
] ]
} }
@@ -34,5 +34,8 @@
], ],
"filament_cooling_final_speed": [ "filament_cooling_final_speed": [
"60" "60"
],
"temperature_vitrification": [
"45"
] ]
} }
@@ -34,5 +34,8 @@
], ],
"nozzle_temperature": [ "nozzle_temperature": [
"220" "220"
],
"temperature_vitrification": [
"45"
] ]
} }
@@ -40,5 +40,8 @@
], ],
"nozzle_temperature": [ "nozzle_temperature": [
"220" "220"
],
"temperature_vitrification": [
"45"
] ]
} }
@@ -31,5 +31,8 @@
], ],
"slow_down_layer_time": [ "slow_down_layer_time": [
"8" "8"
],
"temperature_vitrification": [
"45"
] ]
} }
@@ -50,5 +50,8 @@
"additional_cooling_fan_speed": [ "additional_cooling_fan_speed": [
"70", "70",
"70" "70"
],
"temperature_vitrification": [
"30"
] ]
} }
@@ -137,7 +137,7 @@
"240" "240"
], ],
"temperature_vitrification": [ "temperature_vitrification": [
"60" "30"
], ],
"nozzle_temperature_range_low": [ "nozzle_temperature_range_low": [
"200" "200"
@@ -302,7 +302,7 @@
"-1" "-1"
], ],
"temperature_vitrification": [ "temperature_vitrification": [
"60" "30"
], ],
"textured_cool_plate_temp": [ "textured_cool_plate_temp": [
"40" "40"
@@ -29,5 +29,8 @@
"additional_cooling_fan_speed": [ "additional_cooling_fan_speed": [
"70", "70",
"70" "70"
],
"temperature_vitrification": [
"30"
] ]
} }
@@ -49,5 +49,8 @@
"additional_cooling_fan_speed": [ "additional_cooling_fan_speed": [
"70", "70",
"70" "70"
],
"temperature_vitrification": [
"30"
] ]
} }
@@ -97,7 +97,7 @@
"20", "20",
"12" "12"
], ],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0", "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_tool_change_time": "5", "machine_tool_change_time": "5",
"max_layer_height": [ "max_layer_height": [
"0.14", "0.14",
@@ -97,7 +97,7 @@
"20", "20",
"12" "12"
], ],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0", "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_tool_change_time": "5", "machine_tool_change_time": "5",
"max_layer_height": [ "max_layer_height": [
"0.32", "0.32",
@@ -97,7 +97,7 @@
"20", "20",
"12" "12"
], ],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0", "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_tool_change_time": "5", "machine_tool_change_time": "5",
"max_layer_height": [ "max_layer_height": [
"0.42", "0.42",
@@ -97,7 +97,7 @@
"20", "20",
"12" "12"
], ],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0", "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_tool_change_time": "5", "machine_tool_change_time": "5",
"max_layer_height": [ "max_layer_height": [
"0.56", "0.56",
@@ -109,7 +109,7 @@ const cssColors = [
{hex: "#800080", name: "Purple"}, {hex: "#800080", name: "Purple"},
{hex: "#663399", name: "Rebecca purple"}, {hex: "#663399", name: "Rebecca purple"},
{hex: "#C71585", name: "Medium violet red"}, {hex: "#C71585", name: "Medium violet red"},
{hex: "#DB7093", name: "PaleV violet red"}, {hex: "#DB7093", name: "Pale violet red"},
{hex: "#FF1493", name: "Deep pink"}, {hex: "#FF1493", name: "Deep pink"},
{hex: "#FF69B4", name: "Hot pink"}, {hex: "#FF69B4", name: "Hot pink"},
{hex: "#FFB6C1", name: "Light pink"}, {hex: "#FFB6C1", name: "Light pink"},
@@ -193,7 +193,7 @@ const palettes = {
{hex: "#40E0D0", name: "Turquoise bay"}, {hex: "#40E0D0", name: "Turquoise bay"},
{hex: "#2EBDEF", name: "Sky over reef"}, {hex: "#2EBDEF", name: "Sky over reef"},
{hex: "#87CEEB", name: "Shallow sky"}, {hex: "#87CEEB", name: "Shallow sky"},
{hex: "#1E90FF", name: "Electric bel"}, {hex: "#1E90FF", name: "Electric blue"},
{hex: "#4169E1", name: "Dolphin"}, {hex: "#4169E1", name: "Dolphin"},
{hex: "#0000CD", name: "Ocean depths"}, {hex: "#0000CD", name: "Ocean depths"},
{hex: "#191970", name: "Whale"}, {hex: "#191970", name: "Whale"},
@@ -219,7 +219,7 @@ const palettes = {
{hex: "#EFBF04", name: "1004 Golden yellow"}, {hex: "#EFBF04", name: "1004 Golden yellow"},
{hex: "#D7A500", name: "1005 Honey yellow"}, {hex: "#D7A500", name: "1005 Honey yellow"},
{hex: "#F2D185", name: "1006 Maize yellow"}, {hex: "#F2D185", name: "1006 Maize yellow"},
{hex: "#FDD185", name: "1007 Dalmatian yellow"}, {hex: "#FDD185", name: "1007 Daffodil yellow"},
{hex: "#8B7D6B", name: "1011 Brown beige"}, {hex: "#8B7D6B", name: "1011 Brown beige"},
{hex: "#F5DB50", name: "1012 Lemon yellow"}, {hex: "#F5DB50", name: "1012 Lemon yellow"},
{hex: "#F3E5AB", name: "1013 Oyster white"}, {hex: "#F3E5AB", name: "1013 Oyster white"},
@@ -318,7 +318,7 @@ const palettes = {
{hex: "#0384A7", name: "5015 Sky blue"}, {hex: "#0384A7", name: "5015 Sky blue"},
{hex: "#006A9E", name: "5017 Traffic blue"}, {hex: "#006A9E", name: "5017 Traffic blue"},
{hex: "#005F87", name: "5018 Turquoise blue"}, {hex: "#005F87", name: "5018 Turquoise blue"},
{hex: "#5A3D5B", name: "5019 Capsanthin violet"}, {hex: "#1B5583", name: "5019 Capri blue"},
{hex: "#04749F", name: "5020 Ocean blue"}, {hex: "#04749F", name: "5020 Ocean blue"},
{hex: "#009AAA", name: "5021 Water blue"}, {hex: "#009AAA", name: "5021 Water blue"},
{hex: "#1A3D5F", name: "5022 Night blue"}, {hex: "#1A3D5F", name: "5022 Night blue"},
@@ -366,7 +366,7 @@ const palettes = {
{hex: "#00A550", name: "6039 Mint green"}, {hex: "#00A550", name: "6039 Mint green"},
{hex: "#7D7D7D", name: "7000 Squirrel grey"}, {hex: "#7D7D7D", name: "7000 Squirrel grey"},
{hex: "#A8A9AD", name: "7001 Silver grey"}, {hex: "#A8A9AD", name: "7001 Silver grey"},
{hex: "#6F7368", name: "7002 Olivestone"}, {hex: "#6F7368", name: "7002 Olive grey"},
{hex: "#8F9080", name: "7003 Moss grey"}, {hex: "#8F9080", name: "7003 Moss grey"},
{hex: "#8E9291", name: "7004 Signal grey"}, {hex: "#8E9291", name: "7004 Signal grey"},
{hex: "#8B8788", name: "7005 Mouse grey"}, {hex: "#8B8788", name: "7005 Mouse grey"},
-99
View File
@@ -1,99 +0,0 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# id = "preset-transaction-demo"
# name = "Preset Transaction Demo"
# description = "Stages several settings on one preset with begin_update()/set()/commit() (demo)."
# author = "OrcaSlicer contributors"
# version = "0.2.0"
# type = "script"
# ///
"""Preset transaction demo: change several settings with one commit.
Enable the plugin, then run "Preset Transaction Demo" from the Plugins dialog.
Edit CATEGORY, PRESET_NAME, FILAMENT_SLOT and UPDATES below to try other presets
and settings.
``begin_update()`` resolves the target through Orca's normal preset-selection
flow, so it may open Orca's usual "unsaved changes" dialog. The call is
synchronous: the background Script worker waits until the user answers, while
the UI thread stays responsive. ``set()`` only stages values; ``commit()``
validates and applies the whole batch. The edited preset stays unsaved (dirty)
until the user saves it through the normal preset workflow; nothing here writes
to disk.
Values are Orca's serialized config strings: numbers plain, percentages with
"%", booleans "1"/"0", choices by their internal key, lists ";"-separated.
"""
import orca
# ---------------------------------------------------------------------------
# Edit these. CATEGORY and UPDATES belong together: each presets category only
# accepts its own setting keys.
# ---------------------------------------------------------------------------
# Preset category: "process", "printer", or "filament".
CATEGORY = "process"
# Target preset name for process/printer. "" keeps the currently active preset.
# For filament this optionally selects and assigns the preset for FILAMENT_SLOT;
# "" keeps the preset the slot already has.
PRESET_NAME = ""
# 1-based filament slot. Only used when CATEGORY is "filament".
FILAMENT_SLOT = 1
# Settings staged in order, then committed together.
UPDATES = (
("layer_height", "0.25"),
("wall_loops", "3"),
)
def _begin_update():
"""Bind CATEGORY's target and return a transaction to stage changes on."""
if CATEGORY not in ("process", "printer", "filament"):
raise ValueError('CATEGORY must be "process", "printer", or "filament"')
if CATEGORY == "filament":
# Filament updates are addressed by their 1-based slot number.
return orca.host.preset.filament.begin_update(FILAMENT_SLOT, PRESET_NAME)
return getattr(orca.host.preset, CATEGORY).begin_update(PRESET_NAME)
class PresetTransactionDemo(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Preset transaction demo"
def execute(self):
# A plain def runs on the background Script worker, so begin_update() may
# block here through Orca's selection / unsaved-edit prompts.
try:
transaction = _begin_update()
for key, value in UPDATES:
transaction.set(key, value)
result = transaction.commit()
except Exception as error:
return orca.ExecutionResult.failure(
orca.PluginResult.RecoverableError,
"Preset transaction demo failed: %s" % error,
)
return orca.ExecutionResult.success(
'Committed %d setting(s) to the %s preset "%s"; %s.'
% (
len(result.keys),
result.category,
result.target_name,
"it stays unsaved until you save it"
if result.dirty
else "it already matches its saved state",
)
)
@orca.plugin
class PresetTransactionDemoPackage(orca.base):
def register_capabilities(self):
orca.register_capability(PresetTransactionDemo)
-188
View File
@@ -1,188 +0,0 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# id = "preset-update-demo"
# name = "Preset Update Demo"
# description = "Browse and edit settings in process, printer, and filament presets."
# author = "OrcaSlicer contributors"
# version = "0.4.0"
# type = "script"
# ///
"""Interactive preset editor. Snapshots are read on the UI thread; edits run on execute's worker."""
import queue
import orca
PAGE = r'''<!doctype html><html><head><meta charset="utf-8"><style>
:root{color-scheme:light dark;--a:#087f78;--line:rgba(127,127,127,.25);--soft:rgba(127,127,127,.1)}
*{box-sizing:border-box}body{margin:0;font:13px/1.45 var(--orca-font,system-ui,sans-serif);display:flex;flex-direction:column;height:100vh;color:var(--orca-fg,#20242a);background:var(--orca-bg,#fafafa)}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px}h1{font-size:16px;margin:0}.brand{color:var(--a);font-size:20px}select,input,textarea,button{font:inherit;color:inherit}select,input,textarea{background:var(--orca-bg,#fafafa);border:1px solid var(--line);border-radius:7px;padding:7px 9px}header select{margin-left:auto;min-width:140px}.bar{padding:10px 18px;display:flex;gap:10px;align-items:center;border-bottom:1px solid var(--line)}.bar select{min-width:220px}.bar label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;opacity:.65}.badge{padding:3px 9px;border:1px solid var(--line);border-radius:20px;opacity:.7}.layout{display:grid;grid-template-columns:minmax(230px,42%) 1fr;min-height:0;flex:1}.list{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}.search{padding:9px;border-bottom:1px solid var(--line)}.search input{width:100%}#rows{overflow:auto;padding:4px}.row{width:100%;display:flex;gap:12px;padding:7px 9px;border:0;border-left:3px solid transparent;background:transparent;text-align:left;cursor:pointer;border-radius:3px}.row:hover,.row.sel{background:var(--soft)}.row.sel{border-left-color:var(--a)}.key{flex:1;font-family:ui-monospace,monospace;overflow:hidden;text-overflow:ellipsis}.val{max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.65;font-family:ui-monospace,monospace}.editor{padding:20px;display:flex;flex-direction:column;gap:10px;overflow:auto}.editor h2{font:600 15px ui-monospace,monospace;margin:0;overflow-wrap:anywhere}.muted{opacity:.65;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.current{font-family:ui-monospace,monospace;background:var(--soft);padding:10px;border-radius:7px;overflow-wrap:anywhere;white-space:pre-wrap}textarea{min-height:88px;resize:vertical;font-family:ui-monospace,monospace}.actions{display:flex;gap:8px}button.action{padding:7px 15px;border-radius:7px;border:1px solid var(--line);background:transparent;cursor:pointer}button.primary{background:var(--a);border-color:var(--a);color:white}button:disabled{opacity:.45;cursor:default}.hint{margin-top:auto;padding:10px;border-left:3px solid var(--a);background:var(--soft);border-radius:4px}.status{padding:8px 18px;border-top:1px solid var(--line);opacity:.75}.error{color:#c33}.empty{padding:20px;opacity:.65}@media(max-width:650px){.layout{grid-template-columns:1fr 1fr}.bar{flex-wrap:wrap}.bar select{min-width:120px;flex:1}}
</style></head><body>
<header><span class="brand">◈</span><h1>Preset editor</h1><select id="kind"><option value="process">Process</option><option value="printer">Printer</option><option value="filament">Filament</option></select><button class="action" id="close">Close</button></header>
<div class="bar"><label id="slotlabel" hidden>Slot</label><select id="slot" hidden></select><label>Preset</label><select id="preset"></select><span class="badge" id="dirty"></span><button class="action" id="reload">Refresh</button></div>
<div class="layout"><section class="list"><div class="search"><input id="search" placeholder="Search settings…"></div><div id="rows"></div></section><section class="editor"><div class="muted">Selected setting</div><h2 id="key">Choose a setting</h2><div class="muted">Current value</div><div class="current" id="current">—</div><label class="muted" for="value">New serialized value</label><textarea id="value" disabled></textarea><div class="actions"><button class="action primary" id="apply" disabled>Apply change</button><button class="action" id="reset" disabled>Reset</button></div><div class="hint">Choose a category, preset and filament slot. Edit any listed key, then apply. Orca may ask to switch presets or resolve unsaved changes. Values stay unsaved until saved in Orca.</div></section></div><footer class="status" id="status">Loading presets…</footer>
<script>
const $=id=>document.getElementById(id),S={kind:'process',slot:1,preset:'',rows:[],selected:null,busy:true,notice:'',noticeError:false};
function send(command,extra={}){$('status').textContent='Working…';S.busy=true;controls();orca.postMessage({command,kind:S.kind,slot:S.slot,preset:$('preset').value,...extra})}
function controls(){ $('kind').disabled=S.busy;$('slot').disabled=S.busy;$('preset').disabled=S.busy;$('apply').disabled=S.busy||!S.selected;$('reload').disabled=S.busy }
function load(){send('state')}
function paint(m){const notice=S.notice,noticeError=S.noticeError;S.notice='';S.noticeError=false;S.busy=false;S.kind=m.kind;S.slot=m.slot||1;S.rows=m.rows||[];S.selected=S.rows.some(r=>r.k===S.selected)?S.selected:null;
$('slotlabel').hidden=S.kind!=='filament';$('slot').hidden=S.kind!=='filament';$('slot').innerHTML=(m.slots||[]).map(x=>`<option value="${x.slot}">Slot ${x.slot} · ${esc(x.name||'Empty')}</option>`).join('');$('slot').value=S.slot;
$('preset').innerHTML=(m.presets||[]).map(x=>`<option value="${esc(x)}">${esc(x)}</option>`).join('');$('preset').value=m.preset_name||'';$('dirty').textContent=m.dirty?'Modified':'Saved';
const q=$('search').value.toLowerCase(),rows=S.rows.filter(r=>(r.k+' '+r.v).toLowerCase().includes(q));$('rows').innerHTML=rows.map(r=>`<button class="row ${S.selected===r.k?'sel':''}" data-key="${esc(r.k)}"><span class="key">${esc(r.k)}</span><span class="val">${esc(r.v)}</span></button>`).join('')||'<div class="empty">No matching settings</div>';
const r=S.rows.find(x=>x.k===S.selected);$('key').textContent=r?r.k:'Choose a setting';$('current').textContent=r?r.v:'—';$('value').disabled=!r;$('value').value=r?r.v:'';$('reset').disabled=!r;controls();$('status').textContent=m.error||notice||m.message||`${S.rows.length} settings · changes remain unsaved`;$('status').className='status'+((m.error||noticeError)?' error':'') }
function esc(x){return String(x??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
$('kind').onchange=()=>{S.kind=$('kind').value;S.selected=null;$('preset').value='';load()};$('slot').onchange=()=>{S.slot=Number($('slot').value);S.selected=null;$('preset').value='';load()};$('preset').onchange=()=>{S.selected=null;load()};$('reload').onclick=load;$('close').onclick=()=>orca.close();$('search').oninput=()=>paint({kind:S.kind,slot:S.slot,rows:S.rows,presets:[...$('preset').options].map(o=>o.value),preset_name:$('preset').value});
$('rows').onclick=e=>{let b=e.target.closest('[data-key]');if(!b)return;S.selected=b.dataset.key;let r=S.rows.find(x=>x.k===S.selected);$('key').textContent=r.k;$('current').textContent=r.v;$('value').value=r.v;$('value').disabled=false;$('reset').disabled=false;$('apply').disabled=false;document.querySelectorAll('.row').forEach(x=>x.classList.toggle('sel',x===b))};$('reset').onclick=()=>{let r=S.rows.find(x=>x.k===S.selected);if(r)$('value').value=r.v};$('apply').onclick=()=>send('apply',{key:S.selected,value:$('value').value});orca.onMessage(m=>{if(m.command==='state')paint(m);else if(m.command==='refresh'){S.notice=m.error||m.message||'';S.noticeError=!!m.error;load()}});load();
</script></body></html>'''
class PresetUpdateDemo(orca.script.ScriptPluginCapabilityBase):
window = None
_commands = None
_cancelled = False
def get_name(self):
return "Preset editor demo"
def execute(self):
if self.window is not None and self.window.is_open():
return orca.ExecutionResult.success("Preset editor is already open.")
# The queue exists before the window so on_cancelled() can always unblock this worker, even
# when a forced unload destroys the window without firing on_close.
commands = queue.Queue()
self._commands = commands
try:
if self._cancelled:
return orca.ExecutionResult.success("Preset editor closed.")
self.window = orca.host.ui.create_window(
title="Preset editor", html=PAGE, width=900, height=620,
on_message=self._on_message,
on_close=self._on_close,
)
while True:
message = commands.get()
if self._cancelled or message.get("command") == "close":
return orca.ExecutionResult.success("Preset editor closed.")
try:
notice = {"command": "refresh", "message": self._apply(message)}
except Exception as error:
notice = {"command": "refresh", "error": str(error)}
if self.window is not None and self.window.is_open():
self.window.post(notice)
except Exception as error:
return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError,
"Preset editor failed: %s" % error)
finally:
if self._commands is commands:
self._commands = None
self.window = None
# Both UI callbacks run on the wx main thread with the GIL held.
def _on_message(self, message):
message = dict(message) if isinstance(message, dict) else {}
if message.get("command") == "apply":
self._enqueue(message)
return
try:
state = self._snapshot(message)
except Exception as error:
state = {"command": "state", "kind": message.get("kind", "process"),
"slot": message.get("slot", 1), "rows": [], "presets": [], "slots": [],
"preset_name": "", "dirty": False, "error": str(error)}
window = self.window
if window is not None and window.is_open():
window.post(state)
def _on_close(self):
self._enqueue({"command": "close"})
def on_cancelled(self):
# Called before the unload drain. Enqueueing a close lets a worker parked in get() exit; a
# callback that arrives after the worker already left sees no queue and is harmless.
self._cancelled = True
self._enqueue({"command": "close"})
def _enqueue(self, message):
commands = self._commands
if commands is not None:
commands.put(message)
def _apply(self, message):
kind = message.get("kind", "process")
if kind not in ("process", "printer", "filament"):
raise ValueError("Unknown preset category")
name = message.get("preset") or ""
if not isinstance(name, str):
raise ValueError("Preset name must be a string")
key, value = message.get("key"), message.get("value")
if not isinstance(key, str) or not key or not isinstance(value, str):
raise ValueError("Setting key and serialized value required")
editor = getattr(orca.host.preset, kind)
if kind == "filament":
slot = int(message.get("slot", 1))
if slot < 1:
raise ValueError("Filament slot numbers are 1-based")
transaction = editor.begin_update(slot, name)
else:
transaction = editor.begin_update(name)
transaction.set(key, value)
result = transaction.commit()
status = "Set %s on %s. Changes remain unsaved." % (key, result.target_name)
if result.selection_changed:
status += " Orca switched the active preset."
if kind == "filament" and len(result.shared_slots) > 1:
status += " Shared by filament slots: %s." % ", ".join(str(s) for s in result.shared_slots)
return status
# Read-only snapshot. Runs on the UI thread so no live Preset or PresetBundle object is read
# from the Script worker, and never calls begin_update() or another blocking preset operation.
def _snapshot(self, message):
kind = message.get("kind", "process")
if kind not in ("process", "printer", "filament"):
raise ValueError("Unknown preset category")
try:
slot = int(message.get("slot", 1))
except (TypeError, ValueError):
slot = 1
requested = message.get("preset") or ""
if not isinstance(requested, str):
requested = ""
bundle = orca.host.preset_bundle()
slots = []
if kind == "filament":
assigned_names = list(bundle.current_filament_preset_names())
presets = list(bundle.current_filament_presets())
slots = [{"slot": index + 1,
"name": presets[index].name if presets[index] is not None
else (assigned_names[index] or None)}
for index in range(len(presets))]
if not 1 <= slot <= len(presets):
slot = 1
preset = bundle.filaments.find_preset(requested) if requested else None
if preset is None and presets:
preset = presets[slot - 1]
available = list(bundle.filaments.preset_names())
else:
collection = bundle.prints if kind == "process" else bundle.printers
available = list(collection.preset_names())
preset = collection.find_preset(requested) if requested else None
if preset is None:
preset = (bundle.current_process_preset() if kind == "process"
else bundle.current_printer_preset())
rows = []
if preset is not None:
for key in sorted(preset.config_keys()):
value = preset.config_value(key)
rows.append({"k": key, "v": "" if value is None else str(value)})
return {"command": "state", "kind": kind, "slot": slot, "slots": slots,
"presets": available, "preset_name": preset.name if preset is not None else "",
"dirty": bool(preset.is_dirty) if preset is not None else False, "rows": rows}
@orca.plugin
class PresetUpdateDemoPackage(orca.base):
def register_capabilities(self):
orca.register_capability(PresetUpdateDemo)
+251 -4
View File
@@ -12,6 +12,7 @@ commands:
trim delete profile files no <vendor>.json list references trim delete profile files no <vendor>.json list references
update-index regenerate the *_list sections of <vendor>.json update-index regenerate the *_list sections of <vendor>.json
legacy-compat write the printers user presets can be migrated back to legacy-compat write the printers user presets can be migrated back to
gcode-diff print a readable markdown diff of changed custom G-code
options shared by several commands: options shared by several commands:
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all --vendor VENDOR act on one vendor bundle only; repeatable, empty means all
@@ -94,11 +95,14 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way.
""" """
import argparse import argparse
import difflib
import functools import functools
import html
import json import json
import os import os
import posixpath import posixpath
import re import re
import subprocess
import sys import sys
import uuid import uuid
from collections import Counter, defaultdict from collections import Counter, defaultdict
@@ -192,6 +196,7 @@ VECTOR_KEYS = {
PRINT_CONFIG_CPP = os.path.normpath( PRINT_CONFIG_CPP = os.path.normpath(
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp")) os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
TAB_CPP = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "src", "slic3r", "GUI", "Tab.cpp"))
@functools.lru_cache(maxsize=None) @functools.lru_cache(maxsize=None)
@@ -313,11 +318,11 @@ REMEDY_HINTS = {
} }
def print_error(msg): def print_error(msg, file=None):
print(f"\033[91m[ERROR]\033[0m {msg}") # Red print(f"\033[91m[ERROR]\033[0m {msg}", file=file) # Red
def print_warning(msg): def print_warning(msg, file=None):
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow print(f"\033[93m[WARNING]\033[0m {msg}", file=file) # Yellow
def print_info(msg): def print_info(msg):
print(f"\033[94m[INFO]\033[0m {msg}") # Blue print(f"\033[94m[INFO]\033[0m {msg}") # Blue
@@ -3209,6 +3214,223 @@ def run_legacy_compat(base_dir, profiles_dir, output, check):
return 0 return 0
# ---------------------------------------------------------------------------
# gcode-diff
# ---------------------------------------------------------------------------
# UTF-8 bytes, kept well under GitHub's 65,536-character comment limit.
GCODE_DIFF_BUDGET = 60000
# The file names a section's summary shows before "and N more".
GCODE_DIFF_SUMMARY_FILES = 3
GCODE_DIFF_HEADER = (
"## Custom G-code changes\n"
"\n"
"GitHub shows custom G-code in a profile as one long line, so below is a line by line "
"diff of each changed G-code or other text setting.\n")
@functools.lru_cache(maxsize=None)
def _text_option_keys():
"""The string options PrintConfig.cpp defines as G-code or other multi-line text
(every *_gcode option, and every one it marks multiline, such as notes), plus the
ones Tab.cpp edits as code, such as filename_format."""
with open(PRINT_CONFIG_CPP, encoding="utf-8") as f:
source = f.read()
keys = set()
for body in re.split(r"\bdef\s*=\s*this->add(?:_nullable)?\(", source)[1:]:
match = re.match(r'"(\w+)",\s*coStrings?\b', body)
if match and (match.group(1).endswith("_gcode")
or re.search(r"def->multiline\s*=\s*true", body)):
keys.add(match.group(1))
if "machine_start_gcode" not in keys:
raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines machine_start_gcode")
# Tab.cpp only adds keys, so a missing file is not an error.
try:
with open(TAB_CPP, encoding="utf-8") as f:
gui = f.read()
except OSError:
gui = ""
keys.update(re.findall(r'get_option\("(\w+)"\);(?:(?!get_option\().)*?\bis_code\s*=\s*true',
gui, re.DOTALL))
return frozenset(keys)
def _git(cwd, *args, stdin=None):
return subprocess.run(["git", "-C", cwd, *args], input=stdin, check=True,
capture_output=True).stdout
def _blobs_at(profiles_dir, specs):
"""{(ref, path): bytes, or None if not a file there} for (ref, path relative to
profiles_dir) pairs, all read by one git cat-file process."""
prefix = _git(profiles_dir, "rev-parse", "--show-prefix").decode("utf-8").strip()
out = _git(profiles_dir, "cat-file", "--batch",
stdin="".join(f"{ref}:{prefix}{rel}\n" for ref, rel in specs).encode("utf-8"))
blobs, pos = {}, 0
for spec in specs:
end = out.index(b"\n", pos)
header = out[pos:end]
pos = end + 1
# "<name> missing" and "<name> ambiguous" have no content; "<oid> <type> <size>" does.
if header.endswith((b" missing", b" ambiguous")):
blobs[spec] = None
continue
_oid, kind, size = header.split()
blobs[spec] = out[pos:pos + int(size)] if kind == b"blob" else None
pos += int(size) + 1
return blobs
def _parse_profile(raw, rel, where):
"""The JSON in raw, or None with a warning when it is absent or not JSON."""
if raw is None:
print_warning(f"{rel}: skipped, not found at {where}", file=sys.stderr)
return None
try:
return json.loads(raw.decode("utf-8-sig"))
except ValueError as e:
print_warning(f"{rel}: skipped, cannot read it at {where}: {e}", file=sys.stderr)
return None
def _changed_profiles(profiles_dir, base, head):
"""(old path, new path) of each JSON file under profiles_dir that differs between
base and head (the working tree, untracked files included, when None); the absent
side of an added or deleted file is None, and so is a side that is not JSON. Renames
are paired."""
out = _git(profiles_dir, "diff", "--name-status", "-z", "-M", "--relative", base,
*([head] if head else []), "--", ".")
fields = out.decode("utf-8").split("\0")
pairs, i = [], 0
while i + 1 < len(fields):
status = fields[i]
old = new = fields[i + 1]
if status[0] in "RC":
new = fields[i + 2]
i += 3
else:
i += 2
old = None if status[0] == "A" or not old.endswith(".json") else old
new = None if status[0] == "D" or not new.endswith(".json") else new
if old or new:
pairs.append((old, new))
if head is None:
out = _git(profiles_dir, "ls-files", "-z", "--others", "--exclude-standard", "--", ".")
pairs += [(None, rel) for rel in out.decode("utf-8").split("\0")
if rel.endswith(".json") and rel.split("/")[0] != USER_DIR]
return pairs
def _text_lines(value):
return [line.rstrip() for line in value.split("\n")] if value else []
def _text_changes(old, new):
"""(setting, unified diff) for every G-code or other text value that differs
between two profiles; a vector option with several values gives one per value."""
changes = []
for key in list(new) + [k for k in old if k not in new]:
a, b = _as_list(old.get(key, [])), _as_list(new.get(key, []))
if a == b or not all(isinstance(v, str) for v in a + b):
continue
if key not in _text_option_keys() and not any("\n" in v for v in a + b):
continue
width = max(len(a), len(b))
for i in range(width):
diff = list(difflib.unified_diff(
_text_lines(a[i] if i < len(a) else ""), _text_lines(b[i] if i < len(b) else ""),
lineterm=""))[2:] # drops the ---/+++ file header
if diff:
changes.append((key if width == 1 else f"{key}[{i}]", "\n".join(diff)))
return changes
def _gcode_diff_section(files, setting, diff):
# A fence longer than any backtick run in the G-code cannot be closed by it.
fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", diff)])
# In <code>, the "@BBL" of a filament file name does not mention a GitHub user.
def code(text):
return f"<code>{html.escape(text, quote=False)}</code>"
names, listing = ", ".join(map(code, files)), ""
if len(files) > GCODE_DIFF_SUMMARY_FILES + 1: # never "and 1 more"
listing = f"<p>{names}</p>\n\n" # an HTML block, so markdown in names stays literal
names = (", ".join(map(code, files[:GCODE_DIFF_SUMMARY_FILES]))
+ f" and {len(files) - GCODE_DIFF_SUMMARY_FILES} more")
return (f"\n<details>\n<summary><b>{names} - {code(setting)} (click to expand)</b>"
f"</summary>\n\n{listing}{fence}diff\n{diff}\n{fence}\n\n</details>\n")
def gcode_diff(profiles_dir, base, head=None, vendors=None):
"""Markdown with one collapsed diff per changed G-code setting between base and
head, or "" when none changed. Files whose diff of a setting is identical share
one section. Cut to whole sections under GCODE_DIFF_BUDGET."""
pairs = [(old, new) for old, new in _changed_profiles(profiles_dir, base, head)
if not vendors or any(p and p.split("/")[0] in vendors for p in (old, new))]
specs = ([(base, old) for old, _ in pairs if old]
+ [(head, new) for _, new in pairs if new and head])
blobs = _blobs_at(profiles_dir, specs) if specs else {}
def load(ref, rel):
if rel is None:
return {}
if ref:
return _parse_profile(blobs[(ref, rel)], rel, ref)
try:
return _parse_profile(Path(profiles_dir, rel).read_bytes(), rel, "the working tree")
except OSError as e:
print_warning(f"{rel}: skipped, cannot read it: {e}", file=sys.stderr)
return None
groups = {} # (setting, diff) -> files, in the order git lists them
for old_rel, new_rel in pairs:
old, new = load(base, old_rel), load(head, new_rel)
if not isinstance(old, dict) or not isinstance(new, dict):
continue
for setting, diff in _text_changes(old, new):
groups.setdefault((setting, diff), []).append(new_rel or old_rel)
if not groups:
return ""
sections = [_gcode_diff_section(files, setting, diff)
for (setting, diff), files in groups.items()]
budget = GCODE_DIFF_BUDGET - len(GCODE_DIFF_HEADER.encode("utf-8"))
shown = 0
for section in sections:
budget -= len(section.encode("utf-8"))
if budget < 0:
break
shown += 1
note, hidden = "", len(sections) - shown
if hidden:
note = (f"\n{hidden} of {len(sections)} sections {'is' if hidden == 1 else 'are'} not "
"shown because the comment would be too long for GitHub. See the Files "
"changed tab for the rest.\n")
return GCODE_DIFF_HEADER + "".join(sections[:shown]) + note
def _default_gcode_diff_base(profiles_dir, head):
for main in ("upstream/main", "origin/main"):
try:
return _git(profiles_dir, "merge-base", head or "HEAD", main).decode().strip()
except subprocess.CalledProcessError:
continue
raise RuntimeError("no --base given, and neither upstream/main nor origin/main exists")
def run_gcode_diff(profiles_dir, vendors, base, head):
try:
markdown = gcode_diff(profiles_dir, base or _default_gcode_diff_base(profiles_dir, head),
head, vendors)
# UnicodeDecodeError means git listed a file name that is not UTF-8.
except (OSError, RuntimeError, UnicodeDecodeError, subprocess.CalledProcessError) as e:
stderr = getattr(e, "stderr", None)
print_error(stderr.decode(errors="replace").strip() if stderr else str(e), file=sys.stderr)
return 1
sys.stdout.write(markdown)
return 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -3233,6 +3455,9 @@ examples:
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2) profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
orca_profile_tool.py gcode-diff > gcode.md
the changed custom G-code of the working tree since it left upstream/main,
as markdown with one collapsed diff per setting
after adding, renaming or deleting profile files, run in this order: after adding, renaming or deleting profile files, run in this order:
normalize -> update-index -> generate-id -> check normalize -> update-index -> generate-id -> check
@@ -3387,6 +3612,25 @@ def build_parser():
legacy_cmd.add_argument("--check", action="store_true", legacy_cmd.add_argument("--check", action="store_true",
help="fail if the map differs from what --base implies; write nothing") help="fail if the map differs from what --base implies; write nothing")
gcode_cmd = add(
"gcode-diff", [vendor_opt, profiles_opt],
"print a readable markdown diff of changed custom G-code",
"Print, as markdown, a unified diff of every G-code or other multi-line\n"
"setting that differs between --base and --head in each changed profile\n"
"file, one collapsed section per setting. Files whose diff of a setting is\n"
"identical share a section. Trailing spaces are removed from every line, and\n"
"the output is cut to whole sections to fit a GitHub comment. Prints nothing\n"
"when no such setting changed. CI posts it on profile PRs.\n"
"\n"
"The settings are the *_gcode and multiline string options PrintConfig.cpp\n"
"defines, the options Tab.cpp edits as code (such as filename_format), and any\n"
"string value that spans several lines.")
gcode_cmd.add_argument("--base", metavar="REF",
help="compare against this git ref (default: the merge base "
"with upstream/main, else origin/main)")
gcode_cmd.add_argument("--head", metavar="REF",
help="compare this git ref (default: the working tree)")
return parser return parser
@@ -3423,6 +3667,9 @@ def main(argv=None):
if args.command == "legacy-compat": if args.command == "legacy-compat":
return run_legacy_compat(args.base, profiles_dir, args.output, args.check) return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
if args.command == "gcode-diff":
return run_gcode_diff(profiles_dir, vendors, args.base, args.head)
if args.command == "fix-variant": if args.command == "fix-variant":
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run, _changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
strict=args.strict) strict=args.strict)
+309 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the """Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
normalize, trim, update-index, fix-variant and check commands, and the subcommand normalize, trim, update-index, fix-variant, check and gcode-diff commands, and the
dispatch that reaches them (stdlib unittest, no external deps). subcommand dispatch that reaches them (stdlib unittest, no external deps).
The id halves are covered by test_filament_id.py and test_setting_id.py. The id halves are covered by test_filament_id.py and test_setting_id.py.
@@ -14,9 +14,12 @@ import json
import os import os
import re import re
import shutil import shutil
import stat
import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from unittest import mock
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
@@ -41,7 +44,24 @@ class Tree:
os.makedirs(self.profiles) os.makedirs(self.profiles)
def cleanup(self): def cleanup(self):
shutil.rmtree(self.dir, ignore_errors=True) root = os.path.abspath(self.dir)
def writable_retry(func, path, _exc):
# Windows refuses to delete read-only files such as git's objects, and POSIX
# refuses to delete the entries of a read-only directory.
if not os.path.lexists(path): # already removed, as on a second cleanup
return
parent = os.path.dirname(os.path.abspath(path))
try:
if os.path.commonpath([parent, root]) == root: # never the temp dir itself
os.chmod(parent, stat.S_IRWXU)
if not os.path.islink(path): # chmod would follow it out of the tree
os.chmod(path, stat.S_IRWXU)
func(path)
except (OSError, ValueError): # commonpath raises ValueError across drives
pass
hook = "onexc" if sys.version_info >= (3, 12) else "onerror"
shutil.rmtree(self.dir, **{hook: writable_retry})
def index_path(self, vendor): def index_path(self, vendor):
return os.path.join(self.profiles, vendor + ".json") return os.path.join(self.profiles, vendor + ".json")
@@ -1409,6 +1429,292 @@ class TestDispatch(TreeCase):
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament") self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
# ---------------------------------------------------------------------------
# gcode-diff
# ---------------------------------------------------------------------------
@unittest.skipUnless(shutil.which("git"), "git not installed")
class TestGcodeDiff(TreeCase):
START = "G28\nM104 S200\nG1 Z5"
def git(self, *args):
subprocess.run(["git", "-C", self.t.dir, "-c", "user.name=t", "-c", "user.email=t@t",
"-c", "commit.gpgsign=false", *args], check=True, capture_output=True)
def printer(self, name, **extra):
self.t.write("V", f"machine/{name}.json", {"type": "machine", "name": name, **extra})
def commit(self):
self.git("add", "-A")
self.git("commit", "-q", "--allow-empty", "-m", "x")
@classmethod
def setUpClass(cls):
# git's list of the variables that tie a process to a repository, asked with no
# GIT_* set since a stray GIT_DIR fails the query.
clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
cls.repo_env_vars = set(subprocess.run(
["git", "rev-parse", "--local-env-vars"], env=clean, check=True,
capture_output=True, text=True).stdout.split())
def setUp(self):
super().setUp()
# A git hook's GIT_DIR or GIT_INDEX_FILE would point every git call here, the
# tool's included, at the real repository instead of the temp tree.
env = {k: v for k, v in os.environ.items() if k not in self.repo_env_vars}
patcher = mock.patch.dict(os.environ, env, clear=True)
patcher.start()
self.addCleanup(patcher.stop)
self.git("init", "-q")
for name in ("A", "B", "C"):
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
self.printer("Gone", machine_end_gcode="M84")
self.commit()
def diff(self):
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
self.assertEqual(rc, 0, out)
return out
def test_identical_edits_share_one_section(self):
for name in ("A", "B"):
self.printer(name, machine_start_gcode="G28\nM104 S210 \nG1 Z5")
self.printer("C", machine_start_gcode="G28\nM104 S220\nG1 Z5")
out = self.diff()
self.assertEqual(out.count("<details>"), 2)
self.assertIn("<code>V/machine/A.json</code>, <code>V/machine/B.json</code> - "
"<code>machine_start_gcode</code>", out)
self.assertIn("<code>V/machine/C.json</code> - <code>machine_start_gcode</code>", out)
# Trailing spaces are removed.
self.assertIn("\n-M104 S200\n+M104 S210\n", out)
self.assertIn("\n\n```diff\n@@ ", out)
self.assertIn("\n```\n\n</details>", out)
def test_nothing_is_printed_without_a_text_change(self):
self.printer("A", machine_start_gcode=self.START, nozzle_diameter=["0.6"])
self.assertEqual(self.diff(), "")
def test_added_and_deleted_files_show_the_whole_value(self):
# An untracked file counts as added; the validator's user dir is not a profile.
self.printer("New", machine_start_gcode="G28\nG29")
self.t.write("user", "default/machine/U.json", {"machine_start_gcode": "G28"})
os.remove(self.t.path("V", "machine/Gone.json"))
out = self.diff()
self.assertIn("@@ -0,0 +1,2 @@\n+G28\n+G29\n", out)
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
self.assertNotIn("U.json", out)
def test_a_multi_line_value_counts_whatever_its_key(self):
self.printer("A", machine_start_gcode=self.START, custom_note="one\ntwo")
self.assertIn("<code>V/machine/A.json</code> - <code>custom_note</code>", self.diff())
def test_a_file_name_cannot_mention_a_user(self):
self.t.write("V", "filament/PLA @BBL A1.json", {"filament_start_gcode": ["M106"]})
self.assertIn("<code>V/filament/PLA @BBL A1.json</code>", self.diff())
def test_head_compares_two_commits(self):
self.printer("A", machine_start_gcode="G28")
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("<code>V/machine/A.json</code> - <code>machine_start_gcode</code>", out)
def test_a_renamed_file_diffs_against_its_old_self(self):
os.rename(self.t.path("V", "machine/A.json"), self.t.path("V", "machine/A2.json"))
self.printer("A2", machine_start_gcode="G28\nM104 S210\nG1 Z5", nozzle_diameter=["0.4"])
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("<code>V/machine/A2.json</code> - <code>machine_start_gcode</code>", out)
self.assertIn("\n G28\n-M104 S200\n+M104 S210\n G1 Z5\n", out)
def test_a_file_renamed_off_json_loses_its_values(self):
os.rename(self.t.path("V", "machine/Gone.json"), self.t.path("V", "machine/Gone.json.bak"))
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
def test_a_file_renamed_onto_json_is_new(self):
with open(self.t.path("V", "machine/Draft.txt"), "w", encoding="utf-8") as f:
json.dump({"type": "machine", "machine_start_gcode": self.START}, f)
self.commit()
os.rename(self.t.path("V", "machine/Draft.txt"), self.t.path("V", "machine/Draft.json"))
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("@@ -0,0 +1,3 @@\n+G28\n+M104 S200\n+G1 Z5\n", out)
def test_a_batch_read_survives_paths_that_are_not_files(self):
blobs = apt._blobs_at(self.t.profiles, [("HEAD", "V/machine"), ("HEAD", "V/no such.json"),
("HEAD", "V/machine/Gone.json")])
self.assertIsNone(blobs[("HEAD", "V/machine")])
self.assertIsNone(blobs[("HEAD", "V/no such.json")])
self.assertEqual(json.loads(blobs[("HEAD", "V/machine/Gone.json")])["machine_end_gcode"],
"M84")
def test_each_value_of_a_vector_option_is_its_own_setting(self):
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M107"]})
self.commit()
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M106 S0"]})
out = self.diff()
self.assertEqual(out.count("<details>"), 1)
self.assertIn("<code>filament_start_gcode[1]</code>", out)
def test_vendor_limits_the_files(self):
self.printer("A", machine_start_gcode="G28")
self.t.write("W", "machine/X.json", {"machine_start_gcode": "G28"})
rc, out = self.run_command("gcode-diff", "--base", "HEAD", "--vendor", "V")
self.assertEqual(rc, 0, out)
self.assertIn("V/machine/A.json", out)
self.assertNotIn("W/machine/X.json", out)
def test_vendor_includes_files_moved_out_of_it(self):
self.t.add_vendor("W")
os.rename(self.t.path("V", "machine/A.json"), self.t.path("W", "machine/A.json"))
data = self.t.read("W", "machine/A.json")
self.t.write("W", "machine/A.json", {**data, "machine_start_gcode": "G28\nM104 S210\nG1 Z5"})
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD",
"--vendor", "V")
self.assertEqual(rc, 0, out)
self.assertIn("<code>W/machine/A.json</code> - <code>machine_start_gcode</code>", out)
def same_edit(self, names):
"""The diff after one start G-code edit to each of names; returns (summary, output)."""
for name in names:
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
self.commit()
for name in names:
self.printer(name, machine_start_gcode="G28")
out = self.diff()
return re.search(r"<summary>(.*)</summary>", out).group(1), out
def test_a_long_file_list_moves_into_the_section(self):
summary, out = self.same_edit("ABCDE")
self.assertIn("<code>V/machine/C.json</code> and 2 more - ", summary)
self.assertNotIn("D.json", summary)
self.assertIn("<code>V/machine/D.json</code>, <code>V/machine/E.json</code></p>\n\n```diff",
out)
def test_four_files_are_all_named_in_the_summary(self):
summary, _out = self.same_edit("ABCD")
self.assertIn("<code>V/machine/D.json</code> - ", summary)
self.assertNotIn("more", summary)
def test_an_error_goes_to_stderr_only(self):
err = io.StringIO()
with contextlib.redirect_stderr(err):
rc, out = self.run_command("gcode-diff", "--base", "no-such-ref")
self.assertEqual(rc, 1)
self.assertEqual(out, "")
self.assertIn("no-such-ref", err.getvalue())
def test_a_file_name_that_is_not_utf8_is_an_error(self):
bad = UnicodeDecodeError("utf-8", b"\xe9.json", 0, 1, "invalid continuation byte")
err = io.StringIO()
with mock.patch.object(apt, "_changed_profiles", side_effect=bad), \
contextlib.redirect_stderr(err):
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
self.assertEqual((rc, out), (1, ""))
self.assertIn("invalid continuation byte", err.getvalue())
def test_output_is_cut_to_whole_sections(self):
for name in ("A", "B", "C"):
self.printer(name, machine_start_gcode=name * 300)
size = len(apt.gcode_diff(self.t.profiles, "HEAD").encode("utf-8"))
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", size - 1):
out = self.diff()
self.assertEqual(out.count("<details>"), 2)
self.assertEqual(out.count("</details>"), 2)
self.assertIn("\n1 of 3 sections is not shown", out)
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", 1):
self.assertIn("\n3 of 3 sections are not shown", self.diff())
def test_cleanup_removes_the_repo(self):
# setUp's commit leaves read-only git objects, and a read-only directory blocks
# deletion on POSIX.
os.chmod(self.t.path("V", "machine"), stat.S_IRUSR | stat.S_IXUSR)
self.t.cleanup()
self.assertFalse(os.path.exists(self.t.dir))
with mock.patch.object(os, "chmod") as chmod:
self.t.cleanup() # a second cleanup, as addCleanup runs, must not touch the temp dir
chmod.assert_not_called()
def test_only_repository_variables_are_stripped(self):
self.assertTrue({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} <= self.repo_env_vars)
self.assertFalse({"GIT_SSH", "GIT_EXEC_PATH", "GIT_EDITOR"} & self.repo_env_vars)
def test_cleanup_leaves_symlink_targets_alone(self):
outside = tempfile.mkdtemp(prefix="profile_tool_outside_")
self.addCleanup(shutil.rmtree, outside, True)
target = os.path.join(outside, "f")
open(target, "w").close()
os.chmod(target, stat.S_IRUSR)
self.addCleanup(os.chmod, target, stat.S_IRWXU) # runs before the rmtree above
machine = self.t.path("V", "machine")
try:
os.symlink(target, os.path.join(machine, "link"))
except (OSError, NotImplementedError):
self.skipTest("cannot create symlinks here")
os.chmod(machine, stat.S_IRUSR | stat.S_IXUSR) # so removing the link fails at first
self.t.cleanup()
self.assertFalse(os.path.exists(self.t.dir))
self.assertEqual(stat.S_IMODE(os.stat(target).st_mode) & stat.S_IWUSR, 0)
def test_a_git_hook_environment_is_ignored(self):
scripts = os.path.join(REPO_ROOT, "scripts")
result = subprocess.run(
[sys.executable, "-m", "unittest",
"tests.test_profile_tool.TestGcodeDiff.test_head_compares_two_commits"],
cwd=scripts, capture_output=True, text=True,
env={**os.environ, "GIT_DIR": os.path.join(self.t.dir, "nowhere")})
self.assertEqual(result.returncode, 0, result.stderr)
def test_the_options_are_read_from_the_engine(self):
keys = apt._text_option_keys()
self.assertTrue({"machine_start_gcode", "filament_start_gcode", "printing_by_object_gcode",
"notes", "post_process"} <= keys)
self.assertNotIn("emit_machine_limits_to_gcode", keys) # a coBool
START_DEF = 'def = this->add("machine_start_gcode", coString);\n'
def keys_from(self, print_config, tab=None):
"""_text_option_keys read from these sources; no tab means no Tab.cpp."""
paths = [os.path.join(self.t.dir, "PrintConfig.cpp"), os.path.join(self.t.dir, "Tab.cpp")]
for path, text in zip(paths, (print_config, tab)):
if text is not None:
with open(path, "w", encoding="utf-8") as f:
f.write(text)
apt._text_option_keys.cache_clear()
self.addCleanup(apt._text_option_keys.cache_clear)
with mock.patch.object(apt, "PRINT_CONFIG_CPP", paths[0]), \
mock.patch.object(apt, "TAB_CPP", paths[1]):
return apt._text_option_keys()
def test_multiline_is_read_whatever_its_spacing(self):
keys = self.keys_from(self.START_DEF + 'def = this->add("printer_notes", coString);\n'
' def->multiline = true;\n')
self.assertIn("printer_notes", keys)
def test_the_code_options_of_tab_cpp_are_read(self):
keys = self.keys_from(self.START_DEF, 'option = optgroup->get_option("filename_format");\n'
'option.opt.is_code = true;\n'
'option = optgroup->get_option("layer_height");\n')
self.assertIn("filename_format", keys)
self.assertNotIn("layer_height", keys)
def test_a_missing_tab_cpp_is_not_an_error(self):
self.assertIn("machine_start_gcode", self.keys_from(self.START_DEF))
def test_markdown_in_a_file_name_stays_literal(self):
section = apt._gcode_diff_section(["V/a *b*.json", "V/c.json", "V/d.json", "V/e.json",
"V/f.json"], "machine_start_gcode", "@@ -1 +1 @@")
self.assertIn("\n<p><code>V/a *b*.json</code>, ", section)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# the real tree # the real tree
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+16
View File
@@ -1126,6 +1126,19 @@ static void load_downward_settings_list_from_config(std::string config_file, std
} }
} }
#ifdef __WXGTK__
// Virtual GPUs (VirtualBox, VMware, QEMU) cannot back WebKit's hardware DMABUF renderer.
static bool is_hypervisor_guest()
{
std::ifstream cpuinfo("/proc/cpuinfo");
std::string line;
while (std::getline(cpuinfo, line))
if (boost::starts_with(line, "flags"))
return line.find(" hypervisor") != std::string::npos;
return false;
}
#endif
int CLI::run(int argc, char **argv) int CLI::run(int argc, char **argv)
{ {
// Mark the main thread for the debugger and for runtime checks. // Mark the main thread for the debugger and for runtime checks.
@@ -1176,6 +1189,9 @@ int CLI::run(int argc, char **argv)
::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /* replace */ false); ::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /* replace */ false);
} }
if (is_hypervisor_guest() && WebView::SupportsForceSharedMemory())
::setenv("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1", /* replace */ false);
if (x11_opt_in) { if (x11_opt_in) {
// ===== X11 / XWayland (opted in via GDK_BACKEND=x11) ===== // ===== X11 / XWayland (opted in via GDK_BACKEND=x11) =====
BOOST_LOG_TRIVIAL(info) << "GDK_BACKEND=x11 detected; applying v2.3.2 X11/XWayland workarounds."; BOOST_LOG_TRIVIAL(info) << "GDK_BACKEND=x11 detected; applying v2.3.2 X11/XWayland workarounds.";
+1 -1
View File
@@ -1432,7 +1432,7 @@ ConfigSubstitutions ConfigBase::load_from_gcode_file(const std::string &file, Fo
} }
if (!header_found) { if (!header_found) {
// TRN %1% is the application name. // TRN %1% is the application name.
std::string error_message = format(_u8L("Not a gcode file generated by %1%."), SLIC3R_APP_FULL_NAME); std::string error_message = format(_u8L("Not a G-code file generated by %1%."), SLIC3R_APP_FULL_NAME);
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << error_message; BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << error_message;
throw Slic3r::RuntimeError(error_message.c_str()); throw Slic3r::RuntimeError(error_message.c_str());
} }
+15 -5
View File
@@ -988,7 +988,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
MetadataList metadata; MetadataList metadata;
VolumeMetadataList volumes; VolumeMetadataList volumes;
bool auto_drop { true }; std::optional<bool> auto_drop;
}; };
struct CutObjectInfo struct CutObjectInfo
@@ -1208,6 +1208,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
//IdToGeometryMap m_orig_geometries; // backup & restore //IdToGeometryMap m_orig_geometries; // backup & restore
CurrentConfig m_curr_config; CurrentConfig m_curr_config;
IdToMetadataMap m_objects_metadata; IdToMetadataMap m_objects_metadata;
std::set<int> m_items_auto_drop_off; // object ids with a build item auto_drop="0" (2.4.x layout)
IdToCutObjectInfoMap m_cut_object_infos; IdToCutObjectInfoMap m_cut_object_infos;
IdToLayerHeightsProfileMap m_layer_heights_profiles; IdToLayerHeightsProfileMap m_layer_heights_profiles;
IdToLayerConfigRangesMap m_layer_config_ranges; IdToLayerConfigRangesMap m_layer_config_ranges;
@@ -1463,6 +1464,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_objects.clear(); m_objects.clear();
m_instances.clear(); m_instances.clear();
m_objects_metadata.clear(); m_objects_metadata.clear();
m_items_auto_drop_off.clear();
m_curr_metadata_name.clear(); m_curr_metadata_name.clear();
m_curr_characters.clear(); m_curr_characters.clear();
@@ -1502,6 +1504,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_curr_config.object_id = -1; m_curr_config.object_id = -1;
m_curr_config.volume_id = -1; m_curr_config.volume_id = -1;
m_objects_metadata.clear(); m_objects_metadata.clear();
m_items_auto_drop_off.clear();
m_layer_heights_profiles.clear(); m_layer_heights_profiles.clear();
m_layer_config_ranges.clear(); m_layer_config_ranges.clear();
m_brim_ear_points.clear(); m_brim_ear_points.clear();
@@ -2257,11 +2260,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
ObjectMetadata::VolumeMetadataList volumes; ObjectMetadata::VolumeMetadataList volumes;
ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr; ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr;
// 2.4.x stored auto_drop on build items, shared by all instances of an object; any "off" wins
const bool legacy_auto_drop = !m_items_auto_drop_off.count(object.first.second);
IdToMetadataMap::iterator obj_metadata = m_objects_metadata.find(object.first.second); IdToMetadataMap::iterator obj_metadata = m_objects_metadata.find(object.first.second);
if (obj_metadata != m_objects_metadata.end()) { if (obj_metadata != m_objects_metadata.end()) {
// config data has been found, this model was saved using slic3r pe // config data has been found, this model was saved using slic3r pe
model_object->auto_drop = obj_metadata->second.auto_drop; model_object->auto_drop = obj_metadata->second.auto_drop.value_or(legacy_auto_drop);
// apply object's name and config data // apply object's name and config data
for (const Metadata& metadata : obj_metadata->second.metadata) { for (const Metadata& metadata : obj_metadata->second.metadata) {
@@ -2279,6 +2285,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
} }
else { else {
// config data not found, this model was not saved using slic3r pe // config data not found, this model was not saved using slic3r pe
model_object->auto_drop = legacy_auto_drop;
// add the entire geometry as the single volume to generate // add the entire geometry as the single volume to generate
//volumes.emplace_back(0, (int)obj_geometry->second.triangles.size() - 1); //volumes.emplace_back(0, (int)obj_geometry->second.triangles.size() - 1);
@@ -4121,6 +4128,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
std::string path = bbs_get_attribute_value_string(attributes, num_attributes, PPATH_ATTR); std::string path = bbs_get_attribute_value_string(attributes, num_attributes, PPATH_ATTR);
Transform3d transform = bbs_get_transform_from_3mf_specs_string(bbs_get_attribute_value_string(attributes, num_attributes, TRANSFORM_ATTR)); Transform3d transform = bbs_get_transform_from_3mf_specs_string(bbs_get_attribute_value_string(attributes, num_attributes, TRANSFORM_ATTR));
int printable = bbs_get_attribute_value_bool(attributes, num_attributes, PRINTABLE_ATTR); int printable = bbs_get_attribute_value_bool(attributes, num_attributes, PRINTABLE_ATTR);
if (const char* auto_drop = bbs_get_attribute_value_charptr(attributes, num_attributes, AUTO_DROP_ATTR); auto_drop != nullptr && !::atoi(auto_drop))
m_items_auto_drop_off.insert(object_id);
return !m_load_model || _create_object_instance(path, object_id, transform, printable, 1); return !m_load_model || _create_object_instance(path, object_id, transform, printable, 1);
} }
@@ -4489,7 +4498,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
// int instances_count_id = bbs_get_attribute_value_int(attributes, num_attributes, INSTANCESCOUNT_ATTR); // int instances_count_id = bbs_get_attribute_value_int(attributes, num_attributes, INSTANCESCOUNT_ATTR);
ObjectMetadata metadata; ObjectMetadata metadata;
metadata.auto_drop = bbs_get_attribute_value_bool(attributes, num_attributes, AUTO_DROP_ATTR); if (bbs_get_attribute_value_charptr(attributes, num_attributes, AUTO_DROP_ATTR) != nullptr)
metadata.auto_drop = bbs_get_attribute_value_bool(attributes, num_attributes, AUTO_DROP_ATTR);
m_objects_metadata.insert({ object_id, std::move(metadata) }); m_objects_metadata.insert({ object_id, std::move(metadata) });
m_curr_config.object_id = object_id; m_curr_config.object_id = object_id;
return true; return true;
@@ -10110,7 +10120,7 @@ bool to_xml(std::stringstream &stream, const EmbossShape::SvgFile &svg, const Mo
assert(file_data != nullptr); assert(file_data != nullptr);
if (file_data == nullptr && !svg.path.empty()) file_data = read_from_disk(svg.path); if (file_data == nullptr && !svg.path.empty()) file_data = read_from_disk(svg.path);
if (file_data == nullptr) { if (file_data == nullptr) {
BOOST_LOG_TRIVIAL(warning) << "Can't write svg file no filedata"; BOOST_LOG_TRIVIAL(warning) << "Can't write SVG file no filedata";
return false; return false;
} }
const std::string &file_data_str = *file_data; const std::string &file_data_str = *file_data;
@@ -10126,7 +10136,7 @@ void to_xml(std::stringstream &stream, const EmbossShape &es, const ModelVolume
stream << " <" << SHAPE_TAG << " "; stream << " <" << SHAPE_TAG << " ";
if (es.svg_file.has_value()) if (es.svg_file.has_value())
if (!to_xml(stream, *es.svg_file, volume, archive, export_full_path)) { if (!to_xml(stream, *es.svg_file, volume, archive, export_full_path)) {
BOOST_LOG_TRIVIAL(warning) << "Can't write svg file defiden embossed shape into 3mf"; BOOST_LOG_TRIVIAL(warning) << "Can't write SVG file defiden embossed shape into 3mf";
} }
stream << SHAPE_SCALE_ATTR << "=\"" << es.scale << "\" "; stream << SHAPE_SCALE_ATTR << "=\"" << es.scale << "\" ";
+1
View File
@@ -1421,6 +1421,7 @@ static std::vector<std::string> s_Preset_print_options{
"initial_layer_travel_acceleration", "initial_layer_travel_acceleration",
"initial_layer_travel_jerk", "initial_layer_travel_jerk",
"exclude_object", "exclude_object",
"exclude_object_tolerance",
"slow_down_layers", "slow_down_layers",
"infill_anchor", "infill_anchor",
"infill_anchor_max", "infill_anchor_max",
+8 -1
View File
@@ -756,7 +756,14 @@ public:
Preset& get_edited_preset() { return m_edited_preset; } Preset& get_edited_preset() { return m_edited_preset; }
const Preset& get_edited_preset() const { return m_edited_preset; } const Preset& get_edited_preset() const { return m_edited_preset; }
const Preset& get_selected_preset_base() const { return *get_preset_base(m_presets[m_idx_selected]); } // The root of the selected preset's inherits chain, or the selected preset itself when
// the chain names a preset that is not loaded (e.g. a vendor that is not installed).
const Preset& get_selected_preset_base() const
{
const Preset &selected = m_presets[m_idx_selected];
const Preset *base = get_preset_base(selected);
return base ? *base : selected;
}
// Return the last saved preset. // Return the last saved preset.
// const Preset& get_saved_preset() const { return m_saved_preset; } // const Preset& get_saved_preset() const { return m_saved_preset; }
+2 -1
View File
@@ -309,6 +309,7 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
"gcode_comments", "gcode_comments",
"gcode_label_objects", "gcode_label_objects",
"exclude_object", "exclude_object",
"exclude_object_tolerance",
"support_material_interface_fan_speed", "support_material_interface_fan_speed",
"internal_bridge_fan_speed", // ORCA: Add support for separate internal bridge fan speed control "internal_bridge_fan_speed", // ORCA: Add support for separate internal bridge fan speed control
"ironing_fan_speed", "ironing_fan_speed",
@@ -6929,7 +6930,7 @@ BoundingBoxf3 PrintInstance::get_bounding_box() const {
Polygon PrintInstance::get_convex_hull_2d() { Polygon PrintInstance::get_convex_hull_2d() {
Polygon poly = print_object->model_object()->convex_hull_2d(model_instance->get_matrix()); Polygon poly = print_object->model_object()->convex_hull_2d(model_instance->get_matrix());
poly.douglas_peucker(scale_(0.1)); poly.douglas_peucker(scale_(print_object->print()->config().exclude_object_tolerance.value));
return poly; return poly;
} }
+11
View File
@@ -4629,6 +4629,17 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced; def->mode = comAdvanced;
def->set_default_value(new ConfigOptionBool(false)); def->set_default_value(new ConfigOptionBool(false));
def = this->add("exclude_object_tolerance", coFloat);
def->label = L("Outline tolerance");
def->tooltip = L("Maximum deviation of the object outline written to EXCLUDE_OBJECT_DEFINE from the "
"original outline. Larger values give fewer points, which helps firmware that stalls "
"while parsing G-code with many objects. 0 keeps every point.");
def->sidetext = L("mm");
def->min = 0;
def->max = 5;
def->mode = comExpert;
def->set_default_value(new ConfigOptionFloat(0.1));
def = this->add("gcode_comments", coBool); def = this->add("gcode_comments", coBool);
def->label = L("Verbose G-code"); def->label = L("Verbose G-code");
def->tooltip = L("Enable this to get a commented G-code file, with each line explained by a descriptive text. " def->tooltip = L("Enable this to get a commented G-code file, with each line explained by a descriptive text. "
+1
View File
@@ -2130,6 +2130,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE(
((ConfigOptionPercents, filament_shrinkage_compensation_z)) ((ConfigOptionPercents, filament_shrinkage_compensation_z))
((ConfigOptionBool, gcode_label_objects)) ((ConfigOptionBool, gcode_label_objects))
((ConfigOptionBool, exclude_object)) ((ConfigOptionBool, exclude_object))
((ConfigOptionFloat, exclude_object_tolerance))
((ConfigOptionFloats, grab_length)) ((ConfigOptionFloats, grab_length))
((ConfigOptionBool, gcode_comments)) ((ConfigOptionBool, gcode_comments))
((ConfigOptionInt, slow_down_layers)) ((ConfigOptionInt, slow_down_layers))
+1 -1
View File
@@ -75,7 +75,7 @@ std::vector<float> Layers::get_zs() const
size_t Layers::get_layer_id_at(float z) const size_t Layers::get_layer_id_at(float z) const
{ {
auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z < z; }); auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z > z; });
return std::distance(m_items.begin(), iter); return std::distance(m_items.begin(), iter);
} }
+8 -70
View File
@@ -3,11 +3,9 @@
#include "GUI.hpp" #include "GUI.hpp"
#include "GUI_App.hpp" #include "GUI_App.hpp"
#include "I18N.hpp" #include "I18N.hpp"
#include "ImGuiWrapper.hpp"
#include "MainFrame.hpp" #include "MainFrame.hpp"
#include "NativeCommands.hpp" #include "NativeCommands.hpp"
#include "Notebook.hpp" #include "Notebook.hpp"
#include "NotificationManager.hpp"
#include "OptionsGroup.hpp" #include "OptionsGroup.hpp"
#include "Plater.hpp" #include "Plater.hpp"
#include "SettingsIndex.hpp" #include "SettingsIndex.hpp"
@@ -101,45 +99,6 @@ std::string find_loaded_source_name(PluginManager& manager, const std::string& p
return plugin_key; return plugin_key;
} }
// Translates a Script capability outcome into the action layer's result vocabulary. Shared by
// the synchronous run path and the async completion notification.
AppActionRunResult script_outcome_to_action_result(const ScriptCompletion& completion)
{
// Cancelled by PluginManager (unload/shutdown): expected termination, nothing to show.
if (completion.cancelled)
return {};
if (!completion.error.empty())
return {AppActionRunResult::Level::Error, from_u8(completion.error)};
const ExecutionResult& result = completion.result;
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
result.message.empty() ? fallback : from_u8(result.message)};
}
// Surfaces the eventual outcome of an async script launched from the speed dial. That launch
// already returned (the UI was not blocked), so the finish is reported as a notification. A
// cancellation is silent.
void notify_script_outcome(const ScriptCompletion& completion)
{
if (completion.cancelled)
return;
if (wxTheApp == nullptr || wxGetApp().is_closing())
return;
Plater* plater = wxGetApp().plater();
if (plater == nullptr || wxGetApp().imgui() == nullptr || !wxGetApp().imgui()->display_initialized())
return;
const AppActionRunResult action_result = script_outcome_to_action_result(completion);
const auto level = action_result.level == AppActionRunResult::Level::Error
? NotificationManager::NotificationLevel::ErrorNotificationLevel
: NotificationManager::NotificationLevel::RegularNotificationLevel;
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification, level, into_u8(action_result.message));
}
// A runnable script capability exposed as a speed-dial action. source_key = plugin_key // A runnable script capability exposed as a speed-dial action. source_key = plugin_key
// (identity), so a plugin display-name change does not re-key the action. // (identity), so a plugin display-name change does not re-key the action.
struct PluginScriptAction : AppAction struct PluginScriptAction : AppAction
@@ -165,36 +124,15 @@ struct PluginScriptAction : AppAction
AppActionRunResult run(const std::string& /*param*/) const override AppActionRunResult run(const std::string& /*param*/) const override
{ {
struct Outcome std::string error;
{ const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error);
bool async = false; if (!error.empty())
bool completed = false; return {AppActionRunResult::Level::Error, from_u8(error)};
AppActionRunResult result;
};
auto outcome = std::make_shared<Outcome>();
auto on_done = [outcome](const ScriptCompletion& completion) { const bool skipped = result.status == PluginResult::Skipped;
outcome->result = script_outcome_to_action_result(completion); const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
outcome->completed = true; return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
if (outcome->async) result.message.empty() ? fallback : from_u8(result.message)};
notify_script_outcome(completion);
};
const PluginManager::ScriptLaunchResult launch = PluginManager::instance().launch_script_capability(plugin_key, capability, on_done);
if (launch.launched_async) {
// The completion is delivered on this (GUI) thread once the (sync or async) invocation
// finishes, so marking `async` here cannot race with on_done. The speed dial has already
// shown a result by then; the final outcome arrives as a notification (a cancellation is
// silent).
outcome->async = true;
if (outcome->completed)
return outcome->result;
return {AppActionRunResult::Level::Info, _L("Script plugin started.")};
}
// The launch failed inline; on_done produced the (error) result.
on_done(launch.outcome);
return outcome->result;
} }
}; };
+1 -1
View File
@@ -225,7 +225,7 @@ void BackgroundSlicingProcess::process_fff()
// BBS: add the logic to process from an existed gcode file // BBS: add the logic to process from an existed gcode file
if (m_print->finished()) { if (m_print->finished()) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(" %1%: skip slicing, to process previous gcode file") % __LINE__; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(" %1%: skip slicing, to process previous gcode file") % __LINE__;
m_fff_print->set_status(80, _utf8(L("Processing G-Code from previous file\u2026"))); m_fff_print->set_status(80, _utf8(L("Processing G-code from previous file\u2026" /* \u2026 -> ... */)));
wxCommandEvent evt(m_event_slicing_completed_id); wxCommandEvent evt(m_event_slicing_completed_id);
// Post the Slicing Finished message for the G-code viewer to update. // Post the Slicing Finished message for the G-code viewer to update.
// Passing the timestamp // Passing the timestamp
+10 -8
View File
@@ -303,10 +303,12 @@ nlohmann::json color_picker_ui_strings()
return { return {
{"Color Picker", _u8L("Color Picker")}, {"Color Picker", _u8L("Color Picker")},
{"Basic Colors", _u8L("Basic Colors")}, {"Basic Colors", _u8L("Basic Colors")},
{"Orca Palette", _u8L("Orca Palette")},// TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor. // TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
{"RAL Classic", _u8L("RAL Classic")}, // TRN: Keep the standard/brand name "RAL" unchanged. {"Orca Palette", _u8L("Orca Palette")},
// TRN: Keep the standard/brand name "RAL" unchanged.
{"RAL Classic", _u8L("RAL Classic")},
{"Gradients", _u8L("Gradients")}, {"Gradients", _u8L("Gradients")},
{"Solid", _u8L("Solid")}, {"Solid", _u8L_CONTEXT("Solid", "Color picker")},
{"Gradient", _u8L("Gradient")}, {"Gradient", _u8L("Gradient")},
{"Gradient start", _u8L("Gradient start")}, {"Gradient start", _u8L("Gradient start")},
{"Gradient end", _u8L("Gradient end")}, {"Gradient end", _u8L("Gradient end")},
@@ -440,7 +442,7 @@ nlohmann::json color_picker_ui_strings()
{"Purple", _u8L("Purple")}, {"Purple", _u8L("Purple")},
{"Rebecca purple", _u8L("Rebecca purple")}, {"Rebecca purple", _u8L("Rebecca purple")},
{"Medium violet red", _u8L("Medium violet red")}, {"Medium violet red", _u8L("Medium violet red")},
{"PaleV violet red", _u8L("PaleV violet red")}, {"Pale violet red", _u8L("Pale violet red")},
{"Deep pink", _u8L("Deep pink")}, {"Deep pink", _u8L("Deep pink")},
{"Hot pink", _u8L("Hot pink")}, {"Hot pink", _u8L("Hot pink")},
{"Light pink", _u8L("Light pink")}, {"Light pink", _u8L("Light pink")},
@@ -496,7 +498,7 @@ nlohmann::json color_picker_ui_strings()
{"Turquoise bay", _u8L("Turquoise bay")}, {"Turquoise bay", _u8L("Turquoise bay")},
{"Sky over reef", _u8L("Sky over reef")}, {"Sky over reef", _u8L("Sky over reef")},
{"Shallow sky", _u8L("Shallow sky")}, {"Shallow sky", _u8L("Shallow sky")},
{"Electric bel", _u8L("Electric bel")}, {"Electric blue", _u8L("Electric blue")},
{"Dolphin", _u8L("Dolphin")}, {"Dolphin", _u8L("Dolphin")},
{"Ocean depths", _u8L("Ocean depths")}, {"Ocean depths", _u8L("Ocean depths")},
{"Whale", _u8L("Whale")}, {"Whale", _u8L("Whale")},
@@ -519,7 +521,7 @@ nlohmann::json color_picker_ui_strings()
{"Golden yellow", _u8L("Golden yellow")}, {"Golden yellow", _u8L("Golden yellow")},
{"Honey yellow", _u8L("Honey yellow")}, {"Honey yellow", _u8L("Honey yellow")},
{"Maize yellow", _u8L("Maize yellow")}, {"Maize yellow", _u8L("Maize yellow")},
{"Dalmatian yellow", _u8L("Dalmatian yellow")}, {"Daffodil yellow", _u8L("Daffodil yellow")},
{"Brown beige", _u8L("Brown beige")}, {"Brown beige", _u8L("Brown beige")},
{"Lemon yellow", _u8L("Lemon yellow")}, {"Lemon yellow", _u8L("Lemon yellow")},
{"Oyster white", _u8L("Oyster white")}, {"Oyster white", _u8L("Oyster white")},
@@ -606,7 +608,7 @@ nlohmann::json color_picker_ui_strings()
{"Pigeon blue", _u8L("Pigeon blue")}, {"Pigeon blue", _u8L("Pigeon blue")},
{"Traffic blue", _u8L("Traffic blue")}, {"Traffic blue", _u8L("Traffic blue")},
{"Turquoise blue", _u8L("Turquoise blue")}, {"Turquoise blue", _u8L("Turquoise blue")},
{"Capsanthin violet", _u8L("Capsanthin violet")}, {"Capri blue", _u8L("Capri blue")},
{"Ocean blue", _u8L("Ocean blue")}, {"Ocean blue", _u8L("Ocean blue")},
{"Water blue", _u8L("Water blue")}, {"Water blue", _u8L("Water blue")},
{"Night blue", _u8L("Night blue")}, {"Night blue", _u8L("Night blue")},
@@ -644,7 +646,7 @@ nlohmann::json color_picker_ui_strings()
{"Luminous green", _u8L("Luminous green")}, {"Luminous green", _u8L("Luminous green")},
{"Squirrel grey", _u8L("Squirrel grey")}, {"Squirrel grey", _u8L("Squirrel grey")},
{"Silver grey", _u8L("Silver grey")}, {"Silver grey", _u8L("Silver grey")},
{"Olivestone", _u8L("Olivestone")}, {"Olive grey", _u8L("Olive grey")},
{"Moss grey", _u8L("Moss grey")}, {"Moss grey", _u8L("Moss grey")},
{"Signal grey", _u8L("Signal grey")}, {"Signal grey", _u8L("Signal grey")},
{"Mouse grey", _u8L("Mouse grey")}, {"Mouse grey", _u8L("Mouse grey")},
+2
View File
@@ -1245,6 +1245,8 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in
if(gcf_is_klipper) if(gcf_is_klipper)
toggle_field("accel_to_decel_factor", config->opt_bool("accel_to_decel_enable")); toggle_field("accel_to_decel_factor", config->opt_bool("accel_to_decel_enable"));
toggle_line("exclude_object_tolerance", gcf_is_klipper && config->opt_bool("exclude_object"));
bool have_make_overhang_printable = config->opt_bool("make_overhang_printable"); bool have_make_overhang_printable = config->opt_bool("make_overhang_printable");
toggle_line("make_overhang_printable_angle", have_make_overhang_printable); toggle_line("make_overhang_printable_angle", have_make_overhang_printable);
toggle_line("make_overhang_printable_hole_size", have_make_overhang_printable); toggle_line("make_overhang_printable_hole_size", have_make_overhang_printable);
+2 -2
View File
@@ -3151,7 +3151,7 @@ void CreatePrinterPresetDialog::set_current_visible_printer()
m_printer_name_to_preset.clear(); m_printer_name_to_preset.clear();
for (const Preset &printer_preset : printer_presets) { for (const Preset &printer_preset : printer_presets) {
if (!printer_preset.is_visible) continue; if (!printer_preset.is_visible) continue;
if (preset_bundle->printers.get_preset_base(printer_preset)->name != printer_preset.name) continue; if (preset_bundle->printers.get_preset_base(printer_preset) != &printer_preset) continue;
if (auto printer_model = dynamic_cast<ConfigOptionString*>(const_cast<Preset&>(printer_preset).config.option("printer_model", false))) { if (auto printer_model = dynamic_cast<ConfigOptionString*>(const_cast<Preset&>(printer_preset).config.option("printer_model", false))) {
if (m_printer_name_to_preset.find(printer_model->value) == m_printer_name_to_preset.end()) { if (m_printer_name_to_preset.find(printer_model->value) == m_printer_name_to_preset.end()) {
printer_choice.push_back(from_u8(printer_model->value)); printer_choice.push_back(from_u8(printer_model->value));
@@ -4962,7 +4962,7 @@ void CreatePresetForPrinterDialog::get_visible_printer_and_compatible_filament_p
if (filament_preset.is_default || !filament_preset.is_compatible || filament_preset.is_project_embedded) continue; if (filament_preset.is_default || !filament_preset.is_compatible || filament_preset.is_project_embedded) continue;
ConfigOptionStrings *filament_types; ConfigOptionStrings *filament_types;
const Preset * filament_preset_base = m_preset_bundle->filaments.get_preset_base(filament_preset); const Preset * filament_preset_base = m_preset_bundle->filaments.get_preset_base(filament_preset);
if (filament_preset_base == &filament_preset) { if (filament_preset_base == &filament_preset || filament_preset_base == nullptr) {
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(&filament_preset)->config.option("filament_type")); filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(&filament_preset)->config.option("filament_type"));
} else { } else {
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(filament_preset_base)->config.option("filament_type")); filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(filament_preset_base)->config.option("filament_type"));
+3 -3
View File
@@ -1350,7 +1350,7 @@ void GLGizmoSVG::draw_window()
assert(m_volume_id.valid()); assert(m_volume_id.valid());
if (m_volume == nullptr || if (m_volume == nullptr ||
m_volume_id.invalid()) { m_volume_id.invalid()) {
ImGui::Text("%s", _u8L("Not valid state please report reproduction steps on github").c_str()); ImGui::Text("%s", _u8L("Not valid state please report reproduction steps on GitHub").c_str());
return; return;
} }
@@ -1362,13 +1362,13 @@ void GLGizmoSVG::draw_window()
assert(m_volume->emboss_shape->svg_file.has_value()); assert(m_volume->emboss_shape->svg_file.has_value());
if (!m_volume->emboss_shape->svg_file.has_value()){ if (!m_volume->emboss_shape->svg_file.has_value()){
ImGui::Text("%s", _u8L("Missing svg file in embossed shape").c_str()); ImGui::Text("%s", _u8L("Missing SVG file in embossed shape").c_str());
return; return;
} }
assert(m_volume->emboss_shape->svg_file->file_data != nullptr); assert(m_volume->emboss_shape->svg_file->file_data != nullptr);
if (m_volume->emboss_shape->svg_file->file_data == nullptr){ if (m_volume->emboss_shape->svg_file->file_data == nullptr){
ImGui::Text("%s", _u8L("Missing data of svg file").c_str()); ImGui::Text("%s", _u8L("Missing data of SVG file").c_str());
return; return;
} }
+22
View File
@@ -62,6 +62,7 @@
#include "Search.hpp" #include "Search.hpp"
#include "BitmapCache.hpp" #include "BitmapCache.hpp"
#include "GUI_App.hpp" #include "GUI_App.hpp"
#include "KeyChord.hpp"
#include <nanosvg/nanosvg.h> #include <nanosvg/nanosvg.h>
#include <nanosvg/nanosvgrast.h> #include <nanosvg/nanosvgrast.h>
@@ -2134,6 +2135,27 @@ bool ImGuiWrapper::want_any_input() const
return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput; return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput;
} }
bool ImGuiWrapper::text_input_active() const
{
const ImGuiID id = ImGui::GetActiveID();
return id != 0 && ImGui::GetInputTextState(id) != nullptr;
}
bool ImGuiWrapper::modal_popup_open() const
{
// A modal stays on the popup stack while its canvas is hidden, so it counts only if the last finished
// frame drew it, which NewFrame() moves from Active to WasActive.
const ImGuiWindow* modal = ImGui::GetTopMostPopupModal();
if (modal == nullptr)
return false;
return ImGui::GetCurrentContext()->WithinFrameScope ? modal->WasActive : modal->Active;
}
bool ImGuiWrapper::owns_key(const KeyChord& chord) const
{
return modal_popup_open() || (chord.edits_text() && text_input_active());
}
ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id) ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id)
{ {
auto item = m_custom_glyph_rects_ids.find(tex_id); auto item = m_custom_glyph_rects_ids.find(tex_id);
+7
View File
@@ -35,6 +35,7 @@ struct ImRect;
namespace Slic3r { namespace Slic3r {
namespace GUI { namespace GUI {
struct KeyChord;
bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data); bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data);
@@ -219,6 +220,12 @@ public:
bool want_keyboard() const; bool want_keyboard() const;
bool want_text_input() const; bool want_text_input() const;
bool want_any_input() const; bool want_any_input() const;
// True while an ImGui InputText is active, without the one-frame lag of want_text_input().
bool text_input_active() const;
// True while an ImGui modal popup is on screen.
bool modal_popup_open() const;
// True for every key while a modal is open, and for text-editing keys while an InputText is active.
bool owns_key(const KeyChord& chord) const;
// Optional inputs are used for set up value inside of an optional, with default value // Optional inputs are used for set up value inside of an optional, with default value
// //
+4 -5
View File
@@ -173,7 +173,7 @@ void KBShortcutsDialog::fill_pages()
using Section = ShortcutSection; using Section = ShortcutSection;
if (wxGetApp().is_editor()) { if (wxGetApp().is_editor()) {
page(_L("Global"), _L("Available anywhere in the window, even while typing in a text field."), ShortcutContext::Global, { page(_L("Global"), _L("Available anywhere in the window."), ShortcutContext::Global, {
fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")), fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")),
fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")), fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")),
// wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there. // wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there.
@@ -438,17 +438,16 @@ ShortcutCaptureDialog::ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut
SetBackgroundColour(*wxWHITE); SetBackgroundColour(*wxWHITE);
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL); wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
// A Global shortcut also runs while a text field has the focus, so its hint names the keys it can use. // A Global shortcut runs before the focused control gets the key, so its hint names the keys it can use.
const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0; const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0;
const wxString advice = global_key_advice(); const wxString advice = global_key_advice();
const wxString typing = _L("Global shortcuts also apply while typing.");
const wxString rule = _L("A key that types a character cannot be a global shortcut."); const wxString rule = _L("A key that types a character cannot be a global shortcut.");
m_hint = global ? typing + "\n" + advice : _L("Esc cancels, Enter confirms."); m_hint = global ? advice : _L("Esc cancels, Enter confirms.");
m_rejection = rule + "\n" + advice; m_rejection = rule + "\n" + advice;
// Wide enough for each sentence on a line of its own where the translation allows, within limits. // Wide enough for each sentence on a line of its own where the translation allows, within limits.
int width = FromDIP(450); int width = FromDIP(450);
for (const wxString& sentence : { typing, rule, advice }) { for (const wxString& sentence : { rule, advice }) {
int extent = 0; int extent = 0;
GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font()); GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font());
width = std::max(width, extent); width = std::max(width, extent);
+34
View File
@@ -187,6 +187,40 @@ bool KeyChord::is_menu_accelerator() const
return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE)); return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE));
} }
bool KeyChord::edits_text() const
{
if (!valid())
return false;
if (!is_menu_accelerator())
return true;
const int mods = modifiers & ~wxMOD_SHIFT;
switch (key) {
case WXK_LEFT: case WXK_RIGHT: case WXK_UP: case WXK_DOWN:
case WXK_HOME: case WXK_END: case WXK_BACK: case WXK_DELETE:
#ifdef __APPLE__
return mods == wxMOD_NONE || mods == wxMOD_CONTROL || mods == wxMOD_ALT; // Option moves by word
#else
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
#endif
case WXK_INSERT:
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
case WXK_RETURN: case WXK_TAB: case WXK_ESCAPE: case WXK_PAGEUP: case WXK_PAGEDOWN:
return mods == wxMOD_NONE;
default:
break;
}
if (!is_printable(key))
return false;
#ifdef __APPLE__
if (mods == wxMOD_ALT || mods == wxMOD_RAW_CONTROL)
return true; // Option types a character, Control runs the Cocoa text key bindings
#else
if (mods == (wxMOD_CONTROL | wxMOD_ALT))
return true; // AltGr types a character
#endif
return mods == wxMOD_CONTROL && (key == 'A' || key == 'C' || key == 'V' || key == 'X' || key == 'Y' || key == 'Z');
}
// Only a chord the desktop acts on while still delivering it to the app belongs here. // Only a chord the desktop acts on while still delivering it to the app belongs here.
bool KeyChord::is_system_shortcut() const bool KeyChord::is_system_shortcut() const
{ {
+3
View File
@@ -35,6 +35,9 @@ struct KeyChord
// True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without // True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without
// swallowing typing in text fields. // swallowing typing in text fields.
bool is_menu_accelerator() const; bool is_menu_accelerator() const;
// True for a chord a focused text field acts on: typing a character (AltGr and macOS Option
// characters included), moving the caret, selecting, deleting, or the clipboard and undo chords.
bool edits_text() const;
// True for a chord the desktop acts on although the app receives it, so a binding would // True for a chord the desktop acts on although the app receives it, so a binding would
// take it from the system. // take it from the system.
bool is_system_shortcut() const; bool is_system_shortcut() const;
+28 -16
View File
@@ -50,6 +50,7 @@
#include <wx/panel.h> #include <wx/panel.h>
#include <wx/settings.h> #include <wx/settings.h>
#include <wx/textentry.h> #include <wx/textentry.h>
#include <wx/spinctrl.h>
#include <wx/notebook.h> #include <wx/notebook.h>
#include <wx/listbook.h> #include <wx/listbook.h>
#include <wx/simplebook.h> #include <wx/simplebook.h>
@@ -107,6 +108,8 @@
#include "../Utils/PrintHost.hpp" #include "../Utils/PrintHost.hpp"
#include "GUI_App.hpp" #include "GUI_App.hpp"
#include "ImGuiWrapper.hpp"
#include "OpenGLManager.hpp"
#include "Shortcuts.hpp" #include "Shortcuts.hpp"
#include "UnsavedChangesDialog.hpp" #include "UnsavedChangesDialog.hpp"
#include "PublishSettingsDialog.hpp" #include "PublishSettingsDialog.hpp"
@@ -184,18 +187,31 @@ enum class ERescaleTarget
namespace { namespace {
bool focus_is_text_field(wxWindow* focus)
{
if (dynamic_cast<wxTextEntryBase*>(focus) || dynamic_cast<wxWebView*>(focus))
return true; // a web page can have its own text fields
if (dynamic_cast<wxSpinCtrl*>(focus) || dynamic_cast<wxSpinCtrlDouble*>(focus))
return true; // the native Windows spin control is not a wxTextEntry
return false;
}
// Only a scene canvas draws ImGui, and a hidden one can keep the wx focus after a tab switch.
const ImGuiWrapper* focused_canvas_imgui(wxWindow* focus)
{
if (!OpenGLManager::is_scene_canvas(focus) || !focus->IsShownOnScreen())
return nullptr;
return wxGetApp().imgui();
}
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other // Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
// controls. CHAR_HOOK runs before the focused child, so only take Space when the focused window has // controls, so only take Space when the focused window has no keyboard-activation meaning of its
// no keyboard-activation meaning of its own. Canvases (GLCanvas3D) and panels are not controls and // own. Canvases (GLCanvas3D) and panels are not controls and fall through to "open"; the Notebook
// fall through to "open"; the Notebook itself does too, so Space still opens the dial on any page. // itself does too, so Space still opens the dial on any page.
bool focus_keeps_space(wxWindow* focus) bool focus_keeps_space(wxWindow* focus)
{ {
if (!focus) if (!focus)
return false; return false;
if (dynamic_cast<wxTextEntryBase*>(focus))
return true; // typing a space into a text field
if (dynamic_cast<wxWebView*>(focus))
return true; // web content scrolls and hosts its own text fields
if (dynamic_cast<::Button*>(focus)) if (dynamic_cast<::Button*>(focus))
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl) return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
if (dynamic_cast<StaticBox*>(focus)) if (dynamic_cast<StaticBox*>(focus))
@@ -816,16 +832,12 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
bool MainFrame::handle_global_shortcut(const KeyChord& chord) bool MainFrame::handle_global_shortcut(const KeyChord& chord)
{ {
// ImGui draws its text fields (the text tool's box, a sketch value field) on the GL canvas, so // CHAR_HOOK runs before the focused window, so leave text-editing keys to a focused text field and
// while one of them is typed into the focused wx window is that canvas or an in-canvas frame: // the keys ImGui owns to the focused canvas.
// neither is a wxControl, so focus_keeps_space() cannot see it and CHAR_HOOK takes the chord
// here first -- Space opened the Speed Dial, Ctrl+P the Preferences, and the field never saw
// the key. ImGui raises WantCaptureKeyboard while one of its items owns the keyboard, so leave
// the key to it then. A focused wxControl (text field, list, web view) is untouched: those
// keep their own keys, and ImGui's flag can outlive the focus moving to one of them.
wxWindow* const focus = wxWindow::FindFocus(); wxWindow* const focus = wxWindow::FindFocus();
ImGuiWrapper* const imgui = wxGetApp().imgui(); if (chord.edits_text() && focus_is_text_field(focus))
if (focus != nullptr && imgui != nullptr && imgui->want_keyboard() && dynamic_cast<wxControl*>(focus) == nullptr) return false;
if (const ImGuiWrapper* imgui = focused_canvas_imgui(focus); imgui != nullptr && imgui->owns_key(chord))
return false; return false;
const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord); const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord);
+1 -1
View File
@@ -295,7 +295,7 @@ void NetworkTestDialog::start_test_url(TestJob job, wxString name, wxString url)
} }
}) })
.on_ip_resolve([this,name,job](std::string ip) { .on_ip_resolve([this,name,job](std::string ip) {
wxString ip_report = wxString::Format(_L("test %s ip resolved = %s"), name, ip); wxString ip_report = wxString::Format(_L("test %s IP resolved = %s"), name, ip);
update_status(job, ip_report); update_status(job, ip_report);
}) })
.on_error([this,name,job](std::string body, std::string error, unsigned int status) { .on_error([this,name,job](std::string body, std::string error, unsigned int status) {
+8 -1
View File
@@ -427,6 +427,8 @@ wxGLContext* OpenGLManager::init_glcontext(wxGLCanvas& canvas, const std::pair<i
return m_context; return m_context;
} }
static const char* const SCENE_CANVAS_NAME = "OrcaSceneCanvas";
wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent) wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
{ {
int antialiasing_samples = 4; int antialiasing_samples = 4;
@@ -470,12 +472,17 @@ wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
attribList[samples_value_idx] = 0; attribList[samples_value_idx] = 0;
} }
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS); wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS, SCENE_CANVAS_NAME);
// The GL canvas paints its entire surface, so background erasing is unnecessary. // The GL canvas paints its entire surface, so background erasing is unnecessary.
canvas->SetBackgroundStyle(wxBG_STYLE_PAINT); canvas->SetBackgroundStyle(wxBG_STYLE_PAINT);
return canvas; return canvas;
} }
bool OpenGLManager::is_scene_canvas(const wxWindow* window)
{
return window != nullptr && window->GetName() == SCENE_CANVAS_NAME;
}
void OpenGLManager::detect_multisample(int* attribList) void OpenGLManager::detect_multisample(int* attribList)
{ {
int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER; int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER;
+2
View File
@@ -101,6 +101,8 @@ public:
static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); } static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); }
static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; } static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; }
static wxGLCanvas* create_wxglcanvas(wxWindow& parent); static wxGLCanvas* create_wxglcanvas(wxWindow& parent);
// True for a canvas made by create_wxglcanvas(), the kind a GLCanvas3D draws its scene and ImGui into.
static bool is_scene_canvas(const wxWindow* window);
static const GLInfo& get_gl_info() { return s_gl_info; } static const GLInfo& get_gl_info() { return s_gl_info; }
static bool force_power_of_two_textures() { return s_force_power_of_two_textures; } static bool force_power_of_two_textures() { return s_force_power_of_two_textures; }
-15
View File
@@ -1332,21 +1332,6 @@ boost::any ConfigOptionsGroup::get_config_value2(const DynamicPrintConfig& confi
return ret; return ret;
} }
bool ConfigOptionsGroup::commit_config_value(const t_config_option_key& opt_key, int opt_index)
{
if (m_config == nullptr)
return false;
// m_opt_map is filled while the option lines are created, so it still resolves the option when
// the group's controls (and thus its fields) have not been built yet.
const std::string opt_id = option_id_in_map(m_opt_map, opt_key, opt_index);
if (opt_id.empty())
return false;
on_change_OG(opt_id, get_config_value2(*m_config, opt_key, opt_index));
return true;
}
Field* ConfigOptionsGroup::get_fieldc(const t_config_option_key& opt_key, int opt_index) Field* ConfigOptionsGroup::get_fieldc(const t_config_option_key& opt_key, int opt_index)
{ {
Field* field = get_field(opt_key); Field* field = get_field(opt_key);
-40
View File
@@ -119,37 +119,6 @@ using column_t = std::function<wxWindow*(wxWindow* parent, const Line&)>;
using t_optionfield_map = std::map<t_config_option_key, t_field>; using t_optionfield_map = std::map<t_config_option_key, t_field>;
using t_opt_map = std::map< std::string, std::pair<std::string, int> >; using t_opt_map = std::map< std::string, std::pair<std::string, int> >;
// The option id a group's option map holds for opt_key, and the index it carries. `opt_index` is
// an in/out parameter: a negative value asks for the option's single entry, an explicit one selects
// that index. Returns an empty string when the map does not own the option, or when a negative index
// matches several entries -- a vector option the GUI edits one index at a time, which has no single
// value to commit. A group fills the map while its option lines are created (OptionsGroup::append_line
// / ConfigOptionsGroup::get_option), so this resolves an option even before its controls (and its
// field) have been built.
inline std::string option_id_in_map(const t_opt_map& options, const t_config_option_key& opt_key, int& opt_index)
{
if (opt_index >= 0) {
for (const auto& opt : options) {
if (opt.second.first == opt_key && opt.second.second == opt_index) {
opt_index = opt.second.second;
return opt.first;
}
}
return {};
}
std::string opt_id;
for (const auto& opt : options) {
if (opt.second.first != opt_key)
continue;
if (!opt_id.empty())
return {}; // several entries: a vector option, not one value
opt_id = opt.first;
opt_index = opt.second.second;
}
return opt_id;
}
class OptionsGroup { class OptionsGroup {
public: public:
const bool staticbox{true}; const bool staticbox{true};
@@ -363,15 +332,6 @@ public:
boost::any get_config_value(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1); boost::any get_config_value(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
// BBS: restore all pages in preset // BBS: restore all pages in preset
boost::any get_config_value2(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1); boost::any get_config_value2(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
// Re-run this group's committed-change path for one of its options without a widget event:
// build the option's value from the config this group edits (get_config_value2, the same form
// the option's field reports) and dispatch on_change_OG(), so the group's option-specific
// conversion (e.g. the printer_agent canonicalization) and its registered change handler run
// exactly as for a hand edit. The option map is filled while the option lines are created, so
// this resolves an option whose field is not built yet (a lazy page, or an option the GUI has
// no field for). Returns false when this group does not own the option, or when opt_index < 0
// matches several entries (a vector option the GUI edits index by index has no single value).
bool commit_config_value(const t_config_option_key& opt_key, int opt_index = -1);
Field* get_fieldc(const t_config_option_key& opt_key, int opt_index); Field* get_fieldc(const t_config_option_key& opt_key, int opt_index);
std::pair<OG_CustomCtrl*, bool*> get_custom_ctrl_with_blinking_ptr(const t_config_option_key& opt_key, int opt_index/* = -1*/); std::pair<OG_CustomCtrl*, bool*> get_custom_ctrl_with_blinking_ptr(const t_config_option_key& opt_key, int opt_index/* = -1*/);
+6 -1
View File
@@ -21178,7 +21178,12 @@ void Plater::print_job_finished(wxCommandEvent &evt)
Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager();
if (!dev) return; if (!dev) return;
dev->set_selected_machine(evt.GetString().ToStdString()); const std::string dev_id = evt.GetString().ToStdString();
MachineObject* selected = dev->get_selected_machine();
// After a LAN print, the completion event reselects the same printer.
// Keep the active session while asynchronous status traffic may still be in flight.
if (!selected || selected->get_dev_id() != dev_id || selected->connection_type() != "lan")
dev->set_selected_machine(dev_id);
p->main_frame->request_select_tab(TAB_ID_MONITOR); p->main_frame->request_select_tab(TAB_ID_MONITOR);
// Selects the status page on a built Device tab; one built by the switch starts there. // Selects the status page on a built Device tab; one built by the switch starts there.
MonitorPanel* curr_monitor = MonitorPanel::if_built(); MonitorPanel* curr_monitor = MonitorPanel::if_built();
+26 -44
View File
@@ -1158,7 +1158,10 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
{ {
PluginManager& manager = PluginManager::instance(); PluginManager& manager = PluginManager::instance();
auto complete_with_error = [this, &manager, plugin_key](const std::string& plugin_error, const wxString& status_message) { std::string error;
ExecutionResult result;
auto complete_with_error = [this, &manager, &plugin_key](const std::string& plugin_error, const wxString& status_message) {
const std::string normalized_error = plugin_error.empty() ? _u8L("Script plugin failed.") : plugin_error; const std::string normalized_error = plugin_error.empty() ? _u8L("Script plugin failed.") : plugin_error;
if (!manager.set_plugin_error(plugin_key, normalized_error)) if (!manager.set_plugin_error(plugin_key, normalized_error))
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key; BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
@@ -1174,53 +1177,32 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
show_status(message, "error"); show_status(message, "error");
}; };
// Always runs on the GUI thread. For a started invocation (sync `def execute` or `async def`)
// the runtime delivers it later; it is called inline here only when the launch itself failed.
auto finish = [this, alive = m_alive, plugin_key, &manager, complete_with_error](const ScriptCompletion& completion) {
if (!alive->load())
return;
// Cancelled by PluginManager (unload/shutdown): expected termination. Report neither an
// error nor a normal success, and do not touch the plugin's error/state.
if (completion.cancelled)
return;
if (!completion.error.empty()) {
complete_with_error(completion.error, wxString());
return;
}
const ExecutionResult& result = completion.result;
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
<< " status=" << static_cast<int>(result.status) << " message=" << result.message
<< " data=" << result.data;
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError;
if (failed) {
complete_with_error(result.message, wxString());
return;
}
manager.clear_plugin_error(plugin_key);
send_plugins();
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
show_status(message, skipped ? "info" : "success");
};
PluginManager::ScriptLaunchResult launch;
{ {
wxBusyCursor busy; wxBusyCursor busy;
launch = manager.launch_script_capability(plugin_key, capability_name, finish); result = manager.run_script_capability(plugin_key, capability_name, error);
} }
// Every started invocation — a synchronous `def execute` included — completes later through if (!error.empty()) {
// `finish`; only a launch failure is reported inline. Return without blocking. complete_with_error(error, wxString());
if (!launch.launched_async) return;
finish(launch.outcome); }
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
<< " status=" << static_cast<int>(result.status) << " message=" << result.message << " data=" << result.data;
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError || !error.empty();
if (failed) {
complete_with_error(result.message, wxString());
return;
}
manager.clear_plugin_error(plugin_key);
send_plugins();
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
show_status(message, skipped ? "info" : "success");
} }
void PluginsDialog::update_plugin(const std::string& plugin_key) void PluginsDialog::update_plugin(const std::string& plugin_key)
+1 -1
View File
@@ -395,7 +395,7 @@ bool run_post_process_scripts(
auto gcode_file = boost::filesystem::path(path); auto gcode_file = boost::filesystem::path(path);
if (!boost::filesystem::exists(gcode_file)) if (!boost::filesystem::exists(gcode_file))
throw Slic3r::RuntimeError(_u8L("Post-processor can't find exported gcode file")); throw Slic3r::RuntimeError(_u8L("Post-processor can't find exported G-code file"));
// Store print configuration into environment variables. // Store print configuration into environment variables.
config.setenv_(); config.setenv_();
+1 -1
View File
@@ -4087,7 +4087,7 @@ void SelectMachineDialog::update_user_printer()
{ {
m_printer_last_select = ""; m_printer_last_select = "";
update_select_layout(nullptr); update_select_layout(nullptr);
m_printer_box->GetPrinterComboBox()->SetTextLabel(""); m_printer_box->GetPrinterComboBox()->SetValue(_L("No available printers"));
} }
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
+1 -1
View File
@@ -1229,7 +1229,7 @@ void SendToPrinterDialog::update_user_printer()
} }
else { else {
m_printer_last_select = ""; m_printer_last_select = "";
m_comboBox_printer->SetTextLabel(""); m_comboBox_printer->SetValue(_L("No available printers"));
} }
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
+2 -134
View File
@@ -46,7 +46,6 @@
#include "slic3r/GUI/DeviceCore/DevConfigUtil.h" #include "slic3r/GUI/DeviceCore/DevConfigUtil.h"
#include <deque> #include <deque>
#include <exception> #include <exception>
#include <stdexcept>
#include "libslic3r/LifecycleEvents.hpp" #include "libslic3r/LifecycleEvents.hpp"
#include "libslic3r/Point.hpp" #include "libslic3r/Point.hpp"
#include "slic3r/GUI/SettingsIndex.hpp" #include "slic3r/GUI/SettingsIndex.hpp"
@@ -1646,138 +1645,6 @@ void Tab::load_config(const DynamicPrintConfig& config)
} }
} }
// The option keys a preset of `type` may hold. The tab's config shares the global
// print_config_def with every other preset type, so membership has to be checked against the
// preset type's own key list instead of the config definition, or a printer setting could be
// written into the process preset.
static const std::vector<std::string>& preset_type_options(Preset::Type type)
{
static const std::vector<std::string> none;
switch (type) {
case Preset::TYPE_PRINT: return Preset::print_options();
case Preset::TYPE_FILAMENT: return Preset::filament_options();
case Preset::TYPE_PRINTER: return Preset::printer_options();
default: return none;
}
}
static const char* preset_type_name(Preset::Type type)
{
switch (type) {
case Preset::TYPE_PRINT: return "process";
case Preset::TYPE_FILAMENT: return "filament";
case Preset::TYPE_PRINTER: return "printer";
default: return "preset";
}
}
// Commit one option whose new value already sits in m_config through the same path a hand edit
// runs. The key-specific behaviour (ConfigOptionsGroup::on_change_OG, including the printer_agent
// canonicalization, the group's pre-checks and Tab::on_value_change()) runs exactly as for a hand
// edit. A built field supplies the field's typed/clamped value; otherwise the owning
// ConfigOptionsGroup builds the value from the config it edits and dispatches the same
// on_change_OG(). The option's page is reloaded from the config just committed first, so a built
// field reports the committed value rather than a stale one. No tab-wide refresh happens here: the
// caller does it once for the whole batch.
bool Tab::commit_preset_config_value(const std::string& opt_key)
{
if (Page* field_page = nullptr; Field* field = get_field(opt_key, &field_page)) {
field_page->reload_config();
field->field_changed();
return true;
}
for (const PageShp& page : m_pages)
for (const ConfigOptionsGroupShp& group : page->m_optgroups)
if (group->commit_config_value(opt_key))
return true;
return false;
}
// Set one option of the currently edited preset from its serialized string form, then run the
// same update path a hand edit runs. The edited preset is not saved: it only becomes dirty,
// exactly as if the field had been edited by hand.
void Tab::apply_preset_config_value(const std::string& opt_key, const std::string& value)
{
apply_preset_config_values({{opt_key, value}});
}
// Apply a batch of settings as one transaction: parse and validate the whole batch first, then
// commit it through the same per-setting path apply_preset_config_value() uses, restoring the edited
// preset and the state derived from it if a setting fails. See the header for the contract and its
// limits.
void Tab::apply_preset_config_values(const std::vector<std::pair<std::string, std::string>>& values)
{
if (m_config == nullptr)
throw std::runtime_error("The preset configuration is not available");
if (wxGetApp().plater() == nullptr)
throw std::runtime_error("OrcaSlicer main window is not available");
if (values.empty())
return;
const std::vector<std::string>& options = preset_type_options(m_type);
// Preparation: reject every batch that cannot be applied before touching the edited preset, so a
// bad key or value fails without a partial write. The key must belong to this preset type, it must
// not repeat, and each value is strict-parsed into a scratch copy (the strict deserializer can
// already have mutated an option when it rejects a bad value, so the check has to run somewhere
// the edited preset is not affected). Callback failures cannot be prepared this way: a setting's
// on_change callback is a GUI method, not a pure function of the config, so whether it throws is
// only known by running it. That residual case is handled by the rollback below.
std::set<std::string> seen;
DynamicPrintConfig scratch(*m_config);
for (const auto& kv : values) {
const std::string& opt_key = kv.first;
if (std::find(options.begin(), options.end(), opt_key) == options.end())
throw std::invalid_argument(format("Unknown %1% preset setting \"%2%\"", preset_type_name(m_type), opt_key));
if (!seen.insert(opt_key).second)
throw std::invalid_argument(format("%1% preset setting \"%2%\" is set more than once in the same update",
preset_type_name(m_type), opt_key));
try {
scratch.set_deserialize_strict(opt_key, kv.second);
} catch (const ConfigurationError& err) {
throw std::invalid_argument(err.what());
}
}
// Rollback boundary: snapshot the whole edited config, not just the keys the batch names, because a
// key-specific callback can adjust related settings of the edited preset (mutually exclusive
// options, compatibility, and so on). A failure restores this config and then recomputes the state
// derived from it.
DynamicPrintConfig before(*m_config);
try {
for (const auto& kv : values) {
m_config->set_deserialize_strict(kv.first, kv.second);
if (!commit_preset_config_value(kv.first))
// The GUI edits this setting through a custom widget rather than a group (e.g.
// compatible_printers), so take the same shared no-field commit path the widget uses.
// The value was written above, so saved mode skips the write and keeps the path's
// compatibility recompute, dirty flag, preset-combo and plater updates.
load_key_value(kv.first, boost::any(), /*saved_value=*/true);
}
} catch (...) {
*m_config = before;
// A callback can rewrite state derived from the config (compatibility marks, the preset
// selector, dependent tabs, the Plater and, on the printer tab, the printer agent) before it
// throws. Recompute that derived state from the restored config so none of it is left
// reflecting the failed batch. Effects that are not a function of the config (a dialog the
// user already accepted, an undo snapshot, a command already sent to a device, the target
// selection) cannot be reversed here; see the header.
m_preset_bundle->update_compatible(PresetSelectCompatibleType::Never);
reload_config();
update_dirty();
update();
throw;
}
// Apply the batch's final state once. The commit paths above already maintained the dirty flag,
// preset combo and, on the printer tab, the printer-agent switch; this reloads the visible page
// and pushes the config to the Plater.
reload_config();
update_dirty();
update();
}
// Reload current $self->{config} (aka $self->{presets}->edited_preset->config) into the UI fields. // Reload current $self->{config} (aka $self->{presets}->edited_preset->config) into the UI fields.
void Tab::reload_config() void Tab::reload_config()
{ {
@@ -3402,6 +3269,7 @@ void TabPrint::build()
optgroup->append_single_option_line("gcode_comments", "others_settings_g_code_output#verbose-g-code"); optgroup->append_single_option_line("gcode_comments", "others_settings_g_code_output#verbose-g-code");
optgroup->append_single_option_line("gcode_label_objects", "others_settings_g_code_output#label-objects"); optgroup->append_single_option_line("gcode_label_objects", "others_settings_g_code_output#label-objects");
optgroup->append_single_option_line("exclude_object", "others_settings_g_code_output#exclude-objects"); optgroup->append_single_option_line("exclude_object", "others_settings_g_code_output#exclude-objects");
optgroup->append_single_option_line("exclude_object_tolerance", "others_settings_g_code_output#outline-tolerance");
option = optgroup->get_option("filename_format"); option = optgroup->get_option("filename_format");
// option.opt.full_width = true; // option.opt.full_width = true;
option.opt.is_code = true; option.opt.is_code = true;
@@ -5411,7 +5279,7 @@ void TabPrinter::build_fff()
option.opt.width = Field::def_width_wider(); option.opt.width = Field::def_width_wider();
option.opt.tooltip = L("Select the network agent implementation for printer communication. " option.opt.tooltip = L("Select the network agent implementation for printer communication. "
"Available agents are registered at startup."); "Available agents are registered at startup.");
optgroup->append_single_option_line(option); optgroup->append_single_option_line(option, "printer_basic_information_advanced#printer-agent");
} }
} }
-40
View File
@@ -424,40 +424,6 @@ public:
//BBS update plater presets if update_plater_presets = true //BBS update plater presets if update_plater_presets = true
void update_tab_ui(bool update_plater_presets = false); void update_tab_ui(bool update_plater_presets = false);
void load_config(const DynamicPrintConfig& config); void load_config(const DynamicPrintConfig& config);
// Host/plugin API: set one option of this tab's currently edited preset from its serialized
// string value, then run the same committed-change path a field edit runs. `opt_key` must be a
// setting of this tab's preset type, so a key of another preset type (e.g. a printer setting
// passed to the process tab) is rejected instead of written silently. `value` is parsed by the
// strict config deserializer, which raises std::invalid_argument for an unknown key or a value
// the option cannot parse, and leaves the preset unchanged. The key-specific behaviour runs
// through its ConfigOptionsGroup::on_change_OG(): with the option's built field it uses the
// field's typed value, otherwise the owning group builds the value and dispatches the same call,
// so a not-yet-built field still gets it. A key the GUI edits through a custom widget instead
// takes the shared no-field commit path, Tab::load_key_value() in saved mode.
void apply_preset_config_value(const std::string& opt_key, const std::string& value);
// Host/plugin API: apply a batch of settings to this tab's currently edited preset as one
// transaction. Each pair is a setting of this tab's preset type and its serialized string value.
// The whole batch is parsed and validated before the edited preset is touched; each setting then
// runs the same committed-change path apply_preset_config_value() uses, so setting-specific
// validation, correction and on_change callbacks stay authoritative. Duplicate keys are rejected,
// because the transaction must not resolve an incidental write order, and the batch order is
// preserved. If applying the batch fails, the edited preset's config is restored to its pre-batch
// state and the state derived from it (compatibility marks, the preset selector, dependent tabs,
// the Plater and the printer agent) is recomputed, so the edited preset never keeps a subset of
// the staged settings.
//
// Only the edited preset is restored. A setting's on_change callback is a GUI method, not a pure
// function of the config, so effects outside the edited preset cannot be prepared or undone: the
// transaction does not reverse preset/slot selection made while resolving the target, a dialog
// decision the user already accepted, an undo snapshot, a dependent preset's rewritten config, or
// anything already sent to a device. Exact all-or-nothing across those is not achievable while the
// authoritative GUI callbacks run; this method guarantees the edited preset's own settings, not
// those external effects.
//
// Throws std::invalid_argument for an unknown key, a value the option cannot parse, or a duplicate
// key; std::runtime_error when the preset or main window is unavailable.
void apply_preset_config_values(const std::vector<std::pair<std::string, std::string>>& values);
virtual void reload_config(); virtual void reload_config();
void update_mode(); void update_mode();
void update_visibility(); void update_visibility();
@@ -541,12 +507,6 @@ protected:
wxSizer* compatible_widget_create(wxWindow* parent, PresetDependencies &deps); wxSizer* compatible_widget_create(wxWindow* parent, PresetDependencies &deps);
void compatible_widget_reload(PresetDependencies &deps); void compatible_widget_reload(PresetDependencies &deps);
void load_key_value(const std::string& opt_key, const boost::any& value, bool saved_value = false); void load_key_value(const std::string& opt_key, const boost::any& value, bool saved_value = false);
// Commit one option whose new value already sits in m_config through the same path a field edit
// runs (throwing field, otherwise the owning group's on_change_OG()). Reloads the option's page
// so a built field reports the committed value, but performs no tab-wide refresh. Returns true
// when a built field or a group owns the option, false when the GUI edits it through a custom
// widget with no group entry.
bool commit_preset_config_value(const std::string& opt_key);
//BBS: GUI refactor //BBS: GUI refactor
// return true if cancelled // return true if cancelled
+23 -1
View File
@@ -74,6 +74,7 @@
#endif #endif
#ifdef __LINUX__ #ifdef __LINUX__
#include "LinuxDisplayBackend.hpp"
#include <wx/textfile.h> #include <wx/textfile.h>
#include <wx/tokenzr.h> #include <wx/tokenzr.h>
#include <cstdlib> #include <cstdlib>
@@ -201,7 +202,11 @@ TroubleshootDialog::TroubleshootDialog()
return std::vector<wxString>{GetOStype()}; return std::vector<wxString>{GetOStype()};
return std::vector<wxString>{ return std::vector<wxString>{
GetOSinfo(), GetOSinfo(),
#ifdef __LINUX__
GetPackageType() + GetAppDisplayBackend(),
#else
GetPackageType(), GetPackageType(),
#endif
GetCPUinfo(), GetCPUinfo(),
GetRAMinfo() + " RAM", GetRAMinfo() + " RAM",
GetGPUinfo(), GetGPUinfo(),
@@ -434,10 +439,15 @@ wxString TroubleshootDialog::GetTimestamp()
wxString TroubleshootDialog::GetSysInfoAll() wxString TroubleshootDialog::GetSysInfoAll()
{ {
wxString package = GetPackageType();
#ifdef __LINUX__
package += GetAppDisplayBackend();
#endif
wxString info; wxString info;
info += "Version : " + wxString(SoftFever_VERSION) + "\n" info += "Version : " + wxString(SoftFever_VERSION) + "\n"
+ "Build : " + wxString(build_commit_label) + "\n" + "Build : " + wxString(build_commit_label) + "\n"
+ "Package : " + GetPackageType() + "\n" + "Package : " + package + "\n"
+ "Platform : " + GetOSinfo() + "\n" + "Platform : " + GetOSinfo() + "\n"
+ "Processor : " + GetCPUinfo() + "\n" + "Processor : " + GetCPUinfo() + "\n"
+ "Memory : " + GetRAMinfo() + "RAM" + "\n" + "Memory : " + GetRAMinfo() + "RAM" + "\n"
@@ -751,6 +761,18 @@ wxString TroubleshootDialog::GetLinuxDisplayServer()
return ""; return "";
} }
wxString TroubleshootDialog::GetAppDisplayBackend()
{
// Display backend actually used by the app (GDK), not the session type
wxString display;
switch (get_linux_display_backend()) {
case LinuxDisplayBackend::X11: display = "X11"; break;
case LinuxDisplayBackend::Wayland: display = "Wayland"; break;
default: break;
}
return display.IsEmpty() ? "" : (" (" + display + ")"); // spacing included here to prevent non centered text
}
#endif #endif
wxString TroubleshootDialog::GetPackageType() wxString TroubleshootDialog::GetPackageType()
+1
View File
@@ -68,6 +68,7 @@ protected:
#elif defined(__LINUX__) #elif defined(__LINUX__)
wxString GetLinuxDistroName(); wxString GetLinuxDistroName();
wxString GetLinuxDisplayServer(); wxString GetLinuxDisplayServer();
wxString GetAppDisplayBackend();
#endif #endif
wxString GetPackageType(); wxString GetPackageType();
wxString GetCPUinfo(); wxString GetCPUinfo();
+3 -3
View File
@@ -287,7 +287,7 @@ void ComboBox::DoDeleteOneItem(unsigned int pos)
void ComboBox::ForceDropdownOpen() void ComboBox::ForceDropdownOpen()
{ {
if (!IsEnabled()) if (!IsEnabled() || items.empty())
return; return;
if (!drop_down) { if (!drop_down) {
@@ -398,7 +398,7 @@ void ComboBox::mouseDown(wxMouseEvent &event)
SetFocus(); SetFocus();
if (drop_down) { if (drop_down) {
drop.Hide(); drop.Hide();
} else if (drop.HasDismissLongTime()) { } else if (!items.empty() && drop.HasDismissLongTime()) { // DropDown paints nothing for an empty list
drop.autoPosition(); drop.autoPosition();
drop_down = true; drop_down = true;
drop.Popup(&drop); drop.Popup(&drop);
@@ -426,7 +426,7 @@ void ComboBox::keyDown(wxKeyEvent& event)
case WXK_SPACE: case WXK_SPACE:
if (drop_down) { if (drop_down) {
drop.DismissAndNotify(); drop.DismissAndNotify();
} else if (drop.HasDismissLongTime()) { } else if (!items.empty() && drop.HasDismissLongTime()) {
drop.autoPosition(); drop.autoPosition();
drop_down = true; drop_down = true;
drop.Popup(); drop.Popup();
+220 -60
View File
@@ -5,6 +5,7 @@
#include "bambu_networking.hpp" #include "bambu_networking.hpp"
#include "libslic3r/Preset.hpp" #include "libslic3r/Preset.hpp"
#include "libslic3r/PresetBundle.hpp" #include "libslic3r/PresetBundle.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp" #include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/DeviceManager.hpp" #include "slic3r/GUI/DeviceManager.hpp"
@@ -19,14 +20,19 @@
#include <boost/algorithm/string/trim.hpp> #include <boost/algorithm/string/trim.hpp>
#include <boost/asio/buffer.hpp> #include <boost/asio/buffer.hpp>
#include <boost/asio/connect.hpp> #include <boost/asio/connect.hpp>
#include <boost/asio/error.hpp>
#include <boost/asio/io_context.hpp> #include <boost/asio/io_context.hpp>
#include <boost/asio/ip/tcp.hpp> #include <boost/asio/ip/tcp.hpp>
#include <boost/asio/post.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/beast/core.hpp> #include <boost/beast/core.hpp>
#include <boost/beast/core/tcp_stream.hpp> #include <boost/beast/core/tcp_stream.hpp>
#include <boost/beast/http/field.hpp> #include <boost/beast/http/field.hpp>
#include <boost/beast/core/flat_buffer.hpp> #include <boost/beast/core/flat_buffer.hpp>
#include <boost/beast/core/error.hpp> #include <boost/beast/core/error.hpp>
#include <boost/beast/core/buffers_to_string.hpp> #include <boost/beast/core/buffers_to_string.hpp>
#include <boost/beast/core/stream_traits.hpp>
#include <boost/beast/core/string_type.hpp>
#include <boost/beast/websocket.hpp> #include <boost/beast/websocket.hpp>
#include <boost/beast/websocket/stream.hpp> #include <boost/beast/websocket/stream.hpp>
#include <boost/beast/websocket/stream_base.hpp> #include <boost/beast/websocket/stream_base.hpp>
@@ -37,6 +43,7 @@
#include <boost/filesystem/operations.hpp> #include <boost/filesystem/operations.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <algorithm> #include <algorithm>
#include <atomic>
#include <chrono> #include <chrono>
#include <cstdint> #include <cstdint>
#include <cctype> #include <cctype>
@@ -120,6 +127,31 @@ std::string map_moonraker_state(std::string state)
return "IDLE"; return "IDLE";
} }
// Runs one asynchronous operation to completion, because synchronous Beast calls ignore timeouts and cannot be aborted
template<class Initiate>
beast::error_code run_until_done(net::io_context& ioc, const std::atomic<bool>& stop, Initiate&& initiate)
{
// An abort that runs before the operation starts finds nothing to cancel
if (stop.load()) {
return net::error::operation_aborted;
}
beast::error_code result;
bool done = false;
initiate([&](beast::error_code ec, auto&&...) {
result = ec;
done = true;
});
ioc.restart();
while (!done && ioc.run_one() != 0) {} // run() never returns while the websocket idle timer is armed
return done ? result : beast::error_code{net::error::operation_aborted};
}
uint64_t steady_now_ms()
{
return static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
}
} // namespace } // namespace
namespace Slic3r { namespace Slic3r {
@@ -1298,6 +1330,8 @@ bool MoonrakerPrinterAgent::fetch_object_list(const std::string& base_url,
} }
http.timeout_connect(5) http.timeout_connect(5)
.timeout_max(10) .timeout_max(10)
// Gives up once the status stream, the only caller, stops
.on_progress([this](Http::Progress, bool& cancel) { cancel = ws_stop.load(); })
.on_complete([&](std::string body, unsigned status) { .on_complete([&](std::string body, unsigned status) {
if (status == 200) { if (status == 200) {
response_body = body; response_body = body;
@@ -1468,19 +1502,47 @@ void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, const
return; return;
} }
std::lock_guard<std::mutex> lock(ws_abort_mutex);
ws_stop.store(false); ws_stop.store(false);
ws_thread = std::thread([this, dev_id, base_url, api_key]() { run_status_stream(dev_id, base_url, api_key); }); ws_thread = std::thread(
[this, dev_id, base_url, api_key, timings = ws_timings]() { run_status_stream(dev_id, base_url, api_key, timings); });
} }
void MoonrakerPrinterAgent::stop_status_stream() void MoonrakerPrinterAgent::stop_status_stream()
{ {
ws_stop.store(true); std::thread stopped;
if (ws_thread.joinable()) { {
ws_thread.join(); std::lock_guard<std::mutex> lock(ws_abort_mutex);
ws_stop.store(true);
if (ws_abort) {
ws_abort();
}
// A callback on the stream thread can stop the stream, which then ends on its own and is joined later, outside connect_mutex
if (ws_thread.get_id() != std::this_thread::get_id()) {
stopped = std::move(ws_thread);
}
}
if (stopped.joinable()) {
stopped.join();
} }
} }
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key) void MoonrakerPrinterAgent::join_stopped_status_stream()
{
std::thread stopped;
{
std::lock_guard<std::mutex> lock(ws_abort_mutex);
// A running stream belongs to a newer connection, so only one that is already stopping is joined
if (ws_stop.load() && ws_thread.get_id() != std::this_thread::get_id()) {
stopped = std::move(ws_thread);
}
}
if (stopped.joinable()) {
stopped.join();
}
}
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings)
{ {
WsEndpoint endpoint; WsEndpoint endpoint;
if (!parse_ws_endpoint(base_url, endpoint)) { if (!parse_ws_endpoint(base_url, endpoint)) {
@@ -1492,25 +1554,108 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
return; return;
} }
net::io_context ioc;
net::steady_timer backoff{ioc};
std::function<void()> abort_connection; // Set while a connection attempt runs
const auto run = [&](auto&& initiate) { return run_until_done(ioc, ws_stop, initiate); };
// Registered under the lock stop_status_stream() takes, so a stop either finds this abort or has already set ws_stop
{
std::lock_guard<std::mutex> lock(ws_abort_mutex);
if (ws_stop.load()) {
return;
}
ws_abort = [&] {
net::post(ioc, [&] {
backoff.cancel();
if (abort_connection) {
abort_connection();
}
});
};
}
const ScopeGuard clear_abort([this] {
std::lock_guard<std::mutex> lock(ws_abort_mutex);
ws_abort = nullptr;
});
// Reconnection logic // Reconnection logic
ws_reconnect_requested.store(false); // Reset reconnect flag ws_reconnect_requested.store(false); // Reset reconnect flag
int retry_count = 0; int retry_count = 0;
const int max_retries = 10;
const int base_delay_ms = 1000;
while (!ws_stop.load() && retry_count < max_retries) { while (!ws_stop.load()) {
bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect
tcp::resolver resolver{ioc};
websocket::stream<beast::tcp_stream> ws{ioc};
beast::tcp_stream& stream = beast::get_lowest_layer(ws);
net::steady_timer close_deadline{ioc};
bool closing = false;
bool deadline_pending = false;
auto last_message = std::chrono::steady_clock::now();
// Sends a close frame if the websocket is open, and drops the socket when the printer has not answered within timings.close
const auto close_politely = [&](websocket::close_code code) {
if (!ws.is_open() || closing) {
return;
}
closing = true;
deadline_pending = true;
close_deadline.expires_after(timings.close);
close_deadline.async_wait([&](beast::error_code ec) {
deadline_pending = false;
if (!ec) {
beast::error_code ignored;
stream.socket().close(ignored);
}
});
ws.async_close(code, [&](beast::error_code) {
closing = false;
close_deadline.cancel();
});
};
abort_connection = [&] {
// Beast reports a closing websocket as not open, and dropping its socket would cut off the close frame
if (closing) {
return;
}
if (ws.is_open()) {
close_politely(websocket::close_code::going_away);
} else {
beast::error_code ignored;
stream.socket().close(ignored);
}
};
const ScopeGuard clear_connection_abort([&] { abort_connection = nullptr; });
try { try {
net::io_context ioc; beast::error_code connect_ec = net::error::host_not_found;
tcp::resolver resolver{ioc}; for (const auto& entry : resolver.resolve(endpoint.host, endpoint.port)) {
beast::tcp_stream stream{ioc}; beast::error_code ignored;
stream.socket().close(ignored); // A failed attempt leaves the socket open
stream.expires_after(std::chrono::seconds(5));
connect_ec = run([&](auto&& handler) { stream.async_connect(entry.endpoint(), handler); });
if (!connect_ec || ws_stop.load()) {
break;
}
}
if (connect_ec) {
throw beast::system_error{connect_ec};
}
// Clears the connect deadline, which would otherwise close the socket 5 s after connecting
stream.expires_never();
stream.expires_after(std::chrono::seconds(10)); websocket::stream_base::timeout timeouts{};
auto const results = resolver.resolve(endpoint.host, endpoint.port); timeouts.handshake_timeout = timings.handshake;
stream.connect(results); timeouts.idle_timeout = timings.idle;
timeouts.keep_alive_pings = true;
websocket::stream<beast::tcp_stream> ws{std::move(stream)}; ws.set_option(timeouts);
// Beast pings on a fixed schedule, so the heartbeat also checks how long the printer has sent no message
ws.control_callback([&](websocket::frame_type kind, beast::string_view) {
if (kind == websocket::frame_type::pong && !closing &&
std::chrono::steady_clock::now() - last_message >= timings.heartbeat) {
send_heartbeat_if_due(dev_id, timings.heartbeat);
}
});
ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) { ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) {
req.set(http::field::user_agent, "OrcaSlicer"); req.set(http::field::user_agent, "OrcaSlicer");
if (!api_key.empty()) { if (!api_key.empty()) {
@@ -1522,7 +1667,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
if (!endpoint.port.empty() && endpoint.port != "80") { if (!endpoint.port.empty() && endpoint.port != "80") {
host_header += ":" + endpoint.port; host_header += ":" + endpoint.port;
} }
ws.handshake(host_header, endpoint.target); if (const auto ec = run([&](auto&& handler) { ws.async_handshake(host_header, endpoint.target, handler); })) {
throw beast::system_error{ec};
}
ws.text(true); ws.text(true);
// Send client identification // Send client identification
@@ -1534,7 +1681,10 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
identify["params"]["type"] = "agent"; identify["params"]["type"] = "agent";
identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer"; identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer";
identify["id"] = 0; identify["id"] = 0;
ws.write(net::buffer(identify.dump())); const std::string identify_payload = identify.dump();
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(identify_payload), handler); })) {
throw beast::system_error{ec};
}
std::set<std::string> subscribe_objects = {"print_stats", "virtual_sdcard"}; std::set<std::string> subscribe_objects = {"print_stats", "virtual_sdcard"};
std::set<std::string> available_objects; std::set<std::string> available_objects;
@@ -1586,28 +1736,17 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
} }
subscribe["params"]["objects"] = std::move(objects); subscribe["params"]["objects"] = std::move(objects);
subscribe["id"] = 1; subscribe["id"] = 1;
ws.write(net::buffer(subscribe.dump())); const std::string subscribe_payload = subscribe.dump();
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(subscribe_payload), handler); })) {
throw beast::system_error{ec};
}
// Read loop // Read loop
while (!ws_stop.load()) { while (!ws_stop.load()) {
ws.next_layer().expires_after(std::chrono::seconds(2));
beast::flat_buffer buffer; beast::flat_buffer buffer;
beast::error_code ec; const auto ec = run([&](auto&& handler) { ws.async_read(buffer, handler); });
ws.read(buffer, ec); if (ws_stop.load()) {
if (ec == beast::error::timeout) { break;
const auto now_ms = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
const auto last_ms = ws_last_emit_ms.load();
if (last_ms == 0 || now_ms - last_ms >= 10000) {
nlohmann::json message;
{
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
message = build_print_payload_locked();
}
dispatch_message(dev_id, message.dump());
ws_last_emit_ms.store(now_ms);
}
continue;
} }
if (ec == websocket::error::closed) { if (ec == websocket::error::closed) {
connection_lost = true; connection_lost = true;
@@ -1618,6 +1757,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
connection_lost = true; connection_lost = true;
break; break;
} }
last_message = std::chrono::steady_clock::now();
// A message shows the printer is answering, so the next drop starts the backoff from reconnect_delay
retry_count = 0;
handle_ws_message(dev_id, beast::buffers_to_string(buffer.data())); handle_ws_message(dev_id, beast::buffers_to_string(buffer.data()));
// Check if handle_ws_message triggered reconnection request // Check if handle_ws_message triggered reconnection request
if (ws_reconnect_requested.exchange(false)) { if (ws_reconnect_requested.exchange(false)) {
@@ -1625,20 +1767,18 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
break; break;
} }
} }
beast::error_code ec;
ws.close(websocket::close_code::normal, ec);
// Only reset retry count on clean shutdown (not connection_lost)
if (!connection_lost && !ws_stop.load()) {
retry_count = 0;
}
} catch (const std::exception& e) { } catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what(); if (!ws_stop.load()) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
}
connection_lost = true; connection_lost = true;
} }
close_politely(ws_stop.load() ? websocket::close_code::going_away : websocket::close_code::normal);
ioc.restart();
// Both handlers refer to this connection, so neither may be left queued for the next one
while ((closing || deadline_pending) && ioc.run_one() != 0) {}
// Exit loop on clean shutdown // Exit loop on clean shutdown
if (!connection_lost) { if (!connection_lost) {
break; break;
@@ -1649,17 +1789,13 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
break; break;
} }
// Exponential backoff before reconnection // Exponential backoff before reconnection, capped at 32 times reconnect_delay so a printer that comes back is found again
int delay_ms = base_delay_ms * (1 << std::min(retry_count, 5)); const auto delay = timings.reconnect_delay * (1 << std::min(retry_count, 5));
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay_ms << "ms (attempt " << (retry_count + 1) << ")"; BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay.count() << "ms (attempt " << (retry_count + 1) << ")";
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); backoff.expires_after(delay);
run([&](auto&& handler) { backoff.async_wait(handler); });
retry_count++; retry_count++;
} }
if (retry_count >= max_retries) {
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Max reconnection attempts reached";
dispatch_local_connect(ConnectStatusLost, dev_id, "max_retries");
}
} }
void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload) void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload)
@@ -1733,8 +1869,7 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
} }
if (updated) { if (updated) {
const auto now_ms = static_cast<uint64_t>( const auto now_ms = steady_now_ms();
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
const auto last_dispatch_ms = ws_last_dispatch_ms.load(); const auto last_dispatch_ms = ws_last_dispatch_ms.load();
// Dispatch if: critical change OR throttle interval elapsed // Dispatch if: critical change OR throttle interval elapsed
@@ -1755,6 +1890,24 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
} }
} }
void MoonrakerPrinterAgent::send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval)
{
const auto now_ms = steady_now_ms();
const auto last_ms = ws_last_emit_ms.load();
// Re-sends only a status the printer has reported at least once
if (last_ms == 0 || now_ms - last_ms < static_cast<uint64_t>(interval.count())) {
return;
}
nlohmann::json message;
{
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
message = build_print_payload_locked();
}
dispatch_message(dev_id, message.dump());
ws_last_emit_ms.store(now_ms);
}
void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates) void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates)
{ {
if (!updates.is_object()) { if (!updates.is_object()) {
@@ -2139,8 +2292,15 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg; BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg;
} }
// Start WebSocket status stream join_stopped_status_stream();
start_status_stream(dev_id, base_url, api_key); // Holds connect_mutex across the staleness check and the start, so a disconnect during the queries above prevents the start
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
if (is_stale()) {
return;
}
start_status_stream(dev_id, base_url, api_key);
}
#endif #endif
// Success! // Success!
+19 -3
View File
@@ -5,6 +5,7 @@
#include "bambu_networking.hpp" #include "bambu_networking.hpp"
#include <functional> #include <functional>
#include <chrono>
#include <cstdint> #include <cstdint>
#include <atomic> #include <atomic>
#include <memory> #include <memory>
@@ -85,6 +86,16 @@ protected:
bool use_ssl = false; bool use_ssl = false;
} device_info; } device_info;
// Copied when the stream starts, so changes apply to the next start_status_stream()
struct StatusStreamTimings
{
std::chrono::milliseconds handshake{std::chrono::seconds(10)}; // Opening and closing handshakes
std::chrono::milliseconds close{std::chrono::seconds(1)}; // Wait for the close frame's answer before dropping the socket
std::chrono::milliseconds idle{std::chrono::seconds(30)}; // Silence before giving up, with a ping every half of it
std::chrono::milliseconds heartbeat{std::chrono::seconds(10)}; // Silence before the cached status is sent again
std::chrono::milliseconds reconnect_delay{std::chrono::seconds(1)}; // First wait, doubling per consecutive failure up to 32x
} ws_timings;
// Tray data for AMS payload building // Tray data for AMS payload building
struct AmsTrayData { struct AmsTrayData {
int slot_index = 0; // 0-based slot index int slot_index = 0; // 0-based slot index
@@ -102,6 +113,8 @@ protected:
// Methods that derived classes may need to override or access // Methods that derived classes may need to override or access
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port); virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const; virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
void stop_status_stream();
// State access for derived classes // State access for derived classes
mutable std::recursive_mutex state_mutex; mutable std::recursive_mutex state_mutex;
@@ -133,10 +146,11 @@ private:
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg); void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
void dispatch_printer_connected(const std::string& dev_id); void dispatch_printer_connected(const std::string& dev_id);
void dispatch_message(const std::string& dev_id, const std::string& payload); void dispatch_message(const std::string& dev_id, const std::string& payload);
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key); // Joins a stream stopped from its own callback, so a new stream can start without waiting on it under connect_mutex
void stop_status_stream(); void join_stopped_status_stream();
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key); void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings);
void handle_ws_message(const std::string& dev_id, const std::string& payload); void handle_ws_message(const std::string& dev_id, const std::string& payload);
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
void update_status_cache(const nlohmann::json& updates); void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const; nlohmann::json build_print_payload_locked() const;
@@ -196,6 +210,8 @@ private:
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
std::atomic<uint64_t> ws_last_emit_ms{0}; std::atomic<uint64_t> ws_last_emit_ms{0};
std::thread ws_thread; std::thread ws_thread;
std::mutex ws_abort_mutex; // Guards ws_thread and ws_abort
std::function<void()> ws_abort; // Interrupts whatever the stream thread is waiting on, from any thread
// Throttling configuration for WebSocket updates // Throttling configuration for WebSocket updates
// Critical changes (state transitions) dispatch immediately; telemetry is throttled // Critical changes (state transitions) dispatch immediately; telemetry is throttled
+384 -133
View File
@@ -11,6 +11,7 @@
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <algorithm> #include <algorithm>
#include <cassert>
#include <cctype> #include <cctype>
#include <cstddef> #include <cstddef>
#include <cstdlib> #include <cstdlib>
@@ -130,6 +131,20 @@ static AuditEventCategory event_category(const std::string& event_name)
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second; return it == audit_event_categories.end() ? AuditEventCategory::None : it->second;
} }
// Events the 3.12 stdlib raises with sys.audit() inside the function that performs the whole
// operation, so the raising frame bounds everything the operation does. Every other mapped event
// is a primitive raised from C, whose current frame is whatever Python code called it -- possibly
// a library loop over many targets -- so it never marks an operation. glob.glob and glob.glob/2
// are raised in iglob(), which returns its lazy generator before any directory is listed.
static const std::unordered_set<std::string> operation_root_events{
"urllib.Request", "http.client.connect", "os.walk", "os.fwalk",
"glob.glob", "glob.glob/2", "pathlib.Path.glob", "pathlib.Path.rglob",
"subprocess.Popen", "pty.spawn", "tempfile.mkstemp", "tempfile.mkdtemp",
"shutil.copyfile", "shutil.copymode", "shutil.copystat", "shutil.copytree",
"shutil.chown", "shutil.move", "shutil.rmtree", "shutil.make_archive",
"shutil.unpack_archive",
};
// True for the categories whose targets are filesystem paths, as opposed to a network // True for the categories whose targets are filesystem paths, as opposed to a network
// address or a process command line -- the deny-path and allowed-root checks only make sense // address or a process command line -- the deny-path and allowed-root checks only make sense
// against a path. // against a path.
@@ -146,6 +161,24 @@ static bool is_fs_category(AuditEventCategory category)
} }
} }
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
static const char* category_name(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead: return "FsRead";
case AuditEventCategory::FsReadWrite: return "FsReadWrite";
case AuditEventCategory::FsCreate: return "FsCreate";
case AuditEventCategory::FsDelete: return "FsDelete";
case AuditEventCategory::Http: return "Http";
case AuditEventCategory::Socket: return "Socket";
case AuditEventCategory::ProcessCreate: return "ProcessCreate";
case AuditEventCategory::ProcessReplace: return "ProcessReplace";
case AuditEventCategory::Threading: return "Threading";
case AuditEventCategory::None: break;
}
return "None";
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path safety // Path safety
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -215,26 +248,50 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
thread_local std::string PluginAuditManager::m_current_plugin_key = ""; thread_local std::string PluginAuditManager::m_current_plugin_key = "";
thread_local std::string PluginAuditManager::m_current_capability_name = ""; thread_local std::string PluginAuditManager::m_current_capability_name = "";
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots; thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
thread_local std::vector<PyObject*> PluginAuditManager::m_root_frames;
thread_local int PluginAuditManager::m_releasing_frames = 0;
thread_local bool PluginAuditManager::m_audit_denial_pending = false; thread_local bool PluginAuditManager::m_audit_denial_pending = false;
thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local bool PluginAuditManager::m_has_last_violation = false;
thread_local AuditViolation PluginAuditManager::m_last_violation; thread_local AuditViolation PluginAuditManager::m_last_violation;
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
const std::string& capability_name) const std::string& capability_name)
: m_previous_id(PluginAuditManager::instance().current_plugin()) : m_plugin_key(plugin_key)
, m_capability(capability_name)
, m_previous_id(PluginAuditManager::instance().current_plugin())
, m_previous_capability(PluginAuditManager::instance().current_capability()) , m_previous_capability(PluginAuditManager::instance().current_capability())
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots) , m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
, m_previous_root_frames(std::move(PluginAuditManager::m_root_frames))
{ {
PluginAuditManager::instance().set_current_plugin(plugin_key); PluginAuditManager::instance().set_current_plugin(plugin_key);
PluginAuditManager::instance().set_current_capability(capability_name); PluginAuditManager::instance().set_current_capability(capability_name);
PluginAuditManager::m_scoped_allowed_roots.clear(); PluginAuditManager::m_scoped_allowed_roots.clear();
PluginAuditManager::m_root_frames.clear();
} }
ScopedPluginAuditContext::~ScopedPluginAuditContext() ScopedPluginAuditContext::~ScopedPluginAuditContext()
{ {
PluginAuditManager::instance().set_current_plugin(m_previous_id); PluginAuditManager& mgr = PluginAuditManager::instance();
PluginAuditManager::instance().set_current_capability(m_previous_capability);
// Let this scope's root frames go while it is still the current context, and as its own
// plugin: the trampoline's denial path clears the key before the scope ends, and a finalizer
// must never be judged as the outer plugin, or bypass the audit when there is none. A
// finalizer may record a new root frame, so repeat until none is held.
if (!PluginAuditManager::m_root_frames.empty()) {
assert(PyGILState_Check());
mgr.set_current_plugin(m_plugin_key);
mgr.set_current_capability(m_capability);
while (!PluginAuditManager::m_root_frames.empty()) {
std::vector<PyObject*> frames = std::move(PluginAuditManager::m_root_frames);
PluginAuditManager::m_root_frames.clear();
mgr.release_frames(frames);
}
}
mgr.set_current_plugin(m_previous_id);
mgr.set_current_capability(m_previous_capability);
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots); PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
PluginAuditManager::m_root_frames = std::move(m_previous_root_frames);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -441,6 +498,12 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
candidate = absolute_candidate; candidate = absolute_candidate;
} }
// Importing Python modules reads their source and may inspect package directories. These
// reads are normal interpreter work, not plugin data access; keep the exemption read-only
// and exclude third-party packages from the stdlib roots.
if (!is_write && is_stdlib_path(candidate))
return {true, ""};
// A root that doesn't allow writes only matches a read-shaped request; a write/create/ // A root that doesn't allow writes only matches a read-shaped request; a write/create/
// delete-shaped one falls through to "outside allowed root" for that root even though the // delete-shaped one falls through to "outside allowed root" for that root even though the
// path is physically inside it. // path is physically inside it.
@@ -575,34 +638,6 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
return true; return true;
} }
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids) const
{
if (plugin_key.empty() || call_site_ids.empty())
return false;
std::lock_guard<std::mutex> lock(m_mutex);
auto it = m_approved_call_sites.find(plugin_key);
if (it == m_approved_call_sites.end())
return false;
for (const auto& id : call_site_ids)
if (it->second.count(id))
return true;
return false;
}
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids)
{
if (plugin_key.empty() || call_site_ids.empty())
return;
std::lock_guard<std::mutex> lock(m_mutex);
auto& approved = m_approved_call_sites[plugin_key];
approved.insert(call_site_ids.begin(), call_site_ids.end());
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The C-level audit hook // The C-level audit hook
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -663,40 +698,88 @@ std::string python_str(PyObject* object)
return result; return result;
} }
std::vector<std::string> call_site_identities(const std::string& plugin_root) // The interpreter's stdlib folders, and the site-packages folders inside them that hold
{ // third-party code, both resolved. Set by install_hook(); like the verdict cache below, only
std::vector<std::string> ids; // touched while the GIL is held.
if (plugin_root.empty()) static std::vector<boost::filesystem::path> s_stdlib_dirs;
return ids; static std::vector<boost::filesystem::path> s_site_package_dirs;
static std::unordered_map<std::string, bool> s_stdlib_file_verdicts; // co_filename -> is stdlib
void collect_stdlib_dirs()
{
s_stdlib_dirs.clear();
s_site_package_dirs.clear();
s_stdlib_file_verdicts.clear();
PyObject* sysconfig = PyImport_ImportModule("sysconfig");
PyObject* paths = sysconfig ? PyObject_CallMethod(sysconfig, "get_paths", nullptr) : nullptr;
if (paths && PyDict_Check(paths)) {
const auto collect = [paths](const char* key, std::vector<boost::filesystem::path>& out) {
const std::string dir = python_unicode(PyDict_GetItemString(paths, key)); // borrowed
if (dir.empty())
return;
boost::system::error_code ec;
const boost::filesystem::path resolved = boost::filesystem::weakly_canonical(dir, ec);
out.push_back(ec ? boost::filesystem::path(dir) : resolved);
};
collect("stdlib", s_stdlib_dirs);
collect("platstdlib", s_stdlib_dirs);
collect("purelib", s_site_package_dirs);
collect("platlib", s_site_package_dirs);
}
Py_XDECREF(paths);
Py_XDECREF(sysconfig);
if (PyErr_Occurred()) {
PyErr_Clear();
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to read the stdlib folders from sysconfig";
}
}
bool is_stdlib_frame(PyObject* frame)
{
PyCodeObject* code = PyFrame_GetCode(reinterpret_cast<PyFrameObject*>(frame)); // new reference
const std::string filename = python_unicode(code->co_filename);
Py_DECREF(code);
return PluginAuditManager::is_stdlib_file(filename);
}
// The frames on this thread's Python stack, innermost first. The pointers stay valid while the
// caller runs, because a running activation keeps its frame object alive.
std::vector<PyObject*> stack_frames()
{
std::vector<PyObject*> frames;
PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference
Py_XINCREF(frame); // normalize to an owned reference for the loop below Py_XINCREF(frame); // normalize to an owned reference for the loop below
while (frame) { while (frame) {
PyCodeObject* code = PyFrame_GetCode(frame); // new reference frames.push_back(reinterpret_cast<PyObject*>(frame));
const std::string filename = python_unicode(reinterpret_cast<PyObject*>(code->co_filename));
const bool is_plugin_frame = !filename.empty() && boost::algorithm::starts_with(filename, plugin_root);
std::string id;
if (!is_plugin_frame && !filename.empty()) {
const std::string funcname = python_unicode(reinterpret_cast<PyObject*>(code->co_name));
id = filename + ":" + funcname + ":" + std::to_string(code->co_firstlineno);
}
Py_DECREF(code);
PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack
Py_DECREF(frame); Py_DECREF(frame);
frame = back; frame = back;
if (is_plugin_frame)
break;
if (!id.empty())
ids.push_back(std::move(id));
} }
return frames;
}
Py_XDECREF(frame); // only holds a reference here if the loop exited via break // Moves the held root frames whose operation has returned from `held` to `dropped`. A suspended
// generator's frame (os.walk between two items) is off the stack, but its operation resumes, so
// it stays held until the scope ends.
void take_returned_frames(std::vector<PyObject*>& held, std::vector<PyObject*>& dropped)
{
if (held.empty())
return;
return ids; const std::vector<PyObject*> stack = stack_frames();
const auto running = [&stack](PyObject* frame) {
if (std::find(stack.begin(), stack.end(), frame) != stack.end())
return true;
// NULL once the generator has finished or been deleted: its frame then owns its own data.
PyObject* generator = PyFrame_GetGenerator(reinterpret_cast<PyFrameObject*>(frame)); // new reference
const bool suspended = generator != nullptr;
Py_XDECREF(generator);
return suspended;
};
const auto returned = std::stable_partition(held.begin(), held.end(), running);
dropped.insert(dropped.end(), returned, held.end());
held.erase(returned, held.end());
} }
static const std::unordered_set<std::string> two_path_fs_events{ static const std::unordered_set<std::string> two_path_fs_events{
@@ -717,12 +800,15 @@ static const std::unordered_map<std::string, std::vector<Py_ssize_t>> audit_targ
{"socket.getnameinfo", {0}}, {"socket.getnameinfo", {0}},
{"socket.getservbyname", {0}}, {"socket.getservbyname", {0}},
{"socket.getservbyport", {0}}, {"socket.getservbyport", {0}},
{"socket.sendto", {1}},
{"socket.sendmsg", {1}},
{"os.system", {0}}, {"os.system", {0}},
{"subprocess.Popen", {1, 0}}, {"subprocess.Popen", {1, 0}},
{"os.posix_spawn", {1, 0}}, {"os.posix_spawn", {1, 0}},
{"os.spawn", {2, 1}}, {"os.spawn", {2, 1}},
{"os.startfile", {0}}, {"os.startfile", {0}},
{"os.startfile/2", {0}},
{"pty.spawn", {0}}, {"pty.spawn", {0}},
{"_winapi.CreateProcess", {1, 0}}, {"_winapi.CreateProcess", {1, 0}},
{"_posixsubprocess.fork_exec", {0}}, {"_posixsubprocess.fork_exec", {0}},
@@ -767,7 +853,11 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
const auto it = audit_target_arg_indices.find(event_name); const auto it = audit_target_arg_indices.find(event_name);
if (it != audit_target_arg_indices.end()) { if (it != audit_target_arg_indices.end()) {
for (const Py_ssize_t index : it->second) { for (const Py_ssize_t index : it->second) {
std::string value = python_str(tuple_item(args, index)); // None is an argument left out (sendmsg without an address), not a target named "None".
PyObject* const item = tuple_item(args, index);
if (item == Py_None)
continue;
std::string value = python_str(item);
if (!value.empty()) { if (!value.empty()) {
targets.push_back(std::move(value)); targets.push_back(std::move(value));
break; break;
@@ -778,8 +868,14 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
return targets; return targets;
} }
std::vector<std::string>* permission_list_for(AuditEventCategory category, PluginPermissions& permissions) // The saved list that grants an event: its category's target list, or the event-grant list for
// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an
// os.fork grant is not an os.exec of a command named "os.fork").
std::vector<std::string>* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions)
{ {
if (by_event)
return &permissions.events;
switch (category) { switch (category) {
case AuditEventCategory::FsRead: return &permissions.fs_read; case AuditEventCategory::FsRead: return &permissions.fs_read;
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite; case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
@@ -792,6 +888,13 @@ std::vector<std::string>* permission_list_for(AuditEventCategory category, Plugi
} }
} }
// An event with no target is granted by its effective category and name, e.g. "FsRead:open"
// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open").
std::string event_grant_key(AuditEventCategory category, const std::string& event_name)
{
return std::string(category_name(category)) + ":" + event_name;
}
bool has_permission(const std::vector<std::string>& granted, const std::string& target) bool has_permission(const std::vector<std::string>& granted, const std::string& target)
{ {
return std::find(granted.begin(), granted.end(), target) != granted.end(); return std::find(granted.begin(), granted.end(), target) != granted.end();
@@ -902,22 +1005,41 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state // Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
// freshly because the async prompt outlives the caller's stack copy of it. // freshly because the async prompt outlives the caller's stack copy of it.
void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector<std::string>& targets) void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
{ {
PluginInstallState state; PluginInstallState state;
if (!PluginManager::instance().get_install_state(plugin_key, state)) if (!PluginManager::instance().get_install_state(plugin_key, state))
return; return;
std::vector<std::string>* permission_list = permission_list_for(category, state.permissions); std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
if (!permission_list) if (!permission_list)
return; return;
for (const auto& target : targets) for (const auto& key : keys)
persist_permission(plugin_key, state, *permission_list, target); persist_permission(plugin_key, state, *permission_list, key);
} }
// Requests permission for an audited event, returning true when it is already granted or the user static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
// approves an inline prompt.
static const char* audit_category_name(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead: return "FsRead";
case AuditEventCategory::FsReadWrite: return "FsReadWrite";
case AuditEventCategory::FsCreate: return "FsCreate";
case AuditEventCategory::FsDelete: return "FsDelete";
case AuditEventCategory::Http: return "Http";
case AuditEventCategory::Socket: return "Socket";
case AuditEventCategory::ProcessCreate: return "ProcessCreate";
case AuditEventCategory::ProcessReplace: return "ProcessReplace";
case AuditEventCategory::Threading: return "Threading";
default: return "None";
}
}
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
// no target (by_event), which the prompt does not show as a target.
// //
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the // An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and // SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
@@ -929,56 +1051,227 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, c
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without // prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
// re-prompting. // re-prompting.
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name, bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
const std::string& event_name, const std::vector<std::string>& unresolved) const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
{ {
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
std::string targets_text = by_event ? "<none>" : "";
for (const auto& target : shown) {
if (!targets_text.empty())
targets_text += ", ";
targets_text += target;
}
BOOST_LOG_TRIVIAL(info) << "[AUDIT PROMPT] plugin=" << plugin_key << " event=" << event_name
<< " category=" << audit_category_name(category) << " targets=[" << targets_text << "]"
<< (by_event && !keys.empty() ? " grant=" + keys.front() : std::string{});
if (s_permission_prompt_for_testing)
return s_permission_prompt_for_testing(event_name, shown);
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false; return false;
if (wxIsMainThread()) if (wxIsMainThread())
return prompt_for_targets(category, plugin_name, event_name, unresolved); return prompt_for_targets(category, plugin_name, event_name, shown);
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() { GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return; return;
if (prompt_for_targets(category, plugin_name, event_name, unresolved)) if (prompt_for_targets(category, plugin_name, event_name, shown))
persist_grant(plugin_key, category, unresolved); persist_grant(plugin_key, category, by_event, keys);
}); });
return false; return false;
} }
// Decides an event that no allowed root or running operation covers: a saved grant allows it,
// otherwise the user is asked and a Yes is saved.
int decide_audited_event(PluginAuditManager& mgr, int decide_audited_event(PluginAuditManager& mgr,
PluginInstallState& state, PluginInstallState& state,
const std::string& plugin_key, const std::string& plugin_key,
const std::string& plugin_name, const std::string& plugin_name,
const std::string& event_name, const std::string& event_name,
AuditEventCategory category, AuditEventCategory category,
const std::vector<std::string>& targets, const std::vector<std::string>& targets)
std::vector<std::string>* permission_list,
const std::vector<std::string>& call_site_ids)
{ {
std::vector<std::string> unresolved = targets; const bool by_event = targets.empty();
const std::vector<std::string> keys = by_event ? std::vector<std::string>{event_grant_key(category, event_name)} : targets;
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
std::vector<std::string> unresolved = keys;
if (permission_list) { if (permission_list) {
unresolved.clear(); unresolved.clear();
for (const auto& target : targets) for (const auto& key : keys)
if (!has_permission(*permission_list, target)) if (!has_permission(*permission_list, key))
unresolved.push_back(target); unresolved.push_back(key);
if (!targets.empty() && unresolved.empty()) if (unresolved.empty())
return 0; return 0;
} }
if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved)) // Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
// where no dialog may open: what they do must already be granted.
if (mgr.releasing_frames())
return report_denied(mgr, event_name, {false, "audit permission required"});
if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved))
return report_denied(mgr, event_name, {false, "audit permission required"}); return report_denied(mgr, event_name, {false, "audit permission required"});
if (permission_list) if (permission_list)
for (const auto& target : unresolved) for (const auto& key : unresolved)
persist_permission(plugin_key, state, *permission_list, target); persist_permission(plugin_key, state, *permission_list, key);
mgr.record_approved_call_sites(plugin_key, call_site_ids);
return 0; return 0;
} }
// Judges an audited event raised while a plugin context is active.
int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args)
{
const bool fs_category = is_fs_category(event_type);
const std::vector<std::string> targets = audit_targets(event_name, event_type, args);
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
// unconditional block: checked before the running-operation check below, so no granted
// operation can launder access to one, and before the allowed-root shortcut further down,
// so an allowed root (e.g. the read-only resources folder) cannot make a denied path
// underneath it reachable.
if (fs_category) {
for (const auto& target : targets) {
if (mgr.is_denied_path(boost::filesystem::path(target)))
return report_denied(mgr, event_name, {false, "denied path"}, target);
}
}
// A nested event of an operation already granted, e.g. socket.connect inside an approved
// urlopen(), is not asked again. urllib.Request is always judged on its own URL, so a
// redirect or a nested urlopen() inside a running request is not covered by it.
if (event_name != "urllib.Request" && mgr.covered_by_root_frame())
return 0;
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
// installed/system profile cache), the read-only bundled resources folder, or a per-call
// scoped root such as the current G-code folder -- is part of the plugin system's normal
// workflow and does not need a prompt.
if (fs_category && !targets.empty()) {
const bool is_write = event_type != AuditEventCategory::FsRead;
const bool all_inside_allowed_root =
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
return mgr.check_path_access(boost::filesystem::path(target), is_write).allowed;
});
if (all_inside_allowed_root)
return 0;
}
PluginInstallState state;
const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state);
if (!have_install_state) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin();
}
const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name;
const int result = decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, event_type, targets);
// A granted operation covers its nested events while it runs. A targetless grant names no
// operation, so it covers nothing else.
if (result == 0 && !targets.empty() && operation_root_events.count(event_name))
mgr.record_root_frame();
return result;
}
} // namespace PluginAuditDetail } // namespace PluginAuditDetail
void PluginAuditManager::record_root_frame()
{
PyObject* frame = reinterpret_cast<PyObject*>(PyEval_GetFrame()); // borrowed reference
if (!frame || !PluginAuditDetail::is_stdlib_frame(frame))
return;
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
return; // glob.glob and glob.glob/2 share a frame
Py_INCREF(frame);
m_root_frames.push_back(frame);
}
bool PluginAuditManager::covered_by_root_frame() const
{
if (m_root_frames.empty())
return false;
// Only stdlib frames may sit between the event and the operation: plugin or library code
// the operation calls back into (an os.walk onerror, a urllib handler) acts on its own.
for (PyObject* frame : PluginAuditDetail::stack_frames()) {
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
return true;
if (!PluginAuditDetail::is_stdlib_frame(frame))
return false;
}
return false;
}
bool PluginAuditManager::releasing_frames() const { return m_releasing_frames > 0; }
bool PluginAuditManager::is_stdlib_file(const std::string& filename)
{
// Frozen stdlib modules (os, posixpath, codecs, ...) carry a pseudo filename.
if (boost::algorithm::starts_with(filename, "<frozen "))
return true;
if (filename.empty() || filename.front() == '<')
return false;
// Both sides are resolved: co_filename follows the Python home as it was given, which may
// be a symlink.
const auto [it, inserted] = PluginAuditDetail::s_stdlib_file_verdicts.try_emplace(filename, false);
if (inserted) {
const boost::filesystem::path file(filename);
const auto inside_any = [&file](const std::vector<boost::filesystem::path>& dirs) {
return std::any_of(dirs.begin(), dirs.end(),
[&file](const boost::filesystem::path& dir) { return is_inside_allowed_root(file, dir); });
};
it->second = inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
}
return it->second;
}
bool PluginAuditManager::is_stdlib_path(const boost::filesystem::path& path)
{
if (path.empty())
return false;
const auto inside_any = [&path](const std::vector<boost::filesystem::path>& dirs) {
return std::any_of(dirs.begin(), dirs.end(),
[&path](const boost::filesystem::path& dir) { return is_inside_allowed_root(path, dir); });
};
return inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
}
void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
{
if (frames.empty())
return;
// A finalizer's own denial must neither replace the PermissionError of the event being
// decided nor leave a stale denial behind for the next call.
PyObject* const error = PyErr_GetRaisedException();
const bool denial_pending = m_audit_denial_pending;
const bool has_violation = m_has_last_violation;
const AuditViolation violation = m_last_violation;
std::vector<PyObject*> released;
released.swap(frames);
++m_releasing_frames;
for (PyObject* frame : released)
Py_DECREF(frame);
--m_releasing_frames;
m_audit_denial_pending = denial_pending;
m_has_last_violation = has_violation;
m_last_violation = violation;
PyErr_SetRaisedException(error);
}
void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt)
{
PluginAuditDetail::s_permission_prompt_for_testing = std::move(prompt);
}
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
{ {
auto* mgr = static_cast<PluginAuditManager*>(user_data); auto* mgr = static_cast<PluginAuditManager*>(user_data);
@@ -1006,62 +1299,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
if (event_type == AuditEventCategory::None) if (event_type == AuditEventCategory::None)
return 0; return 0;
const bool fs_category = is_fs_category(event_type); // Root frames whose operation has returned are let go once this event is decided, so that a
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args); // finalizer they run re-enters a hook whose held frames are already consistent.
std::vector<PyObject*> returned_frames;
// A denied path (secrets, certificates, config files -- see is_denied_path) is an PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames);
// unconditional block: checked before the ancestor-cascade check below, so a cascade const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args);
// approval recorded for an unrelated action can never launder access to one, and before mgr->release_frames(returned_frames);
// the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources return result;
// folder) cannot make a denied path underneath it reachable.
if (fs_category) {
for (const auto& target : targets) {
if (mgr->is_denied_path(boost::filesystem::path(target)))
return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target);
}
}
PluginDescriptor plugin_descriptor;
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
// was already approved, socket.connect won't trigger another permission dialog.
const std::vector<std::string> call_site_ids =
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
return 0;
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
// installed/system profile cache), the read-only bundled resources folder, or a per-call
// scoped root such as the current G-code folder -- is part of the plugin system's normal
// workflow and does not need a prompt.
if (fs_category && !targets.empty()) {
const bool is_write = event_type != AuditEventCategory::FsRead;
const bool all_inside_allowed_root =
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed;
});
if (all_inside_allowed_root)
return 0;
}
PluginInstallState state;
const bool have_install_state = PluginManager::instance().get_install_state(mgr->current_plugin(), state);
if (!have_install_state) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr->current_plugin();
}
const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name;
std::vector<std::string>* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions);
return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name,
event_type, targets, permission_list, call_site_ids);
} }
void PluginAuditManager::install_hook() void PluginAuditManager::install_hook()
{ {
// Identifies stdlib frames for operation roots and stdlib paths for read-only access.
PluginAuditDetail::collect_stdlib_dirs();
// data_dir() is the primary globally-allowed root during plugin execution: read+write. It // data_dir() is the primary globally-allowed root during plugin execution: read+write. It
// covers the plugin system's own workflow needs -- each plugin's storage folder // covers the plugin system's own workflow needs -- each plugin's storage folder
// (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) -- // (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more