mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-19 21:51:06 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddaf9ba5bc | ||
|
|
c5cfe40a1f | ||
|
|
e5b6cbf091 | ||
|
|
3b9b871ba6 | ||
|
|
cb2260f057 | ||
|
|
3fcbb2adf9 | ||
|
|
252bbbf9f5 | ||
|
|
4c2ae6d202 | ||
|
|
ac022b56ad |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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
|
||||
@@ -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
|
||||
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),
|
||||
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.
|
||||
|
||||
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;
|
||||
- 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
|
||||
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:
|
||||
- `scripts/run_gettext.sh` / `.bat` for extraction and merging;
|
||||
- `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.
|
||||
|
||||
## Editing this skill
|
||||
|
||||
@@ -28,13 +28,14 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[4]
|
||||
I18N = REPO / "localization" / "i18n"
|
||||
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"
|
||||
MISSING = {"", "—"}
|
||||
RAW_URL = "https://raw.githubusercontent.com/OrcaSlicer/OrcaSlicer_WIKI/main/"
|
||||
@@ -252,30 +253,40 @@ def all_langs():
|
||||
|
||||
def find_wiki(arg):
|
||||
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 None
|
||||
|
||||
|
||||
def _download(rel):
|
||||
cache = Path(tempfile.gettempdir()) / ("orca_glossary_" + rel.name)
|
||||
def _download(rel, required=True):
|
||||
cache = Path(tempfile.gettempdir()) / "orca_glossary" / rel
|
||||
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:
|
||||
cache.write_bytes(r.read())
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
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.langs = langs # only these languages' files are read (and downloaded); None means all
|
||||
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:
|
||||
self.report_clone_state()
|
||||
else:
|
||||
if not quiet:
|
||||
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()
|
||||
|
||||
def report_clone_state(self):
|
||||
@@ -290,11 +301,23 @@ class Glossary:
|
||||
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)
|
||||
|
||||
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):
|
||||
rows = [l.split("\t") for l in self.tsv.read_text(encoding="utf-8-sig").splitlines() if l.strip()]
|
||||
self.header = rows[0]
|
||||
# "—" marks a language with no established rendering yet
|
||||
self.rows = [[c if c.strip() not in MISSING else "" for c in r] + [""] * (len(rows[0]) - len(r)) for r in rows[1:]]
|
||||
terms = [l.split(" ") for l in self._read("terms.tsv")[1:] if l.strip()]
|
||||
langs = [l.strip() for l in self._read("languages.txt") if l.strip()]
|
||||
langs = [l for l in langs if self.langs is None or l in self.langs]
|
||||
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 = []
|
||||
for row in self.rows:
|
||||
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):
|
||||
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)]
|
||||
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
|
||||
@@ -533,7 +556,7 @@ def cmd_pending(a):
|
||||
|
||||
def cmd_apply(a):
|
||||
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"))
|
||||
items = data["entries"] if isinstance(data, dict) else data
|
||||
idx = c.index()
|
||||
@@ -588,7 +611,7 @@ def find_msgfmt():
|
||||
|
||||
|
||||
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()
|
||||
status = 0
|
||||
for lang in a.lang or all_langs():
|
||||
@@ -646,7 +669,7 @@ def cmd_lookup(a):
|
||||
|
||||
|
||||
def cmd_glossary(a):
|
||||
g = Glossary(a.wiki)
|
||||
g = Glossary(a.wiki, langs=[a.lang])
|
||||
col = g.column(a.lang)
|
||||
if col is None:
|
||||
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 ''}")
|
||||
|
||||
|
||||
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):
|
||||
wiki = find_wiki(a.wiki)
|
||||
if not wiki:
|
||||
@@ -741,8 +775,21 @@ def cmd_glossary_set(a):
|
||||
f" {a.term}: " + ", ".join(a.set) + "\n"
|
||||
"Clone https://github.com/OrcaSlicer/OrcaSlicer_WIKI next to this repository, or pass --wiki.", file=sys.stderr)
|
||||
return 2
|
||||
tsv = wiki / GLOSSARY_TSV
|
||||
lock = tsv.with_suffix(".lock")
|
||||
gdir = wiki / GLOSSARY_DIR
|
||||
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):
|
||||
try:
|
||||
fd = os.open(lock, os.O_CREAT | os.O_EXCL)
|
||||
@@ -752,50 +799,51 @@ def cmd_glossary_set(a):
|
||||
else:
|
||||
sys.exit(f"could not lock {lock}; remove it if no other run is active")
|
||||
try:
|
||||
data = tsv.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()
|
||||
header = lines[0].split("\t")
|
||||
values = {}
|
||||
for s in a.set:
|
||||
lang, _, val = s.partition("=")
|
||||
if lang not in header:
|
||||
sys.exit(f"no glossary column '{lang}'")
|
||||
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]
|
||||
terms = (gdir / "terms.tsv").read_text(encoding="utf-8-sig").splitlines()
|
||||
new = not any(l.split("\t")[0] == a.term for l in terms[1:])
|
||||
if new and not a.description:
|
||||
sys.exit("a new term needs --description (one sentence saying what it is)")
|
||||
# an established translation is only replaced on request
|
||||
conflicts = []
|
||||
for lang, v in values.items():
|
||||
for line in (gdir / f"{lang}.tsv").read_text(encoding="utf-8-sig").splitlines()[1:]:
|
||||
cells = line.split("\t")
|
||||
if cells[0] == a.term and len(cells) > 2 and cells[2].strip() not in MISSING and cells[2] != v:
|
||||
conflicts.append(f"{lang}: '{cells[2]}' -> '{v}'")
|
||||
if conflicts and not a.replace:
|
||||
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
|
||||
lines[row_i] = "\t".join(row)
|
||||
tsv.write_bytes((b"\xef\xbb\xbf" if bom else b"") + (nl.join(lines) + nl).encode("utf-8"))
|
||||
|
||||
def edit_terms(lines):
|
||||
for i, l in enumerate(lines):
|
||||
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:
|
||||
os.close(fd)
|
||||
os.unlink(lock)
|
||||
print(f"glossary: {'added' if new else 'updated'} '{a.term}'")
|
||||
gen = wiki / "generate_glossary.py"
|
||||
if gen.exists():
|
||||
r = subprocess.run([sys.executable, str(gen), "--update"], cwd=wiki, capture_output=True, text=True)
|
||||
print((r.stdout + r.stderr).strip())
|
||||
return r.returncode
|
||||
return 0
|
||||
r = run_gen("--update")
|
||||
print((r.stdout + r.stderr).strip())
|
||||
return r.returncode
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -91,7 +91,7 @@ See `.claude/skills/orca-translations/references/source-audit.md` for the full c
|
||||
### 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.
|
||||
- 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.
|
||||
- Reuse one template per recurring message shape (`Failed to connect to …`, `Are you sure you want to …?`), even where the English wording varies.
|
||||
|
||||
|
||||
Vendored
+17
-7
@@ -16,6 +16,8 @@ else()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(_openssl_dir ${DESTDIR})
|
||||
|
||||
if(WIN32)
|
||||
set(_openssl_msvc_env CC=cl CXX=cl RC=rc CL=/FS)
|
||||
# OpenSSL's perl Configure honors the CC environment variable, but the
|
||||
@@ -43,6 +45,14 @@ if(WIN32)
|
||||
else()
|
||||
if(APPLE)
|
||||
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()
|
||||
# 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
|
||||
@@ -81,7 +91,7 @@ ExternalProject_Add(dep_OpenSSL
|
||||
# URL_HASH SHA256=8c776993154652d0bb393f506d850b811517c8bd8d24b1008aef57fbe55d3f31
|
||||
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/OpenSSL
|
||||
CONFIGURE_COMMAND ${_conf_cmd} ${_cross_arch}
|
||||
"--openssldir=${DESTDIR}"
|
||||
"--openssldir=${_openssl_dir}"
|
||||
"--prefix=${DESTDIR}"
|
||||
# OpenSSL's linux-x86_64 target sets multilib=64, so it installs to
|
||||
# <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}"
|
||||
)
|
||||
|
||||
if (NOT WIN32 AND NOT APPLE)
|
||||
# OpenSSL's object rules do not depend on CFLAGS, so reconfiguring it (for
|
||||
# example to add -fvisibility=hidden) relinks the archives from stale
|
||||
# objects instead of recompiling them, and the change silently has no
|
||||
# effect. Drop the objects whenever this recipe changes so the next build
|
||||
# actually recompiles them.
|
||||
if (NOT WIN32)
|
||||
# OpenSSL's object rules do not depend on the configured flags or paths, so
|
||||
# reconfiguring it (a new compiler flag, a different --openssldir) relinks
|
||||
# the archives from stale objects instead of recompiling them, and the
|
||||
# change silently has no effect. Drop the objects whenever this recipe
|
||||
# changes so the next build actually recompiles them.
|
||||
ExternalProject_Get_Property(dep_OpenSSL SOURCE_DIR)
|
||||
ExternalProject_Add_Step(dep_OpenSSL clean_objects
|
||||
DEPENDEES configure
|
||||
|
||||
Vendored
+5
-4
@@ -365,10 +365,11 @@ if(TARGET dep_ZLIB)
|
||||
add_dependencies(dep_python3 dep_ZLIB)
|
||||
endif()
|
||||
|
||||
if (NOT WIN32 AND NOT APPLE)
|
||||
# CPython's Makefile rules for _ssl and _hashlib depend only on their own
|
||||
# sources, not on the OpenSSL archives, so a rebuilt OpenSSL does not make
|
||||
# them relink and they keep the previous symbols. On an incremental tree,
|
||||
if (NOT WIN32)
|
||||
# _ssl and _hashlib embed the static OpenSSL built in deps/ (Windows uses
|
||||
# CPython's prebuilt OpenSSL instead). Their Makefile rules depend only on
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\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"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -1637,16 +1637,16 @@ msgstr ""
|
||||
msgid "Stroke of shape (%1%) contains unsupported: %2%."
|
||||
msgstr ""
|
||||
|
||||
msgid "Not valid state please report reproduction steps on github"
|
||||
msgid "Not valid state please report reproduction steps on GitHub"
|
||||
msgstr ""
|
||||
|
||||
msgid "No embossed file"
|
||||
msgstr ""
|
||||
|
||||
msgid "Missing svg file in embossed shape"
|
||||
msgid "Missing SVG file in embossed shape"
|
||||
msgstr ""
|
||||
|
||||
msgid "Missing data of svg file"
|
||||
msgid "Missing data of SVG file"
|
||||
msgstr ""
|
||||
|
||||
msgid "Face the camera"
|
||||
@@ -4579,7 +4579,7 @@ msgstr ""
|
||||
msgid "Unknown C++ exception."
|
||||
msgstr ""
|
||||
|
||||
msgid "Processing G-Code from previous file…"
|
||||
msgid "Processing G-code from previous file…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Slicing complete"
|
||||
@@ -6049,7 +6049,7 @@ msgstr ""
|
||||
msgid "Size:"
|
||||
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)."
|
||||
msgstr ""
|
||||
|
||||
@@ -12318,7 +12318,7 @@ msgstr ""
|
||||
msgid "Left mouse"
|
||||
msgstr ""
|
||||
|
||||
msgid "Available anywhere in the window, even while typing in a text field."
|
||||
msgid "Available anywhere in the window."
|
||||
msgstr ""
|
||||
|
||||
msgid "Run favorite 1 to 10"
|
||||
@@ -12415,9 +12415,6 @@ msgstr ""
|
||||
msgid "The default %s is assigned to %s. Reassign it to %s?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Global shortcuts also apply while typing."
|
||||
msgstr ""
|
||||
|
||||
msgid "A key that types a character cannot be a global shortcut."
|
||||
msgstr ""
|
||||
|
||||
@@ -15648,6 +15645,12 @@ msgstr ""
|
||||
msgid "Enable this option to add EXCLUDE OBJECT command in G-code."
|
||||
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"
|
||||
msgstr ""
|
||||
|
||||
@@ -20543,7 +20546,7 @@ msgid "test %s start..."
|
||||
msgstr ""
|
||||
|
||||
#, possible-c-format, possible-boost-format
|
||||
msgid "test %s ip resolved = %s"
|
||||
msgid "test %s IP resolved = %s"
|
||||
msgstr ""
|
||||
|
||||
#, possible-c-format, possible-boost-format
|
||||
@@ -26495,17 +26498,18 @@ msgstr ""
|
||||
msgid "Basic Colors"
|
||||
msgstr ""
|
||||
|
||||
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
|
||||
msgid "Orca Palette"
|
||||
msgstr ""
|
||||
|
||||
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
|
||||
#. TRN: Keep the standard/brand name "RAL" unchanged.
|
||||
msgid "RAL Classic"
|
||||
msgstr ""
|
||||
|
||||
#. TRN: Keep the standard/brand name "RAL" unchanged.
|
||||
msgid "Gradients"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "Color picker"
|
||||
msgid "Solid"
|
||||
msgstr ""
|
||||
|
||||
@@ -26880,7 +26884,7 @@ msgstr ""
|
||||
msgid "Medium violet red"
|
||||
msgstr ""
|
||||
|
||||
msgid "PaleV violet red"
|
||||
msgid "Pale violet red"
|
||||
msgstr ""
|
||||
|
||||
msgid "Deep pink"
|
||||
@@ -27042,7 +27046,7 @@ msgstr ""
|
||||
msgid "Shallow sky"
|
||||
msgstr ""
|
||||
|
||||
msgid "Electric bel"
|
||||
msgid "Electric blue"
|
||||
msgstr ""
|
||||
|
||||
msgid "Dolphin"
|
||||
@@ -27111,7 +27115,7 @@ msgstr ""
|
||||
msgid "Maize yellow"
|
||||
msgstr ""
|
||||
|
||||
msgid "Dalmatian yellow"
|
||||
msgid "Daffodil yellow"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brown beige"
|
||||
@@ -27372,7 +27376,7 @@ msgstr ""
|
||||
msgid "Turquoise blue"
|
||||
msgstr ""
|
||||
|
||||
msgid "Capsanthin violet"
|
||||
msgid "Capri blue"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ocean blue"
|
||||
@@ -27486,7 +27490,7 @@ msgstr ""
|
||||
msgid "Silver grey"
|
||||
msgstr ""
|
||||
|
||||
msgid "Olivestone"
|
||||
msgid "Olive grey"
|
||||
msgstr ""
|
||||
|
||||
msgid "Moss grey"
|
||||
@@ -28074,10 +28078,19 @@ msgstr ""
|
||||
msgid "Built-in"
|
||||
msgstr ""
|
||||
|
||||
msgid "My textures"
|
||||
msgstr ""
|
||||
|
||||
msgid "No textures found."
|
||||
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 ""
|
||||
|
||||
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."
|
||||
@@ -30130,6 +30143,9 @@ msgstr ""
|
||||
msgid "Slicing Pipeline"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resource Texture"
|
||||
msgstr ""
|
||||
|
||||
msgid "The plugin's configuration UI was empty. Showing the default editor."
|
||||
msgstr ""
|
||||
|
||||
@@ -30245,6 +30261,24 @@ msgstr ""
|
||||
msgid "Plugin directory does not contain a .py or .whl entry file"
|
||||
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
|
||||
msgid "Slicing pipeline plugin '%1%' error: %2%"
|
||||
msgstr ""
|
||||
@@ -30601,7 +30635,7 @@ msgstr ""
|
||||
|
||||
#. TRN %1% is the application name.
|
||||
#, possible-boost-format
|
||||
msgid "Not a gcode file generated by %1%."
|
||||
msgid "Not a G-code file generated by %1%."
|
||||
msgstr ""
|
||||
|
||||
#. TRN %1% is a tag in the G-code file that must not be translated.
|
||||
@@ -30689,7 +30723,7 @@ msgstr ""
|
||||
msgid "Failed making a temporary copy of G-code file %1% before running a post-processing script: %2%"
|
||||
msgstr ""
|
||||
|
||||
msgid "Post-processor can't find exported gcode file"
|
||||
msgid "Post-processor can't find exported G-code file"
|
||||
msgstr ""
|
||||
|
||||
#, 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
@@ -3,7 +3,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Orca Slicer\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"
|
||||
"Last-Translator: Alexandre Folle de Menezes\n"
|
||||
"Language-Team: \n"
|
||||
@@ -1633,16 +1633,16 @@ msgstr ""
|
||||
msgid "Stroke of shape (%1%) contains unsupported: %2%."
|
||||
msgstr ""
|
||||
|
||||
msgid "Not valid state please report reproduction steps on github"
|
||||
msgid "Not valid state please report reproduction steps on GitHub"
|
||||
msgstr ""
|
||||
|
||||
msgid "No embossed file"
|
||||
msgstr ""
|
||||
|
||||
msgid "Missing svg file in embossed shape"
|
||||
msgid "Missing SVG file in embossed shape"
|
||||
msgstr ""
|
||||
|
||||
msgid "Missing data of svg file"
|
||||
msgid "Missing data of SVG file"
|
||||
msgstr ""
|
||||
|
||||
msgid "Face the camera"
|
||||
@@ -4575,7 +4575,7 @@ msgstr ""
|
||||
msgid "Unknown C++ exception."
|
||||
msgstr ""
|
||||
|
||||
msgid "Processing G-Code from previous file…"
|
||||
msgid "Processing G-code from previous file…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Slicing complete"
|
||||
@@ -6045,7 +6045,7 @@ msgstr ""
|
||||
msgid "Size:"
|
||||
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)."
|
||||
msgstr ""
|
||||
|
||||
@@ -10653,6 +10653,9 @@ msgstr ""
|
||||
msgid "Send Failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "No available printers"
|
||||
msgstr ""
|
||||
|
||||
msgid "The name length exceeds the limit."
|
||||
msgstr ""
|
||||
|
||||
@@ -12311,7 +12314,7 @@ msgstr ""
|
||||
msgid "Left mouse"
|
||||
msgstr ""
|
||||
|
||||
msgid "Available anywhere in the window, even while typing in a text field."
|
||||
msgid "Available anywhere in the window."
|
||||
msgstr ""
|
||||
|
||||
msgid "Run favorite 1 to 10"
|
||||
@@ -12408,9 +12411,6 @@ msgstr ""
|
||||
msgid "The default %s is assigned to %s. Reassign it to %s?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Global shortcuts also apply while typing."
|
||||
msgstr ""
|
||||
|
||||
msgid "A key that types a character cannot be a global shortcut."
|
||||
msgstr ""
|
||||
|
||||
@@ -15641,6 +15641,12 @@ msgstr ""
|
||||
msgid "Enable this option to add EXCLUDE OBJECT command in G-code."
|
||||
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"
|
||||
msgstr ""
|
||||
|
||||
@@ -20536,7 +20542,7 @@ msgid "test %s start..."
|
||||
msgstr ""
|
||||
|
||||
#, c-format, boost-format
|
||||
msgid "test %s ip resolved = %s"
|
||||
msgid "test %s IP resolved = %s"
|
||||
msgstr ""
|
||||
|
||||
#, c-format, boost-format
|
||||
@@ -26488,17 +26494,18 @@ msgstr ""
|
||||
msgid "Basic Colors"
|
||||
msgstr ""
|
||||
|
||||
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
|
||||
msgid "Orca Palette"
|
||||
msgstr ""
|
||||
|
||||
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
|
||||
#. TRN: Keep the standard/brand name "RAL" unchanged.
|
||||
msgid "RAL Classic"
|
||||
msgstr ""
|
||||
|
||||
#. TRN: Keep the standard/brand name "RAL" unchanged.
|
||||
msgid "Gradients"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "Color picker"
|
||||
msgid "Solid"
|
||||
msgstr ""
|
||||
|
||||
@@ -26873,7 +26880,7 @@ msgstr ""
|
||||
msgid "Medium violet red"
|
||||
msgstr ""
|
||||
|
||||
msgid "PaleV violet red"
|
||||
msgid "Pale violet red"
|
||||
msgstr ""
|
||||
|
||||
msgid "Deep pink"
|
||||
@@ -27035,7 +27042,7 @@ msgstr ""
|
||||
msgid "Shallow sky"
|
||||
msgstr ""
|
||||
|
||||
msgid "Electric bel"
|
||||
msgid "Electric blue"
|
||||
msgstr ""
|
||||
|
||||
msgid "Dolphin"
|
||||
@@ -27104,7 +27111,7 @@ msgstr ""
|
||||
msgid "Maize yellow"
|
||||
msgstr ""
|
||||
|
||||
msgid "Dalmatian yellow"
|
||||
msgid "Daffodil yellow"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brown beige"
|
||||
@@ -27365,7 +27372,7 @@ msgstr ""
|
||||
msgid "Turquoise blue"
|
||||
msgstr ""
|
||||
|
||||
msgid "Capsanthin violet"
|
||||
msgid "Capri blue"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ocean blue"
|
||||
@@ -27479,7 +27486,7 @@ msgstr ""
|
||||
msgid "Silver grey"
|
||||
msgstr ""
|
||||
|
||||
msgid "Olivestone"
|
||||
msgid "Olive grey"
|
||||
msgstr ""
|
||||
|
||||
msgid "Moss grey"
|
||||
@@ -28067,10 +28074,19 @@ msgstr ""
|
||||
msgid "Built-in"
|
||||
msgstr ""
|
||||
|
||||
msgid "My textures"
|
||||
msgstr ""
|
||||
|
||||
msgid "No textures found."
|
||||
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 ""
|
||||
|
||||
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"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resource Texture"
|
||||
msgstr ""
|
||||
|
||||
msgid "The plugin's configuration UI was empty. Showing the default editor."
|
||||
msgstr ""
|
||||
|
||||
@@ -30238,6 +30257,24 @@ msgstr ""
|
||||
msgid "Plugin directory does not contain a .py or .whl entry file"
|
||||
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
|
||||
msgid "Slicing pipeline plugin '%1%' error: %2%"
|
||||
msgstr ""
|
||||
@@ -30594,7 +30631,7 @@ msgstr ""
|
||||
|
||||
#. TRN %1% is the application name.
|
||||
#, boost-format
|
||||
msgid "Not a gcode file generated by %1%."
|
||||
msgid "Not a G-code file generated by %1%."
|
||||
msgstr ""
|
||||
|
||||
#. 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%"
|
||||
msgstr ""
|
||||
|
||||
msgid "Post-processor can't find exported gcode file"
|
||||
msgid "Post-processor can't find exported G-code file"
|
||||
msgstr ""
|
||||
|
||||
#, boost-format
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1046
-549
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
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Slic3rPE\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"
|
||||
"Last-Translator: Handle <mail@bysb.net>\n"
|
||||
"Language-Team: \n"
|
||||
@@ -1751,7 +1751,7 @@ msgid "Stroke of shape (%1%) contains unsupported: %2%."
|
||||
msgstr "形状(%1%)的描边包含不受支持的内容:%2%。"
|
||||
|
||||
# AI Translated
|
||||
msgid "Not valid state please report reproduction steps on github"
|
||||
msgid "Not valid state please report reproduction steps on GitHub"
|
||||
msgstr "状态无效,请在 GitHub 上报告复现步骤"
|
||||
|
||||
# AI Translated
|
||||
@@ -1759,11 +1759,11 @@ msgid "No embossed file"
|
||||
msgstr "没有浮雕文件"
|
||||
|
||||
# AI Translated
|
||||
msgid "Missing svg file in embossed shape"
|
||||
msgid "Missing SVG file in embossed shape"
|
||||
msgstr "浮雕形状中缺少 SVG 文件"
|
||||
|
||||
# AI Translated
|
||||
msgid "Missing data of svg file"
|
||||
msgid "Missing data of SVG file"
|
||||
msgstr "缺少 SVG 文件数据"
|
||||
|
||||
# AI Translated
|
||||
@@ -5008,7 +5008,7 @@ msgid "Unknown C++ exception."
|
||||
msgstr "未知的 C++ 异常。"
|
||||
|
||||
# AI Translated
|
||||
msgid "Processing G-Code from previous file…"
|
||||
msgid "Processing G-code from previous file…"
|
||||
msgstr "正在处理之前文件的 G-code…"
|
||||
|
||||
msgid "Slicing complete"
|
||||
@@ -6699,7 +6699,7 @@ msgstr "体积:"
|
||||
msgid "Size:"
|
||||
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)."
|
||||
msgstr "发现G-code路径在层%d,高度为%.2lf mm处有冲突。请将有冲突的对象分离得更远(%s <-> %s)。"
|
||||
|
||||
@@ -12013,6 +12013,10 @@ msgstr "上传文件到打印机存储失败,请重试。"
|
||||
msgid "Send Failed"
|
||||
msgstr "发送失败"
|
||||
|
||||
# AI Translated
|
||||
msgid "No available printers"
|
||||
msgstr "没有可用的打印机"
|
||||
|
||||
msgid "The name length exceeds the limit."
|
||||
msgstr "名称长度超过限制。"
|
||||
|
||||
@@ -13922,8 +13926,8 @@ msgid "Left mouse"
|
||||
msgstr "鼠标左键"
|
||||
|
||||
# AI Translated
|
||||
msgid "Available anywhere in the window, even while typing in a text field."
|
||||
msgstr "在窗口任意位置可用,即使正在文本框中输入。"
|
||||
msgid "Available anywhere in the window."
|
||||
msgstr "在窗口任意位置可用。"
|
||||
|
||||
# AI Translated
|
||||
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?"
|
||||
msgstr "默认的 %s 已分配给 %s。是否将其重新分配给 %s?"
|
||||
|
||||
# AI Translated
|
||||
msgid "Global shortcuts also apply while typing."
|
||||
msgstr "全局快捷键在输入文字时同样生效。"
|
||||
|
||||
# AI Translated
|
||||
msgid "A key that types a character cannot be a global shortcut."
|
||||
msgstr "会输入字符的按键不能用作全局快捷键。"
|
||||
@@ -18086,6 +18086,14 @@ msgstr "对象排除"
|
||||
msgid "Enable this option to add EXCLUDE OBJECT command in G-code."
|
||||
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"
|
||||
msgstr "注释G-code"
|
||||
|
||||
@@ -24004,7 +24012,7 @@ msgstr "开始测试 %s..."
|
||||
|
||||
# AI Translated
|
||||
#, c-format, boost-format
|
||||
msgid "test %s ip resolved = %s"
|
||||
msgid "test %s IP resolved = %s"
|
||||
msgstr "测试 %s:IP 解析结果 = %s"
|
||||
|
||||
# AI Translated
|
||||
@@ -31498,20 +31506,21 @@ msgid "Basic Colors"
|
||||
msgstr "基本颜色"
|
||||
|
||||
# AI Translated
|
||||
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
|
||||
msgid "Orca Palette"
|
||||
msgstr "Orca 调色板"
|
||||
|
||||
# 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"
|
||||
msgstr "RAL 经典色"
|
||||
|
||||
# AI Translated
|
||||
#. TRN: Keep the standard/brand name "RAL" unchanged.
|
||||
msgid "Gradients"
|
||||
msgstr "渐变预设"
|
||||
|
||||
# AI Translated
|
||||
msgctxt "Color picker"
|
||||
msgid "Solid"
|
||||
msgstr "纯色"
|
||||
|
||||
@@ -32010,7 +32019,7 @@ msgid "Medium violet red"
|
||||
msgstr "中紫罗兰红"
|
||||
|
||||
# AI Translated
|
||||
msgid "PaleV violet red"
|
||||
msgid "Pale violet red"
|
||||
msgstr "浅紫罗兰红"
|
||||
|
||||
# AI Translated
|
||||
@@ -32226,8 +32235,7 @@ msgid "Shallow sky"
|
||||
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"
|
||||
msgid "Electric blue"
|
||||
msgstr "电光蓝"
|
||||
|
||||
# AI Translated
|
||||
@@ -32319,9 +32327,8 @@ msgid "Maize yellow"
|
||||
msgstr "玉米黄"
|
||||
|
||||
# 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 "达尔马提亚黄"
|
||||
msgid "Daffodil yellow"
|
||||
msgstr "水仙黄"
|
||||
|
||||
# AI Translated
|
||||
msgid "Brown beige"
|
||||
@@ -32668,8 +32675,8 @@ msgid "Turquoise blue"
|
||||
msgstr "绿松石蓝"
|
||||
|
||||
# AI Translated
|
||||
msgid "Capsanthin violet"
|
||||
msgstr "辣椒红素紫"
|
||||
msgid "Capri blue"
|
||||
msgstr "卡普里蓝"
|
||||
|
||||
# AI Translated
|
||||
msgid "Ocean blue"
|
||||
@@ -32820,8 +32827,8 @@ msgid "Silver grey"
|
||||
msgstr "银灰色"
|
||||
|
||||
# AI Translated
|
||||
msgid "Olivestone"
|
||||
msgstr "橄榄石色"
|
||||
msgid "Olive grey"
|
||||
msgstr "橄榄灰"
|
||||
|
||||
# AI Translated
|
||||
msgid "Moss grey"
|
||||
@@ -33597,13 +33604,25 @@ msgstr "从“我的纹理”中移除"
|
||||
msgid "Built-in"
|
||||
msgstr "内置"
|
||||
|
||||
# AI Translated
|
||||
msgid "My textures"
|
||||
msgstr "我的纹理"
|
||||
|
||||
# AI Translated
|
||||
msgid "No textures found."
|
||||
msgstr "未找到纹理。"
|
||||
|
||||
# AI Translated
|
||||
msgid "My textures"
|
||||
msgstr "我的纹理"
|
||||
msgid "Texture libraries"
|
||||
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
|
||||
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"
|
||||
msgstr "切片流水线"
|
||||
|
||||
# AI Translated
|
||||
msgid "Resource Texture"
|
||||
msgstr "资源纹理"
|
||||
|
||||
# AI Translated
|
||||
msgid "The plugin's configuration UI was empty. Showing the default editor."
|
||||
msgstr "插件的配置界面为空。正在显示默认编辑器。"
|
||||
@@ -36433,6 +36456,30 @@ msgstr "插件目录同时包含 .py 和 .whl 入口文件"
|
||||
msgid "Plugin directory does not contain a .py or .whl entry file"
|
||||
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
|
||||
#, boost-format
|
||||
msgid "Slicing pipeline plugin '%1%' error: %2%"
|
||||
@@ -36891,7 +36938,7 @@ msgstr "加载配置文件“%1%”失败:%2%"
|
||||
# AI Translated
|
||||
#. TRN %1% is the application name.
|
||||
#, boost-format
|
||||
msgid "Not a gcode file generated by %1%."
|
||||
msgid "Not a G-code file generated by %1%."
|
||||
msgstr "不是由 %1% 生成的 G-code 文件。"
|
||||
|
||||
# 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%"
|
||||
|
||||
# AI Translated
|
||||
msgid "Post-processor can't find exported gcode file"
|
||||
msgid "Post-processor can't find exported G-code file"
|
||||
msgstr "后处理器找不到导出的 G-code 文件"
|
||||
|
||||
# AI Translated
|
||||
@@ -37400,6 +37447,40 @@ msgstr ""
|
||||
"避免翘曲\n"
|
||||
"您知道吗?打印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"
|
||||
#~ msgstr "请选择耗材丝颜色"
|
||||
|
||||
@@ -37865,9 +37946,6 @@ msgstr ""
|
||||
#~ msgid "no configured tool"
|
||||
#~ msgstr "未配置的工具头"
|
||||
|
||||
#~ msgid "Add end G-Code when finishing the entire print."
|
||||
#~ msgstr "所有打印结束时的结尾G-code"
|
||||
|
||||
#~ msgid "Maximum X speed"
|
||||
#~ 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."
|
||||
#~ 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
|
||||
# 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."
|
||||
#~ 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 "
|
||||
#~ msgstr " 不在合理范围内"
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Orca Slicer\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"
|
||||
"Last-Translator: tntchn <15895303+tntchn@users.noreply.github.com>\n"
|
||||
"Language-Team: \n"
|
||||
@@ -1826,7 +1826,7 @@ msgid "Stroke of shape (%1%) contains unsupported: %2%."
|
||||
msgstr "形狀(%1%)的筆劃包含不支援的內容:%2%。"
|
||||
|
||||
# AI Translated
|
||||
msgid "Not valid state please report reproduction steps on github"
|
||||
msgid "Not valid state please report reproduction steps on GitHub"
|
||||
msgstr "狀態無效,請在 GitHub 上回報重現步驟"
|
||||
|
||||
# AI Translated
|
||||
@@ -1834,11 +1834,11 @@ msgid "No embossed file"
|
||||
msgstr "沒有浮雕檔案"
|
||||
|
||||
# AI Translated
|
||||
msgid "Missing svg file in embossed shape"
|
||||
msgid "Missing SVG file in embossed shape"
|
||||
msgstr "浮雕形狀中缺少 SVG 檔案"
|
||||
|
||||
# AI Translated
|
||||
msgid "Missing data of svg file"
|
||||
msgid "Missing data of SVG file"
|
||||
msgstr "缺少 SVG 檔案的資料"
|
||||
|
||||
msgid "Face the camera"
|
||||
@@ -5219,7 +5219,7 @@ msgid "Unknown C++ exception."
|
||||
msgstr "未知的 C++ 例外。"
|
||||
|
||||
# AI Translated
|
||||
msgid "Processing G-Code from previous file…"
|
||||
msgid "Processing G-code from previous file…"
|
||||
msgstr "正在處理先前檔案的 G-code…"
|
||||
|
||||
msgid "Slicing complete"
|
||||
@@ -6912,7 +6912,7 @@ msgid "Size:"
|
||||
msgstr "尺寸:"
|
||||
|
||||
# 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)."
|
||||
msgstr "在第 %d 層(Z = %.2lf mm)發現 G-code 路徑衝突。請將有衝突的物件分開得更遠(%s <-> %s)。"
|
||||
|
||||
@@ -12298,6 +12298,10 @@ msgstr "無法將檔案上傳至列印裝置的儲存空間,請重試。"
|
||||
msgid "Send Failed"
|
||||
msgstr "傳送失敗"
|
||||
|
||||
# AI Translated
|
||||
msgid "No available printers"
|
||||
msgstr "沒有可用的列印裝置"
|
||||
|
||||
msgid "The name length exceeds the limit."
|
||||
msgstr "名稱長度超過限制。"
|
||||
|
||||
@@ -14251,8 +14255,8 @@ msgid "Left mouse"
|
||||
msgstr "滑鼠左鍵"
|
||||
|
||||
# AI Translated
|
||||
msgid "Available anywhere in the window, even while typing in a text field."
|
||||
msgstr "在視窗中任何位置皆可使用,即使正在文字欄位中輸入也可以。"
|
||||
msgid "Available anywhere in the window."
|
||||
msgstr "在視窗中任何位置皆可使用。"
|
||||
|
||||
# AI Translated
|
||||
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?"
|
||||
msgstr "預設的 %s 已指派給 %s。要改為指派給 %s 嗎?"
|
||||
|
||||
# AI Translated
|
||||
msgid "Global shortcuts also apply while typing."
|
||||
msgstr "全域快速鍵在輸入文字時也會生效。"
|
||||
|
||||
# AI Translated
|
||||
msgid "A key that types a character cannot be a global shortcut."
|
||||
msgstr "會輸入字元的按鍵不能作為全域快速鍵。"
|
||||
@@ -18513,6 +18513,14 @@ msgstr "物件排除"
|
||||
msgid "Enable this option to add EXCLUDE OBJECT command in G-code."
|
||||
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"
|
||||
msgstr "詳細 G-code"
|
||||
|
||||
@@ -24597,7 +24605,7 @@ msgstr "測試 %s 開始..."
|
||||
|
||||
# AI Translated
|
||||
#, c-format, boost-format
|
||||
msgid "test %s ip resolved = %s"
|
||||
msgid "test %s IP resolved = %s"
|
||||
msgstr "測試 %s IP 解析結果 = %s"
|
||||
|
||||
# AI Translated
|
||||
@@ -32124,21 +32132,22 @@ msgid "Basic Colors"
|
||||
msgstr "基本顏色"
|
||||
|
||||
# AI Translated
|
||||
#. TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
|
||||
msgid "Orca Palette"
|
||||
msgstr "Orca 調色盤"
|
||||
|
||||
# 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"
|
||||
msgstr "RAL 經典色"
|
||||
|
||||
# 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.
|
||||
#. TRN: Keep the standard/brand name "RAL" unchanged.
|
||||
msgid "Gradients"
|
||||
msgstr "漸層配色"
|
||||
|
||||
# AI Translated
|
||||
msgctxt "Color picker"
|
||||
msgid "Solid"
|
||||
msgstr "純色"
|
||||
|
||||
@@ -32637,7 +32646,7 @@ msgid "Medium violet red"
|
||||
msgstr "中紫羅蘭紅"
|
||||
|
||||
# AI Translated
|
||||
msgid "PaleV violet red"
|
||||
msgid "Pale violet red"
|
||||
msgstr "淡紫羅蘭紅"
|
||||
|
||||
# AI Translated
|
||||
@@ -32853,8 +32862,7 @@ msgid "Shallow sky"
|
||||
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"
|
||||
msgid "Electric blue"
|
||||
msgstr "電光藍"
|
||||
|
||||
# AI Translated
|
||||
@@ -32946,9 +32954,8 @@ msgid "Maize yellow"
|
||||
msgstr "玉米黃"
|
||||
|
||||
# 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 "達爾馬提亞黃"
|
||||
msgid "Daffodil yellow"
|
||||
msgstr "水仙黃"
|
||||
|
||||
# AI Translated
|
||||
msgid "Brown beige"
|
||||
@@ -33296,9 +33303,8 @@ msgid "Turquoise blue"
|
||||
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 "辣椒紅素紫"
|
||||
msgid "Capri blue"
|
||||
msgstr "卡布里藍"
|
||||
|
||||
# AI Translated
|
||||
msgid "Ocean blue"
|
||||
@@ -33449,8 +33455,8 @@ msgid "Silver grey"
|
||||
msgstr "銀灰"
|
||||
|
||||
# AI Translated
|
||||
msgid "Olivestone"
|
||||
msgstr "橄欖石色"
|
||||
msgid "Olive grey"
|
||||
msgstr "橄欖灰"
|
||||
|
||||
# AI Translated
|
||||
msgid "Moss grey"
|
||||
@@ -34226,13 +34232,25 @@ msgstr "從「我的紋理」移除"
|
||||
msgid "Built-in"
|
||||
msgstr "內建"
|
||||
|
||||
# AI Translated
|
||||
msgid "My textures"
|
||||
msgstr "我的紋理"
|
||||
|
||||
# AI Translated
|
||||
msgid "No textures found."
|
||||
msgstr "找不到紋理。"
|
||||
|
||||
# AI Translated
|
||||
msgid "My textures"
|
||||
msgstr "我的紋理"
|
||||
msgid "Texture libraries"
|
||||
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
|
||||
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"
|
||||
msgstr "切片管線"
|
||||
|
||||
# AI Translated
|
||||
msgid "Resource Texture"
|
||||
msgstr "資源紋理"
|
||||
|
||||
# AI Translated
|
||||
msgid "The plugin's configuration UI was empty. Showing the default editor."
|
||||
msgstr "外掛的設定介面為空。將顯示預設編輯器。"
|
||||
@@ -37075,6 +37097,30 @@ msgstr "外掛目錄同時包含 .py 和 .whl 進入點檔案"
|
||||
msgid "Plugin directory does not contain a .py or .whl entry file"
|
||||
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
|
||||
#, boost-format
|
||||
msgid "Slicing pipeline plugin '%1%' error: %2%"
|
||||
@@ -37533,7 +37579,7 @@ msgstr "載入設定檔「%1%」失敗:%2%"
|
||||
# AI Translated
|
||||
#. TRN %1% is the application name.
|
||||
#, boost-format
|
||||
msgid "Not a gcode file generated by %1%."
|
||||
msgid "Not a G-code file generated by %1%."
|
||||
msgstr "不是由 %1% 產生的 G-code 檔案。"
|
||||
|
||||
# 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%"
|
||||
|
||||
# AI Translated
|
||||
msgid "Post-processor can't find exported gcode file"
|
||||
msgid "Post-processor can't find exported G-code file"
|
||||
msgstr "後處理程式找不到匯出的 G-code 檔案"
|
||||
|
||||
# AI Translated
|
||||
@@ -38049,6 +38095,41 @@ msgstr ""
|
||||
"避免翹曲\n"
|
||||
"您知道嗎?當列印容易翹曲的材料(如 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"
|
||||
#~ msgstr "請選擇線材顏色"
|
||||
|
||||
@@ -38513,9 +38594,6 @@ msgstr ""
|
||||
#~ msgid "no configured tool"
|
||||
#~ msgstr "未設定的工具頭"
|
||||
|
||||
#~ msgid "Add end G-Code when finishing the entire print."
|
||||
#~ msgstr "所有列印結束時的結尾 G-code"
|
||||
|
||||
#~ msgid "Maximum X speed"
|
||||
#~ 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."
|
||||
#~ 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
|
||||
#~ msgid "Per-plate override mapping physical heads to filament slots (1-based). Empty means use pem-inversion defaults."
|
||||
#~ 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 "
|
||||
#~ msgstr " 不在合理的區間"
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ const cssColors = [
|
||||
{hex: "#800080", name: "Purple"},
|
||||
{hex: "#663399", name: "Rebecca purple"},
|
||||
{hex: "#C71585", name: "Medium violet red"},
|
||||
{hex: "#DB7093", name: "PaleV violet red"},
|
||||
{hex: "#DB7093", name: "Pale violet red"},
|
||||
{hex: "#FF1493", name: "Deep pink"},
|
||||
{hex: "#FF69B4", name: "Hot pink"},
|
||||
{hex: "#FFB6C1", name: "Light pink"},
|
||||
@@ -193,7 +193,7 @@ const palettes = {
|
||||
{hex: "#40E0D0", name: "Turquoise bay"},
|
||||
{hex: "#2EBDEF", name: "Sky over reef"},
|
||||
{hex: "#87CEEB", name: "Shallow sky"},
|
||||
{hex: "#1E90FF", name: "Electric bel"},
|
||||
{hex: "#1E90FF", name: "Electric blue"},
|
||||
{hex: "#4169E1", name: "Dolphin"},
|
||||
{hex: "#0000CD", name: "Ocean depths"},
|
||||
{hex: "#191970", name: "Whale"},
|
||||
@@ -219,7 +219,7 @@ const palettes = {
|
||||
{hex: "#EFBF04", name: "1004 Golden yellow"},
|
||||
{hex: "#D7A500", name: "1005 Honey 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: "#F5DB50", name: "1012 Lemon yellow"},
|
||||
{hex: "#F3E5AB", name: "1013 Oyster white"},
|
||||
@@ -318,7 +318,7 @@ const palettes = {
|
||||
{hex: "#0384A7", name: "5015 Sky blue"},
|
||||
{hex: "#006A9E", name: "5017 Traffic 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: "#009AAA", name: "5021 Water blue"},
|
||||
{hex: "#1A3D5F", name: "5022 Night blue"},
|
||||
@@ -366,7 +366,7 @@ const palettes = {
|
||||
{hex: "#00A550", name: "6039 Mint green"},
|
||||
{hex: "#7D7D7D", name: "7000 Squirrel 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: "#8E9291", name: "7004 Signal grey"},
|
||||
{hex: "#8B8788", name: "7005 Mouse grey"},
|
||||
|
||||
@@ -1432,7 +1432,7 @@ ConfigSubstitutions ConfigBase::load_from_gcode_file(const std::string &file, Fo
|
||||
}
|
||||
if (!header_found) {
|
||||
// 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;
|
||||
throw Slic3r::RuntimeError(error_message.c_str());
|
||||
}
|
||||
|
||||
@@ -988,7 +988,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
||||
|
||||
MetadataList metadata;
|
||||
VolumeMetadataList volumes;
|
||||
bool auto_drop { true };
|
||||
std::optional<bool> auto_drop;
|
||||
};
|
||||
|
||||
struct CutObjectInfo
|
||||
@@ -1208,6 +1208,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
||||
//IdToGeometryMap m_orig_geometries; // backup & restore
|
||||
CurrentConfig m_curr_config;
|
||||
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;
|
||||
IdToLayerHeightsProfileMap m_layer_heights_profiles;
|
||||
IdToLayerConfigRangesMap m_layer_config_ranges;
|
||||
@@ -1463,6 +1464,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
||||
m_objects.clear();
|
||||
m_instances.clear();
|
||||
m_objects_metadata.clear();
|
||||
m_items_auto_drop_off.clear();
|
||||
m_curr_metadata_name.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.volume_id = -1;
|
||||
m_objects_metadata.clear();
|
||||
m_items_auto_drop_off.clear();
|
||||
m_layer_heights_profiles.clear();
|
||||
m_layer_config_ranges.clear();
|
||||
m_brim_ear_points.clear();
|
||||
@@ -2257,11 +2260,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
||||
ObjectMetadata::VolumeMetadataList volumes;
|
||||
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);
|
||||
if (obj_metadata != m_objects_metadata.end()) {
|
||||
// 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
|
||||
for (const Metadata& metadata : obj_metadata->second.metadata) {
|
||||
@@ -2279,6 +2285,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
||||
}
|
||||
else {
|
||||
// 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
|
||||
//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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
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_curr_config.object_id = object_id;
|
||||
return true;
|
||||
@@ -10110,7 +10120,7 @@ bool to_xml(std::stringstream &stream, const EmbossShape::SvgFile &svg, const Mo
|
||||
assert(file_data != nullptr);
|
||||
if (file_data == nullptr && !svg.path.empty()) file_data = read_from_disk(svg.path);
|
||||
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;
|
||||
}
|
||||
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 << " ";
|
||||
if (es.svg_file.has_value())
|
||||
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 << "\" ";
|
||||
|
||||
@@ -1421,6 +1421,7 @@ static std::vector<std::string> s_Preset_print_options{
|
||||
"initial_layer_travel_acceleration",
|
||||
"initial_layer_travel_jerk",
|
||||
"exclude_object",
|
||||
"exclude_object_tolerance",
|
||||
"slow_down_layers",
|
||||
"infill_anchor",
|
||||
"infill_anchor_max",
|
||||
|
||||
@@ -309,6 +309,7 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
|
||||
"gcode_comments",
|
||||
"gcode_label_objects",
|
||||
"exclude_object",
|
||||
"exclude_object_tolerance",
|
||||
"support_material_interface_fan_speed",
|
||||
"internal_bridge_fan_speed", // ORCA: Add support for separate internal bridge fan speed control
|
||||
"ironing_fan_speed",
|
||||
@@ -6929,7 +6930,7 @@ BoundingBoxf3 PrintInstance::get_bounding_box() const {
|
||||
|
||||
Polygon PrintInstance::get_convex_hull_2d() {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -4629,6 +4629,17 @@ void PrintConfigDef::init_fff_params()
|
||||
def->mode = comAdvanced;
|
||||
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->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. "
|
||||
|
||||
@@ -2130,6 +2130,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE(
|
||||
((ConfigOptionPercents, filament_shrinkage_compensation_z))
|
||||
((ConfigOptionBool, gcode_label_objects))
|
||||
((ConfigOptionBool, exclude_object))
|
||||
((ConfigOptionFloat, exclude_object_tolerance))
|
||||
((ConfigOptionFloats, grab_length))
|
||||
((ConfigOptionBool, gcode_comments))
|
||||
((ConfigOptionInt, slow_down_layers))
|
||||
|
||||
@@ -225,7 +225,7 @@ void BackgroundSlicingProcess::process_fff()
|
||||
// BBS: add the logic to process from an existed gcode file
|
||||
if (m_print->finished()) {
|
||||
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);
|
||||
// Post the Slicing Finished message for the G-code viewer to update.
|
||||
// Passing the timestamp
|
||||
|
||||
@@ -303,10 +303,12 @@ nlohmann::json color_picker_ui_strings()
|
||||
return {
|
||||
{"Color Picker", _u8L("Color Picker")},
|
||||
{"Basic Colors", _u8L("Basic Colors")},
|
||||
{"Orca Palette", _u8L("Orca Palette")},// 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.
|
||||
// TRN: Keep the brand name "Orca" unchanged; translate only the palette descriptor.
|
||||
{"Orca Palette", _u8L("Orca Palette")},
|
||||
// TRN: Keep the standard/brand name "RAL" unchanged.
|
||||
{"RAL Classic", _u8L("RAL Classic")},
|
||||
{"Gradients", _u8L("Gradients")},
|
||||
{"Solid", _u8L("Solid")},
|
||||
{"Solid", _u8L_CONTEXT("Solid", "Color picker")},
|
||||
{"Gradient", _u8L("Gradient")},
|
||||
{"Gradient start", _u8L("Gradient start")},
|
||||
{"Gradient end", _u8L("Gradient end")},
|
||||
@@ -440,7 +442,7 @@ nlohmann::json color_picker_ui_strings()
|
||||
{"Purple", _u8L("Purple")},
|
||||
{"Rebecca purple", _u8L("Rebecca purple")},
|
||||
{"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")},
|
||||
{"Hot pink", _u8L("Hot pink")},
|
||||
{"Light pink", _u8L("Light pink")},
|
||||
@@ -496,7 +498,7 @@ nlohmann::json color_picker_ui_strings()
|
||||
{"Turquoise bay", _u8L("Turquoise bay")},
|
||||
{"Sky over reef", _u8L("Sky over reef")},
|
||||
{"Shallow sky", _u8L("Shallow sky")},
|
||||
{"Electric bel", _u8L("Electric bel")},
|
||||
{"Electric blue", _u8L("Electric blue")},
|
||||
{"Dolphin", _u8L("Dolphin")},
|
||||
{"Ocean depths", _u8L("Ocean depths")},
|
||||
{"Whale", _u8L("Whale")},
|
||||
@@ -519,7 +521,7 @@ nlohmann::json color_picker_ui_strings()
|
||||
{"Golden yellow", _u8L("Golden yellow")},
|
||||
{"Honey yellow", _u8L("Honey yellow")},
|
||||
{"Maize yellow", _u8L("Maize yellow")},
|
||||
{"Dalmatian yellow", _u8L("Dalmatian yellow")},
|
||||
{"Daffodil yellow", _u8L("Daffodil yellow")},
|
||||
{"Brown beige", _u8L("Brown beige")},
|
||||
{"Lemon yellow", _u8L("Lemon yellow")},
|
||||
{"Oyster white", _u8L("Oyster white")},
|
||||
@@ -606,7 +608,7 @@ nlohmann::json color_picker_ui_strings()
|
||||
{"Pigeon blue", _u8L("Pigeon blue")},
|
||||
{"Traffic blue", _u8L("Traffic blue")},
|
||||
{"Turquoise blue", _u8L("Turquoise blue")},
|
||||
{"Capsanthin violet", _u8L("Capsanthin violet")},
|
||||
{"Capri blue", _u8L("Capri blue")},
|
||||
{"Ocean blue", _u8L("Ocean blue")},
|
||||
{"Water blue", _u8L("Water blue")},
|
||||
{"Night blue", _u8L("Night blue")},
|
||||
@@ -644,7 +646,7 @@ nlohmann::json color_picker_ui_strings()
|
||||
{"Luminous green", _u8L("Luminous green")},
|
||||
{"Squirrel grey", _u8L("Squirrel grey")},
|
||||
{"Silver grey", _u8L("Silver grey")},
|
||||
{"Olivestone", _u8L("Olivestone")},
|
||||
{"Olive grey", _u8L("Olive grey")},
|
||||
{"Moss grey", _u8L("Moss grey")},
|
||||
{"Signal grey", _u8L("Signal grey")},
|
||||
{"Mouse grey", _u8L("Mouse grey")},
|
||||
|
||||
@@ -1245,6 +1245,8 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in
|
||||
if(gcf_is_klipper)
|
||||
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");
|
||||
toggle_line("make_overhang_printable_angle", have_make_overhang_printable);
|
||||
toggle_line("make_overhang_printable_hole_size", have_make_overhang_printable);
|
||||
|
||||
@@ -1350,7 +1350,7 @@ void GLGizmoSVG::draw_window()
|
||||
assert(m_volume_id.valid());
|
||||
if (m_volume == nullptr ||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1362,13 +1362,13 @@ void GLGizmoSVG::draw_window()
|
||||
|
||||
assert(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;
|
||||
}
|
||||
|
||||
assert(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -295,7 +295,7 @@ void NetworkTestDialog::start_test_url(TestJob job, wxString name, wxString url)
|
||||
}
|
||||
})
|
||||
.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);
|
||||
})
|
||||
.on_error([this,name,job](std::string body, std::string error, unsigned int status) {
|
||||
|
||||
@@ -395,7 +395,7 @@ bool run_post_process_scripts(
|
||||
|
||||
auto gcode_file = boost::filesystem::path(path);
|
||||
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.
|
||||
config.setenv_();
|
||||
|
||||
@@ -3269,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_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_tolerance", "others_settings_g_code_output#outline-tolerance");
|
||||
option = optgroup->get_option("filename_format");
|
||||
// option.opt.full_width = true;
|
||||
option.opt.is_code = true;
|
||||
@@ -5278,7 +5279,7 @@ void TabPrinter::build_fff()
|
||||
option.opt.width = Field::def_width_wider();
|
||||
option.opt.tooltip = L("Select the network agent implementation for printer communication. "
|
||||
"Available agents are registered at startup.");
|
||||
optgroup->append_single_option_line(option);
|
||||
optgroup->append_single_option_line(option, "printer_basic_information_advanced#printer-agent");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#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;
|
||||
}
|
||||
|
||||
// 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
|
||||
// address or a process command line -- the deny-path and allowed-root checks only make sense
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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_capability_name = "";
|
||||
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_has_last_violation = false;
|
||||
thread_local AuditViolation PluginAuditManager::m_last_violation;
|
||||
|
||||
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
|
||||
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_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_capability(capability_name);
|
||||
PluginAuditManager::m_scoped_allowed_roots.clear();
|
||||
PluginAuditManager::m_root_frames.clear();
|
||||
}
|
||||
|
||||
ScopedPluginAuditContext::~ScopedPluginAuditContext()
|
||||
{
|
||||
PluginAuditManager::instance().set_current_plugin(m_previous_id);
|
||||
PluginAuditManager::instance().set_current_capability(m_previous_capability);
|
||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
|
||||
// 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_root_frames = std::move(m_previous_root_frames);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -575,34 +632,6 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -663,40 +692,88 @@ std::string python_str(PyObject* object)
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> call_site_identities(const std::string& plugin_root)
|
||||
{
|
||||
std::vector<std::string> ids;
|
||||
if (plugin_root.empty())
|
||||
return ids;
|
||||
// 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
|
||||
// touched while the GIL is held.
|
||||
static std::vector<boost::filesystem::path> s_stdlib_dirs;
|
||||
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
|
||||
Py_XINCREF(frame); // normalize to an owned reference for the loop below
|
||||
|
||||
while (frame) {
|
||||
PyCodeObject* code = PyFrame_GetCode(frame); // new reference
|
||||
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);
|
||||
|
||||
frames.push_back(reinterpret_cast<PyObject*>(frame));
|
||||
PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack
|
||||
Py_DECREF(frame);
|
||||
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{
|
||||
@@ -717,12 +794,15 @@ static const std::unordered_map<std::string, std::vector<Py_ssize_t>> audit_targ
|
||||
{"socket.getnameinfo", {0}},
|
||||
{"socket.getservbyname", {0}},
|
||||
{"socket.getservbyport", {0}},
|
||||
{"socket.sendto", {1}},
|
||||
{"socket.sendmsg", {1}},
|
||||
|
||||
{"os.system", {0}},
|
||||
{"subprocess.Popen", {1, 0}},
|
||||
{"os.posix_spawn", {1, 0}},
|
||||
{"os.spawn", {2, 1}},
|
||||
{"os.startfile", {0}},
|
||||
{"os.startfile/2", {0}},
|
||||
{"pty.spawn", {0}},
|
||||
{"_winapi.CreateProcess", {1, 0}},
|
||||
{"_posixsubprocess.fork_exec", {0}},
|
||||
@@ -767,7 +847,11 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
||||
const auto it = audit_target_arg_indices.find(event_name);
|
||||
if (it != audit_target_arg_indices.end()) {
|
||||
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()) {
|
||||
targets.push_back(std::move(value));
|
||||
break;
|
||||
@@ -778,8 +862,14 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
||||
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) {
|
||||
case AuditEventCategory::FsRead: return &permissions.fs_read;
|
||||
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
|
||||
@@ -792,6 +882,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)
|
||||
{
|
||||
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
||||
@@ -902,22 +999,25 @@ 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
|
||||
// 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;
|
||||
if (!PluginManager::instance().get_install_state(plugin_key, state))
|
||||
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)
|
||||
return;
|
||||
|
||||
for (const auto& target : targets)
|
||||
persist_permission(plugin_key, state, *permission_list, target);
|
||||
for (const auto& key : keys)
|
||||
persist_permission(plugin_key, state, *permission_list, key);
|
||||
}
|
||||
|
||||
// Requests permission for an audited event, returning true when it is already granted or the user
|
||||
// approves an inline prompt.
|
||||
static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
||||
|
||||
// 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
|
||||
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
|
||||
@@ -929,56 +1029,205 @@ 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
|
||||
// re-prompting.
|
||||
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;
|
||||
|
||||
if (s_permission_prompt_for_testing)
|
||||
return s_permission_prompt_for_testing(event_name, shown);
|
||||
|
||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||
return false;
|
||||
|
||||
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())
|
||||
return;
|
||||
if (prompt_for_targets(category, plugin_name, event_name, unresolved))
|
||||
persist_grant(plugin_key, category, unresolved);
|
||||
if (prompt_for_targets(category, plugin_name, event_name, shown))
|
||||
persist_grant(plugin_key, category, by_event, keys);
|
||||
});
|
||||
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,
|
||||
PluginInstallState& state,
|
||||
const std::string& plugin_key,
|
||||
const std::string& plugin_name,
|
||||
const std::string& event_name,
|
||||
AuditEventCategory category,
|
||||
const std::vector<std::string>& targets,
|
||||
std::vector<std::string>* permission_list,
|
||||
const std::vector<std::string>& call_site_ids)
|
||||
const std::vector<std::string>& targets)
|
||||
{
|
||||
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) {
|
||||
unresolved.clear();
|
||||
for (const auto& target : targets)
|
||||
if (!has_permission(*permission_list, target))
|
||||
unresolved.push_back(target);
|
||||
if (!targets.empty() && unresolved.empty())
|
||||
for (const auto& key : keys)
|
||||
if (!has_permission(*permission_list, key))
|
||||
unresolved.push_back(key);
|
||||
if (unresolved.empty())
|
||||
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"});
|
||||
|
||||
if (permission_list)
|
||||
for (const auto& target : unresolved)
|
||||
persist_permission(plugin_key, state, *permission_list, target);
|
||||
|
||||
mgr.record_approved_call_sites(plugin_key, call_site_ids);
|
||||
for (const auto& key : unresolved)
|
||||
persist_permission(plugin_key, state, *permission_list, key);
|
||||
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
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
auto* mgr = static_cast<PluginAuditManager*>(user_data);
|
||||
@@ -1006,62 +1255,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
|
||||
if (event_type == AuditEventCategory::None)
|
||||
return 0;
|
||||
|
||||
const bool fs_category = is_fs_category(event_type);
|
||||
const std::vector<std::string> targets = PluginAuditDetail::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 ancestor-cascade check below, so a cascade
|
||||
// approval recorded for an unrelated action can never 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 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);
|
||||
// Root frames whose operation has returned are let go once this event is decided, so that a
|
||||
// finalizer they run re-enters a hook whose held frames are already consistent.
|
||||
std::vector<PyObject*> returned_frames;
|
||||
PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames);
|
||||
const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args);
|
||||
mgr->release_frames(returned_frames);
|
||||
return result;
|
||||
}
|
||||
|
||||
void PluginAuditManager::install_hook()
|
||||
{
|
||||
// Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame).
|
||||
PluginAuditDetail::collect_stdlib_dirs();
|
||||
|
||||
// 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
|
||||
// (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// Via pybind11 so this file requests the same python3xx.lib as everything else.
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <pybind11/conduit/wrap_include_python_h.h>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
@@ -144,15 +143,26 @@ public:
|
||||
void clear_last_violation();
|
||||
bool last_violation(AuditViolation& violation) const;
|
||||
|
||||
// --- call-site cascade cache ---
|
||||
// A single plugin action often fires several nested CPython audit events as it passes
|
||||
// through stdlib layers (urllib.request calling http.client calling socket, for example).
|
||||
// Once the user approves one event, every stdlib frame still on the stack for that call
|
||||
// is recorded here by (filename, function, first line) identity. A later event whose own
|
||||
// ancestor chain still contains one of those frames is the same logical action seen from
|
||||
// a deeper layer, so it is auto-approved instead of prompting again.
|
||||
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
|
||||
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
|
||||
// --- operation-root frames ---
|
||||
// One stdlib operation fires several nested audit events as it passes through lower layers:
|
||||
// urlopen() raises urllib.Request, then http.client and socket events. When an operation's
|
||||
// own root event is granted (by a saved grant or the user), the stdlib frame that raised it
|
||||
// is held, and a later event whose stack reaches that frame through stdlib frames only is
|
||||
// part of the same operation, so it is allowed without a prompt of its own. Plugin or
|
||||
// library code the operation calls back into is judged on its own. A frame is let go once it
|
||||
// returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may
|
||||
// run finalizers of objects its locals held; they are judged as the plugin and never prompt.
|
||||
void record_root_frame();
|
||||
bool covered_by_root_frame() const;
|
||||
bool releasing_frames() const;
|
||||
|
||||
// True when code from `filename` (a code object's co_filename) is the interpreter's own
|
||||
// stdlib: the only code a root frame's coverage passes through. Valid after install_hook().
|
||||
static bool is_stdlib_file(const std::string& filename);
|
||||
|
||||
// Answers permission prompts in place of the dialog, for tests. Production never sets it.
|
||||
using PermissionPrompt = std::function<bool(const std::string& event_name, const std::vector<std::string>& targets)>;
|
||||
static void set_permission_prompt_for_testing(PermissionPrompt prompt);
|
||||
|
||||
bool verbose_events = true;
|
||||
|
||||
@@ -163,24 +173,32 @@ private:
|
||||
|
||||
static int audit_hook(const char* event, PyObject* args, void* user_data);
|
||||
|
||||
// Drops the references in `frames`, keeping the caller's error and denial state intact.
|
||||
void release_frames(std::vector<PyObject*>& frames);
|
||||
|
||||
static thread_local std::string m_current_plugin_key;
|
||||
static thread_local std::string m_current_capability_name;
|
||||
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
|
||||
static thread_local std::vector<PyObject*> m_root_frames; // strong references
|
||||
static thread_local int m_releasing_frames;
|
||||
static thread_local bool m_audit_denial_pending;
|
||||
static thread_local bool m_has_last_violation;
|
||||
static thread_local AuditViolation m_last_violation;
|
||||
|
||||
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
|
||||
// mutable: is_denied_filename() is a const query that must lock.
|
||||
mutable std::mutex m_mutex;
|
||||
std::vector<AllowedRoot> m_global_allowed_roots;
|
||||
std::vector<std::string> m_denied_filenames;
|
||||
std::vector<std::string> m_denied_path_keywords;
|
||||
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
|
||||
};
|
||||
|
||||
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
||||
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
|
||||
// single capability.
|
||||
//
|
||||
// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before
|
||||
// restoring the outer context. That needs the GIL whenever a frame is still held, so construct
|
||||
// the scope after the GIL guard, to be destroyed before it.
|
||||
class ScopedPluginAuditContext
|
||||
{
|
||||
public:
|
||||
@@ -194,9 +212,12 @@ public:
|
||||
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
|
||||
|
||||
private:
|
||||
std::string m_plugin_key;
|
||||
std::string m_capability;
|
||||
std::string m_previous_id;
|
||||
std::string m_previous_capability;
|
||||
std::vector<AllowedRoot> m_previous_scoped_roots;
|
||||
std::vector<PyObject*> m_previous_root_frames;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
@@ -808,6 +808,7 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
|
||||
read_string_list("network_socket", parsed.permissions.network_socket);
|
||||
read_string_list("process", parsed.permissions.process);
|
||||
read_string_list("threading", parsed.permissions.threading);
|
||||
read_string_list("events", parsed.permissions.events);
|
||||
}
|
||||
|
||||
if (state.contains("enabled") && state["enabled"].is_boolean())
|
||||
@@ -852,6 +853,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
|
||||
{"network_socket", state.permissions.network_socket},
|
||||
{"process", state.permissions.process},
|
||||
{"threading", state.permissions.threading},
|
||||
{"events", state.permissions.events},
|
||||
};
|
||||
|
||||
nlohmann::json capabilities = nlohmann::json::array();
|
||||
|
||||
@@ -104,6 +104,8 @@ struct PluginPermissions
|
||||
std::vector<std::string> network_socket;
|
||||
std::vector<std::string> process;
|
||||
std::vector<std::string> threading;
|
||||
// Grants for audit events that expose no target, keyed "<category>:<event>" (e.g. "Socket:socket.__new__").
|
||||
std::vector<std::string> events;
|
||||
};
|
||||
|
||||
struct PluginInstallState {
|
||||
|
||||
@@ -102,9 +102,9 @@ public:
|
||||
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
|
||||
nlohmann::json get_default_config() const override
|
||||
{
|
||||
pybind11::gil_scoped_acquire gil;
|
||||
ORCA_PY_AUDIT_SCOPE();
|
||||
try {
|
||||
pybind11::gil_scoped_acquire gil;
|
||||
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
|
||||
if (!override)
|
||||
return Base::get_default_config();
|
||||
|
||||
+1
-1
@@ -282,11 +282,11 @@ public:
|
||||
int request_bind_ticket(std::string* ticket) override
|
||||
{
|
||||
try {
|
||||
ORCA_PY_AUDIT_SCOPE();
|
||||
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
|
||||
::Slic3r::PythonGILState gil;
|
||||
if (!gil)
|
||||
throw std::runtime_error("Python interpreter is shutting down");
|
||||
ORCA_PY_AUDIT_SCOPE();
|
||||
pybind11::function override =
|
||||
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
|
||||
if (!override)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -18,48 +17,15 @@
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
// The fan stays off while printing the first close_fan_the_first_x_layers layers and runs afterwards.
|
||||
// The fan is held off for the first close_fan_the_first_x_layers layers, so an explicit
|
||||
// fan-off command is emitted.
|
||||
TEST_CASE("Fan is held off for the initial layers", "[Cooling]")
|
||||
{
|
||||
const int fan_off_layers = 5;
|
||||
const std::string gcode = slice({ cube(20) }, {
|
||||
{ "close_fan_the_first_x_layers", fan_off_layers },
|
||||
{ "full_fan_speed_layer", 0 },
|
||||
{ "fan_min_speed", 50 },
|
||||
{ "fan_max_speed", 100 },
|
||||
{ "fan_cooling_layer_time", 1000 }, // every layer is short enough to run the fan
|
||||
{ "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
{ "cooling", true },
|
||||
{ "close_fan_the_first_x_layers", 5 },
|
||||
});
|
||||
|
||||
// Record, for each layer, whether any extrusion was printed with the part fan running.
|
||||
std::vector<bool> extruded_with_fan;
|
||||
int layer = -1;
|
||||
bool fan_on = false;
|
||||
std::istringstream input(gcode);
|
||||
std::string line;
|
||||
while (std::getline(input, line)) {
|
||||
if (line.rfind(";LAYER_CHANGE", 0) == 0) {
|
||||
++layer;
|
||||
extruded_with_fan.push_back(false);
|
||||
} else if (line.rfind("M107", 0) == 0) {
|
||||
fan_on = false;
|
||||
} else if (line.rfind("M106 ", 0) == 0 && line.find(" P") == std::string::npos) {
|
||||
const size_t s = line.find(" S");
|
||||
fan_on = s != std::string::npos && std::stod(line.substr(s + 2)) > 0.;
|
||||
} else if (layer >= 0 && fan_on && line.rfind("G1 ", 0) == 0 && line.find(" E") != std::string::npos &&
|
||||
(line.find(" X") != std::string::npos || line.find(" Y") != std::string::npos)) {
|
||||
extruded_with_fan[layer] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A 20 mm cube at 0.2 mm layers has 100 layers.
|
||||
REQUIRE(extruded_with_fan.size() == 100);
|
||||
for (int i = 0; i < fan_off_layers; ++i) {
|
||||
CAPTURE(i);
|
||||
CHECK_FALSE(extruded_with_fan[i]);
|
||||
}
|
||||
CHECK(extruded_with_fan[fan_off_layers]);
|
||||
CHECK(gcode.find("M106 S0") != std::string::npos);
|
||||
}
|
||||
|
||||
// The cooling pass resolves and strips its internal speed placeholders; none leak into
|
||||
|
||||
@@ -86,9 +86,10 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set));
|
||||
REQUIRE(paths.size() == 1); // one continuous path
|
||||
|
||||
// Lines are 50 mm apart and the square is inset by half a spacing, so the lines on its edges drop out
|
||||
// and a single line crosses the middle, ending within half a spacing of the edges.
|
||||
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinAbs(100., filler->spacing / 2.));
|
||||
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
|
||||
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
|
||||
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length';
|
||||
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
|
||||
|
||||
test_set.clear();
|
||||
}
|
||||
@@ -147,9 +148,10 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
Slic3r::Polylines paths = test(Slic3r::ExPolygon(points));
|
||||
REQUIRE(paths.size() == 1); // one continuous path
|
||||
|
||||
// A solid fill covers the area with lines one spacing apart, so a missing segment shortens the path.
|
||||
const double expected_length = unscale<double>(unscale<double>(Slic3r::ExPolygon(points).area())) / filler->spacing;
|
||||
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinRel(expected_length, 0.01));
|
||||
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
|
||||
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
|
||||
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length';
|
||||
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
|
||||
}
|
||||
|
||||
SECTION("Rotated Square") {
|
||||
@@ -166,9 +168,8 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
fill_params.density = 1.0;
|
||||
filler->spacing = flow.spacing();
|
||||
|
||||
for (auto angle : { 0.0, PI / 4.0 }) {
|
||||
for (auto angle : { 0.0, 45.0}) {
|
||||
surface.expolygon.rotate(angle, Point(0,0));
|
||||
filler->bounding_box = get_extents(surface.expolygon.contour);
|
||||
Polylines paths = filler->fill_surface(&surface, fill_params);
|
||||
REQUIRE(paths.size() == 1);
|
||||
}
|
||||
@@ -221,7 +222,7 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
||||
};
|
||||
Slic3r::ExPolygon expolygon(points);
|
||||
|
||||
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, PI / 4.0, 0.99) == true);
|
||||
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, 45.0, 0.99) == true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,44 @@ TEST_CASE("Klipper object labels name each copy without the characters Klipper c
|
||||
}
|
||||
}
|
||||
|
||||
// The outline written to EXCLUDE_OBJECT_DEFINE is simplified with exclude_object_tolerance. A larger value must
|
||||
// leave fewer points, which keeps the exclude-object array small on printers that stall while parsing it.
|
||||
TEST_CASE("A larger exclude object tolerance writes fewer outline points", "[GCode]")
|
||||
{
|
||||
const auto slice_with_tolerance = [](const std::string &tolerance) {
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({{"gcode_flavor", "klipper"}, {"exclude_object", "1"},
|
||||
{"exclude_object_tolerance", tolerance}});
|
||||
// Default facet angle (2 degrees): 180 hull points, well inside either tolerance.
|
||||
return Test::slice({make_cylinder(20., 5.)}, config);
|
||||
};
|
||||
// Points of the POLYGON=[[x,y],...] on the first EXCLUDE_OBJECT_DEFINE line.
|
||||
const auto polygon_points = [](const std::string &gcode) {
|
||||
const size_t define = gcode.find("EXCLUDE_OBJECT_DEFINE");
|
||||
REQUIRE(define != std::string::npos);
|
||||
const size_t begin = gcode.find("POLYGON=", define);
|
||||
REQUIRE(begin != std::string::npos);
|
||||
const std::string polygon = gcode.substr(begin, gcode.find('\n', begin) - begin);
|
||||
size_t points = 1;
|
||||
for (size_t pos = polygon.find("],["); pos != std::string::npos; pos = polygon.find("],[", pos + 1))
|
||||
++points;
|
||||
return points;
|
||||
};
|
||||
|
||||
const size_t fine = polygon_points(slice_with_tolerance("0.1"));
|
||||
const size_t coarse = polygon_points(slice_with_tolerance("0.4"));
|
||||
INFO("points at 0.1 mm: " << fine << ", at 0.4 mm: " << coarse);
|
||||
CHECK(coarse >= 4);
|
||||
CHECK(coarse < fine);
|
||||
}
|
||||
|
||||
// Existing profiles must keep writing the outlines they wrote when the tolerance was a constant 0.1 mm.
|
||||
TEST_CASE("The exclude object tolerance defaults to the former fixed 0.1 mm", "[GCode]")
|
||||
{
|
||||
CHECK_THAT(DynamicPrintConfig::full_print_config().opt_float("exclude_object_tolerance"),
|
||||
Catch::Matchers::WithinAbs(0.1, 1e-9));
|
||||
}
|
||||
|
||||
// Firmwares read the compatibility header lines (Anycubic's GoKlipper heats the bed with
|
||||
// first_layer_bed_temperature), so they must describe the filament the plate prints, not filament 1.
|
||||
TEST_CASE("The G-code header names the first layer temperatures of the filament the plate prints", "[GCode]")
|
||||
|
||||
@@ -123,58 +123,38 @@ SCENARIO("Changing the number of solid shell layers does not make all surfaces i
|
||||
config.set_deserialize_strict({
|
||||
{ "top_shell_layers", 2 },
|
||||
{ "bottom_shell_layers", 1 },
|
||||
// Shell counts alone decide which layers are solid.
|
||||
{ "top_shell_thickness", 0 },
|
||||
{ "bottom_shell_thickness", 0 },
|
||||
{ "sparse_infill_density", "20%" },
|
||||
{ "layer_height", 0.25 }, // 80 layers: 0..79
|
||||
{ "layer_height", 0.25 }, // get a known number of layers
|
||||
{ "initial_layer_print_height", 0.25 }
|
||||
});
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
Slic3r::Test::init_print({cube(20)}, print, model, config);
|
||||
// True when the layer has fill surfaces and all of them are solid.
|
||||
auto is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
|
||||
// Precondition: Ensure that the model has 2 solid top layers (79, 78)
|
||||
// and one solid bottom layer (0).
|
||||
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
|
||||
const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id));
|
||||
bool has_fill = false;
|
||||
for (const LayerRegion *region : layer.regions())
|
||||
for (const Surface &surface : region->fill_surfaces.surfaces) {
|
||||
if (! surface.is_solid())
|
||||
return false;
|
||||
has_fill = true;
|
||||
}
|
||||
return has_fill;
|
||||
};
|
||||
// True when the layer has a sparse fill surface, so a layer with no fill at all does not count.
|
||||
auto has_sparse_infill = [&print](size_t obj_id, size_t layer_id) {
|
||||
const Layer &layer = *(print.objects().at(obj_id)->get_layer(static_cast<int>(layer_id)));
|
||||
for (const LayerRegion *region : layer.regions())
|
||||
// iterate over all of the regions in the layer
|
||||
for (const LayerRegion *region : layer.regions()) {
|
||||
// for each region, iterate over the fill surfaces
|
||||
for (const Surface &surface : region->fill_surfaces.surfaces)
|
||||
if (! surface.is_solid())
|
||||
return true;
|
||||
return false;
|
||||
CHECK(surface.is_solid());
|
||||
}
|
||||
};
|
||||
print.process();
|
||||
// Precondition: 2 solid top layers (79, 78) and one solid bottom layer (0).
|
||||
REQUIRE(is_solid_infill(0, 0));
|
||||
REQUIRE(is_solid_infill(0, 79));
|
||||
REQUIRE(is_solid_infill(0, 78));
|
||||
REQUIRE(has_sparse_infill(0, 77));
|
||||
test_is_solid_infill(0, 0); // should be solid
|
||||
test_is_solid_infill(0, 79); // should be solid
|
||||
test_is_solid_infill(0, 78); // should be solid
|
||||
WHEN("Model is re-sliced with top_shell_layers == 3") {
|
||||
config.set("top_shell_layers", 3);
|
||||
print.apply(model, config);
|
||||
print.process();
|
||||
THEN("Print object does not have 0 solid bottom layers.") {
|
||||
CHECK(is_solid_infill(0, 0));
|
||||
test_is_solid_infill(0, 0);
|
||||
}
|
||||
AND_THEN("Print object has 3 top solid layers") {
|
||||
CHECK(is_solid_infill(0, 79));
|
||||
CHECK(is_solid_infill(0, 78));
|
||||
CHECK(is_solid_infill(0, 77));
|
||||
CHECK(has_sparse_infill(0, 76));
|
||||
}
|
||||
AND_THEN("Middle layers keep sparse infill") {
|
||||
CHECK(has_sparse_infill(0, 40));
|
||||
test_is_solid_infill(0, 79);
|
||||
test_is_solid_infill(0, 78);
|
||||
test_is_solid_infill(0, 77);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -692,18 +672,14 @@ TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "
|
||||
{
|
||||
const std::string gcode = slice({ cube(20) }, {
|
||||
{ "gcode_skip_config_block", true },
|
||||
{ "gcode_comments", true },
|
||||
});
|
||||
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
|
||||
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
|
||||
CHECK(gcode.find("; layer_height =") == std::string::npos);
|
||||
CHECK(gcode.find("; sparse_infill_density =") == std::string::npos);
|
||||
CHECK(gcode.find("; fill_density =") == std::string::npos);
|
||||
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
|
||||
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
|
||||
|
||||
const std::string with_block = slice({ cube(20) }, { { "gcode_skip_config_block", false } });
|
||||
CHECK(with_block.find("; CONFIG_BLOCK_START") != std::string::npos);
|
||||
CHECK(with_block.find("; layer_height =") != std::string::npos);
|
||||
CHECK(with_block.find("; sparse_infill_density =") != std::string::npos);
|
||||
}
|
||||
|
||||
// Some firmwares only scan the last N lines of the file for "estimated printing time", so it
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
#include "libslic3r/TriangleMeshSlicer.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
@@ -164,29 +162,21 @@ SCENARIO("Slice behavior", "[TriangleMesh]") {
|
||||
const std::vector<Vec3f> vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}};
|
||||
const std::vector<Vec3i32> facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}};
|
||||
|
||||
TriangleMesh mesh(vertices, facets);
|
||||
// A 50x5x20 wall with a 20x15x10 block against it, so the block's top is at Z = 10.
|
||||
const double wall_area = 50. * 5.;
|
||||
const double wall_block_area = wall_area + 20. * 15.;
|
||||
const auto area_mm2 = [](const ExPolygons &slice) { return slice.at(0).area() * SCALING_FACTOR * SCALING_FACTOR; };
|
||||
auto cube = make_cube();
|
||||
WHEN(" a top tangent plane is sliced") {
|
||||
std::vector<ExPolygons> slices = mesh.slice({5.0, 10.0});
|
||||
// At Z = 10 we have a top horizontal surface.
|
||||
std::vector<ExPolygons> slices = cube.slice({5.0, 10.0});
|
||||
THEN( "its area is included") {
|
||||
REQUIRE(slices.at(0).size() == 1);
|
||||
REQUIRE(slices.at(1).size() == 1);
|
||||
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
|
||||
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
|
||||
REQUIRE(slices.at(0).at(0).area() > 0);
|
||||
REQUIRE(slices.at(1).at(0).area() > 0);
|
||||
}
|
||||
}
|
||||
WHEN(" a model that has been transformed is sliced") {
|
||||
mesh.mirror_z();
|
||||
std::vector<ExPolygons> slices = mesh.slice({-5.0, -10.0});
|
||||
THEN( "it is sliced like the original, and the mirrored block's bottom plane is not part of it") {
|
||||
REQUIRE(slices.at(0).size() == 1);
|
||||
REQUIRE(slices.at(1).size() == 1);
|
||||
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
|
||||
// A triangle owns its top edge but not its bottom edge, so a bottom tangent plane is left out.
|
||||
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_area, 1e-6));
|
||||
cube.mirror_z();
|
||||
std::vector<ExPolygons> slices = cube.slice({-5.0, -10.0});
|
||||
THEN( "it is sliced properly (mirrored bottom plane area is included)") {
|
||||
REQUIRE(slices.at(0).at(0).area() > 0);
|
||||
REQUIRE(slices.at(1).at(0).area() > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,18 +319,9 @@ TEST_CASE("FilamentGroup property checks", "[filament_group][property]") {
|
||||
// build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every
|
||||
// candidate grouping; this is a guard against hangs, not a micro-perf gate).
|
||||
REQUIRE(result.elapsed_ms < 40000.0);
|
||||
REQUIRE(result.flush_cost >= 0);
|
||||
|
||||
// Every case, feasible or not, must yield a usable grouping: each used filament is
|
||||
// assigned to an existing nozzle.
|
||||
auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments);
|
||||
const auto &nozzle_list = tc.context.nozzle_info.nozzle_list;
|
||||
for (unsigned int fil : used_filaments) {
|
||||
INFO("Filament: " << fil);
|
||||
REQUIRE(fil < result.filament_map.size());
|
||||
CHECK(result.filament_map[fil] >= 0);
|
||||
CHECK(result.filament_map[fil] < static_cast<int>(nozzle_list.size()));
|
||||
}
|
||||
|
||||
if (is_constraint_feasible(tc.context, used_filaments)) {
|
||||
if (!result.constraints_ok) {
|
||||
for (auto& v : result.violations)
|
||||
|
||||
@@ -505,6 +505,28 @@ TEST_CASE("A project whose components reference themselves fails to load", "[3mf
|
||||
REQUIRE_FALSE(loaded);
|
||||
}
|
||||
|
||||
TEST_CASE("A project in the 2.4.x layout keeps auto drop off from its build item", "[3mf][Regression]")
|
||||
{
|
||||
// 2.4.x wrote auto_drop on the build item and left it off the object, so the object is re-laid out here.
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
{
|
||||
Model model;
|
||||
ModelObject* object = model.add_object();
|
||||
object->add_volume(make_cube(10., 10., 10.));
|
||||
object->add_instance();
|
||||
object->auto_drop = false;
|
||||
store_project(model, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence);
|
||||
}
|
||||
REQUIRE(replace_in_3mf_entry(temp.string(), "model_settings.config", " auto_drop=\"0\"", ""));
|
||||
REQUIRE(replace_in_3mf_entry(temp.string(), "3dmodel.model", "printable=\"1\"/>", "printable=\"1\" auto_drop=\"0\"/>"));
|
||||
|
||||
ScopedTemporaryDir backup_dir("orca_auto_drop_dst");
|
||||
Model model;
|
||||
REQUIRE(load_project(temp.string(), model, backup_dir));
|
||||
REQUIRE(model.objects.size() == 1);
|
||||
CHECK_FALSE(model.objects.front()->auto_drop);
|
||||
}
|
||||
|
||||
TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]")
|
||||
{
|
||||
// The importer queues at most 100000 component references per object. Every reference besides the
|
||||
@@ -1033,19 +1055,18 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
|
||||
}
|
||||
}
|
||||
|
||||
// A project without multi-nozzle metadata must load through the BBS importer and must not
|
||||
// fabricate a filament_volume_map.
|
||||
SCENARIO("Projects without multi-nozzle metadata load without a per-filament volume map", "[3mf][MultiNozzle]") {
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
|
||||
GIVEN("a legacy project with a bare model and no plate metadata") {
|
||||
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
||||
Model model;
|
||||
DynamicPrintConfig config;
|
||||
PlateDataPtrs plates;
|
||||
// A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS
|
||||
// importer and must not fabricate a filament_volume_map.
|
||||
SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") {
|
||||
GIVEN("a project without any multi-nozzle metadata") {
|
||||
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
||||
Model model;
|
||||
DynamicPrintConfig config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
|
||||
WHEN("loaded through the BBS importer") {
|
||||
bool loaded = false;
|
||||
@@ -1053,32 +1074,10 @@ SCENARIO("Projects without multi-nozzle metadata load without a per-filament vol
|
||||
&project_presets, &is_bbl_3mf, &is_orca_3mf,
|
||||
&file_version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
|
||||
THEN("the model loads") {
|
||||
REQUIRE(loaded);
|
||||
REQUIRE_FALSE(model.objects.empty());
|
||||
}
|
||||
release_PlateData_list(plates);
|
||||
}
|
||||
}
|
||||
|
||||
GIVEN("a project with plate metadata but no filament volume map") {
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
store_painted_cube(temp.string());
|
||||
ScopedTemporaryDir backup_dir("orca_volume_map_dst");
|
||||
Model model;
|
||||
model.set_backup_path(backup_dir.string());
|
||||
DynamicPrintConfig config;
|
||||
PlateDataPtrs plates;
|
||||
|
||||
WHEN("loaded through the BBS importer") {
|
||||
const bool loaded = load_bbs_3mf(temp.string().c_str(), &config, &ctxt, &model, &plates,
|
||||
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version,
|
||||
nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
||||
THEN("every plate loads without a per-filament volume map") {
|
||||
REQUIRE(loaded);
|
||||
REQUIRE_FALSE(plates.empty());
|
||||
for (PlateData* p : plates)
|
||||
CHECK(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
|
||||
THEN("it does not crash and invents no per-filament volume map") {
|
||||
for (PlateData* p : plates) {
|
||||
REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
|
||||
}
|
||||
}
|
||||
release_PlateData_list(plates);
|
||||
}
|
||||
@@ -2049,8 +2048,8 @@ SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") {
|
||||
REQUIRE(dst_model.model_info != nullptr);
|
||||
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
|
||||
|
||||
// The loaded value must parse back with every extended field intact.
|
||||
nlohmann::json entries = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG]);
|
||||
// The value must parse back with every extended field intact.
|
||||
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
|
||||
REQUIRE(entries.is_array());
|
||||
REQUIRE(entries.size() == 1);
|
||||
REQUIRE(entries[0]["full"].get<bool>() == true);
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
@@ -54,7 +53,6 @@
|
||||
#include <BRepAdaptor_Surface.hxx>
|
||||
#include <TopExp_Explorer.hxx>
|
||||
#include <TopoDS.hxx>
|
||||
#include <BRepAlgoAPI_Common.hxx>
|
||||
#include <BRepGProp.hxx>
|
||||
#include <GProp_GProps.hxx>
|
||||
#include <BRepPrimAPI_MakeBox.hxx>
|
||||
@@ -774,10 +772,16 @@ TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument]
|
||||
|
||||
TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]")
|
||||
{
|
||||
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
|
||||
const std::string font =
|
||||
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
|
||||
REQUIRE(std::ifstream(font).good());
|
||||
std::string font = resources_dir().empty()
|
||||
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
|
||||
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
|
||||
{
|
||||
std::ifstream probe(font);
|
||||
if (!probe.good()) {
|
||||
SUCCEED("bundled font not reachable in this environment; covered live on :10");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const std::string charset =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
@@ -2849,10 +2853,9 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
CadDocument doc;
|
||||
// A cylinder's end cap is bounded by one closed circular edge, which offers no in-plane
|
||||
// direction, so the frame has to fall back on coordsys_x_hint.
|
||||
int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle");
|
||||
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl");
|
||||
// Build a body so we have a face to reference for FaceAndDirection.
|
||||
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
||||
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.bodies.size() == 1);
|
||||
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
||||
@@ -2862,33 +2865,34 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
|
||||
if (fn.z() > 0.9) { top_face = i; break; }
|
||||
}
|
||||
REQUIRE(top_face >= 0);
|
||||
const Vec3d Z = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, top_face));
|
||||
|
||||
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1");
|
||||
REQUIRE(cs >= 0);
|
||||
doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection;
|
||||
doc.features[cs].coordsys_body = 0;
|
||||
doc.features[cs].coordsys_face = top_face;
|
||||
// Deliberately non-perpendicular X hint (NOT orthogonal to the face normal +Z).
|
||||
// Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z).
|
||||
doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5);
|
||||
|
||||
auto css = doc.resolve_datum_coordsys();
|
||||
REQUIRE(css.size() == 1);
|
||||
REQUIRE(css[0].error.empty());
|
||||
|
||||
const Vec3d X = css[0].x, Y = css[0].y;
|
||||
// X is the hint projected into the face plane.
|
||||
const Vec3d expected_x = Vec3d(3.0, -1.0, 0.0).normalized();
|
||||
CHECK_THAT(X.x(), WithinAbs(expected_x.x(), 1e-9));
|
||||
CHECK_THAT(X.y(), WithinAbs(expected_x.y(), 1e-9));
|
||||
CHECK_THAT(X.z(), WithinAbs(expected_x.z(), 1e-9));
|
||||
// Orthonormal, with Z the face normal and the frame right-handed.
|
||||
Vec3d X = css[0].x, Y = css[0].y;
|
||||
// Orthonormality: each axis has unit length
|
||||
CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9));
|
||||
CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9));
|
||||
CHECK_THAT(X.dot(Y), WithinAbs(0.0, 1e-9));
|
||||
CHECK_THAT(X.dot(Z), WithinAbs(0.0, 1e-9));
|
||||
CHECK_THAT(Y.dot(Z), WithinAbs(0.0, 1e-9));
|
||||
CHECK_THAT(X.cross(Y).dot(Z), WithinAbs(1.0, 1e-9));
|
||||
// Pairwise dot products are ~0
|
||||
CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9));
|
||||
// Z = X x Y (derived), also unit and perpendicular
|
||||
Vec3d Z = X.cross(Y);
|
||||
CHECK_THAT(Z.norm(), WithinAbs(1.0, 1e-9));
|
||||
CHECK_THAT(std::abs(X.dot(Z)), WithinAbs(0.0, 1e-9));
|
||||
CHECK_THAT(std::abs(Y.dot(Z)), WithinAbs(0.0, 1e-9));
|
||||
// Right-handedness: X x Y == Z
|
||||
CHECK_THAT(Z.x(), WithinAbs((X.cross(Y)).x(), 1e-9));
|
||||
CHECK_THAT(Z.y(), WithinAbs((X.cross(Y)).y(), 1e-9));
|
||||
CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]")
|
||||
@@ -3913,23 +3917,17 @@ TEST_CASE("Use/Convert Entities: construction references never become solid", "[
|
||||
}
|
||||
REQUIRE(top_face >= 0);
|
||||
|
||||
const size_t body_count_before = doc.bodies.size();
|
||||
const double volume_before = doc.body_mass_properties(0).volume;
|
||||
|
||||
int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use");
|
||||
int appended = doc.project_edges_into_sketch(target, 0, {}, top_face);
|
||||
REQUIRE(appended == 4);
|
||||
doc.add_extrude(target, 5.0, false, BooleanMode::New, "UseExt");
|
||||
|
||||
SECTION("construction edges form no profile, so the extrude fails") {
|
||||
REQUIRE_FALSE(doc.recompute());
|
||||
REQUIRE_FALSE(doc.error.empty());
|
||||
}
|
||||
SECTION("the same edges as solid geometry extrude a 20x20x5 body") {
|
||||
for (auto& e : doc.features[target].entities)
|
||||
e.construction = false;
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
REQUIRE(doc.bodies.size() == 2);
|
||||
REQUIRE_THAT(doc.body_mass_properties(1).volume, WithinRel(20.0 * 20.0 * 5.0, 1e-6));
|
||||
}
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
REQUIRE(doc.bodies.size() == body_count_before);
|
||||
REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]")
|
||||
@@ -4254,7 +4252,7 @@ TEST_CASE("a previous-format recipe is refused, not silently misread", "[CadDocu
|
||||
REQUIRE(doc.features.empty()); // nothing half-read was left behind
|
||||
}
|
||||
|
||||
TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]")
|
||||
TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
|
||||
{
|
||||
using Catch::Matchers::WithinRel;
|
||||
using Catch::Matchers::WithinAbs;
|
||||
@@ -4284,16 +4282,20 @@ TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]")
|
||||
}
|
||||
|
||||
CadDocument expected = make_golden_doc_v1();
|
||||
// Scoped tightly: this advice is ONLY valid for a count mismatch. It must not be in
|
||||
// scope for the field-value assertions below, where "regenerate the fixture" is the
|
||||
// one thing you must never do -- regenerating after a reorder bakes the corrupted
|
||||
// layout in as the new golden and permanently disarms this test.
|
||||
{
|
||||
INFO("Feature count changed - make_golden_doc_v1() must keep building exactly the tree "
|
||||
"frozen in cad_recipe_v4.bin, so do not add or remove features there.");
|
||||
INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?");
|
||||
INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test.");
|
||||
REQUIRE(features.size() == expected.features.size());
|
||||
}
|
||||
|
||||
// A failure BELOW this point means the on-disk serialization format changed: some field
|
||||
// in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be
|
||||
// APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass --
|
||||
// fix the field order instead.
|
||||
// fix the field order instead. See scripts/CAD/run-kernel-tests.sh and the [.regen] case.
|
||||
|
||||
// Field-by-field assertions against expected values.
|
||||
// Every field set to a distinctive non-default literal must be checked here.
|
||||
@@ -4548,48 +4550,34 @@ TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layer 2: the production v4 load rebuilds the same geometry as the in-memory tree ---
|
||||
// The golden tree is serialization coverage and does not recompute as a whole: these
|
||||
// features fail on the geometry they get, so they are left off in both trees.
|
||||
const std::set<std::string> unbuildable{"Fillet_Lat35", "Chamfer_Top2", "Draft_F3", "Shell_T1375",
|
||||
"GoldenProject"};
|
||||
// --- Layer 2: geometry check (optional — only if the document recomputes) ---
|
||||
// Build expected document and try to recompute it.
|
||||
// ponytail: the field-value layer above is the real tripwire;
|
||||
// this layer is a bonus sanity check on the full recompute path.
|
||||
CadDocument exp_doc = make_golden_doc_v1();
|
||||
// Fields appended after v4 are not in the file, so a v4 project reads them as defaults
|
||||
// (a v4 thread, for one, is not major-nominal). The reference has to match that.
|
||||
const CadFeature def;
|
||||
for (CadFeature& f : exp_doc.features) {
|
||||
f.thread_major_nominal = def.thread_major_nominal;
|
||||
f.pattern_inclusive = def.pattern_inclusive;
|
||||
f.dressup_edges = def.dressup_edges;
|
||||
f.text_string = def.text_string;
|
||||
f.text_font = def.text_font;
|
||||
f.text_height = def.text_height;
|
||||
f.revolve_axis_entity = def.revolve_axis_entity;
|
||||
bool exp_ok = exp_doc.recompute();
|
||||
if (!exp_ok) {
|
||||
INFO("Expected document from make_golden_doc_v1() could not recompute "
|
||||
"(complex feature tree). Field-value checks above are sufficient.");
|
||||
}
|
||||
|
||||
CadDocument doc;
|
||||
doc.deserialize_recipe(blob);
|
||||
REQUIRE(doc.features.size() == exp_doc.features.size());
|
||||
for (size_t i = 0; i < exp_doc.features.size(); ++i)
|
||||
exp_doc.features[i].enabled = doc.features[i].enabled = unbuildable.count(exp_doc.features[i].name) == 0;
|
||||
const bool exp_built = exp_doc.recompute();
|
||||
INFO("reference error: " << exp_doc.error);
|
||||
REQUIRE(exp_built);
|
||||
const bool built = doc.recompute();
|
||||
INFO("loaded error: " << doc.error);
|
||||
REQUIRE(built);
|
||||
REQUIRE_FALSE(exp_doc.bodies.empty());
|
||||
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
|
||||
for (size_t b = 0; b < doc.bodies.size(); ++b) {
|
||||
INFO("body " << b);
|
||||
TriangleMesh mesh = SketchEngine::tessellate(doc.bodies[b].shape);
|
||||
TriangleMesh exp_mesh = SketchEngine::tessellate(exp_doc.bodies[b].shape);
|
||||
CHECK_THAT(double(mesh.volume()), WithinRel(double(exp_mesh.volume()), 1e-6));
|
||||
// Volume alone is blind to fields that only place a body, such as a transform.
|
||||
const BoundingBoxf3 bb = mesh.bounding_box(), exp_bb = exp_mesh.bounding_box();
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
CHECK_THAT(bb.min[k], WithinAbs(exp_bb.min[k], 1e-6));
|
||||
CHECK_THAT(bb.max[k], WithinAbs(exp_bb.max[k], 1e-6));
|
||||
bool ser_ok = doc.deserialize_recipe(blob);
|
||||
|
||||
if (exp_ok && ser_ok) {
|
||||
REQUIRE(doc.error.empty());
|
||||
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
|
||||
for (size_t i = 0; i < doc.bodies.size(); ++i) {
|
||||
double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume());
|
||||
double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume());
|
||||
REQUIRE_THAT(v, WithinRel(ev, 1e-6));
|
||||
}
|
||||
} else {
|
||||
INFO("The golden recipe fixture was loaded and field-value checks passed.");
|
||||
INFO("Recompute on the deserialized or expected document failed — this is");
|
||||
INFO("expected for the extended golden fixture (many feature types coexist");
|
||||
INFO("purely for serialization coverage). The field-value tripwire above is");
|
||||
INFO("the primary format check.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5793,9 +5781,8 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
// A 20x10 plate, so a quarter turn about the connector's Z swaps its X and Y extents.
|
||||
int sk2 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "Plate");
|
||||
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "PlateExt");
|
||||
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
|
||||
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
@@ -5814,13 +5801,6 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
|
||||
gp_Pnt com = props.CentreOfMass();
|
||||
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
||||
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
||||
|
||||
Bnd_Box bb;
|
||||
BRepBndLib::Add(doc.bodies[1].shape, bb);
|
||||
double x0, y0, z0, x1, y1, z1;
|
||||
bb.Get(x0, y0, z0, x1, y1, z1);
|
||||
REQUIRE_THAT(x1 - x0, WithinAbs(10.0, 1e-3));
|
||||
REQUIRE_THAT(y1 - y0, WithinAbs(20.0, 1e-3));
|
||||
}
|
||||
|
||||
TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
||||
@@ -5843,9 +5823,7 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
// The connector sits on the cylinder's base, so the flip decides which side of the
|
||||
// fixed connector (z = 5) the 10 mm cylinder hangs: above it unflipped, below it flipped.
|
||||
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS_Moving");
|
||||
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
|
||||
doc.features[cs_moving].coordsys_body = 1;
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
@@ -5859,7 +5837,8 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
||||
gp_Pnt com = props.CentreOfMass();
|
||||
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
||||
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
||||
REQUIRE_THAT(double(com.Z()), WithinAbs(5.0 - 10.0 / 2, 1e-4));
|
||||
// Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid,
|
||||
// but the mate executed cleanly and the body moved to the target connector.
|
||||
}
|
||||
|
||||
TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]")
|
||||
@@ -6546,19 +6525,23 @@ TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]")
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
|
||||
// The same assembly mated once without and once with flip, each in its own copy.
|
||||
auto mated_com_z = [&](bool flip) {
|
||||
CadDocument d = doc;
|
||||
d.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, flip, "Mate");
|
||||
REQUIRE(d.recompute());
|
||||
REQUIRE(d.error.empty());
|
||||
REQUIRE(d.mate_conflicts.empty());
|
||||
GProp_GProps props;
|
||||
BRepGProp::VolumeProperties(d.bodies[1].shape, props);
|
||||
return props.CentreOfMass().Z();
|
||||
};
|
||||
const double nf_z = mated_com_z(false);
|
||||
const double f_z = mated_com_z(true);
|
||||
// Fastened, NO flip
|
||||
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
GProp_GProps nf_props;
|
||||
BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props);
|
||||
double nf_z = nf_props.CentreOfMass().Z();
|
||||
|
||||
doc.undo();
|
||||
|
||||
// Fastened, WITH flip
|
||||
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.error.empty());
|
||||
GProp_GProps f_props;
|
||||
BRepGProp::VolumeProperties(doc.bodies[1].shape, f_props);
|
||||
double f_z = f_props.CentreOfMass().Z();
|
||||
|
||||
// Flip changes the centroid Z for an asymmetric body
|
||||
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
|
||||
@@ -7341,22 +7324,13 @@ TEST_CASE("interference: sheet bodies are skipped", "[CadDocument][interference]
|
||||
CadDocument doc;
|
||||
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
||||
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
||||
// Four 10x5 walls standing inside the 20x20x10 solid: a sheet has no volume, so no interference.
|
||||
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Sheet");
|
||||
// A sheet passing straight through the solid: it has no volume, so no interference.
|
||||
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 0, "Sheet");
|
||||
doc.add_surface_extrude(sk_s, 5.0, "SE");
|
||||
REQUIRE(doc.recompute());
|
||||
REQUIRE(doc.bodies.size() == 2);
|
||||
REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape));
|
||||
|
||||
// The raw overlap of the walls with the solid does measure a volume, so only the sheet
|
||||
// skip keeps it out of the report.
|
||||
BRepAlgoAPI_Common common(doc.bodies[0].shape, doc.bodies[1].shape);
|
||||
common.Build();
|
||||
REQUIRE(common.IsDone());
|
||||
GProp_GProps props;
|
||||
BRepGProp::VolumeProperties(common.Shape(), props);
|
||||
REQUIRE(std::abs(props.Mass()) > 1e-6);
|
||||
|
||||
REQUIRE(doc.check_interference().empty());
|
||||
}
|
||||
|
||||
@@ -7566,8 +7540,9 @@ TEST_CASE("A cut that removes no material is an error, not a silent success", "[
|
||||
CHECK(doc.error.find("removed no material") != std::string::npos);
|
||||
|
||||
// And the body is left as it was, not half-applied.
|
||||
REQUIRE(doc.bodies.size() == 1);
|
||||
CHECK_THAT(doc.body_mass_properties(0).volume,
|
||||
CadDocument again = box();
|
||||
REQUIRE(again.recompute());
|
||||
CHECK_THAT(again.body_mass_properties(0).volume,
|
||||
Catch::Matchers::WithinAbs(solid, 1e-6));
|
||||
}
|
||||
|
||||
|
||||
@@ -75,10 +75,11 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
|
||||
}
|
||||
}
|
||||
WHEN("A boolean option is set to a string value representing something other than 0 or 1") {
|
||||
config.set("gcode_comments", true);
|
||||
THEN("A BadOptionTypeException exception is thrown and the value is unchanged.") {
|
||||
THEN("A BadOptionTypeException exception is thrown.") {
|
||||
REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException);
|
||||
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == true);
|
||||
}
|
||||
AND_THEN("Value is unchanged.") {
|
||||
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == false);
|
||||
}
|
||||
}
|
||||
WHEN("A boolean option is set to an int value") {
|
||||
@@ -116,10 +117,12 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
|
||||
}
|
||||
}
|
||||
WHEN("A numeric option is set to a non-numeric value.") {
|
||||
config.set("max_bridge_length", 7.5);
|
||||
THEN("A BadOptionValueException exception is thrown and the value does not change.") {
|
||||
auto prev_value = config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat();
|
||||
THEN("A BadOptionTypeException exception is thrown.") {
|
||||
REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException);
|
||||
REQUIRE_THAT(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat(), Catch::Matchers::WithinAbs(7.5, 1e-9));
|
||||
}
|
||||
THEN("The value does not change.") {
|
||||
REQUIRE(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat() == prev_value);
|
||||
}
|
||||
}
|
||||
WHEN("A string option is set through the string interface") {
|
||||
@@ -195,7 +198,7 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
|
||||
}
|
||||
}
|
||||
WHEN("An invalid option is requested during set.") {
|
||||
THEN("A UnknownOptionException exception is thrown.") {
|
||||
THEN("A BadOptionTypeException exception is thrown.") {
|
||||
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException);
|
||||
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException);
|
||||
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException);
|
||||
|
||||
@@ -65,25 +65,41 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("A mixed slot whose components are already used adds no filaments", "[FilamentMixer][belt]")
|
||||
TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[FilamentMixer][belt]")
|
||||
{
|
||||
// The belt purge tower sizes its islands from the expanded set. A mixed slot is virtual: no
|
||||
// nozzle carries it and ToolOrdering::resolve_mixed_filaments() replaces it with its
|
||||
// components, so it must not count as a filament of its own.
|
||||
// Regression for the "extra purge tower" on a belt printer with a mixed
|
||||
// filament (MCTEST5). The belt purge prism is sized as
|
||||
// n_islands = used_filaments.size() - 1
|
||||
// and GUI::ensure_belt_purge_tower() collected those filaments straight off
|
||||
// the model objects' extruder assignments. A mixed slot is VIRTUAL -- no
|
||||
// nozzle carries it, and ToolOrdering::resolve_mixed_filaments() replaces it
|
||||
// with its components before any G-code is emitted -- so counting it as a
|
||||
// filament of its own provisions one island that can never be reached.
|
||||
//
|
||||
// MCTEST5: five cubes on extruders 1..5, where filament 5 is a 50/50 blend of
|
||||
// filaments 2 and 4. The G-code uses only T0..T3 and reports
|
||||
// "filament used [g] = 53.35, 141.11, 40.84, 107.23, 0.00" -- filament 5
|
||||
// consumes nothing, exactly as a virtual slot should.
|
||||
const std::vector<unsigned char> is_mixed = {0, 0, 0, 0, 1};
|
||||
const std::vector<std::string> comp_strs = {"", "", "", "", "2,4"};
|
||||
|
||||
// The set the sizer used to see: slots 0..4 (filaments 1..5).
|
||||
const std::vector<unsigned int> assigned = {0, 1, 2, 3, 4};
|
||||
const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs);
|
||||
|
||||
// Slot 4 dissolves into 1 and 3, which are already present.
|
||||
REQUIRE(physical == std::vector<unsigned int>({0, 1, 2, 3}));
|
||||
|
||||
// Four physical filaments => three transitions => three islands, not four.
|
||||
REQUIRE(int(physical.size()) - 1 == 3);
|
||||
REQUIRE(int(assigned.size()) - 1 == 4); // what it produced before the fix
|
||||
|
||||
SECTION("A mixed slot whose components are otherwise unused still counts them") {
|
||||
// Only the mixed slot is assigned: it must still yield its two components rather than
|
||||
// collapsing to nothing.
|
||||
// Only the mixed slot is assigned: it must still yield its two components,
|
||||
// i.e. one island, rather than collapsing to zero.
|
||||
const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs);
|
||||
REQUIRE(only_mixed == std::vector<unsigned int>({1, 3}));
|
||||
REQUIRE(int(only_mixed.size()) - 1 == 1);
|
||||
}
|
||||
|
||||
SECTION("No mixed filaments anywhere leaves the set untouched") {
|
||||
|
||||
@@ -1334,17 +1334,12 @@ TEST_CASE("Renamed printer/process names are normalized into compatible lists on
|
||||
CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") ==
|
||||
(std::vector<std::string>{ "New Printer", "Unknown Printer" }));
|
||||
|
||||
// Normalization runs before selection, so selecting copies the rewritten list into the edited preset.
|
||||
bundle.prints.select_preset_by_name("My Process", true);
|
||||
REQUIRE(bundle.prints.get_selected_preset_name() == "My Process");
|
||||
CHECK(bundle.prints.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
|
||||
std::vector<std::string>{ "New Printer" });
|
||||
// Normalizing rewrites config in place without flagging the preset dirty.
|
||||
CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty);
|
||||
|
||||
// Normalizing again is a no-op: the stored preset keeps the current name and still matches the
|
||||
// edited copy, so the selection is not reported modified.
|
||||
// A system preset that already references the current name is left untouched (idempotent no-op).
|
||||
bundle.normalize_compatible_presets();
|
||||
CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" });
|
||||
CHECK_FALSE(bundle.prints.current_is_dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]")
|
||||
@@ -1374,17 +1369,11 @@ TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists"
|
||||
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") ==
|
||||
std::vector<std::string>{ "New Process" });
|
||||
|
||||
// Normalization runs before selection, so selecting copies the rewritten lists into the edited preset.
|
||||
bundle.filaments.select_preset_by_name("System Filament", true);
|
||||
REQUIRE(bundle.filaments.get_selected_preset_name() == "System Filament");
|
||||
CHECK(bundle.filaments.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
|
||||
std::vector<std::string>{ "New Printer" });
|
||||
|
||||
// Normalizing again is a no-op, so the selection is not reported modified.
|
||||
// The rewrite does not flag the system preset dirty, and is idempotent.
|
||||
CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty);
|
||||
bundle.normalize_compatible_presets();
|
||||
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") ==
|
||||
std::vector<std::string>{ "New Printer" });
|
||||
CHECK_FALSE(bundle.filaments.current_is_dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]")
|
||||
@@ -5577,6 +5566,16 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai
|
||||
CHECK(bundle.num_mixed_filaments() == 0);
|
||||
CHECK(bundle.num_physical_filaments() == target);
|
||||
}
|
||||
|
||||
SECTION("applied as a floor, the mix is left alone") {
|
||||
if (target > bundle.filament_presets.size())
|
||||
bundle.set_num_filaments(target);
|
||||
|
||||
CHECK(bundle.filament_presets.size() == 6);
|
||||
CHECK(bundle.num_mixed_filaments() == 1);
|
||||
CHECK(bundle.is_mixed_filament(5));
|
||||
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
|
||||
}
|
||||
}
|
||||
|
||||
// After a published-3MF import relocated mixed-filament definitions, the freshly loaded
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
#include "libslic3r/CAD/SketchImport.hpp"
|
||||
#include "libslic3r/Utils.hpp" // resources_dir
|
||||
#include "test_utils.hpp" // ScopedTemporaryFile
|
||||
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include "libslic3r/Point.hpp"
|
||||
@@ -78,10 +78,18 @@ TEST_CASE("transform_regions moves and scales independently", "[SketchImport]")
|
||||
|
||||
TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]")
|
||||
{
|
||||
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
|
||||
const std::string font =
|
||||
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
|
||||
REQUIRE(std::ifstream(font).good());
|
||||
// Locate the bundled font; resources_dir() may be unset under ctest, so
|
||||
// fall back to a cwd-relative path (tests run from the repo root).
|
||||
std::string font = resources_dir().empty()
|
||||
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
|
||||
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
|
||||
{
|
||||
std::ifstream probe(font);
|
||||
if (!probe.good()) {
|
||||
SUCCEED("bundled font not reachable in this environment; covered live on :10");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Bad input is rejected without throwing.
|
||||
REQUIRE(text_to_regions("", 10.0, font).empty());
|
||||
|
||||
@@ -33,9 +33,8 @@ TEST_CASE("inference: cursor near a line endpoint snaps Coincident-able to it",
|
||||
TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]")
|
||||
{
|
||||
std::vector<SketchEntity> ents = { line({0, 0}, {2, 0}) };
|
||||
// Query 0.2 from the midpoint (1,0) and 0.8 from the endpoint (2,0): the endpoint tier
|
||||
// wins even though the midpoint is nearer.
|
||||
auto s = infer_point_snap(ents, {1.2, 0.0}, 5.0, /*include_origin=*/false);
|
||||
// Query equidistant-ish but closer to the endpoint: endpoint tier wins regardless.
|
||||
auto s = infer_point_snap(ents, {1.9, 0.0}, 5.0);
|
||||
CHECK(s.kind == K::Endpoint);
|
||||
CHECK(s.role == SketchPointRole::P1);
|
||||
}
|
||||
|
||||
@@ -113,11 +113,14 @@ TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProf
|
||||
REQUIRE(closed_wires(out) == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("profile: offsetting a closed rectangle outward keeps it one closed loop", "[SketchProfile]")
|
||||
TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]")
|
||||
{
|
||||
// Outward is -d for a CCW loop. The miter join reconnects the corners, so the result is one
|
||||
// closed wire, not four disconnected segments. Its area is checked by the sign test below.
|
||||
const auto out = SketchEngine::offset_entities(rect(40, 20), -5.0);
|
||||
// A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d,
|
||||
// so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except
|
||||
// for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible;
|
||||
// a set of four disconnected segments is not, and that is what this measures.
|
||||
const double w = 40, h = 20, d = 5;
|
||||
const auto out = SketchEngine::offset_entities(rect(w, h), d);
|
||||
const auto ws = SketchEngine::entities_to_wires(out, xy_plane());
|
||||
REQUIRE(ws.size() == 1);
|
||||
REQUIRE(ws[0].Closed());
|
||||
|
||||
@@ -60,8 +60,9 @@ std::vector<NozzleInfo> single_nozzle_per_extruder(int extruder_count)
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "[ToolOrdering][H2C]")
|
||||
TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// The multi-nozzle gate: std::any_of(extruder_max_nozzle_count > 1).
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
|
||||
@@ -71,11 +72,15 @@ TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "
|
||||
// 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later).
|
||||
REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr);
|
||||
|
||||
// Default for every existing printer: 1 nozzle per extruder, so the multi-nozzle gate
|
||||
// (any extruder_max_nozzle_count > 1) stays closed.
|
||||
REQUIRE_FALSE(opt->values.empty());
|
||||
for (int count : opt->values)
|
||||
CHECK(count == 1);
|
||||
auto has_multiple_nozzle = [](const std::vector<int> &values) {
|
||||
return std::any_of(values.begin(), values.end(), [](int v) { return v > 1; });
|
||||
};
|
||||
|
||||
// Default for every existing printer: 1 nozzle per extruder => gate is closed.
|
||||
REQUIRE_FALSE(has_multiple_nozzle(opt->values));
|
||||
|
||||
// Synthetic H2C-like machine: extruder 1 is a 6-nozzle cluster => gate opens.
|
||||
REQUIRE(has_multiple_nozzle(std::vector<int>{1, 6}));
|
||||
}
|
||||
|
||||
TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]")
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
using namespace Slic3r;
|
||||
|
||||
using VD = Geometry::VoronoiDiagram;
|
||||
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd);
|
||||
using BoostVD = boost::polygon::voronoi_diagram<double>;
|
||||
|
||||
// https://svn.boost.org/trac10/ticket/12067
|
||||
@@ -199,12 +198,8 @@ TEST_CASE("Voronoi missing edges - Alessandro gapfill 12707", "[Voronoi]")
|
||||
REQUIRE(intersecting_edges({ poly }).empty());
|
||||
|
||||
Lines lines = to_lines(poly);
|
||||
VD vd;
|
||||
vd.construct_voronoi(lines.begin(), lines.end());
|
||||
// Orca's detector finds nothing wrong with this input, so no repair is needed.
|
||||
CHECK(VD::detect_known_issues(vd, lines.begin(), lines.end()) == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_NOT_NEEDED);
|
||||
CHECK_FALSE(has_missing_voronoi_vertices({ poly }, vd));
|
||||
BoostVD vd;
|
||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
||||
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(),
|
||||
@@ -310,16 +305,9 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]")
|
||||
}
|
||||
#endif
|
||||
|
||||
VD vd;
|
||||
BoostVD vd;
|
||||
Lines lines = to_lines(poly);
|
||||
vd.construct_voronoi(lines.begin(), lines.end());
|
||||
// Unrepaired, the diagram is missing a Voronoi vertex. construct_voronoi detects it and repairs it
|
||||
// by rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(VD::detect_known_issues(raw_vd, lines.begin(), lines.end()) == VD::IssueType::MISSING_VORONOI_VERTEX);
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
||||
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(),
|
||||
@@ -359,8 +347,9 @@ TEST_CASE("Voronoi division by zero 12903", "[Voronoi]")
|
||||
|
||||
// https://svn.boost.org/trac10/ticket/12139
|
||||
// Funny sample from a dental industry?
|
||||
// The input data contain self intersections, so no valid Voronoi diagram can be built from it.
|
||||
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]")
|
||||
// Vojtech confirms this test fails and rightly so, because the input data contain self intersections.
|
||||
// This test is suppressed.
|
||||
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
|
||||
{
|
||||
Lines lines = {
|
||||
{ { 260500,1564400 }, { 261040,1562960 } },
|
||||
@@ -1338,13 +1327,18 @@ TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]")
|
||||
}
|
||||
#endif
|
||||
|
||||
VD vd;
|
||||
vd.construct_voronoi(lines.begin(), lines.end());
|
||||
// The diagram built from this self-intersecting input has non-finite vertices, and rotating the
|
||||
// input does not repair it. It must be reported as invalid, which is what MedialAxis::build
|
||||
// checks before falling back to a morphological closing.
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_UNSUCCESSFUL);
|
||||
CHECK_FALSE(vd.is_valid());
|
||||
BoostVD vd;
|
||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
||||
|
||||
for (const auto& edge : vd.edges())
|
||||
if (edge.is_finite()) {
|
||||
auto v0 = edge.vertex0();
|
||||
auto v1 = edge.vertex1();
|
||||
REQUIRE((v0->x() == 0 || std::isnormal(v0->x())));
|
||||
REQUIRE((v0->y() == 0 || std::isnormal(v0->y())));
|
||||
REQUIRE((v1->x() == 0 || std::isnormal(v1->x())));
|
||||
REQUIRE((v1->y() == 0 || std::isnormal(v1->y())));
|
||||
}
|
||||
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-NaNs.svg").c_str(),
|
||||
@@ -1941,7 +1935,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
|
||||
}
|
||||
|
||||
// Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex.
|
||||
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
|
||||
[[maybe_unused]] static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
|
||||
{
|
||||
auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast<double>()).norm() <= SCALED_EPSILON; };
|
||||
|
||||
@@ -1963,7 +1957,7 @@ static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
|
||||
// into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An
|
||||
// infinity-edge passes through the missing Voronoi vertex. Therefore, this missing Voronoi vertex and edge can be reconstructed
|
||||
// using the intersection between the infinity-edge with the input polygon.
|
||||
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||
// Rotation of the polygon solves this problem.
|
||||
TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
|
||||
{
|
||||
Polygon poly = {
|
||||
@@ -1987,14 +1981,7 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines);
|
||||
#endif
|
||||
|
||||
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||
// rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(has_missing_voronoi_vertices({poly}, raw_vd));
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK_FALSE(has_missing_voronoi_vertices({poly}, vd));
|
||||
// REQUIRE(!has_missing_voronoi_vertices({poly}, vd));
|
||||
}
|
||||
|
||||
// This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a
|
||||
@@ -2002,7 +1989,7 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
|
||||
// that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices.
|
||||
// Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the
|
||||
// input polygon.
|
||||
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||
// Rotation of the polygons solves this problem.
|
||||
TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
|
||||
{
|
||||
Polygons poly = {
|
||||
@@ -2034,20 +2021,13 @@ TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines);
|
||||
#endif
|
||||
|
||||
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||
// rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
|
||||
// REQUIRE(!has_missing_voronoi_vertices(poly, vd));
|
||||
}
|
||||
|
||||
// This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge,
|
||||
// and for this point is Voronoi vertex missing. A difference between the previous cases and this case through the missing
|
||||
// Voronoi vertex is passing finite edge between one inner Voronoi vertex and one outer Voronoi vertex.
|
||||
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||
// Rotating the polygon also help solve this problem.
|
||||
TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
|
||||
{
|
||||
Polygons poly = {
|
||||
@@ -2075,27 +2055,20 @@ TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
|
||||
// polygons_rotate(poly, PI/180);
|
||||
// polygons_rotate(poly, PI/6);
|
||||
|
||||
VD vd;
|
||||
BoostVD vd;
|
||||
Lines lines = to_lines(poly);
|
||||
vd.construct_voronoi(lines.begin(), lines.end());
|
||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
||||
#ifdef VORONOI_DEBUG_OUT
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines);
|
||||
#endif
|
||||
|
||||
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||
// rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
|
||||
// REQUIRE(!has_missing_voronoi_vertices(poly, vd));
|
||||
}
|
||||
|
||||
// In this case, the Voronoi vertex (146873, -146873) is included twice.
|
||||
// Also, near to those duplicate Voronoi vertices is another Voronoi vertex (146872, -146872).
|
||||
// Rotating the polygon will help solve this problem, but then there arise three very close Voronoi vertices.
|
||||
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||
// Rotating of the input polygon will help solve this problem.
|
||||
TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
|
||||
{
|
||||
Polygon poly = {
|
||||
@@ -2122,7 +2095,7 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines);
|
||||
#endif
|
||||
|
||||
auto has_duplicate_vertices = [](const VD &vd) -> bool {
|
||||
[[maybe_unused]] auto has_duplicate_vertices = [](const VD &vd) -> bool {
|
||||
std::vector<Vec2d> vertices;
|
||||
for (const auto &vertex : vd.vertices())
|
||||
vertices.emplace_back(Vec2d(vertex.x(), vertex.y()));
|
||||
@@ -2131,21 +2104,14 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
|
||||
return std::unique(vertices.begin(), vertices.end()) != vertices.end();
|
||||
};
|
||||
|
||||
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||
// rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(has_duplicate_vertices(raw_vd));
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK_FALSE(has_duplicate_vertices(vd));
|
||||
// REQUIRE(!has_duplicate_vertices(vd));
|
||||
}
|
||||
|
||||
// In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one
|
||||
// missing Voronoi vertex. One infinity-edge (after clip [(146872, -70146871), (146872, -146871)]) passes through this missing
|
||||
// Voronoi vertex. This infinite edge [(146872, -70146871), (146872, -146871)] and edge [(146873, -146873), (0, 0)] are intersecting.
|
||||
// They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex.
|
||||
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||
// Rotating of the input polygon will help solve this problem.
|
||||
TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
|
||||
{
|
||||
Polygon poly = {
|
||||
@@ -2169,7 +2135,7 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
|
||||
dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines);
|
||||
#endif
|
||||
|
||||
auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
|
||||
[[maybe_unused]] auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
|
||||
BoundingBox bbox = get_extents(poly);
|
||||
const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y()));
|
||||
|
||||
@@ -2200,12 +2166,5 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
|
||||
return false;
|
||||
};
|
||||
|
||||
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||
// rotating the input.
|
||||
VD raw_vd;
|
||||
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||
CHECK(has_intersecting_edges(poly, raw_vd));
|
||||
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||
CHECK_FALSE(has_intersecting_edges(poly, vd));
|
||||
// REQUIRE(!has_intersecting_edges(poly, vd));
|
||||
}
|
||||
@@ -150,6 +150,13 @@ public:
|
||||
return m_close_frames;
|
||||
}
|
||||
|
||||
// The printer answers a close frame before its own read completes, so the agent's stop can return first.
|
||||
bool wait_for_close_frames(int count)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
return m_changed.wait_for(lock, 10s, [&] { return m_close_frames >= count; });
|
||||
}
|
||||
|
||||
// Hangs up every connection that is waiting.
|
||||
void release()
|
||||
{
|
||||
@@ -277,6 +284,7 @@ private:
|
||||
if (ec == websocket::error::closed) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
++m_close_frames;
|
||||
m_changed.notify_all();
|
||||
}
|
||||
if (ec)
|
||||
reading = false;
|
||||
@@ -449,6 +457,7 @@ TEST_CASE("Stopping the status stream sends the printer a close frame", "[Moonra
|
||||
REQUIRE(f.printer.wait_for_ready_connections(1));
|
||||
|
||||
CHECK(f.stops_within(3s));
|
||||
CHECK(f.printer.wait_for_close_frames(1));
|
||||
CHECK(f.printer.close_frames() == 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
#include <boost/asio/write.hpp>
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/filesystem/fstream.hpp>
|
||||
#include <boost/nowide/cstdlib.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <atomic>
|
||||
@@ -23,7 +22,6 @@
|
||||
#include <istream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
@@ -196,29 +194,6 @@ private:
|
||||
std::vector<std::shared_ptr<Connection>> m_connections;
|
||||
};
|
||||
|
||||
// Sets an environment variable for the lifetime of the guard and restores the previous value.
|
||||
class ScopedEnvVar
|
||||
{
|
||||
public:
|
||||
ScopedEnvVar(std::string name, const std::string& value) : m_name(std::move(name))
|
||||
{
|
||||
if (const char* old = boost::nowide::getenv(m_name.c_str()))
|
||||
m_previous = old;
|
||||
boost::nowide::setenv(m_name.c_str(), value.c_str(), 1);
|
||||
}
|
||||
~ScopedEnvVar()
|
||||
{
|
||||
if (m_previous)
|
||||
boost::nowide::setenv(m_name.c_str(), m_previous->c_str(), 1);
|
||||
else
|
||||
boost::nowide::unsetenv(m_name.c_str());
|
||||
}
|
||||
|
||||
private:
|
||||
std::string m_name;
|
||||
std::optional<std::string> m_previous;
|
||||
};
|
||||
|
||||
// What the agent reported through its callbacks, recorded on whichever thread delivers it so the
|
||||
// assertions can run on the test thread.
|
||||
struct CallbackLog
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
|
||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
#include <slic3r/Utils/IPrinterAgent.hpp>
|
||||
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
|
||||
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_string.hpp>
|
||||
#include "libslic3r_version.h"
|
||||
#include "plugin_test_utils.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/cast.h>
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/eval.h>
|
||||
#include <pybind11/gil.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/pytypes.h>
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <ios>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
namespace py = pybind11;
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -349,3 +375,747 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit
|
||||
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The audit hook, run in the embedded interpreter
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system
|
||||
// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their
|
||||
// cases instead of using SECTIONs, which would restart the interpreter for every section.
|
||||
|
||||
namespace {
|
||||
|
||||
// Discovered, never loaded: the hook only needs the plugin's root and its sidecar.
|
||||
const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Audit Plugin"
|
||||
# description = "Plugin audit hook fixture"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
)PY";
|
||||
|
||||
// A third-party library, installed outside the stdlib and outside the plugin.
|
||||
const char* const LIBRARY_SOURCE = R"PY(
|
||||
import socket
|
||||
import urllib.request
|
||||
|
||||
def read_all(paths):
|
||||
for path in paths:
|
||||
with open(path) as f:
|
||||
f.read()
|
||||
|
||||
def connect_from_host(port):
|
||||
socket.gethostname()
|
||||
socket.create_connection(("127.0.0.1", port)).close()
|
||||
|
||||
def fetch(url):
|
||||
with urllib.request.urlopen(url) as response:
|
||||
return response.read()
|
||||
|
||||
def fetch_each(urls):
|
||||
for url in urls:
|
||||
yield fetch(url)
|
||||
|
||||
class ReadingHandler(urllib.request.BaseHandler):
|
||||
"""Reads a file while each response is processed."""
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.outcome = None
|
||||
|
||||
def http_response(self, request, response):
|
||||
try:
|
||||
open(self.path).close()
|
||||
self.outcome = "ok"
|
||||
except PermissionError:
|
||||
self.outcome = "denied"
|
||||
return response
|
||||
)PY";
|
||||
|
||||
// Test-side Python, run outside any plugin context: files outside every allowed root, a local
|
||||
// HTTP server, the library, and every module the tests use imported up front, so that no import
|
||||
// happens inside a plugin context. Code from exec() runs as "<string>", which is not stdlib, so
|
||||
// attempt() and fetch() stand in for plugin code.
|
||||
const char* const AUDIT_SETUP = R"PY(
|
||||
import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading
|
||||
import urllib.request, zipfile
|
||||
if os.name != "nt":
|
||||
import pty
|
||||
|
||||
APPROVED = os.path.join(OUTSIDE, "approved.txt")
|
||||
PRIVATE = os.path.join(OUTSIDE, "private.txt")
|
||||
MISSING = os.path.join(OUTSIDE, "missing")
|
||||
TREE = os.path.join(OUTSIDE, "tree")
|
||||
TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"]
|
||||
for path in (APPROVED, PRIVATE):
|
||||
with open(path, "w") as f:
|
||||
f.write("x")
|
||||
for sub in ("one", "two"):
|
||||
os.makedirs(os.path.join(TREE, sub))
|
||||
with open(os.path.join(TREE, sub, "f.txt"), "w") as f:
|
||||
f.write("x")
|
||||
|
||||
LIBRARY = os.path.join(OUTSIDE, "library")
|
||||
os.makedirs(LIBRARY)
|
||||
with open(os.path.join(LIBRARY, "netlib.py"), "w") as f:
|
||||
f.write(LIBRARY_SOURCE)
|
||||
sys.path.insert(0, LIBRARY)
|
||||
import netlib
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/redirect":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", URL_B)
|
||||
body = b""
|
||||
else:
|
||||
self.send_response(200)
|
||||
body = b"ok"
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
PORT = server.server_address[1]
|
||||
BASE = "http://127.0.0.1:%d" % PORT
|
||||
URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect"
|
||||
urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({})))
|
||||
|
||||
def fetch(url):
|
||||
with urllib.request.urlopen(url) as response:
|
||||
return response.read()
|
||||
|
||||
fetch(URL_A) # urllib imports some modules on its first request
|
||||
|
||||
def attempt(action):
|
||||
try:
|
||||
action()
|
||||
return "ok"
|
||||
except PermissionError:
|
||||
return "denied"
|
||||
|
||||
def spawn_checked(argv):
|
||||
parent = os.getpid()
|
||||
try:
|
||||
return pty.spawn(argv)
|
||||
finally:
|
||||
if os.getpid() != parent:
|
||||
os._exit(1) # a child whose exec was refused must not go on running the tests
|
||||
|
||||
finalizer_outcomes = []
|
||||
|
||||
class TrackedRequest(urllib.request.Request):
|
||||
def __del__(self):
|
||||
finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||
)PY";
|
||||
|
||||
struct Prompt
|
||||
{
|
||||
std::string plugin;
|
||||
std::string event;
|
||||
std::vector<std::string> targets;
|
||||
};
|
||||
|
||||
// One line per prompt, "event: target ...", so a failed check shows every prompt.
|
||||
std::string describe(const std::vector<Prompt>& prompts)
|
||||
{
|
||||
std::string text;
|
||||
for (const Prompt& prompt : prompts) {
|
||||
text += prompt.event + ":";
|
||||
for (const std::string& target : prompt.targets)
|
||||
text += " " + target;
|
||||
text += "\n";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// The plugin system with the audit hook installed, and the permission dialog replaced by a
|
||||
// recorder that answers with `approve`. Declare it first in a test, so it is destroyed last.
|
||||
struct AuditHarness
|
||||
{
|
||||
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
|
||||
// data_dir() is a global allowed root from then on, so the test files live in `outside`.
|
||||
ScopedDataDir data{"plugin-audit-hook"};
|
||||
bool initialized = PluginManager::instance().initialize();
|
||||
ScopedTemporaryDir outside{"orca-plugin-audit-outside"};
|
||||
std::vector<Prompt> prompts;
|
||||
std::function<bool(const Prompt&)> approve = [](const Prompt&) { return false; };
|
||||
|
||||
AuditHarness()
|
||||
{
|
||||
PluginAuditManager::set_permission_prompt_for_testing(
|
||||
[this](const std::string& event, const std::vector<std::string>& targets) {
|
||||
prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets});
|
||||
return approve(prompts.back());
|
||||
});
|
||||
}
|
||||
|
||||
~AuditHarness()
|
||||
{
|
||||
PluginAuditManager::set_permission_prompt_for_testing(nullptr);
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
|
||||
// Call before taking the GIL: discovery is not Python work.
|
||||
void add_plugin(const std::string& key)
|
||||
{
|
||||
const fs::path root = data.plugins_dir() / key;
|
||||
fs::create_directories(root);
|
||||
std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE;
|
||||
save_permissions(key, {});
|
||||
PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
}
|
||||
|
||||
// Grants as if saved in an earlier session.
|
||||
void save_permissions(const std::string& key, const PluginPermissions& permissions)
|
||||
{
|
||||
PluginInstallState state;
|
||||
state.installed_from = "local";
|
||||
state.installed_version = "1.0";
|
||||
state.plugin_name = key;
|
||||
state.permissions = permissions;
|
||||
REQUIRE(write_install_state(data.plugins_dir() / key, state));
|
||||
}
|
||||
|
||||
PluginPermissions saved_permissions(const std::string& key) const
|
||||
{
|
||||
PluginInstallState state;
|
||||
read_install_state(data.plugins_dir() / key, state);
|
||||
return state.permissions;
|
||||
}
|
||||
};
|
||||
|
||||
// The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP
|
||||
// server before the interpreter shuts down.
|
||||
struct PythonSide
|
||||
{
|
||||
py::dict globals;
|
||||
|
||||
explicit PythonSide(const AuditHarness& harness)
|
||||
{
|
||||
globals["OUTSIDE"] = harness.outside.path().string();
|
||||
globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE;
|
||||
exec(AUDIT_SETUP);
|
||||
}
|
||||
|
||||
// A Python error fails the test here, as text: an error_already_set left to propagate would
|
||||
// outlive the interpreter the harness shuts down.
|
||||
void exec(const char* code)
|
||||
{
|
||||
std::string error;
|
||||
try {
|
||||
py::exec(code, globals);
|
||||
} catch (py::error_already_set& raised) {
|
||||
error = raised.what();
|
||||
}
|
||||
if (!error.empty())
|
||||
FAIL(error);
|
||||
}
|
||||
|
||||
~PythonSide()
|
||||
{
|
||||
try {
|
||||
globals["server"].attr("shutdown")();
|
||||
globals["server"].attr("server_close")();
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string str(const char* name) const { return py::str(globals[name]).cast<std::string>(); }
|
||||
std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast<std::string>(); }
|
||||
std::vector<std::string> strings(const char* name) const { return globals[name].cast<std::vector<std::string>>(); }
|
||||
|
||||
// Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an
|
||||
// audit scope of its own.
|
||||
void run_as(const std::string& plugin_key, const char* code)
|
||||
{
|
||||
ScopedPluginAuditContext audit(plugin_key);
|
||||
exec(code);
|
||||
}
|
||||
};
|
||||
|
||||
PluginPermissions http_grants(std::vector<std::string> urls)
|
||||
{
|
||||
PluginPermissions permissions;
|
||||
permissions.network_http = std::move(urls);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const char* const PLUGIN = "AuditPlugin";
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")}));
|
||||
|
||||
python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))");
|
||||
|
||||
CHECK(python.str("outcome") == "ok");
|
||||
CHECK(describe(harness.prompts).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A saved request grant does not cover another URL or a redirect", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")}));
|
||||
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)]
|
||||
outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)]
|
||||
outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B]))))
|
||||
)PY");
|
||||
|
||||
// The redirect target is asked for inside the approved redirecting request, and a library's
|
||||
// wrapper or generator asks for the second URL as plain code does.
|
||||
CHECK(python.strings("outcomes") == std::vector<std::string>{"ok", "denied", "denied", "ok", "denied", "denied"});
|
||||
const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n";
|
||||
CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b);
|
||||
}
|
||||
|
||||
TEST_CASE("Code an approved operation calls back into is judged on its own", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
python.exec(R"PY(
|
||||
CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy")
|
||||
handler = netlib.ReadingHandler(PRIVATE)
|
||||
handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler)
|
||||
walk_outcomes = []
|
||||
copy_outcomes = []
|
||||
|
||||
def on_walk_error(error):
|
||||
walk_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||
|
||||
def copy_file(source, destination):
|
||||
copy_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||
)PY");
|
||||
PluginPermissions permissions = http_grants({python.str("URL_A")});
|
||||
permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")};
|
||||
permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")};
|
||||
harness.save_permissions(PLUGIN, permissions);
|
||||
|
||||
// A library handler that urllib calls while the approved request runs, the plugin's own
|
||||
// os.walk error callback and copytree copy function (called once per file), and a library
|
||||
// loop over a saved file and an unsaved one.
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
request_outcome = attempt(lambda: handler_opener.open(URL_A).read())
|
||||
for _ in os.walk(MISSING, onerror=on_walk_error):
|
||||
pass
|
||||
shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file)
|
||||
loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE]))
|
||||
)PY");
|
||||
|
||||
CHECK(python.str("request_outcome") == "ok");
|
||||
CHECK(python.eval("handler.outcome") == "denied");
|
||||
CHECK(python.strings("walk_outcomes") == std::vector<std::string>{"denied"});
|
||||
CHECK(python.strings("copy_outcomes") == std::vector<std::string>{"denied", "denied"});
|
||||
CHECK(python.str("loop_outcome") == "denied");
|
||||
const std::string private_file = "open: " + python.str("PRIVATE") + "\n";
|
||||
CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file);
|
||||
}
|
||||
|
||||
TEST_CASE("A saved directory walk covers each directory it lists", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
PluginPermissions permissions;
|
||||
permissions.fs_read = {python.str("TREE"), python.str("APPROVED")};
|
||||
harness.save_permissions(PLUGIN, permissions);
|
||||
|
||||
// The walk lists the tree's two subdirectories on later resumes, after the plugin's own
|
||||
// audited reads in between. Abandoned walks leave frames whose generator is gone, which the
|
||||
// next audited event lets go.
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
walked = []
|
||||
for root, dirs, files in os.walk(TREE):
|
||||
walked.append(root)
|
||||
open(APPROVED).close()
|
||||
globbed = list(pathlib.Path(TREE).glob("**/*"))
|
||||
|
||||
abandoned = os.walk(TREE)
|
||||
next(abandoned)
|
||||
del abandoned
|
||||
open(APPROVED).close()
|
||||
for _ in pathlib.Path(TREE).glob("*"):
|
||||
break
|
||||
open(APPROVED).close()
|
||||
)PY");
|
||||
|
||||
CHECK(py::len(python.globals["walked"]) == 3);
|
||||
CHECK(py::len(python.globals["globbed"]) == 4);
|
||||
CHECK(describe(harness.prompts).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A freshly approved operation covers its nested events", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
|
||||
struct Case
|
||||
{
|
||||
const char* prompts;
|
||||
const char* code;
|
||||
};
|
||||
// Each operation names targets no earlier one saved, so each is asked once. FsCreate and
|
||||
// FsDelete grants are never saved, so those operations are asked every time. Only the
|
||||
// expected prompts are approved, so a nested event left uncovered fails the operation.
|
||||
std::vector<Case> cases = {
|
||||
{"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"},
|
||||
{"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"},
|
||||
{"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"},
|
||||
{"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"},
|
||||
{"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"},
|
||||
{"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"},
|
||||
{"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"},
|
||||
{"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"},
|
||||
{"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"},
|
||||
{"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"},
|
||||
{"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"},
|
||||
// send() runs after connect() has returned, so its own grant is asked once.
|
||||
{"http.client.connect\nhttp.client.send",
|
||||
"connection = http.client.HTTPConnection('127.0.0.1', PORT)\n"
|
||||
"connection.request('GET', '/a')\n"
|
||||
"connection.getresponse().read()\n"
|
||||
"connection.close()"},
|
||||
};
|
||||
#ifndef _WIN32
|
||||
cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"});
|
||||
// The forked child's exec is covered by the spawn too: had it prompted, the child's own
|
||||
// denial would end it with status 1.
|
||||
cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"});
|
||||
#endif
|
||||
|
||||
for (const Case& operation : cases) {
|
||||
INFO(operation.code);
|
||||
const std::string expected = std::string(operation.prompts) + "\n";
|
||||
harness.approve = [&expected](const Prompt& prompt) {
|
||||
return expected.find(prompt.event + "\n") != std::string::npos;
|
||||
};
|
||||
harness.prompts.clear();
|
||||
python.run_as(PLUGIN, operation.code);
|
||||
|
||||
std::string asked;
|
||||
for (const Prompt& prompt : harness.prompts)
|
||||
asked += (asked.empty() ? "" : "\n") + prompt.event;
|
||||
CHECK(asked == operation.prompts);
|
||||
}
|
||||
#ifndef _WIN32
|
||||
CHECK(python.eval("spawn_status") == "0");
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; };
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
socket.socket().close()
|
||||
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9)))
|
||||
udp.close()
|
||||
)PY");
|
||||
|
||||
// The socket is asked for once, without a target to show; the datagram's address is a target.
|
||||
CHECK(python.str("sendto_outcome") == "denied");
|
||||
CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n");
|
||||
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"Socket:socket.__new__"});
|
||||
CHECK(harness.saved_permissions(PLUGIN).network_socket.empty());
|
||||
|
||||
harness.prompts.clear();
|
||||
python.run_as(PLUGIN, "socket.socket().close()");
|
||||
CHECK(describe(harness.prompts).empty());
|
||||
|
||||
// A grant for an event with no target covers nothing else: a library's approved
|
||||
// gethostname() does not carry the connection it makes next.
|
||||
harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; };
|
||||
python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))");
|
||||
CHECK(python.str("connect_outcome") == "denied");
|
||||
CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n");
|
||||
harness.prompts.clear();
|
||||
harness.approve = [](const Prompt&) { return true; };
|
||||
|
||||
#ifndef _WIN32
|
||||
// sendmsg() without an address names no target either.
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
left, right = socket.socketpair()
|
||||
left.sendmsg([b"x"])
|
||||
left.close()
|
||||
right.close()
|
||||
)PY");
|
||||
CHECK(describe(harness.prompts) == "socket.sendmsg:\n");
|
||||
CHECK(harness.saved_permissions(PLUGIN).events ==
|
||||
std::vector<std::string>{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"});
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; };
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
python.exec(R"PY(
|
||||
READ_FD = os.open(APPROVED, os.O_RDONLY)
|
||||
WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT)
|
||||
)PY");
|
||||
|
||||
// A file descriptor has no path to show or save: a granted read of one is not a write.
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()),
|
||||
attempt(lambda: open(WRITE_FD, "w", closefd=False).close())]
|
||||
)PY");
|
||||
CHECK(python.strings("fd_outcomes") == std::vector<std::string>{"ok", "denied"});
|
||||
CHECK(describe(harness.prompts) == "open:\nopen:\n");
|
||||
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"FsRead:open"});
|
||||
|
||||
// A saved os.fork event grant is not an os.system command named "os.fork".
|
||||
PluginPermissions permissions;
|
||||
permissions.events = {"FsRead:open", "ProcessCreate:os.fork"};
|
||||
harness.save_permissions(PLUGIN, permissions);
|
||||
harness.prompts.clear();
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close())
|
||||
system_outcome = attempt(lambda: os.system("os.fork"))
|
||||
os.close(READ_FD)
|
||||
os.close(WRITE_FD)
|
||||
)PY");
|
||||
CHECK(python.str("reread_outcome") == "ok");
|
||||
CHECK(python.str("system_outcome") == "denied");
|
||||
REQUIRE(harness.prompts.size() == 1);
|
||||
CHECK(harness.prompts.front().event == "os.system");
|
||||
CHECK(harness.saved_permissions(PLUGIN).process.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin(PLUGIN);
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
PluginPermissions permissions = http_grants({python.str("URL_A")});
|
||||
permissions.fs_read = {python.str("APPROVED")};
|
||||
harness.save_permissions(PLUGIN, permissions);
|
||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
|
||||
// Once the request returns, only its held frame keeps the request object alive, and the
|
||||
// request's finalizer reads a file the plugin was never granted.
|
||||
python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))");
|
||||
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"}); // at scope end
|
||||
CHECK(describe(harness.prompts).empty());
|
||||
CHECK_FALSE(mgr.audit_denial_pending());
|
||||
|
||||
python.exec("finalizer_outcomes.clear()");
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
fetch(TrackedRequest(URL_A))
|
||||
open(APPROVED).close() # the next event lets the returned request's frame go
|
||||
outcomes_before_scope_end = list(finalizer_outcomes)
|
||||
)PY");
|
||||
CHECK(python.strings("outcomes_before_scope_end") == std::vector<std::string>{"denied"});
|
||||
CHECK(describe(harness.prompts).empty());
|
||||
|
||||
// The finalizer's denial during another event's decision leaves that event's own denial intact.
|
||||
python.exec("finalizer_outcomes.clear()");
|
||||
python.run_as(PLUGIN, R"PY(
|
||||
fetch(TrackedRequest(URL_A))
|
||||
try:
|
||||
os.listdir(OUTSIDE)
|
||||
outer_error = ""
|
||||
except PermissionError as error:
|
||||
outer_error = str(error)
|
||||
)PY");
|
||||
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"});
|
||||
CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir"));
|
||||
CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n");
|
||||
AuditViolation violation;
|
||||
REQUIRE(mgr.last_violation(violation));
|
||||
CHECK(violation.event_name == "os.listdir");
|
||||
CHECK(mgr.audit_denial_pending());
|
||||
}
|
||||
|
||||
TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.add_plugin("PluginA");
|
||||
harness.add_plugin("PluginB");
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
PluginPermissions permissions;
|
||||
permissions.fs_read = {python.str("TREE")};
|
||||
harness.save_permissions("PluginA", permissions);
|
||||
|
||||
{
|
||||
// Two walks plugin A started and suspended after listing the tree itself.
|
||||
ScopedPluginAuditContext plugin_a("PluginA");
|
||||
python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)");
|
||||
{
|
||||
// Plugin B resuming one of them is asked for each subdirectory, as plugin B.
|
||||
ScopedPluginAuditContext plugin_b("PluginB");
|
||||
python.exec("listed_by_b = len(list(first))");
|
||||
}
|
||||
// Back in plugin A's scope, its walks are covered again.
|
||||
python.exec("listed_by_a = len(list(second))");
|
||||
}
|
||||
|
||||
CHECK(python.globals["listed_by_b"].cast<int>() == 0);
|
||||
CHECK(python.globals["listed_by_a"].cast<int>() == 2);
|
||||
REQUIRE(harness.prompts.size() == 2);
|
||||
for (const Prompt& prompt : harness.prompts) {
|
||||
CHECK(prompt.plugin == "PluginB");
|
||||
CHECK(prompt.event == "os.scandir");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; };
|
||||
py::gil_scoped_acquire gil;
|
||||
PythonSide python(harness);
|
||||
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
|
||||
python.globals["orca"] = py::module_::import("orca");
|
||||
|
||||
// walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker
|
||||
// in its locals; the frame is let go when the capability call ends, which runs the marker's
|
||||
// finalizer.
|
||||
python.exec(R"PY(
|
||||
class Marker:
|
||||
def __init__(self, action=None):
|
||||
self.action = action
|
||||
def __del__(self):
|
||||
finalized.append(self.action() if self.action else "ran")
|
||||
|
||||
finalized = []
|
||||
|
||||
def walk_and_drop(marker):
|
||||
walker = os.walk(TREE, onerror=marker)
|
||||
next(walker)
|
||||
del walker
|
||||
|
||||
class ConfigCapability(orca.PythonPluginBase):
|
||||
def get_name(self):
|
||||
return "config_cap"
|
||||
def get_default_config(self):
|
||||
walk_and_drop(Marker())
|
||||
return {"finalized_during_call": len(finalized)}
|
||||
def has_config_ui(self):
|
||||
walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close())))
|
||||
next(walker)
|
||||
attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key
|
||||
del walker # the walk's frame is now held by the audit scope alone
|
||||
raise RuntimeError("the capability fails after a denial")
|
||||
|
||||
class TicketAgent(orca.printer_agent.PrinterAgentBase):
|
||||
def get_name(self):
|
||||
return "ticket_agent"
|
||||
def request_bind_ticket(self):
|
||||
walk_and_drop(Marker())
|
||||
return (0, "ticket")
|
||||
)PY");
|
||||
|
||||
py::object config_instance = python.globals["ConfigCapability"]();
|
||||
auto config_cap = config_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
config_cap->set_audit_plugin_key("GilPlugin");
|
||||
config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script);
|
||||
|
||||
py::object agent_instance = python.globals["TicketAgent"]();
|
||||
auto agent_cap = agent_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
agent_cap->set_audit_plugin_key("GilPlugin");
|
||||
agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection);
|
||||
auto agent = std::dynamic_pointer_cast<IPrinterAgent>(agent_cap);
|
||||
REQUIRE(agent);
|
||||
|
||||
// A host call arrives without the GIL; both calls take it before their audit scope.
|
||||
nlohmann::json config;
|
||||
int ticket_result = -1;
|
||||
std::string ticket;
|
||||
{
|
||||
py::gil_scoped_release released;
|
||||
config = config_cap->get_default_config();
|
||||
ticket_result = agent->request_bind_ticket(&ticket);
|
||||
}
|
||||
CHECK(config == nlohmann::json{{"finalized_during_call", 0}});
|
||||
CHECK(ticket_result == 0);
|
||||
CHECK(ticket == "ticket");
|
||||
CHECK(python.strings("finalized") == std::vector<std::string>{"ran", "ran"});
|
||||
|
||||
// On the trampoline's denial path the frame is let go as the plugin, though the ambient key
|
||||
// was cleared: the finalizer's read is audited and denied without a prompt.
|
||||
python.exec("finalized.clear()");
|
||||
harness.prompts.clear();
|
||||
CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set);
|
||||
CHECK(python.strings("finalized") == std::vector<std::string>{"denied"});
|
||||
CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n");
|
||||
}
|
||||
|
||||
TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]")
|
||||
{
|
||||
AuditHarness harness;
|
||||
if (!harness.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil;
|
||||
|
||||
const py::dict paths = py::module_::import("sysconfig").attr("get_paths")();
|
||||
const fs::path stdlib = py::str(paths["stdlib"]).cast<std::string>();
|
||||
const fs::path purelib = py::str(paths["purelib"]).cast<std::string>();
|
||||
const fs::path link = harness.outside.path() / "linked-stdlib";
|
||||
boost::system::error_code ec;
|
||||
fs::create_directory_symlink(stdlib, link, ec);
|
||||
if (ec)
|
||||
SKIP("Cannot create a directory symlink here: " + ec.message());
|
||||
|
||||
CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string()));
|
||||
CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string()));
|
||||
CHECK(PluginAuditManager::is_stdlib_file("<frozen os>"));
|
||||
CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string()));
|
||||
CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string()));
|
||||
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
|
||||
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include "plugin_test_utils.hpp"
|
||||
#include <pybind11/pytypes.h>
|
||||
#include <pybind11/eval.h>
|
||||
@@ -18,12 +17,6 @@
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/filesystem/path.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <ios>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <pybind11/cast.h>
|
||||
@@ -112,72 +105,6 @@ PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, co
|
||||
return {type, name, plugin_key};
|
||||
}
|
||||
|
||||
// restore_config_response looks the capability up in PluginManager, so the restore tests load a real
|
||||
// plugin rather than building a detached capability.
|
||||
const char* const RESTORE_PLUGIN_KEY = "Restore_Plugin";
|
||||
|
||||
const char* const RESTORE_PLUGIN_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Restore Plugin"
|
||||
# description = "Restore defaults fixture"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
import orca
|
||||
|
||||
class Target(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self): return "Target"
|
||||
def execute(self, ctx): return orca.ExecutionResult.success()
|
||||
def get_default_config(self): return {"speed": 1}
|
||||
|
||||
class Bystander(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self): return "Bystander"
|
||||
def execute(self, ctx): return orca.ExecutionResult.success()
|
||||
def get_default_config(self): return {"speed": 2}
|
||||
|
||||
# Loading seeds defaults through get_default_config and fails the plugin if it raises, so this one
|
||||
# answers the load and raises from then on.
|
||||
class Raising(orca.script.ScriptPluginCapabilityBase):
|
||||
calls = 0
|
||||
def get_name(self): return "Raising"
|
||||
def execute(self, ctx): return orca.ExecutionResult.success()
|
||||
def get_default_config(self):
|
||||
Raising.calls += 1
|
||||
if Raising.calls > 1:
|
||||
raise RuntimeError("boom")
|
||||
return {}
|
||||
|
||||
@orca.plugin
|
||||
class RestorePackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(Target)
|
||||
orca.register_capability(Bystander)
|
||||
orca.register_capability(Raising)
|
||||
)PY";
|
||||
|
||||
// Writes the restore fixture into {data_dir}/orca_plugins and loads it, returning the load error
|
||||
// (empty on success). Call without holding the GIL: the load runs on a worker thread.
|
||||
std::string load_restore_plugin(const ScopedDataDir& data_dir_guard)
|
||||
{
|
||||
const boost::filesystem::path plugin_dir = data_dir_guard.plugins_dir() / RESTORE_PLUGIN_KEY;
|
||||
boost::filesystem::create_directories(plugin_dir);
|
||||
{
|
||||
std::ofstream out((plugin_dir / (std::string(RESTORE_PLUGIN_KEY) + ".py")).string(), std::ios::binary);
|
||||
out << RESTORE_PLUGIN_SOURCE;
|
||||
}
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
manager.load_plugin(RESTORE_PLUGIN_KEY, /*skip_deps=*/true);
|
||||
std::string error;
|
||||
if (!manager.wait_for_plugin_load(RESTORE_PLUGIN_KEY, std::chrono::seconds(120), error) && error.empty())
|
||||
error = "the plugin did not load";
|
||||
return error;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
|
||||
@@ -430,27 +357,26 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-restore");
|
||||
host_config().load();
|
||||
const std::string load_error = load_restore_plugin(data_dir_guard);
|
||||
INFO("load error: " << load_error);
|
||||
REQUIRE(load_error.empty());
|
||||
|
||||
const PluginCapabilityId target = capability_id(PluginCapabilityType::Script, "Target", RESTORE_PLUGIN_KEY);
|
||||
const PluginCapabilityId bystander = capability_id(PluginCapabilityType::Script, "Bystander", RESTORE_PLUGIN_KEY);
|
||||
const std::string defaults_body = " def get_name(self): return 'cap'\n"
|
||||
" def get_default_config(self): return {'speed': 1}\n";
|
||||
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
|
||||
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
|
||||
|
||||
const json edited = json{{"speed", 99}};
|
||||
REQUIRE(host_config().store_capability_config(target, edited));
|
||||
REQUIRE(host_config().store_capability_config(bystander, edited));
|
||||
REQUIRE(py_save_config(target, edited));
|
||||
REQUIRE(py_save_config(bystander, edited));
|
||||
|
||||
const json response = PluginConfig::restore_config_response(target);
|
||||
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
|
||||
auto iface = as_interface(target);
|
||||
REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
|
||||
|
||||
CHECK(response["ok"] == true);
|
||||
CHECK(response["config"] == json{{"speed", 1}});
|
||||
CHECK(host_config().get_config(target)->config == json{{"speed", 1}});
|
||||
CHECK(host_config().get_config(bystander)->config == edited);
|
||||
|
||||
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}});
|
||||
}
|
||||
|
||||
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
|
||||
@@ -458,23 +384,24 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
|
||||
host_config().load();
|
||||
const std::string load_error = load_restore_plugin(data_dir_guard);
|
||||
INFO("load error: " << load_error);
|
||||
REQUIRE(load_error.empty());
|
||||
|
||||
const PluginCapabilityId raising = capability_id(PluginCapabilityType::Script, "Raising", RESTORE_PLUGIN_KEY);
|
||||
REQUIRE(host_config().store_capability_config(raising, json{{"keep", "me"}}));
|
||||
py::object cap = make_capability("RaisingDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): raise RuntimeError('boom')\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
const json response = PluginConfig::restore_config_response(raising);
|
||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||
|
||||
CHECK(response["ok"] == false);
|
||||
CHECK_FALSE(response["error"].get<std::string>().empty());
|
||||
CHECK(host_config().get_config(raising)->config == json{{"keep", "me"}});
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set);
|
||||
|
||||
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
|
||||
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings.
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
|
||||
|
||||
@@ -76,12 +76,12 @@ TEST_CASE("plugin latest version uses the authoritative catalog field", "[Plugin
|
||||
{
|
||||
PluginDescriptor descriptor;
|
||||
descriptor.version = "1.3.0";
|
||||
descriptor.latest_version = "1.4.0";
|
||||
descriptor.latest_version = "1.3.0";
|
||||
PluginChangelog changelog;
|
||||
changelog.version = "1.2.0";
|
||||
descriptor.changelog.push_back(changelog);
|
||||
|
||||
CHECK(descriptor.latest_available_version() == "1.4.0");
|
||||
CHECK(descriptor.latest_available_version() == "1.3.0");
|
||||
}
|
||||
|
||||
TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <ios>
|
||||
#include <libslic3r/LifecycleEvents.hpp>
|
||||
#include <memory>
|
||||
#include <slic3r/Utils/Http.hpp>
|
||||
#include <slic3r/Utils/NetworkAgentFactory.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
@@ -14,8 +15,13 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include "plugin_test_utils.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <pybind11/cast.h>
|
||||
#include <pybind11/gil.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/pytypes.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
@@ -1068,3 +1074,43 @@ TEST_CASE("Unloading a plugin that is not loaded is a no-op", "[PluginLifecycle]
|
||||
CHECK(manager.unload_plugin("No_Such_Plugin"));
|
||||
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// CA certificates in the default client context, as a plugin's urllib or http.client request would
|
||||
// verify against.
|
||||
int default_context_ca_count()
|
||||
{
|
||||
pybind11::gil_scoped_acquire gil;
|
||||
const pybind11::dict stats = pybind11::module_::import("ssl").attr("create_default_context")().attr("cert_store_stats")();
|
||||
return stats["x509_ca"].cast<int>();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("The bundled Python trusts the system CA certificates", "[PluginLifecycle][Python]")
|
||||
{
|
||||
// The app runs this before plugins load; on Linux it is what points OpenSSL at the distro bundle.
|
||||
Http::tls_global_init();
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
CHECK(default_context_ca_count() > 0);
|
||||
}
|
||||
|
||||
#ifndef _WIN32 // Windows adds its system store whatever SSL_CERT_FILE names.
|
||||
TEST_CASE("SSL_CERT_FILE still overrides the bundled Python's default CA certificates", "[PluginLifecycle][Python]")
|
||||
{
|
||||
// Existing but empty: Linux's CurlGlobalInit replaces an SSL_CERT_FILE that names a missing file.
|
||||
ScopedTemporaryFile empty_bundle(".pem");
|
||||
std::ofstream(empty_bundle.string()).close();
|
||||
ScopedEnvVar ssl_cert_file{"SSL_CERT_FILE", empty_bundle.string()};
|
||||
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
CHECK(default_context_ca_count() == 0);
|
||||
}
|
||||
#endif
|
||||
@@ -615,19 +615,12 @@ TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_s
|
||||
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
|
||||
CHECK(c.shape(0) == 4);
|
||||
|
||||
// lslices are derived: make_slices() re-derives them from the region slices and refreshes the
|
||||
// bbox cache.
|
||||
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
|
||||
TestLayer layer;
|
||||
layer.add_region(nullptr);
|
||||
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
|
||||
ly.attr("regions")().cast<py::list>()[0].attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
|
||||
REQUIRE(layer.lslices.empty());
|
||||
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
|
||||
ly.attr("make_slices")();
|
||||
REQUIRE(layer.lslices.size() == 1);
|
||||
CHECK_THAT(layer.lslices.front().area(), WithinRel((double) s * (double) s, 1e-9));
|
||||
REQUIRE(layer.lslices_bboxes.size() == 1);
|
||||
CHECK(layer.lslices_bboxes.front().min == Point(0, 0));
|
||||
CHECK(layer.lslices_bboxes.front().max == Point(s, s));
|
||||
CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
#include <libslic3r/Utils.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/nowide/cstdlib.hpp>
|
||||
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#if defined(WIN32) || defined(_WIN32)
|
||||
#define PATH_SEPARATOR R"(\)"
|
||||
@@ -197,4 +200,27 @@ private:
|
||||
boost::filesystem::path m_previous;
|
||||
};
|
||||
|
||||
// Sets an environment variable for the lifetime of the guard and restores the previous value.
|
||||
class ScopedEnvVar
|
||||
{
|
||||
public:
|
||||
ScopedEnvVar(std::string name, const std::string& value) : m_name(std::move(name))
|
||||
{
|
||||
if (const char* old = boost::nowide::getenv(m_name.c_str()))
|
||||
m_previous = old;
|
||||
boost::nowide::setenv(m_name.c_str(), value.c_str(), 1);
|
||||
}
|
||||
~ScopedEnvVar()
|
||||
{
|
||||
if (m_previous)
|
||||
boost::nowide::setenv(m_name.c_str(), m_previous->c_str(), 1);
|
||||
else
|
||||
boost::nowide::unsetenv(m_name.c_str());
|
||||
}
|
||||
|
||||
private:
|
||||
std::string m_name;
|
||||
std::optional<std::string> m_previous;
|
||||
};
|
||||
|
||||
#endif // SLIC3R_TEST_UTILS
|
||||
Reference in new issue
Block a user