Merge remote-tracking branch 'origin/feature/texture_displacement' into feature/texture_displacement

# Conflicts:
#	src/libslic3r/TextureDisplacement.cpp
This commit is contained in:
ExPikaPaka
2026-09-21 09:00:18 +02:00
7386 changed files with 272086 additions and 157862 deletions
+53
View File
@@ -0,0 +1,53 @@
# Design-tab scripts
Everything here supports the parametric Design tab (`src/libslic3r/CAD/`,
`src/slic3r/GUI/CAD/`). Nothing here is needed to build or run OrcaSlicer — these
are the development and verification tools for that one feature.
The verb in the name is the role:
| | |
|---|---|
| `build-…` | produce a binary |
| `start-…` | bring something up and leave it running |
| `run-…` | run a suite and report pass/fail |
| `check-…` | one specific assertion, usually driving a live app |
## Verification
| Script | What it proves | Needs |
|---|---|---|
| `run-kernel-tests.sh` | The CAD kernel builds and the Catch2 `[CadDocument]` tags pass — every case builds a document, recomputes it and asserts on real geometry. **Exit 0 is the verification contract.** | Docker only. No display. |
| `run-all-checks.sh` | Every check below, in one command. The gate before pushing a Design-tab change. | Docker + the GUI container |
| `check-sketch-engine.py` | A ladder of 2D sketches of increasing complexity, judged on loop count, closure and void attribution rather than on area. | Kernel only |
| `check-sketch-engine-corpus.py` | The same ladder graded against a systematic sample of real drawings instead of shapes we chose. | Kernel + corpus |
| `check-gui-sketching.py` | The same profiles drawn the way a person draws them — synthetic mouse gestures and typed values. | Headless GUI |
| `check-gui-context-menu.py` | That right-click is the pivot of the design gesture, and adapts to what was clicked. | Headless GUI |
| `check-mcp-sketch.py` | The sketch layer driven over the MCP socket, asserting what decides whether a profile is buildable. | Headless GUI + `ORCA_CAD_MCP` |
**`run-kernel-tests.sh` is the only one CI can run.** The rest need a live
application with an OpenGL canvas and synthetic input, which hosted runners do not
have. The kernel suite itself is already in CI by an ordinary route: the cases are
registered in `tests/libslic3r/CMakeLists.txt` under `if (SLIC3R_CAD)`, so they are
part of `libslic3r_tests` and run under `ctest` on every platform like any other
unit test. This script exists for the local loop, where it is a two-minute round
trip instead of a full application build.
## Build and run
| Script | Purpose |
|---|---|
| `build-gui.sh` | Build the GUI binary in a throwaway container, writing into the build-cache volume the long-lived GUI container reads. |
| `build-gui-incremental.sh` | Incremental build against the deps-baked image, for a fast edit/compile loop. |
| `start-headless-gui.sh` | Bring the app up on a headless X display (Xvfb + a window manager), ready to drive or attach to over VNC. |
Two constraints that are not obvious and have each cost a session:
- **Never build inside the GUI container.** Its baked source tree silently
reconfigures the shared build directory and this fork's targets vanish.
- **A window manager is required.** Without one, windows are never focused, and an
unfocused GTK app ignores synthetic keys — which looks exactly like a code bug.
`docs/rig_build_traps.md` documents these and three more, with symptoms and exact
recovery commands. Read it before debugging a configure or link failure one of
these scripts reports.
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Incremental slicer build against the orcacad-deps base image.
#
# The deps-baked image (built from scripts/Dockerfile.deps) carries the pinned
# dependencies at /OrcaSlicer/deps/build/destdir. This script mounts the LIVE source
# tree and resources over the baked copy so code/CMake edits apply immediately, and
# persists /OrcaSlicer/build in a named volume so ninja recompiles only what changed.
#
# Result: edit -> rebuild in seconds-to-minutes instead of a full Docker rebuild.
#
# Usage (run on the build host, e.g. behemoth, from anywhere):
# scripts/CAD/build-gui-incremental.sh
# IMAGE=orcacad-deps scripts/CAD/build-gui-incremental.sh
#
# On success the binary is inside the persistent volume at
# /OrcaSlicer/build/package/bin/orca-slicer (copy it out with a follow-up
# `docker run --rm -v orcacad_buildcache:/b alpine cp ...` or via this script's tail).
# Rig build traps already paid for once each (stale project, NLopt cache, pybind11, OCCT_LIBS, SLIC3R_CAD gate): docs/rig_build_traps.md
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# orcacad-deps, NOT snapmaker-deps: see the note in run-kernel-tests.sh — the wrong image
# fails at CMake configure, not at link time.
IMAGE="${IMAGE:-orcacad-deps}"
BUILD_VOL="${BUILD_VOL:-orcacad_buildcache}"
echo "REPO=$REPO IMAGE=$IMAGE BUILD_VOL=$BUILD_VOL"
# The root CMakeLists.txt and cmake/ must be mounted too, not taken from the baked image:
# they carry the build-time gates (e.g. SLIC3R_CAD -> add_definitions(-DSLIC3R_CAD)) that the
# mounted headers are compiled against. With a stale baked copy the gate silently stays off and
# the build fails with "class GLCanvas3D has no member named set_design_sketch_tool".
#
# build_linux.sh must be mounted for the same reason, and here the stale copy is guaranteed
# wrong rather than merely risky: orcacad-deps is layered on snapmaker-deps, so the baked script
# is the OTHER fork's and builds `--target Snapmaker_Orca`. This fork's target is `OrcaSlicer`,
# so without this mount configure succeeds and then ninja dies on "unknown target".
# scripts/ likewise: build_linux.sh's packaging step sources scripts/appimage_lib_policy.sh,
# which the baked Snapmaker tree does not have, so a fully successful link still exited
# non-zero with "missing AppImage helper" and the binary check never ran.
# ---- OOM guard (2026-08-21) -------------------------------------------------------------
# Two of these builds ran at once on 2026-08-21, each with ninja -j$(nproc)=16: ~36 cc1plus
# holding 42 GB of a 62 GB box -> global OOM at 21:05, a 2h28m kill storm, ssh unreachable,
# lightdm destroyed. Neither build produced a single object. scripts/CAD/build-gui.sh grew the
# bounds first; every script that starts a compile needs the same three, or the guard is only
# as strong as the script you happened not to use.
# flock — the lock path is SHARED with build-gui.sh and the other fork on purpose, so
# concurrent builds serialise instead of summing.
# -j — bounded parallelism; ~1.17 GB per cc1plus was the measured average.
# --memory — the actual guarantee: a runaway build dies in its own cgroup instead of taking
# the host down. --memory-swap equal to --memory forbids swap, which is what made
# ssh hang.
JOBS="${JOBS:-12}"
MEM="${MEM:-40g}"
LOCK=/tmp/orca-rig-build.lock
exec 9>"$LOCK"
if ! flock -n 9; then
echo "another build holds $LOCK — waiting (this is the OOM guard, not a hang)"
flock 9
fi
docker run --rm \
--memory="$MEM" --memory-swap="$MEM" \
-v "$REPO/src":/OrcaSlicer/src \
-v "$REPO/resources":/OrcaSlicer/resources \
-v "$REPO/CMakeLists.txt":/OrcaSlicer/CMakeLists.txt \
-v "$REPO/cmake":/OrcaSlicer/cmake \
-v "$REPO/deps_src":/OrcaSlicer/deps_src \
-v "$REPO/build_linux.sh":/OrcaSlicer/build_linux.sh \
-v "$REPO/scripts":/OrcaSlicer/scripts \
-v "$BUILD_VOL":/OrcaSlicer/build \
"$IMAGE" \
bash -lc "cd /OrcaSlicer && ./build_linux.sh -sr -j $JOBS"
# src/CMakeLists.txt:151 renames the OrcaSlicer target's output to "orca-slicer" — not
# "snapmaker-orca", which is the other fork's binary name.
echo "=== build finished; checking for binary ==="
docker run --rm -v "$BUILD_VOL":/b "$IMAGE" \
bash -lc 'ls -lh /b/package/bin/orca-slicer 2>/dev/null && file /b/package/bin/orca-slicer || echo "NO BINARY"'
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Rebuild the GUI binary the design rig launches — in a THROWAWAY container, writing into the
# same build-cache volume the rig's long-lived GUI container reads from.
#
# NEVER build inside the GUI container (snapmaker-gui / orcacad-gui). Its baked /OrcaSlicer tree
# is the Jun-13 Snapmaker-derived source, so a `cmake .` in there silently reconfigures the
# shared build dir as project(Snapmaker_Orca) and this fork's targets vanish. That is Trap 1 of
# five; all of them, with symptoms and exact recovery commands, are in docs/rig_build_traps.md.
# Read that file before debugging a configure or link failure this script reports.
#
# Usage:
# scripts/CAD/build-gui.sh # configure + build the fork's GUI target
# DRY_RUN=1 scripts/CAD/build-gui.sh # print the resolved fork identity and exit, no container
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Fork identity is DERIVED from the repo, never hardcoded, so this file is byte-identical in
# both forks and cannot be mirrored into the wrong one. Pointing a fork at the other fork's
# image or volume is not a slow failure: with the wrong image CMake dies at configure, and with
# the wrong volume the two forks silently trade build artefacts.
PROJECT="$(sed -n 's/^project(\([A-Za-z_0-9]*\)).*/\1/p' "$REPO/CMakeLists.txt" | head -1)"
case "$PROJECT" in
Snapmaker_Orca) PREFIX=snapmaker; BIN=snapmaker-orca ;;
OrcaSlicer) PREFIX=orcacad; BIN=orca-slicer ;;
*) echo "FATAL: unrecognised project($PROJECT) in $REPO/CMakeLists.txt" >&2; exit 2 ;;
esac
TARGET="$PROJECT"
IMAGE="${PREFIX}-deps"
BUILD_VOL="${PREFIX}_buildcache"
echo "REPO=$REPO PROJECT=$PROJECT IMAGE=$IMAGE BUILD_VOL=$BUILD_VOL TARGET=$TARGET BIN=$BIN"
if [ -n "${DRY_RUN:-}" ]; then
echo "DRY_RUN: resolution only, no container started"
exit 0
fi
# Three memory bounds. On 2026-08-21 both forks ran this script at the same time, each with
# ninja -j$(nproc)=16: ~36 cc1plus holding 42 GB of a 62 GB box -> global OOM at 21:05, a
# 2h28m kill storm, ssh unreachable, lightdm destroyed, 2946 session kill events. Neither
# build produced a single object. The bounds, weakest to strongest:
# flock — the lock path is shared by both forks on purpose, so they SERIALISE instead of
# summing. Peak is one build's worth no matter who else starts one.
# -j12 — measured 1.17 GB average per cc1plus in the incident dump, so 12 in flight
# is ~14 GB typical and leaves the box usable. JOBS=n overrides.
# --memory — the actual guarantee. A runaway build hits its own cgroup limit and dies alone;
# the host never reaches global OOM again, whatever -j or flock do.
# --memory-swap equal to --memory forbids swap, which is what made ssh hang.
JOBS="${JOBS:-12}"
MEM="${MEM:-40g}"
LOCK=/tmp/orca-rig-build.lock
exec 9>"$LOCK"
if ! flock -n 9; then
echo "another fork's build-gui holds $LOCK — waiting (this is the OOM guard, not a hang)"
flock 9
fi
# Every one of these mounts covers a trap, none is decorative:
# CMakeLists.txt + cmake/ carry the SLIC3R_CAD gate — inherit the baked copies and the cache
# says SLIC3R_CAD=ON while -DSLIC3R_CAD is never defined, so every #ifdef block compiles out.
# deps_src/ carries pybind11, which the image predates.
# src/, resources/, localization/, version.inc are the code under test.
rc=0
docker run --rm \
--memory="$MEM" --memory-swap="$MEM" \
-v "$REPO/src":/OrcaSlicer/src \
-v "$REPO/resources":/OrcaSlicer/resources \
-v "$REPO/cmake":/OrcaSlicer/cmake \
-v "$REPO/deps_src":/OrcaSlicer/deps_src \
-v "$REPO/localization":/OrcaSlicer/localization \
-v "$REPO/CMakeLists.txt":/OrcaSlicer/CMakeLists.txt \
-v "$REPO/version.inc":/OrcaSlicer/version.inc \
-v "$BUILD_VOL":/OrcaSlicer/build \
"$IMAGE" bash -lc "
cd /OrcaSlicer/build || exit 1
cmake . > /tmp/cfg.log 2>&1 || { echo 'CONFIGURE FAILED'; tail -25 /tmp/cfg.log; exit 1; }
# Twice, deliberately. src/libslic3r/CMakeLists.txt publishes OCCT_LIBS as CACHE INTERNAL
# at the END of its own configure, so a first pass after that list changes links the
# PREVIOUS one and drops TKBool/TKOffset — a wall of TopOpeBRepBuild undefined references
# that reads as a broken OCCT install and is not. Trap 4.
cmake . > /tmp/cfg2.log 2>&1 || { echo 'RECONFIGURE FAILED'; tail -25 /tmp/cfg2.log; exit 1; }
ninja -f build-Release.ninja -j$JOBS $TARGET > /tmp/bld.log 2>&1
rc=\$?
echo \"EXIT=\$rc\"
grep -n 'error:' /tmp/bld.log | head -20
tail -4 /tmp/bld.log
ls -la /OrcaSlicer/build/src/Release/$BIN 2>/dev/null
exit \$rc
" || rc=$?
# A target-only build writes src/Release/, but this fork's start-headless-gui.sh may default BIN to the
# PACKAGED path that only build_linux.sh refreshes — launching with the default would then run a
# stale binary. Pass BIN explicitly. See docs/rig_build_traps.md.
echo "=== launch the rig on the binary just built ==="
echo " docker exec -e BIN=/OrcaSlicer/build/src/Release/$BIN ${PREFIX}-gui /OrcaSlicer/scripts/CAD/start-headless-gui.sh"
exit "$rc"
+658
View File
@@ -0,0 +1,658 @@
#!/usr/bin/env python3
"""The click-edit contract: a value field that opens must accept what is TYPED into it.
WHY THIS EXISTS SEPARATELY FROM check-gui-sketching.py. That ladder draws geometry and grades the
result, and to make its values land it calls focus_field() — one synthetic click INTO the field
before typing. Its own docstring says why:
WITHOUT THIS THE TYPED VALUE IS SILENTLY DISCARDED. The field is shown and raised but the
window manager does not give it the keyboard, so xdotool's digits go to the canvas and Return
commits the value the field opened with — the pre-filled as-drawn number.
That click is a workaround for a defect, and a suite that performs it can never see the defect
again. A user cannot be told to click the field first; when they do not, they get the as-drawn
number and report "the label value is not editable". So this ladder types IMMEDIATELY after the
field opens, exactly as a person does, and fails if the prefill is what gets committed.
WHAT IT GRADES. The app emits one line per event under ORCA_CAD_UXTRACE=1:
[UX] open title=Length prefill=154.76
[UX] commit title=Length typed=80 value=80.0000
[UX] refused title=Length typed=8O
[UX] cancel title=Length
For every field the driver opens it asserts: a commit arrived, what the field received is what we
typed, the parsed value equals it, and it differs from the prefill. The last clause is the one
that matters — a field that is on screen but deaf commits its prefill, and every other signal
(the field is visible, a constraint is created, the solve succeeds) looks perfectly healthy.
scripts/CAD/check-gui-click-edit.py --display :10 --bin build/src/Release/orca-slicer
With --attach it drives an already-running app instead of launching one; the app must have been
started with ORCA_CAD_UXTRACE=1 and its stderr redirected to --trace.
Exit 0 = every field took what was typed.
"""
import argparse, json, os, re, shutil, signal, socket, subprocess, sys, tempfile, time
AP = argparse.ArgumentParser()
AP.add_argument("--display", default=os.environ.get("DISPLAY", ":10"))
AP.add_argument("--bin", default="build/src/Release/orca-slicer")
AP.add_argument("--datadir", default="")
AP.add_argument("--trace", default="")
AP.add_argument("--sock", default="/tmp/mcp-uxcheck.sock",
help="the app's MCP socket: the oracle for whether a sketch is really open")
AP.add_argument("--attach", action="store_true", help="drive a running app; do not launch one")
AP.add_argument("--keep", action="store_true", help="leave the app running afterwards")
AP.add_argument("--no-defocus", action="store_true",
help="do NOT take focus off the field before typing (weakens the gate; see below)")
AP.add_argument("--seed-from", default=os.path.expanduser("~/.config/OrcaCAD/OrcaSlicer.conf"),
help="an existing OrcaSlicer.conf to copy presets/settings from")
A = AP.parse_args()
DISP = A.display
TRACE = A.trace or os.path.join(tempfile.gettempdir(), "ux-click-edit.log")
_fail = 0
_checks = 0
_n = 0
def call(method, **params):
"""One MCP request over the app's unix socket. The socket is the only witness that cannot
lie about sketch state: the keytrace says a key ARRIVED, a screenshot says something is on
screen, and neither distinguishes an open sketch from sketch mode with the plane offer up."""
global _n
_n += 1
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(30)
s.connect(A.sock)
s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method,
"params": params}) + "\n").encode())
buf = b""
while b"\n" not in buf:
d = s.recv(65536)
if not d:
break
buf += d
r = json.loads(buf.decode().strip())
if "error" in r:
raise RuntimeError(f"{method}: {r['error']['message']}")
return r["result"]
def try_call(method, **params):
try:
return call(method, **params)
except Exception:
return None
def sh(cmd):
# bash -c, NOT -lc: a login shell sources the profile on every xdotool call, and this driver
# makes hundreds. On a GNOME box that meant im-config running per call, thousands of journal
# lines, and a window poll slow enough to time out before the app had finished starting.
return subprocess.run(["bash", "-c", cmd], capture_output=True, text=True).stdout
def xdo(args):
sh(f"DISPLAY={DISP} xdotool {args}")
def key(k, pause=0.35, window=None):
xdo(f"key {'--window ' + str(window) + ' ' if window else ''}{k}")
time.sleep(pause)
def typ(s, pause=0.35, window=None):
# --clearmodifiers so a modifier left down by an earlier synthetic key cannot turn digits
# into something else; --delay 60 because ImGui reads one character per frame.
#
# `window` targets a specific window with XSendEvent instead of following the input focus.
# That is the whole gate: see type_into_open_field.
tgt = f"--window {window} " if window else ""
xdo(f"type {tgt}--clearmodifiers --delay 60 -- '{s}'")
time.sleep(pause)
def die(msg):
print(f"FATAL {msg}", file=sys.stderr)
sys.exit(2)
# ---------------------------------------------------------------- the app
_proc = None
def seed_datadir(datadir):
"""The Design tab does not exist unless enable_cad_feature is on, and it needs a RESTART.
A fresh datadir has it off, so a driver that just points the app at an empty directory gets
Prepare/Preview/Device/Project, no Design tab, and every rung fails for a reason that has
nothing to do with what is being tested. Seed the flag before the first launch.
"""
os.makedirs(datadir, exist_ok=True)
conf = os.path.join(datadir, "OrcaSlicer.conf")
data = {}
if os.path.exists(A.seed_from):
try:
with open(A.seed_from) as f:
data = json.load(f)
except Exception:
data = {}
app = data.setdefault("app", {})
app["enable_cad_feature"] = True
# Deterministic starting state for the rungs that follow: the bed drawn, loops welded as the
# ~90% case expects. A ladder whose result depends on the developer's own preferences is not
# a gate.
app["auto_close_sketch_loops"] = True
# SILENCE THE NETWORK PLUGIN PROMPT. Without this, GUI_App::post_init() re-raises "Bambu
# Network Plug-in Required" from an IDLE event — after any modal sweep this driver does at
# startup — and ShowModal() then runs a nested event loop. The app is alive, its window is
# there, and the MCP socket answers nothing: indistinguishable from a hang, and it was
# investigated as one, with gdb, twice. `installed_networking` false stops the whole
# networking-plugin path, so m_networking_need_update is never set and the dialog never
# exists to be swept.
app["installed_networking"] = False
with open(conf, "w") as f:
json.dump(data, f, indent=1)
for sub in ("user", "system", "presets", "vendor"):
src = os.path.join(os.path.dirname(A.seed_from), sub)
dst = os.path.join(datadir, sub)
if os.path.isdir(src) and not os.path.exists(dst):
shutil.copytree(src, dst)
def launch():
global _proc
datadir = A.datadir or os.path.join(tempfile.gettempdir(), "orcacad-uxcheck")
seed_datadir(datadir)
env = dict(os.environ)
# WAYLAND_DISPLAY MUST GO, and GDK_BACKEND must say x11. GTK prefers Wayland whenever
# WAYLAND_DISPLAY is set and ignores DISPLAY entirely, so a driver launched from a systemd
# user unit (which inherits it) started the app on the DESKTOP session instead of the rig:
# the process was alive, `xdotool search` on the rig display found nothing, and the window
# was sitting on the user's own screen. Silent, and it drives a stray app at someone's face.
env.pop("WAYLAND_DISPLAY", None)
env.update(DISPLAY=DISP, GDK_BACKEND="x11", ORCA_CAD_UXTRACE="1",
LIBGL_ALWAYS_SOFTWARE="1", GALLIUM_DRIVER="llvmpipe",
# The rig's Xvfb has no input-method daemon, and a dead ibus context makes a
# GtkEntry drop every character while the app looks fine. It cannot affect the
# in-canvas field (ImGui needs no IM) but the app has other text fields, and a
# display full of IBUS warnings has cost a whole misdiagnosis before.
GTK_IM_MODULE="gtk-im-context-simple", XMODIFIERS="@im=none",
# The key tracer is this driver's only positive signal that a keystroke reached
# the Design panel at all. Without it "the field never opened" is indistinguishable
# from "we never got into sketch mode", and the first run of this ladder reported
# seven product failures that were really one driver racing a still-loading app.
ORCA_CAD_KEYTRACE="1", ORCA_CAD_MCP=A.sock,
SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt",
WEBKIT_DISABLE_DMABUF_RENDERER="1", WEBKIT_DISABLE_COMPOSITING_MODE="1")
if os.path.exists(A.sock):
os.unlink(A.sock) # a stale socket from a dead run answers nothing, slowly
log = open(TRACE, "wb")
_proc = subprocess.Popen([A.bin, "--datadir", datadir], env=env,
stdout=subprocess.DEVNULL, stderr=log)
for _ in range(120):
if win_id():
return
time.sleep(1)
die("the app never showed a window on " + DISP)
def window_pid(w):
"""_NET_WM_PID for a window, or 0. The property is how we tell a live app from its ghost."""
out = sh(f"DISPLAY={DISP} xprop -id {w} _NET_WM_PID 2>/dev/null")
m = re.search(r"= *(\d+)", out)
return int(m.group(1)) if m else 0
def pid_alive(pid):
return pid > 0 and os.path.isdir(f"/proc/{pid}")
def win_id():
"""The main window: OURS if we launched it, otherwise the biggest LIVE top-level.
Two rules here, each paid for.
By PID, not by size, whenever we launched the app. An X window outlives its client if the
connection is not torn down cleanly, and a killed OrcaSlicer can leave a full-screen ghost
mapped on the display. It answers geometry queries exactly like the real thing, it wins "the
biggest window" every time, and every synthetic keystroke sent to it goes nowhere. That is
indistinguishable, from the driver's side, from an app that ignores the keyboard — which is
the very defect this ladder exists to measure. One run reported the entire contract broken
while the real app sat beside the ghost, untouched.
Never by title: a saved project renames the main window.
"""
if _proc is not None:
for w in sh(f"DISPLAY={DISP} xdotool search --pid {_proc.pid} --onlyvisible --name '.'").split():
g = dict(l.split("=", 1) for l in
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
if "=" in l)
if "WIDTH" in g and int(g["WIDTH"]) * int(g["HEIGHT"]) > 400 * 400:
return (w, int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"]))
return None
best = None
for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --name '.'").split():
g = dict(l.split("=", 1) for l in
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
if "=" in l)
if "WIDTH" not in g:
continue
if not pid_alive(window_pid(w)): # a ghost: no client is behind it any more
continue
a = int(g["WIDTH"]) * int(g["HEIGHT"])
if a > 400 * 400 and (best is None or a > best[0]):
best = (a, w, int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"]))
return best[1:] if best else None
_win = None
def win():
global _win
if _win is None:
w = win_id()
if w is None:
die("no app window on " + DISP)
sh(f"DISPLAY={DISP} xdotool windowactivate --sync {w[0]}")
sh(f"DISPLAY={DISP} xdotool windowsize {w[0]} 1920 1080")
sh(f"DISPLAY={DISP} xdotool windowmove {w[0]} 0 0")
time.sleep(1.0)
_win = (w[0], 0, 0, 1920, 1080)
return _win
def click(px, py, pause=0.5, btn=1):
_, X, Y, _, _ = win()
xdo(f"mousemove {X+int(px)} {Y+int(py)} click --delay 120 {btn}")
time.sleep(pause)
def visible_windows():
"""(id, name, w, h) for every MAPPED top-level, main window included.
`--onlyvisible` is what makes this usable. Without it xdotool also returns the app's unmapped
helper windows — a 10x10 and a 200x200 that exist for the whole session — and a caller that
tries to reason about "extra windows" from that list is reasoning about furniture.
"""
out = []
for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --name '.'").split():
g = dict(l.split("=", 1) for l in
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
if "=" in l)
if "WIDTH" not in g:
continue
if not pid_alive(window_pid(w)): # see win_id(): a ghost cannot be closed, only ignored
continue
n = sh(f"DISPLAY={DISP} xdotool getwindowname {w}").strip()
out.append((w, n, int(g["WIDTH"]), int(g["HEIGHT"])))
return out
def dismiss_modals(timeout=30):
"""Close every modal over the main window, and PROVE none is left.
This is the rung that decides whether any of the others mean anything. A fresh datadir opens
"Bambu Network Plug-in Required" — 440x259, centred at 742,450 — which sits exactly on top of
the point every drawing gesture in TOOLS starts from. The whole ladder then reports eleven
product failures, all of them the driver clicking a dialog.
The old version pressed Escape and moved on. This dialog ignores Escape, so it "dismissed"
nothing and said so to no one; the run that found this was red for a reason that had nothing
to do with the contract under test. Escape is still tried first because it is the gentlest
thing that works on the wizard, then WM_DELETE_WINDOW, and then the function asserts what it
was supposed to have achieved instead of assuming it.
"""
# NEVER run without knowing which window to spare. The first version took `keep = main[0] if
# main else None`, so a win_id() that raced the app's mapping made keep None and every visible
# window a modal — this function then sent WM_DELETE to the app's own main window. The app
# survived as a process, printed "GdkWindow unexpectedly destroyed", and answered nothing
# afterwards; the ladder reported "no sketch opened" for 180s. Losing the main window is not a
# state to recover from silently.
deadline = time.time() + timeout
keep = None
while keep is None and time.time() < deadline:
main = win_id()
keep = main[0] if main else None
if keep is None:
time.sleep(0.5)
if keep is None:
die(f"no main window to protect after {timeout}s — refusing to close anything")
scr = sh(f"DISPLAY={DISP} xdotool getdisplaygeometry").split()
full = int(scr[0]) * int(scr[1]) if len(scr) == 2 else 1920 * 1080
while time.time() < deadline:
# A modal is small. Anything covering half the screen is the app, whatever id win_id()
# happened to return this instant — a second belt on the rule above, because the cost of
# being wrong here is an app that looks alive and answers nothing.
extra = [x for x in visible_windows() if x[0] != keep and x[2] * x[3] < full * 0.5]
if not extra:
return
for (w, n, _, _) in extra:
sh(f"DISPLAY={DISP} xdotool windowactivate {w}")
time.sleep(0.4)
key("Escape", 0.4)
if any(x[0] == w for x in visible_windows()):
sh(f"DISPLAY={DISP} xdotool windowclose {w}")
time.sleep(0.6)
time.sleep(0.5)
left = [f"{n!r} ({w}x{h})" for (i, n, w, h) in visible_windows()
if i != keep and w * h < full * 0.5]
die("a modal is still covering the canvas after " + str(timeout) + "s: " + ", ".join(left) +
" — every drawing gesture would land in it, so nothing below this line could be trusted")
def dismiss_first_run():
dismiss_modals()
# ---------------------------------------------------------------- the trace
def trace_lines():
try:
with open(TRACE, "r", errors="replace") as f:
return [l.strip() for l in f if l.startswith("[UX] ")]
except OSError:
return []
def trace_mark():
return len(trace_lines())
def parse(line):
m = re.match(r"\[UX\] (\w+) title=(.*?) (.*)$", line)
if not m:
return None
ev, title, rest = m.group(1), m.group(2), m.group(3)
kv = dict(re.findall(r"(\w+)=(\S*)", rest))
return ev, title, kv
# ---------------------------------------------------------------- grading
def check(cond, what):
"""Returns the verdict so a caller can abandon a rung whose precondition failed."""
global _fail, _checks
_checks += 1
if cond:
print(f" ok {what}")
else:
print(f" FAIL {what}", file=sys.stderr)
_fail += 1
return bool(cond)
def type_into_open_field(value, mark):
"""Type `value` into whatever field is open, WITHOUT clicking it first, and grade the pair.
No click: the click is the workaround this ladder exists to refuse. If the field cannot take
the keyboard on its own, `typed` will be the prefill and this fails — which is the report.
"""
# POLL for the field. It opens from a CallAfter that runs after a re-solve, so on llvmpipe it
# is simply not there yet when a fast driver looks — and "no field opened" is the same message
# whether the product never opened one or the driver asked too early. Wait, then decide.
opens = []
deadline = time.time() + 8.0
while time.time() < deadline:
opens = [e for e in (parse(l) for l in trace_lines()[mark:]) if e and e[0] == "open"]
if opens:
break
time.sleep(0.25)
if not opens:
check(False, f"a value field opened (nothing did; cannot type {value})")
return mark
title = opens[-1][1]
prefill = opens[-1][2].get("prefill", "")
m2 = trace_mark()
# TYPE NORMALLY. NOTHING TO DEFOCUS ANY MORE.
#
# The value field is drawn INSIDE the GL canvas by ImGui, so it is not a window: there is no
# second toplevel for a window manager to grant or refuse the keyboard, and the keystrokes go
# to the app's one window exactly as a person's would. That is the entire point of the design
# — the WM has no say — and it is why this ladder no longer tries to manufacture the failing
# condition.
#
# When the field WAS a floating wxFrame, this spot held two attempts to reproduce
# "field open, keyboard elsewhere", and both are recorded here so neither is tried again:
# - XSetInputFocus onto the main window (`xdotool windowfocus`): the field's own re-focus
# CallAfter wins the race every time; four retries all lost, and the ladder passed twice
# against a binary with the fix compiled out.
# - XSendEvent at the main window (`xdotool type --window`): GTK discards synthetic key
# events, so NEITHER build received anything and every run was red regardless of the code.
# A run that used the second of those is what produced "the app never saw a digit" — a
# property of xdotool, not of the product.
#
# For the in-canvas field the honest gate is simply: type, and see whether the value the app
# commits is the value that was typed.
diag = sh(f"DISPLAY={DISP} xdotool getwindowfocus").strip()
typ(str(value), 0.4)
key("Return", 0.9)
after, commits, refused, commit_at = [], [], [], None
deadline = time.time() + 5.0
while time.time() < deadline:
after = [parse(l) for l in trace_lines()[m2:]]
commits = [(i, e) for i, e in enumerate(after) if e and e[0] == "commit"]
refused = [e for e in after if e and e[0] == "refused"]
if commits:
commit_at = m2 + commits[-1][0]
commits = [e for _, e in commits]
if commits or refused:
break
time.sleep(0.25)
if refused and not commits:
check(False, f"{title}: field REFUSED {value!r} (typed={refused[-1][2].get('typed')!r})")
key("Escape", 0.5)
return trace_mark()
if not commits:
check(False, f"{title}: typed {value} but nothing committed — the field took no keys")
key("Escape", 0.5)
return trace_mark()
typed = commits[-1][2].get("typed", "")
got = commits[-1][2].get("value", "")
check(typed == str(value),
f"{title}: field received what was typed (typed={typed!r} wanted={value!r}"
f"{' <-- it committed its PREFILL, so it never got the keyboard' if typed == prefill else ''})")
# A value that will not parse is a FAILED CHECK, never an exception. An unguarded float()
# here met a locale-formatted "61,0000" and took the whole run down immediately after the
# first check in the ladder's history had passed — the seven rungs below it were never tried
# and the report read as a total failure.
try:
ok_val = abs(float(got) - float(value)) < 1e-6
except (TypeError, ValueError):
ok_val = False
check(ok_val, f"{title}: committed value is {got!r} (wanted {value})")
check(str(value) != prefill, f"{title}: the test value differs from the prefill {prefill!r}")
# RESUME JUST AFTER THE COMMIT, not at the end of the trace. A queued chain opens its next
# field from the commit callback, so by the time trace_mark() is read here that "open" line
# is already written — and the next call, searching only after this mark, never sees it. The
# rectangle's Height, the slot's Radius and the label reopen all failed as "nothing did"
# while the trace plainly showed the field open and waiting.
return (commit_at + 1) if commit_at is not None else trace_mark()
# ---------------------------------------------------------------- the ladder
def enter_sketch(timeout=180):
"""Open a real sketch on a real plane, and PROVE it with the socket before drawing anything.
THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. Shift+S enters sketch MODE and pops the plane
offer; the offer must be dismissed; and the plane itself is chosen by clicking it in the
viewport BEFORE Shift+S. check-gui-sketching.py has always done all four steps. This ladder
did two of them — Design tab, then Shift+S — and went straight to the tool letters.
That intermediate state is the trap. `is_sketching` reads 1, every tool key is accepted and
traced, and not one click draws anything, because there is no plane under them. The ladder
then reports eleven product failures, all of them "a value field opened (nothing did)", and
every one is the driver's. Two whole runs were spent on it.
So the gate is the ORACLE, not the keytrace: sketch_describe answers only when a sketch is
genuinely open. Waiting on a mode flag is what allowed the wrong state to pass for the right
one in the first place.
"""
deadline = time.time() + timeout
while time.time() < deadline:
click(132, 53) # Design tab
time.sleep(2.0)
dismiss_modals()
click(*PLANE_PX) # pick the plane IN THE VIEWPORT — before Shift+S
key("shift+s", 1.0)
key("Escape", 0.5) # entering sketch mode pops the offer; dismiss it
key("p", 0.6) # any sketch tool starts the session on that plane
if try_call("sketch_describe") is not None:
# NO Escape here. Every rung already opens with one to drop whatever tool the last
# one left armed, and Escape in the Design tab walks a LIFO: first press drops the
# armed tool, second LEAVES THE SKETCH. Pressing it here made that second press the
# rung's own, so the ladder exited the sketch before drawing anything and then
# reported all eleven checks failed with "nothing opened" — the tools were arming
# into an empty Feature-mode document.
return
die("no sketch opened after plane click + Shift+S within "
f"{timeout}s — sketch_describe never answered on {A.sock} (trace {TRACE})")
# tool key, the clicks that draw it, and one distinct value per queued field. The values are
# deliberately nothing like the as-drawn size, so a committed prefill cannot coincide with them.
# Where the plane label sits in the viewport before a sketch is open. Same constant the gesture
# ladder uses; it is a label on the 3D view, not a widget, so it moves only if the camera does.
PLANE_PX = (913, 359)
# Every coordinate below stays inside 1000..1400 x 500..760 — the box check-gui-sketching.py's
# calibration probes land four Points in, i.e. the region PROVEN to be live canvas on a 1920x1080
# window. Earlier values started at x=950, which is left of that box and also, on a fresh datadir,
# underneath the "Bambu Network Plug-in Required" modal.
TOOLS = [
("L", "Line", [(1030, 540), (1360, 540)], [61]),
("R", "Rectangle", [(1030, 540), (1360, 730)], [62, 43]),
("C", "Circle", [(1180, 620), (1330, 620)], [64]),
("S", "Slot", [(1030, 580), (1300, 580), (1300, 640)], [66]),
("G", "Polygon", [(1180, 620), (1320, 620)], [67]),
("E", "Ellipse", [(1180, 620), (1370, 620), (1180, 720)], [68]),
("A", "Arc", [(1040, 660), (1340, 660), (1190, 560)], [69]),
]
def rung_tool(k, name, clicks, values):
print(f" {name}")
key("Escape", 0.6) # back to Select, whatever the last tool left armed
# Every rung re-establishes that a sketch is STILL open. One stray Escape too many leaves it,
# and from then on every tool arms into a Feature-mode document that cannot open a value
# field — which the checks below report as eleven independent product failures.
if try_call("sketch_describe") is None:
die(f"{name}: the sketch is no longer open before this rung — an earlier rung left it")
key(k, 0.8)
# MARK BEFORE THE CLICKS, not after. The field is opened from a CallAfter scheduled by the
# render that follows the last click, so it can already be open by the time a mark taken
# afterwards is read — and type_into_open_field, which only looks at events AFTER its mark,
# then finds none and reports "a value field opened (nothing did)" for a field that is on
# screen, open, and waiting. That message accused the product of the exact defect the ladder
# exists to detect, from a bug in the ladder's own bookkeeping.
mark = trace_mark()
for (x, y) in clicks:
click(x, y)
for v in values:
mark = type_into_open_field(v, mark)
def rung_rounded_rect():
"""The shape the user actually reported: a ROUNDED rectangle, Width -> Height -> Radius.
It has no keyboard shortcut — the rectangle family binds R to CornerRect and leaves the other
modes in the toolbar flyout — so TOOLS above cannot reach it and the whole three-step chain
went untested. `run_verb` arms it the way the offer menu does.
NOTE the id: the OFFER verb is `sk_rect_rounded`; `design_rect_rounded` is the ACTION name and
run_verb throws on it, leaving the tool as Select. A run that misses that draws nothing and
still reaches its assertions, so arm-and-verify rather than arm-and-hope.
"""
print(" Rounded rectangle")
key("Escape", 0.6)
tool = None
for _ in range(8):
try_call("run_verb", verb="sk_rect_rounded")
time.sleep(0.8)
tool = (try_call("sketch_describe") or {}).get("tool")
if tool == "rect_rounded":
break
if not check(tool == "rect_rounded", f"the rounded-rectangle tool armed (tool={tool!r})"):
return
mark = trace_mark()
click(1030, 540); click(1330, 700); click(1300, 660) # corners, then the radius point
for v in (63, 41, 7):
mark = type_into_open_field(v, mark)
def rung_label_click():
"""The user's own report: click an existing dimension label and type a new value into it.
KEEP THE SHAPE AS DRAWN. An earlier version committed 55 and 47 into the queued chain first,
which resized the rectangle — and then clicked the pixel where the label had been before the
resize. It missed, every time, and reported the reopen broken. The shape's on-screen position
is only predictable if nothing has moved it, so Escape the chain instead: the rectangle stays
exactly between the two corners we clicked.
FIND THE LABEL, do not assume its offset. A dimension label is drawn beside its edge at an
offset that depends on zoom and text metrics, so a single hardcoded pixel is a guess that
silently becomes wrong. Walk a short band across the top edge instead and stop at the first
click that opens a field; if none of them does, that is a real failure and it says so.
"""
print(" label click-to-edit")
key("Escape", 0.6) # Select mode
key("R", 0.8)
click(1020, 530)
click(1350, 740)
time.sleep(1.5)
key("Escape", 0.8) # keep as drawn: abandon the queued value chain
time.sleep(0.8)
key("Escape", 0.6) # back to Select so a click picks rather than draws
mid_x, top_y = (1020 + 1350) // 2, 530
candidates = [(mid_x, top_y + dy) for dy in (-26, -20, -14, -8, 0, 8, 14)]
for (cx, cy) in candidates:
mark = trace_mark()
click(cx, cy)
deadline = time.time() + 2.0
while time.time() < deadline:
if [e for e in (parse(l) for l in trace_lines()[mark:]) if e and e[0] == "open"]:
check(True, f"clicking a dimension label reopened its value field (at {cx},{cy})")
type_into_open_field(71, mark)
return
time.sleep(0.2)
check(False, "clicking a dimension label reopened its value field "
f"(tried {len(candidates)} points across the top edge at x={mid_x})")
def main():
if not A.attach:
if not os.path.exists(A.bin):
die(f"no binary at {A.bin}")
open(TRACE, "w").close()
launch()
dismiss_first_run()
win()
print(f"click-edit ladder on {DISP}, trace {TRACE}")
enter_sketch()
for (k, name, clicks, values) in TOOLS:
rung_tool(k, name, clicks, values)
rung_rounded_rect()
rung_label_click()
print()
if _fail:
print(f"CLICK-EDIT LADDER FAILED — {_fail} of {_checks} checks", file=sys.stderr)
else:
print(f"CLICK-EDIT LADDER HELD — {_checks} checks")
if _proc is not None and not A.keep:
_proc.send_signal(signal.SIGTERM)
try:
_proc.wait(20)
except subprocess.TimeoutExpired:
_proc.kill()
return 1 if _fail else 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Autonomous 2D-sketch loop: drive the Design tab's sketch layer over the MCP socket and
assert the things that decide whether a profile is buildable.
WHY THIS EXISTS. The 2D layer used to be reachable only by clicking, so every question about it
("is this loop closed?", "did the offset survive?", "is the circle a void or a second body?")
cost a GUI session and a human. The socket verbs make each one a call, and this script is the
loop: build a known profile, ask the app what it thinks it has, compare against arithmetic.
RUN IT AGAINST A RUNNING APP:
ORCA_CAD_MCP=/tmp/mcp.sock <binary> # launch with the socket enabled
python3 scripts/CAD/check-mcp-sketch.py [socket] # default /tmp/mcp.sock
Exit 0 = every assertion held. Anything else prints the first mismatch and stops.
"""
import json, math, socket, sys
SOCK = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mcp.sock"
_n = 0
def call(method, **params):
global _n
_n += 1
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(30)
s.connect(SOCK)
s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method,
"params": params}) + "\n").encode())
buf = b""
while b"\n" not in buf:
d = s.recv(65536)
if not d:
break
buf += d
r = json.loads(buf.decode().strip())
if "error" in r:
raise RuntimeError(f"{method}: {r['error']}")
return r["result"]
def near(a, b, tol=1e-6):
return abs(a - b) < tol
def check(cond, what):
if not cond:
print(f"FAIL: {what}", file=sys.stderr)
sys.exit(1)
print(f" ok {what}")
def areas(rep):
return sorted(round(l["area"], 6) for l in rep["closed_loops"])
print("1. a rectangle is one closed loop of exactly its own area")
try:
call("sketch_cancel")
except Exception:
pass
call("sketch_begin", plane="XY")
call("sketch_add", rect=[0, 0, 80, 50])
r = call("sketch_describe")
check(r["buildable"], "buildable")
check(areas(r) == [4000.0], f"one loop of 4000 mm^2 (got {areas(r)})")
print("2. a circle inside it is a VOID, not a second profile")
call("sketch_add", type="circle", center=[40, 25], radius=10)
r = call("sketch_describe")
outer = [l for l in r["closed_loops"] if near(l["area"], 4000.0)][0]
check(len(outer["holes"]) == 1, "the rectangle encloses exactly one void")
hole = r["closed_loops"][outer["holes"][0]]
check(near(hole["area"], math.pi * 100), f"the void is pi*r^2 (got {hole['area']})")
print("3. offsetting the outer loop inward keeps it CLOSED and exact")
call("sketch_select", entities=[0, 1, 2, 3])
call("sketch_offset", distance=5)
r = call("sketch_describe")
check(r["open_ends"] == [], "no open ends after the offset")
check(any(near(l["area"], 70 * 40) for l in r["closed_loops"]),
f"the offset loop is 70x40 (got {areas(r)})")
print("4. a gap is REPORTED with its coordinates, then healed into a constraint")
call("sketch_cancel")
call("sketch_begin", plane="XY")
call("sketch_add", entities=[
{"type": "line", "p0": [0, 0], "p1": [60, 0]},
{"type": "line", "p0": [60, 0], "p1": [60, 40]},
{"type": "line", "p0": [60, 40], "p1": [0, 40]},
{"type": "line", "p0": [0, 40], "p1": [0.4, 0]}, # 0.4 mm short of closing
])
r = call("sketch_validate", tolerance=1.0)
check(not r["buildable"], "a 0.4 mm gap makes the profile unbuildable")
check(len(r["open_ends"]) == 2, f"both free ends are named (got {r['open_ends']})")
dof_before = r["dof"]
r = call("sketch_heal", tolerance=1.0)
check(r["welded"] == 1, f"one pair welded (got {r['welded']})")
check(r["buildable"] and r["open_ends"] == [], "healed profile is buildable")
check(areas(r) == [2400.0], f"healed loop is 60x40 (got {areas(r)})")
check(r["dof"] < dof_before,
f"the weld recorded a real constraint: DoF {dof_before} -> {r['dof']}")
print("5. construction geometry is excluded from the profile")
call("sketch_select", entities=[0])
call("sketch_construction")
r = call("sketch_describe")
check(not r["buildable"], "turning one side into a guide opens the profile again")
call("sketch_construction")
r = call("sketch_describe")
check(r["buildable"], "turning it back closes it again")
print("6. re-dimensioning one side keeps the rectangle a single closed loop")
call("sketch_cancel")
call("sketch_begin", plane="XY")
call("sketch_add", rect=[0, 0, 60, 40])
r = call("sketch_describe")
check(areas(r) == [2400.0], f"one loop of 2400 mm^2 (got {areas(r)})")
call("sketch_select", entities=[0]) # the bottom edge, y=0, from x=0 to x=60
r = call("sketch_set_value", value=40)
check(r["kind"] == "length", f"dimension kind is length (got {r['kind']})")
check(near(r["before"], 60), f"the edge measured 60 before (got {r['before']})")
r = call("sketch_describe")
check(len(r["closed_loops"]) == 1, "the rectangle is still exactly one closed loop")
check(r["open_ends"] == [], "no open ends after re-dimensioning")
# The point of the whole section: a rectangle must SURVIVE one side being re-dimensioned. We do
# not assert a specific area — only that the topology held — but print it so a topology-preserving
# yet geometry-wrong result is visible in the output.
print(f" note resulting rectangle area = {areas(r)} mm^2 (topology held; geometry is what it is)")
call("sketch_cancel")
print("\nall sketch assertions held")
+492
View File
@@ -0,0 +1,492 @@
#!/usr/bin/env python3
"""Rung 9: the ladder, graded against real drawings instead of shapes I chose.
Rungs 1-8 are hand-built. That is their weakness: I wrote both the geometry and the
assertion, so they prove the engine does what I expected on cases I picked. This rung
takes a SYSTEMATIC sample of the StudyCadCam corpus (every 20th sheet, 1..996 — no
cherry-picking) and grades the engine against each drawing's OWN vector geometry,
extracted from the PDF. Nothing here is transcribed by eye; the drawing is the input.
The method: pdftocairo renders the sheet to SVG, where the drawn geometry is exactly the
stroked (fill="none") paths and the text is filled glyph paths. Beziers are flattened, so
every entity handed to the engine is a straight line and every comparison below is EXACT
— no faceting tolerance to hide behind. The closed chains are then found twice: once by
this script, in plain Python, and once by the engine. The assertions are that the two
agree, and that the engine's own operations preserve what they promise.
CLOSED the engine finds the same closed loops this script does
AREA the engine's area for each loop equals the shoelace area, to 1e-6
VOID the engine attributes each void to the loop that actually contains it
MIRROR a real closed profile, mirrored, is still exactly one closed loop
OFFSET a real closed profile, offset, is still closed
Usage: check-sketch-engine-corpus.py [--sample N] [--corpus DIR]
"""
import argparse
import glob
import json
import math
import os
import re
import socket
import subprocess
import sys
import tempfile
import time
SOCK = os.environ.get("ORCA_CAD_MCP", "/tmp/mcp.sock")
TOL = 1e-6 # exact-comparison tolerance (all inputs are lines)
WELD = 0.05 # endpoint-coincidence tolerance, in PDF units
# ── the socket ───────────────────────────────────────────────────────────────
_id = [0]
def try_call(method, **params):
"""sketch_cancel throws when nothing is open, which is not an error to us."""
try:
return call(method, **params)
except RuntimeError:
return None
def call(method, **params):
_id[0] += 1
req = json.dumps({"jsonrpc": "2.0", "id": _id[0], "method": method,
"params": params}) + "\n"
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(30)
s.connect(SOCK)
s.sendall(req.encode())
buf = b""
while not buf.endswith(b"\n"):
chunk = s.recv(65536)
if not chunk:
break
buf += chunk
s.close()
r = json.loads(buf.decode())
if "error" in r:
raise RuntimeError(r["error"]["message"])
return r["result"]
# ── SVG → line segments ──────────────────────────────────────────────────────
NUM = r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?"
def bezier(p0, p1, p2, p3, n=16):
"""Flatten a cubic to n straight segments — the engine then sees only lines."""
out = []
for i in range(n):
t0, t1 = i / n, (i + 1) / n
pts = []
for t in (t0, t1):
u = 1 - t
x = (u ** 3 * p0[0] + 3 * u * u * t * p1[0]
+ 3 * u * t * t * p2[0] + t ** 3 * p3[0])
y = (u ** 3 * p0[1] + 3 * u * u * t * p1[1]
+ 3 * u * t * t * p2[1] + t ** 3 * p3[1])
pts.append((x, y))
out.append((pts[0], pts[1]))
return out
def path_segments(d):
"""Parse one SVG path's `d` into straight segments."""
toks = re.findall(r"([MLCZmlcz])|(" + NUM + ")", d)
cmds, cur, start, segs, i = [], None, None, [], 0
flat = []
for a, b in toks:
flat.append(a if a else float(b))
while i < len(flat):
t = flat[i]
if isinstance(t, str):
cmd = t
i += 1
# numbers repeat the previous command, as SVG allows
if cmd in ("M", "m"):
x, y = flat[i], flat[i + 1]; i += 2
cur = (x, y); start = cur
elif cmd in ("L", "l"):
x, y = flat[i], flat[i + 1]; i += 2
segs.append((cur, (x, y))); cur = (x, y)
elif cmd in ("C", "c"):
p1 = (flat[i], flat[i + 1]); p2 = (flat[i + 2], flat[i + 3])
p3 = (flat[i + 4], flat[i + 5]); i += 6
segs.extend(bezier(cur, p1, p2, p3)); cur = p3
elif cmd in ("Z", "z"):
if cur and start and dist(cur, start) > TOL:
segs.append((cur, start))
cur = start
else:
i += 1
return segs
def dist(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
def drawing_segments(pdf):
"""Every stroked segment on the sheet, in PDF units (y already flipped up)."""
with tempfile.TemporaryDirectory() as td:
svg = os.path.join(td, "p.svg")
subprocess.run(["pdftocairo", "-svg", pdf, svg],
check=True, capture_output=True)
s = open(svg).read()
segs = []
# Drawn geometry is stroked with no fill; glyphs are filled with no stroke.
for m in re.finditer(r'<path([^>]*)d="([^"]+)"', s):
attrs, d = m.group(1), m.group(2)
if 'fill="none"' not in attrs or "stroke=" not in attrs:
continue
segs.extend(path_segments(d))
return [((a[0], -a[1]), (b[0], -b[1])) for a, b in segs if dist(a, b) > TOL]
# ── closed-chain finding, independent of the engine ──────────────────────────
def find_loops(segs):
"""Chain segments into closed loops. Returns a list of point rings."""
key = lambda p: (round(p[0] / WELD), round(p[1] / WELD))
adj = {}
for i, (a, b) in enumerate(segs):
adj.setdefault(key(a), []).append((i, a, b))
adj.setdefault(key(b), []).append((i, b, a))
used, loops = set(), []
for i0 in range(len(segs)):
if i0 in used:
continue
a, b = segs[i0]
ring, cur, prev = [a, b], b, i0
used.add(i0)
while True:
nxt = None
for (j, p, q) in adj.get(key(cur), []):
if j in used:
continue
nxt = (j, q)
break
if nxt is None:
break
used.add(nxt[0])
cur = nxt[1]
ring.append(cur)
if dist(cur, ring[0]) <= WELD:
# Snap the seam shut. The gap is a flattening artefact of the PDF, up to
# WELD wide, and handing the engine a ring that misses closing by 0.03 would
# be testing my extractor's sloppiness rather than the engine's chaining.
ring[-1] = ring[0]
loops.append(ring)
ring = None
break
# an open chain is simply not a loop; it is dropped
return loops
def shoelace(ring):
a = 0.0
for i in range(len(ring) - 1):
a += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]
return abs(a) * 0.5
def point_in(pt, ring):
inside = False
for i in range(len(ring) - 1):
A, B = ring[i], ring[i + 1]
if (A[1] > pt[1]) != (B[1] > pt[1]) and \
pt[0] < (B[0] - A[0]) * (pt[1] - A[1]) / (B[1] - A[1]) + A[0]:
inside = not inside
return inside
def interior_point(ring):
"""A point strictly inside a simple closed ring (first == last).
The lowest vertex of a simple polygon is always convex, so stepping from it along the
bisector of its two edges goes inward; the step is a small fraction of the shorter edge so
it stays inside however sharp the corner is.
"""
q = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
if len(q) < 3:
return ring[0]
k = min(range(len(q)), key=lambda i: (q[i][1], q[i][0]))
v = q[k]
a = (q[(k - 1) % len(q)][0] - v[0], q[(k - 1) % len(q)][1] - v[1])
b = (q[(k + 1) % len(q)][0] - v[0], q[(k + 1) % len(q)][1] - v[1])
la, lb = math.hypot(*a), math.hypot(*b)
if la < 1e-12 or lb < 1e-12:
return v
a = (a[0] / la, a[1] / la)
b = (b[0] / lb, b[1] / lb)
bx, by = a[0] + b[0], a[1] + b[1]
n = math.hypot(bx, by)
if n < 1e-12:
return v
step = 1e-3 * min(la, lb)
return (v[0] + bx / n * step, v[1] + by / n * step)
# ── one drawing ──────────────────────────────────────────────────────────────
def grade(pdf, name, report):
segs = drawing_segments(pdf)
loops = find_loops(segs)
if len(loops) < 2:
report(name, "SKIP", f"no nested closed geometry found ({len(loops)} loops)")
return None
# The biggest loop is the sheet frame; the part outlines live inside it. Take the
# largest loop that is NOT the frame, plus every loop contained in it.
loops.sort(key=shoelace, reverse=True)
outer = loops[1]
voids = [r for r in loops[2:]
if shoelace(r) > 1.0 and point_in(r[0], outer)]
if shoelace(outer) < 100.0:
# Not a defect and not a near miss: on these sheets the part outline is not a closed
# stroked path at all, so the only loops extraction recovers are glyph counters and
# arrowheads. Measured on MPD12/30/31/60: the LARGEST loop on the sheet is 5 to 132 mm2.
# Say the number, so nobody has to re-measure to know which kind of skip this is.
report(name, "SKIP", f"no part outline on this sheet — largest loop is only "
f"{shoelace(outer):.1f} mm2")
return None
# Feed the drawing's own geometry to the engine, as lines only.
ents = []
rings = [outer] + voids
for ring in rings:
for i in range(len(ring) - 1):
ents.append({"type": "line",
"p0": [ring[i][0], ring[i][1]],
"p1": [ring[i + 1][0], ring[i + 1][1]]})
try_call("sketch_cancel")
call("sketch_begin", plane="XY")
call("sketch_add", entities=ents)
r = call("sketch_describe")
ok = True
got = r["closed_loops"]
ok &= report(name, "CLOSED", f"engine finds {len(got)} closed loops, this script "
f"finds {len(rings)}", len(got) == len(rings))
# AREA — exact, because every entity is a line
mine = sorted(shoelace(x) for x in rings)
theirs = sorted(abs(l["area"]) for l in got)
# The bar: 1e-3 absolute, or 1e-6 relative for the big loops. Not bit-exactness — the
# auto-constraint pass still snaps segments that are already axis-aligned to within its
# 1e-4 rad tolerance, which moves an area by ~1e-4. It is deliberately tight enough to
# have caught the real defect this rung was written for: with the old 3 degree gesture
# slack applied to scripted input, a flattened circle came back 0.067% small — 0.266 on
# an area of 397, some 300x above this line.
same = len(mine) == len(theirs) and all(
abs(a - b) <= max(1e-3, 1e-6 * a) for a, b in zip(mine, theirs))
ok &= report(name, "AREA", "every loop area matches the shoelace value exactly", same)
# VOID — a loop belongs to the SMALLEST loop that contains it, not to every loop that
# encloses it. A hole inside a boss inside the part is a void of the boss, and the part
# owns the boss. Comparing against "everything inside the outline" was measuring my own
# sloppiness: on MPD781 that counted 36 voids where 26 of them are nested inside another
# void. So compute the same rule here, independently, and compare the whole attribution.
if got:
rings = [outer] + voids
# Probe from a point STRICTLY INSIDE each ring, never from one of its vertices — the
# same rule the engine now uses (DesignSketchTool::region_loops). A vertex is exactly
# where two loops touch in a real drawing, and a ray cast from a point lying ON the
# polygon under test answers by rounding: that alone accounted for every one of the 6
# sheets where the two attributions used to disagree. 5hvl.
probes = [interior_point(r) for r in rings]
mine_parent = {}
for i, r in enumerate(rings):
best, best_a = -1, 0.0
for j, q in enumerate(rings):
if i == j or not point_in(probes[i], q):
continue
a = shoelace(q)
if best < 0 or a < best_a:
best, best_a = j, a
if best >= 0:
mine_parent.setdefault(best, []).append(i)
big = max(range(len(got)), key=lambda i: abs(got[i]["area"]))
# match engine loops to my rings by area, then compare the two attributions by COUNT
mine_counts = sorted(len(v) for v in mine_parent.values())
got_counts = sorted(len(l["holes"]) for l in got if l["holes"])
ok &= report(name, "VOID",
f"void attribution matches: engine {got_counts}, containment "
f"{mine_counts}", got_counts == mine_counts)
# MIRROR / OFFSET — engine operations on a REAL profile, not a tidy one
n_outer = len(outer) - 1
try_call("sketch_cancel")
call("sketch_begin", plane="XY")
call("sketch_add", entities=ents[:n_outer])
xs = [p[0] for p in outer]
axis = min(xs) - 10.0
call("sketch_select", entities=list(range(n_outer)))
try:
call("sketch_mirror", axis_a=[axis, 0], axis_b=[axis, 1])
m = call("sketch_describe")
ok &= report(name, "MIRROR", "the mirrored copy is closed too",
len(m["closed_loops"]) == 2 and m["open_ends"] == [])
except RuntimeError as e:
ok &= report(name, "MIRROR", f"refused: {e}", False)
try_call("sketch_cancel")
call("sketch_begin", plane="XY")
call("sketch_add", entities=ents[:n_outer])
try:
call("sketch_offset", distance=0.5, entities=list(range(n_outer)))
o = call("sketch_describe")
ok &= report(name, "OFFSET", "the offset profile is still closed",
any(l["closed"] for l in o["closed_loops"]))
except RuntimeError as e:
ok &= report(name, "OFFSET", f"refused: {e}", False)
return ok
# ── scale ────────────────────────────────────────────────────────────────────
def grade_scale(pdf, name, report, budget):
"""Same exactness, on a profile of several hundred entities, and timed.
"Interactive" is measurable from here even though nothing is clicked: every MCP verb is
serviced on the UI THREAD, so the time a reply takes is time the window was not repainting.
A round trip that stays inside the budget is a window that stayed responsive.
"""
segs = drawing_segments(pdf)
loops = find_loops(segs)
if len(loops) < 2:
report(name, "SKIP", f"no nested closed geometry found ({len(loops)} loops)")
return None
loops.sort(key=shoelace, reverse=True)
outer = loops[1]
voids = [r for r in loops[2:] if shoelace(r) > 1.0 and point_in(r[0], outer)]
rings = [outer] + voids
ents = []
for ring in rings:
for i in range(len(ring) - 1):
ents.append({"type": "line",
"p0": [ring[i][0], ring[i][1]],
"p1": [ring[i + 1][0], ring[i + 1][1]]})
if len(ents) < 300:
report(name, "SKIP", f"only {len(ents)} entities — not a scale case")
return None
try_call("sketch_cancel")
call("sketch_begin", plane="XY")
t0 = time.monotonic(); call("sketch_add", entities=ents); t_add = time.monotonic() - t0
t0 = time.monotonic(); r = call("sketch_describe"); t_desc = time.monotonic() - t0
t0 = time.monotonic(); call("sketch_select", entities=list(range(len(ents))))
t_sel = time.monotonic() - t0
t0 = time.monotonic(); call("sketch_validate"); t_val = time.monotonic() - t0
ok = True
ok &= report(name, "SCALE", f"{len(ents)} entities in {len(rings)} loops", True)
got = r["closed_loops"]
ok &= report(name, "CLOSED", f"engine finds {len(got)} closed loops, this script "
f"finds {len(rings)}", len(got) == len(rings))
mine = sorted(shoelace(x) for x in rings)
theirs = sorted(abs(l["area"]) for l in got)
same = len(mine) == len(theirs) and all(
abs(a - b) <= max(1e-3, 1e-6 * a) for a, b in zip(mine, theirs))
ok &= report(name, "AREA", "every loop area matches the shoelace value exactly", same)
worst = max(t_add, t_desc, t_sel, t_val)
ok &= report(name, "TIME", f"add {t_add*1000:.0f} ms, describe {t_desc*1000:.0f} ms, "
f"select {t_sel*1000:.0f} ms, validate {t_val*1000:.0f} ms "
f"(budget {budget*1000:.0f} ms)", worst <= budget)
return ok
def _pdf_error(path):
"""What poppler says about a file it refused, so a refusal can be classified."""
r = subprocess.run(["pdfinfo", path], capture_output=True, text=True)
return (r.stderr or "") + (r.stdout or "")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--corpus", default=os.path.expanduser("~/studycadcam"))
ap.add_argument("--step", type=int, default=20)
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--scale", action="store_true",
help="grade the LARGEST drawings instead: exactness plus a UI-thread budget")
ap.add_argument("--budget", type=float, default=2.0,
help="seconds; the slowest round trip a scale drawing may take")
a = ap.parse_args()
files = {}
for f in glob.glob(os.path.join(a.corpus, "MPD*.pdf")):
m = re.search(r"MPD(\d+)", os.path.basename(f))
if m:
files[int(m.group(1))] = f
# step 1 means EVERY sheet. Written as `n % step == 1` it silently selected nothing, because
# n % 1 is always 0 — and the run then printed "RUNG 9 HELD" over zero drawings graded. A
# gate that passes by grading nothing is worse than no gate, so the count is checked below.
picks = [files[n] for n in sorted(files) if a.step <= 1 or n % a.step == 1]
if a.scale:
# The heaviest real profiles in the corpus, biggest first — up to ~1300 entities.
sized = []
for f in files.values():
try:
segs = drawing_segments(f)
loops = find_loops(segs)
if len(loops) < 2:
continue
loops.sort(key=shoelace, reverse=True)
outer = loops[1]
voids = [r for r in loops[2:] if shoelace(r) > 1.0 and point_in(r[0], outer)]
sized.append((sum(len(r) - 1 for r in [outer] + voids), f))
except Exception: # noqa: BLE001
continue
sized.sort(reverse=True)
picks = [f for _, f in sized[:max(1, a.limit or 6)]]
elif a.limit:
picks = picks[:a.limit]
print(f"corpus: {len(files)} sheets; systematic sample every {a.step}th "
f"-> {len(picks)} drawings\n")
fails, results = [], []
def report(name, tag, msg, cond=True):
if tag == "SKIP":
print(f" {name:9s} SKIP {msg}")
return True
mark = "ok " if cond else "FAIL"
print(f" {name:9s} {tag:8s} {mark} {msg}")
if not cond:
fails.append(f"{name} {tag}: {msg}")
return cond
for f in picks:
name = re.search(r"MPD\d+", os.path.basename(f)).group(0)
try:
r = (grade_scale(f, name, report, a.budget) if a.scale
else grade(f, name, report))
if r is not None:
results.append((name, r))
except Exception as e: # noqa: BLE001
# An unreadable SOURCE file is not a grading failure. MPD133 of this corpus is
# password-protected, and pdftocairo says so on stderr while exiting non-zero;
# reporting that as ERROR made one encrypted sheet look like an engine defect.
if "password" in _pdf_error(f).lower():
report(name, "SKIP", "the PDF is password-protected — nothing to extract")
else:
report(name, "ERROR", str(e)[:120], False)
graded = len(results)
passed = sum(1 for _, r in results if r)
if graded == 0:
print("\nNOTHING WAS GRADED — that is a harness failure, not a clean run", file=sys.stderr)
sys.exit(2)
print(f"\ngraded {graded} drawings; {passed} fully clean, {graded - passed} with "
f"at least one failure")
if fails:
print("\nfailures:")
for x in fails:
print(" " + x)
sys.exit(1)
print("\nRUNG 9 HELD — the engine agrees with the drawings, not with me")
if __name__ == "__main__":
main()
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""A ladder of 2D sketches of increasing complexity, judged the way a person judges them.
WHY NOT AREA. Area is derived and no one can confirm it by looking. What a human checks at a
glance, and can be exactly right or exactly wrong about, is:
VERTEX is the corner where I said it is
LENGTH is the side the length I gave it
ARC is the radius the radius I gave it
TANGENT does the straight run into the curve smoothly, or is there a kink
SYMMETRY is the mirrored half the exact reflection of the half I drew
CLOSED is it one closed loop, or does it just look like one
Every rung asserts those. Area appears only as a cross-check, never as the verdict.
Entirely 2D: sketch entities only, no extrude, revolve or any solid feature.
ORCA_CAD_MCP=/tmp/mcp.sock <binary>
python3 scripts/CAD/check-sketch-engine.py [socket]
Exit 0 = every rung held. Otherwise the first broken property is named and the run stops.
"""
import json, math, socket, sys
SOCK = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mcp.sock"
EPS = 1e-9
_n = 0
_fail = 0
def call(method, **params):
global _n
_n += 1
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(30)
s.connect(SOCK)
s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method,
"params": params}) + "\n").encode())
buf = b""
while b"\n" not in buf:
d = s.recv(65536)
if not d:
break
buf += d
r = json.loads(buf.decode().strip())
if "error" in r:
raise RuntimeError(f"{method}: {r['error']['message']}")
return r["result"]
def check(kind, cond, what):
global _fail
if cond:
print(f" {kind:9s} ok {what}")
else:
print(f" {kind:9s} FAIL {what}", file=sys.stderr)
_fail += 1
def near(a, b, tol=1e-6):
return abs(a - b) <= tol
def pt_near(p, q, tol=1e-6):
return math.hypot(p[0] - q[0], p[1] - q[1]) <= tol
def fresh(plane="XY"):
try:
call("sketch_cancel")
except Exception:
pass
call("sketch_begin", plane=plane)
def ents():
return call("sketch_describe")["entities"]
def rep():
return call("sketch_describe")
def endpoints(e):
"""Both ends of an open curve, as tuples. Closed curves have none."""
if "p0" not in e or "p1" not in e:
return ()
return tuple(e["p0"]), tuple(e["p1"])
def tangent(e, at_end):
"""Unit tangent of entity e at one of its ends, pointing ALONG the curve (p0->p1)."""
if e["type"] == "line":
dx = e["p1"][0] - e["p0"][0]
dy = e["p1"][1] - e["p0"][1]
else: # arc
a = e["start_angle"] if not at_end else e["end_angle"]
ccw = e["end_angle"] >= e["start_angle"]
# d/dtheta (cos, sin) = (-sin, cos), reversed when the sweep is clockwise
dx, dy = -math.sin(a), math.cos(a)
if not ccw:
dx, dy = -dx, -dy
n = math.hypot(dx, dy)
return (dx / n, dy / n)
def tangent_at_point(e, p):
"""Unit tangent of e at whichever of its ends is p, oriented leaving that point."""
p0, p1 = endpoints(e)
if pt_near(p0, p):
t = tangent(e, False)
return t
t = tangent(e, True)
return (-t[0], -t[1]) # leaving p1 means going back along the curve
def smooth(e1, e2, p):
"""G1 at shared point p: the tangent leaving e1 is opposite the tangent leaving e2."""
a = tangent_at_point(e1, p)
b = tangent_at_point(e2, p)
return abs(a[0] * (-b[0]) - 0) >= 0 and abs(a[0] * b[1] - a[1] * b[0]) <= 1e-6
def closed_one_loop(r, voids=0):
return (r["buildable"] and r["open_ends"] == []
and len([l for l in r["closed_loops"] if not any(
i in h["holes"] for h in r["closed_loops"] for i in [])]) >= 1)
def outer_loop(r):
"""The loop that encloses the others (or the only one)."""
if not r["closed_loops"]:
return None
return max(r["closed_loops"], key=lambda l: abs(l["area"]))
# ─────────────────────────────────────────────────────────────────────────────
print("RUNG 1 — rectangle: four corners, four lengths, four right angles")
fresh()
W, H = 80.0, 50.0
call("sketch_add", rect=[0, 0, W, H])
r = rep()
es = r["entities"]
corners = {(0, 0), (W, 0), (W, H), (0, H)}
got = set()
for e in es:
got.add(tuple(e["p0"]))
got.add(tuple(e["p1"]))
check("VERTEX", all(any(pt_near(c, g) for g in got) for c in corners),
f"all four corners exactly where asked {sorted(corners)}")
lens = sorted(round(e["length"], 9) for e in es)
check("LENGTH", lens == sorted([W, W, H, H]), f"sides are {W}/{H} twice each (got {lens})")
# right angles: consecutive sides meet at 90 degrees
ang_ok = True
for e in es:
for f in es:
if e is f:
continue
for p in endpoints(e):
if any(pt_near(p, q) for q in endpoints(f)):
a, b = tangent_at_point(e, p), tangent_at_point(f, p)
if abs(a[0] * b[0] + a[1] * b[1]) > 1e-6:
ang_ok = False
check("ANGLE", ang_ok, "every corner is exactly 90 degrees")
check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed loop, no free ends")
print("\nRUNG 2 — a circular void inside it")
call("sketch_add", type="circle", center=[W / 2, H / 2], radius=12)
r = rep()
c = [e for e in r["entities"] if e["type"] == "circle"][0]
check("VERTEX", pt_near(tuple(c["center"]), (W / 2, H / 2)), "void centred exactly where asked")
check("ARC", near(c["radius"], 12), f"void radius exactly 12 (got {c['radius']})")
out = outer_loop(r)
check("CLOSED", len(out["holes"]) == 1, "the rectangle encloses exactly one void")
check("CLOSED", r["buildable"] and r["open_ends"] == [], "still closed with the void present")
print("\nRUNG 3 — stadium: straights running into caps, tangent at every junction")
fresh()
L, R = 50.0, 15.0
call("sketch_add", entities=[
{"type": "line", "p0": [-L, -R], "p1": [L, -R]},
{"type": "arc", "center": [L, 0], "radius": R,
"start_angle": -math.pi / 2, "end_angle": math.pi / 2},
{"type": "line", "p0": [L, R], "p1": [-L, R]},
{"type": "arc", "center": [-L, 0], "radius": R,
"start_angle": math.pi / 2, "end_angle": 3 * math.pi / 2},
])
r = rep()
es = r["entities"]
check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed loop, no free ends")
arcs = [e for e in es if e["type"] == "arc"]
check("ARC", all(near(a["radius"], R) for a in arcs), f"both caps exactly R={R}")
check("LENGTH", all(near(e["length"], 2 * L) for e in es if e["type"] == "line"),
f"both straights exactly {2*L}")
# tangency at all four line/arc junctions
tang = True
for a in arcs:
for p in endpoints(a):
mates = [e for e in es if e is not a and any(pt_near(p, q) for q in endpoints(e))]
for m in mates:
if not smooth(a, m, p):
tang = False
check("TANGENT", tang, "straight meets cap smoothly at all four junctions (no kink)")
print("\nRUNG 4 — mirror: the reflected half is the exact reflection")
fresh()
half = [
{"type": "line", "p0": [0, -R], "p1": [L, -R]},
{"type": "arc", "center": [L, 0], "radius": R,
"start_angle": -math.pi / 2, "end_angle": math.pi / 2},
{"type": "line", "p0": [L, R], "p1": [0, R]},
]
call("sketch_add", entities=half)
r = rep()
check("CLOSED", not r["buildable"] and len(r["open_ends"]) == 2,
f"half profile is correctly OPEN, both ends named {r['open_ends']}")
call("sketch_select", entities=[0, 1, 2])
call("sketch_mirror", axis_a=[0, 0], axis_b=[0, 1])
r = rep()
es = r["entities"]
check("CLOSED", r["buildable"] and r["open_ends"] == [], "mirroring closed the loop")
# every source vertex must have its exact reflection present
src = []
for e in es[:3]:
src += [tuple(e["p0"]), tuple(e["p1"])]
allv = []
for e in es:
allv += [tuple(e["p0"]), tuple(e["p1"])]
sym = all(any(pt_near((-x, y), v) for v in allv) for (x, y) in src)
check("SYMMETRY", sym, "every vertex has its exact mirror twin across x=0")
mirrored_arc = [e for e in es[3:] if e["type"] == "arc"]
check("ARC", mirrored_arc and near(mirrored_arc[0]["radius"], R)
and pt_near(tuple(mirrored_arc[0]["center"]), (-L, 0)),
f"mirrored cap keeps R={R} and lands at (-{L}, 0)")
print("\nRUNG 5 — offset: every curve moves by exactly d, and it stays closed")
d = 4.0
call("sketch_select", entities=list(range(len(es))))
call("sketch_offset", distance=-d) # -d = outward for this CCW loop
r = rep()
new = r["entities"][len(es):]
check("CLOSED", r["buildable"] and r["open_ends"] == [], "offset result is closed")
off_arcs = [e for e in new if e["type"] == "arc"]
check("ARC", all(near(a["radius"], R + d) for a in off_arcs),
f"each cap radius grew by exactly {d} -> {R+d}")
off_lines = [e for e in new if e["type"] == "line"]
check("VERTEX", all(near(abs(e["p0"][1]), R + d) for e in off_lines),
f"each straight moved out to |y| = {R+d} exactly")
print("\nRUNG 6 — a gap is found by coordinate, then closed by a real constraint")
fresh()
call("sketch_add", entities=[
{"type": "line", "p0": [0, 0], "p1": [60, 0]},
{"type": "line", "p0": [60, 0], "p1": [60, 40]},
{"type": "line", "p0": [60, 40], "p1": [0, 40]},
{"type": "line", "p0": [0, 40], "p1": [0.35, 0]}, # 0.35 mm short
])
r = call("sketch_validate", tolerance=1.0)
check("CLOSED", not r["buildable"] and len(r["open_ends"]) == 2,
f"the gap is reported, both free ends named {r['open_ends']}")
dof0 = r["dof"]
r = call("sketch_heal", tolerance=1.0)
check("CLOSED", r["buildable"] and r["open_ends"] == [], "healed into a closed loop")
check("VERTEX", r["welded"] == 1, "exactly one pair of vertices welded")
check("ANGLE", r["dof"] < dof0,
f"the weld is a real constraint, not a nudge: DoF {dof0} -> {r['dof']}")
es = ents()
check("VERTEX", pt_near(tuple(es[3]["p1"]), tuple(es[0]["p0"])),
"the two ends are now the same point")
print("\nRUNG 7 — the composite: mirrored, tangent, two voids, all at once")
fresh()
call("sketch_add", entities=half)
call("sketch_select", entities=[0, 1, 2])
call("sketch_mirror", axis_a=[0, 0], axis_b=[0, 1])
call("sketch_add", type="circle", center=[-25, 0], radius=6)
call("sketch_add", type="circle", center=[25, 0], radius=6)
r = rep()
es = r["entities"]
out = outer_loop(r)
check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed outer loop, no free ends")
check("CLOSED", len(out["holes"]) == 2, "it encloses exactly two voids")
circles = [e for e in es if e["type"] == "circle"]
check("ARC", all(near(c["radius"], 6) for c in circles), "both voids exactly R=6")
check("SYMMETRY", pt_near(tuple(circles[0]["center"]), (-25, 0))
and pt_near(tuple(circles[1]["center"]), (25, 0)),
"the voids sit symmetrically at x = -25 and +25")
tang = True
for a in [e for e in es if e["type"] == "arc"]:
for p in endpoints(a):
for m in [e for e in es if e is not a and any(pt_near(p, q) for q in endpoints(e))]:
if not smooth(a, m, p):
tang = False
check("TANGENT", tang, "every straight-to-cap junction is still smooth")
exact = 2 * L * 2 * R + math.pi * R * R
check("LENGTH", near(out["area"], exact, 1e-6),
f"cross-check: enclosed area {out['area']:.4f} = 2L*2R + pi*R^2 = {exact:.4f}")
print("\nRUNG 8 — a real drawing: StudyCadCam MPD5, the pin's revolve half-profile")
# Ø27 x 95 pin: C1 chamfer on the left end, cylinder to a corner at x=85, an R5 fillet into a
# cone at 23 degrees to the axis, right face at x=95. Interpretation stated so the rung is
# reproducible: 85 is to the CORNER, 23 deg is to the AXIS, C1 is 1 x 45.
fresh()
RAD, LEN, TX, ANG, RF, CH = 13.5, 95.0, 85.0, math.radians(23), 5.0, 1.0
t = RF * math.tan(ANG / 2)
ax, ay = TX - t, RAD # fillet tangent point on the cylinder
cx, cy = ax, RAD - RF # fillet centre
bx, by = TX + t * math.cos(-ANG), RAD + t * math.sin(-ANG) # tangent point on the cone
ey = by - (LEN - bx) * math.tan(ANG) # where the cone meets the right face
call("sketch_add", entities=[
{"type": "line", "p0": [0, 0], "p1": [0, RAD - CH]}, # left face
{"type": "line", "p0": [0, RAD - CH], "p1": [CH, RAD]}, # C1 chamfer
{"type": "line", "p0": [CH, RAD], "p1": [ax, ay]}, # cylinder top
{"type": "arc", "center": [cx, cy], "radius": RF,
"start_angle": math.pi / 2, "end_angle": math.pi / 2 - ANG}, # R5 fillet
{"type": "line", "p0": [bx, by], "p1": [LEN, ey]}, # 23 deg cone
{"type": "line", "p0": [LEN, ey], "p1": [LEN, 0]}, # right face
{"type": "line", "p0": [LEN, 0], "p1": [0, 0]}, # axis
])
r = rep()
es = r["entities"]
check("CLOSED", r["buildable"] and r["open_ends"] == [], "the half-profile is one closed loop")
xs = [v[0] for e in es if "p0" in e for v in (e["p0"], e["p1"])]
ys = [v[1] for e in es if "p0" in e for v in (e["p0"], e["p1"])]
check("LENGTH", near(max(xs) - min(xs), LEN), f"overall length exactly {LEN} (the 95 dimension)")
check("VERTEX", near(max(ys), RAD), f"outer radius exactly {RAD} (the dia 27)")
fil = [e for e in es if e["type"] == "arc"][0]
check("ARC", near(fil["radius"], RF), f"the corner fillet is exactly R{RF:g}")
cone = [e for e in es if e["type"] == "line"
and not near(e["p0"][0], e["p1"][0]) and not near(e["p0"][1], e["p1"][1])
and e["length"] > 5]
if cone:
c0 = cone[0]
a = abs(math.degrees(math.atan2(c0["p1"][1] - c0["p0"][1], c0["p1"][0] - c0["p0"][0])))
check("ANGLE", near(a, 23, 1e-6), f"the cone is exactly 23 degrees to the axis (got {a:.6f})")
cham = [e for e in es if e["type"] == "line" and near(e["length"], CH * math.sqrt(2), 1e-9)]
check("ANGLE", bool(cham), "the C1 chamfer is exactly 1 x 45 (length 1*sqrt2)")
tang = True
for p in endpoints(fil):
for m in [e for e in es if e is not fil and any(pt_near(p, q) for q in endpoints(e))]:
if not smooth(fil, m, p):
tang = False
check("TANGENT", tang, "the fillet is tangent to BOTH the cylinder and the cone (no kink)")
call("sketch_cancel")
try:
call("sketch_cancel")
except Exception:
pass # a rung may have closed it already
print(f"\n{'ALL RUNGS HELD' if _fail == 0 else str(_fail) + ' CHECK(S) FAILED'}")
sys.exit(1 if _fail else 0)
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# One turn of the keyboard-focus convergence loop, start to verdict, with no human in it.
#
# scripts/CAD/focus-loop.sh # full turn: sync -> build -> restart -> assert
# SKIP_BUILD=1 scripts/CAD/focus-loop.sh # re-assert against the binary already on the host
#
# Exit 0 only when every gate holds. Any other exit is a failing gate and names which.
#
# WHY THIS EXISTS. The focus defects in the Design tab were chased for days by hand: build, launch
# the GUI, drive it with xdotool, read a screenshot, guess, repeat. That needs a person at every
# step and it is where the days went. This does not: behemoth carries an agent-owned Xvfb :10 with
# openbox, the app, xdotool and an MCP socket that reports sketch state as JSON, so a turn is
# sync -> build -> restart -> assert, and the ASSERTION is the verdict, not my reading of a picture.
#
# WHY BEHEMOTH AND NOT THE orcacad-gui RIG CONTAINER. The rig was the obvious host and it does not
# work for this: its image pins a dependency set 216 non-CAD source files behind cad-mainline
# (assimp among them), so today's CAD sources call GUI_App::is_auto_close_sketch_loops and
# MainFrame::ensure_design_panel, which that tree has never heard of. Syncing all of src/ to fix
# that needs a deps rebuild measured in hours. behemoth already builds this exact tree, already
# runs a WM on :10, and is the machine the user actually runs the product on — so the loop asserts
# against the shipping artefact rather than a stale twin. Reviving the rig means rebuilding its
# deps image first; until then it cannot adjudicate anything about this code.
#
# SC2029: every ssh command below quotes locally-expanded config (HOST, SRC, DISP) on purpose
# -- the remote tree is not this checkout and has no such config of its own.
# shellcheck disable=SC2029
set -uo pipefail
HOST="${HOST:-tommaso@100.103.234.2}"
DISP="${DISP:-:10}"
SRC="${SRC:-\$HOME/projects/orca/orcacad-native/src}"
TRACE="${TRACE:-/tmp/ux-focus-loop.log}"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BIN="build/src/Release/orca-slicer"
say() { printf '\n=== %s\n' "$*"; }
die() { printf 'GATE FAILED: %s\n' "$*" >&2; exit 1; }
ssh -o ConnectTimeout=10 "$HOST" true || die "cannot reach $HOST"
# ---------------------------------------------------------------- S1 sync
# Only the CAD paths and the ladders. behemoth's tree is a full cad-mainline checkout kept in step
# by its own realign; pushing unrelated files from here would make the build host disagree with
# git for reasons no later session could reconstruct.
say "S1 sync"
rsync -q "$REPO"/src/slic3r/GUI/CAD/*.{cpp,hpp} "$HOST:$SRC/src/slic3r/GUI/CAD/" || die "sync GUI/CAD"
rsync -q "$REPO"/src/libslic3r/CAD/*.{cpp,hpp} "$HOST:$SRC/src/libslic3r/CAD/" || die "sync libslic3r/CAD"
rsync -q "$REPO"/scripts/CAD/check-gui-click-edit.py "$REPO"/scripts/CAD/check-gui-sketching.py \
"$HOST:/tmp/" || die "sync ladders"
echo " sources + ladders in place"
# ---------------------------------------------------------------- S2 build
# flock: two concurrent Orca builds once OOM'd this machine for 2h28m. Every build script on the
# fleet takes this same lock.
#
# Grade the BINARY'S TIMESTAMP, never the build command's exit code. This is a Ninja Multi-Config
# tree whose default rules are Debug while the artefact under test is Release, so a wrong-config
# invocation returns success in seconds having touched nothing — it cost a wasted cycle here
# before anyone thought to look at the file.
if [ -z "${SKIP_BUILD:-}" ]; then
say "S2 build"
before=$(ssh "$HOST" "stat -c %Y $SRC/$BIN 2>/dev/null || echo 0")
ssh "$HOST" "flock /tmp/orca-rig-build.lock \$HOME/projects/orca/orcacad-native/rebuild.sh > /tmp/focus-build.log 2>&1"
rc=$?
after=$(ssh "$HOST" "stat -c %Y $SRC/$BIN 2>/dev/null || echo 0")
if [ "$rc" != 0 ] || [ "$after" = "$before" ]; then
ssh "$HOST" "grep -m5 -B2 'error:' /tmp/focus-build.log; tail -5 /tmp/focus-build.log"
die "S2 build (exit $rc, binary $( [ "$after" = "$before" ] && echo unchanged || echo rebuilt ))"
fi
echo " built"
fi
# ---------------------------------------------------------------- S3 F2P
# The ladder launches and tears down the app itself, in its own datadir, so nothing here has to
# manage a process. It types WITHOUT clicking the field first, which is the whole contract.
say "S3 fail-to-pass: type without clicking the field"
ssh "$HOST" "cd $SRC && DISPLAY=$DISP python3 /tmp/check-gui-click-edit.py \
--display $DISP --bin $BIN --trace $TRACE"
f2p=$?
# ---------------------------------------------------------------- S4 P2P
say "S4 pass-to-pass: the existing gesture ladder"
ssh "$HOST" "cd $SRC && DISPLAY=$DISP python3 /tmp/check-gui-sketching.py 2>&1 | tail -3"
p2p=$?
say "VERDICT"
[ "$f2p" = 0 ] || die "F2P: a tool did not take the typed value (exit $f2p)"
[ "$p2p" = 0 ] || die "P2P: the gesture ladder regressed (exit $p2p)"
echo "ALL GATES HELD"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Every ladder, in one command, as the gate before a push that touched the Design tab.
#
# WHY A SCRIPT AND NOT CI. Three of the four rungs need a running application with an OpenGL
# canvas and synthetic input; GitHub's runners have neither. So the gate is local and explicit:
# run this, read the last line, and do not push a red one. The kernel suite is the only part CI
# can carry, and it already does.
#
# scripts/CAD/run-all-checks.sh # kernel + engine + corpus (every 20th) + gestures + offer
# FULL=1 scripts/CAD/run-all-checks.sh # corpus over ALL 997 sheets (~25 min)
# SKIP_GUI=1 scripts/CAD/run-all-checks.sh # kernel only, for a machine with no rig
#
# The rig container is expected to be up with the app running and ORCA_CAD_MCP set; bring it up
# with scripts/CAD/start-headless-gui.sh inside it. The corpus lives at /corpus in that container.
set -uo pipefail
# ../.. -- this script lives in scripts/CAD/, so one level up is scripts/, not the repo
# root. It was scripts/ladder-all.sh when it was written; the move fixed the three sibling
# scripts and missed this one, which left every rung looking for its own path under
# scripts/scripts/ and reporting instant failures that were all the same typo.
cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1
# orcacad-gui, NOT snapmaker-gui: that is the other fork's rig, and defaulting to it makes
# this gate verify the wrong fork's binary while reporting green. run-kernel-tests.sh
# carries the same warning about the build volume, where the defect was found first.
C="${C:-orcacad-gui}"
CORPUS="${CORPUS:-/corpus}"
STEP="${STEP:-20}"
[ -n "${FULL:-}" ] && STEP=1
fail=0
step() {
local name="$1"; shift
echo
echo "=== $name ==="
if "$@"; then echo "--- $name OK"; else echo "--- $name FAILED"; fail=1; fi
}
# This fork's rig runs Xvfb on :11, the other fork's on :10, and the check scripts default
# to ":10" when DISPLAY is unset -- which docker exec leaves unset. The rungs that drive the
# GUI therefore looked for a window on a display that does not exist here and reported
# "FATAL no app window on :10", which reads like a dead app rather than a wrong display.
RIG_DISPLAY="${RIG_DISPLAY:-:11}"
# SC2329: every call goes through step(), which invokes it via "$@", so shellcheck
# cannot see the callers below.
# shellcheck disable=SC2329
run_in_rig() { # copy the script in fresh, then run it there
docker cp "$1" "$C:/tmp/$(basename "$1")" >/dev/null || return 1
shift
docker exec -e DISPLAY="$RIG_DISPLAY" "$C" python3 "$@"
}
# FIRST, and it needs no rig: the offer table the menu is compiled from must be what the atlas
# says. The header calls itself GENERATED and had been hand-edited anyway — which cost four rows
# that existed only in the header, one row wired to the wrong action, and a count of 91 for a
# 92-row array, so the last verb was unreachable (z8rs, ziam).
# docs/CAD/, not docs/: SoftFever moved the design docs into the CAD subfolder
# (bbd1989e1e) and this line kept the old path, so the rung failed on a missing file
# rather than on anything about the table. The other fork still has docs/ux/.
step "offer table matches the atlas" python3 docs/CAD/ux/mockups/gen_offer_table.py --check
step "kernel suite" scripts/CAD/run-kernel-tests.sh --vol "${KVOL:-orcacad_kerneltest}"
if [ -z "${SKIP_GUI:-}" ]; then
step "engine ladder (rungs 1-8, scripted geometry)" \
run_in_rig scripts/CAD/check-sketch-engine.py /tmp/check-sketch-engine.py
step "corpus rung (real drawings, every ${STEP}th)" \
run_in_rig scripts/CAD/check-sketch-engine-corpus.py /tmp/check-sketch-engine-corpus.py --corpus "$CORPUS" --step "$STEP"
step "corpus scale rung (the heaviest sheets)" \
run_in_rig scripts/CAD/check-sketch-engine-corpus.py /tmp/check-sketch-engine-corpus.py --corpus "$CORPUS" --scale
step "gesture ladder (mouse and keyboard)" \
run_in_rig scripts/CAD/check-gui-sketching.py /tmp/check-gui-sketching.py
# The offer ladder needs TWO extra things the others do not: the app must have been launched
# with ORCA_CAD_KEYTRACE=1 (its [OFFER] lines are the whole instrument), and it reads the
# generated offer table to predict what each selection should show — which is not in the
# container's own baked source tree, so it is copied in beside the script — /tmp, where
# run_in_rig puts the script, is one of the paths the ladder looks in.
docker cp src/slic3r/GUI/CAD/DesignOffer.hpp "$C:/tmp/DesignOffer.hpp" >/dev/null
step "offer ladder (right-click, the menu, the verbs behind it)" \
run_in_rig scripts/CAD/check-gui-context-menu.py /tmp/check-gui-context-menu.py
fi
echo
if [ "$fail" -eq 0 ]; then echo "ALL LADDERS HELD"; else echo "AT LEAST ONE LADDER FAILED"; fi
exit "$fail"
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# Headless CAD-kernel test loop. THE verification contract for delegated work:
# exit 0 means the kernel builds and the selected Catch2 tags pass. Nothing else counts.
#
# Builds only the `libslic3r_tests` target (not the GUI app), so a round trip is
# minutes, not tens of minutes. Needs no display: every [CadDocument] case builds a
# CadDocument, recompute()s it and asserts on geometry.
#
# Usage:
# scripts/CAD/run-kernel-tests.sh # [CadDocument] tags, default volume
# scripts/CAD/run-kernel-tests.sh --tags '[CadDocument],[Sketch]'
# scripts/CAD/run-kernel-tests.sh --vol wt_mirror # private build cache (parallel workers)
# scripts/CAD/run-kernel-tests.sh --host tommaso@100.103.234.2 # build on a remote host
#
# Parallel workers MUST pass a distinct --vol: two builds sharing one cache corrupt
# each other. A new volume pays one full build; runs after that are incremental.
#
# --host exists because the deps image lives wherever it was first built. It rsyncs this
# working tree to a per-volume staging dir on that host and re-runs this same script
# there, so the verification contract is identical either way. Drop --host once the image
# is present locally.
# Rig build traps already paid for once each (stale project, NLopt cache, pybind11, OCCT_LIBS, SLIC3R_CAD gate): docs/rig_build_traps.md
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# orcacad-deps, NOT snapmaker-deps: this fork is mainline-based and needs Eigen 5.0.1,
# CGAL 5.6.3, wx 3.3.2 and Python 3.12 Development.Embed, none of which snapmaker-deps has.
# With the wrong image CMake dies at configure, which is exactly why this fork went
# M1-M8 without ever compiling (see commit 1633005bba).
IMAGE="${IMAGE:-orcacad-deps}"
# Must NOT default to snapmaker_buildcache: that is the other fork's volume, and pointing
# this fork at it makes the two silently trade build artefacts. build-gui-incremental.sh had the
# identical defect and was fixed to orcacad_buildcache; this script was missed.
VOL="${BUILD_VOL:-orcacad_kerneltest}"
# No exclusions. Both cases that used to be quarantined now run: the solver SIGABRT on
# circle-line tangency is fixed (tkz), and the internal-thread case turned out to have
# correct geometry and a wrong reference in the test (kzy). A green run here now means
# the whole CAD suite passed, not "everything except the two we gave up on".
#
# ...and that claim was still not true, because the default tag was [CadDocument] alone while
# four CAD test files carry their own tags and NOTHING ELSE selected them. test_sketchinference
# ([inference], 15), test_sketchedit ([SketchEdit], 23), test_sketchconstraints
# ([SketchConstraints], 8) and test_sketchimport ([SketchImport], 4) never ran here, nor did the
# older [slvs]-only cases in test_slvs_constraints. Measured 2026-08-31: the default reported
# 2624 assertions / 206 cases, the full set 7648 / 264 -- so the gate was speaking for about a
# third of the assertions, and a whole file could be added, tagged by its own convention, and
# stay dark while the suite printed green. All 58 were passing; the coverage was simply never
# exercised. Adding a tag here is now part of adding a test file.
TAGS="${TAGS:-[CadDocument],[inference],[SketchEdit],[SketchConstraints],[SketchImport],[slvs]}"
HOST=""
while [[ $# -gt 0 ]]; do
case "$1" in
--vol) VOL="$2"; shift 2 ;;
--tags) TAGS="$2"; shift 2 ;;
--image) IMAGE="$2"; shift 2 ;;
--host) HOST="$2"; shift 2 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
echo "REPO=$REPO IMAGE=$IMAGE VOL=$VOL TAGS=$TAGS HOST=${HOST:-local}"
if [[ -n "$HOST" ]]; then
# Stage per-volume so parallel workers never share a remote tree.
REMOTE="kt-$VOL" # relative: ssh and rsync both start in the remote home dir
# SC2029: $REMOTE expanding on the CLIENT is the point -- it is derived from $VOL here,
# and the remote has no such variable. The rsync destination below expands it the same way.
# shellcheck disable=SC2029
ssh "$HOST" "mkdir -p $REMOTE"
# Only the inputs the build reads. --delete keeps a stale file from a prior worker from
# silently compiling in.
rsync -a --delete \
"$REPO/src" "$REPO/tests" "$REPO/resources" "$REPO/cmake" "$REPO/scripts" \
"$REPO/CMakeLists.txt" \
"$HOST:$REMOTE/"
exec ssh "$HOST" "cd $REMOTE && scripts/CAD/run-kernel-tests.sh --vol '$VOL' --tags '$TAGS' --image '$IMAGE'"
fi
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "FATAL: image '$IMAGE' not present locally. Either transfer it, or pass" >&2
echo " --host <user@host> to build where the image already exists." >&2
exit 3
fi
# A private volume is always built CLEAN on first use. Cloning a warm cache from another
# tree looks tempting but is a correctness trap: rsync preserves source mtimes, so ninja
# compares foreign object timestamps against them, decides everything is up to date, and
# relinks stale objects. That produced a binary with NO [CadDocument] tests at all while
# reporting success -- a green run that tested nothing. Pay the one-off full build instead;
# incremental rebuilds within a volume are correct because the mtimes then share a lineage.
if ! docker volume inspect "$VOL" >/dev/null 2>&1; then
echo "=== $VOL: new volume, clean configure + full build (one-off, slow) ==="
docker volume create "$VOL" >/dev/null
fi
# tests/ is mounted too -- unlike build-gui-incremental.sh, this script exists precisely to
# compile tests being edited. CMakeLists.txt and cmake/ carry the SLIC3R_CAD gate; taking
# them from the baked image instead leaves the gate off and the CAD symbols vanish.
# ---- OOM guard (2026-08-21) -------------------------------------------------------------
# Two of these builds ran at once on 2026-08-21, each with ninja -j$(nproc)=16: ~36 cc1plus
# holding 42 GB of a 62 GB box -> global OOM at 21:05, a 2h28m kill storm, ssh unreachable,
# lightdm destroyed. Neither build produced a single object. scripts/CAD/build-gui.sh grew the
# bounds first; every script that starts a compile needs the same three, or the guard is only
# as strong as the script you happened not to use.
# flock — the lock path is SHARED with build-gui.sh and the other fork on purpose, so
# concurrent builds serialise instead of summing.
# -j — bounded parallelism; ~1.17 GB per cc1plus was the measured average.
# --memory — the actual guarantee: a runaway build dies in its own cgroup instead of taking
# the host down. --memory-swap equal to --memory forbids swap, which is what made
# ssh hang.
JOBS="${JOBS:-12}"
MEM="${MEM:-40g}"
LOCK=/tmp/orca-rig-build.lock
exec 9>"$LOCK"
if ! flock -n 9; then
echo "another build holds $LOCK — waiting (this is the OOM guard, not a hang)"
flock 9
fi
docker run --rm \
--memory="$MEM" --memory-swap="$MEM" \
-v "$REPO/src":/OrcaSlicer/src \
-v "$REPO/tests":/OrcaSlicer/tests \
-v "$REPO/resources":/OrcaSlicer/resources \
-v "$REPO/CMakeLists.txt":/OrcaSlicer/CMakeLists.txt \
-v "$REPO/cmake":/OrcaSlicer/cmake \
-v "$REPO/deps_src":/OrcaSlicer/deps_src \
-v "$VOL":/OrcaSlicer/build \
"$IMAGE" \
bash -lc "set -e
cd /OrcaSlicer
DESTDIR=/OrcaSlicer/deps/build/destdir/usr/local
export PATH=\$DESTDIR/bin:\$PATH # wx-config, and anything else the deps prefix ships
# Configure unconditionally. Guarding on 'CMakeCache.txt exists' is wrong: a FAILED
# configure still writes that file, so the guard then skips reconfiguring forever and
# every later run reuses a poisoned cache while ignoring corrected flags. A no-op
# reconfigure costs seconds; that bug costs an afternoon.
# SLIC3R_GTK=3 and BUILD_TESTS=ON are not optional extras: src/CMakeLists.txt turns
# SLIC3R_GTK into 'wx-config --toolkit=gtk<N>', so omitting it asks for toolkit 'gtk'
# and no wx build matches -> 'Could NOT find wxWidgets'. BUILD_TESTS=ON is what makes
# the libslic3r_tests target exist at all. build_linux.sh sets both (lines 218, 226).
cmake -S . -B build -G 'Ninja Multi-Config' \
-DCMAKE_PREFIX_PATH=\$DESTDIR \
-DwxWidgets_CONFIG_EXECUTABLE=\$DESTDIR/bin/wx-config \
-DSLIC3R_GTK=3 -DBUILD_TESTS=ON -DSLIC3R_GUI=OFF \
-DSLIC3R_CAD=ON -DSLIC3R_STATIC=1 -DORCA_TOOLS=ON -DCMAKE_BUILD_TYPE=Release
# SLIC3R_GUI=OFF: this script builds ONLY libslic3r_tests, which links libslic3r and no GUI
# code, but cmake still PROCESSES the if SLIC3R_GUI block of src/CMakeLists.txt (lines 16-98)
# and every find_package inside it. That made the kernel suite depend on the GUI dependency
# set for no benefit, and it broke the moment upstream added wxInspector as a REQUIRED
# find_package at line 92: the orcacad-deps image predates it, so configure died pointing at
# src/CMakeLists.txt:92 while nothing about the kernel had changed. Turning the block off is
# not a workaround for that one dependency; it is the kernel suite finally declaring what it
# actually needs, so the next GUI-side dependency upstream adds cannot break it either.
cmake --build build --config Release --target libslic3r_tests -- -j$JOBS
./build/tests/libslic3r/Release/libslic3r_tests '$TAGS' --order decl"
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# Bring the headless GUI up on a VNC-served X display, ready to drive or to attach Remmina to.
#
# Runs INSIDE the long-lived GUI container (see the header of scripts/CAD/build-gui-incremental.sh for how
# that container is created). Idempotent: safe to re-run to recover a session whose app died.
#
# docker exec <container> /OrcaSlicer/scripts/CAD/start-headless-gui.sh # launch + settle
# docker exec <container> /OrcaSlicer/scripts/CAD/start-headless-gui.sh --status # report, change nothing
#
# WHY THIS EXISTS. Dismissing the first-run dialogs by computing the titlebar close box from
# `xdotool getwindowgeometry --shell` and clicking it went wrong whenever the dialog had already
# closed: the eval left the geometry variables stale or empty, the click landed at a garbage
# coordinate, and it repeatedly hit the Sketch button in the toolbar underneath, so the app came up
# in sketch mode with a stray Sketch feature. Three of those in one session.
#
# The titlebar click is nevertheless the RIGHT mechanism and is kept. `xdotool windowclose` looks
# cleaner but kills the app: it destroys the GdkWindow out from under the dialog and the process
# dies with "GdkWindow unexpectedly destroyed", three GLib-GObject criticals and a segfault
# (measured 2026-07-30). Escape does not close the Setup Wizard either. So the fix is not a
# different mechanism, it is refusing to click on geometry we have not validated.
set -euo pipefail
DISP="${DISP:-:11}"
GEOM="${GEOM:-1920x1080}"
BIN="${BIN:-/OrcaSlicer/build/src/Release/orca-slicer}"
# Packaging cannot bundle python (the deps python layer carries a doubled-DESTDIR RUNPATH), so the
# build-tree binary needs the deps libpython on the path. Packaging-only issue; the app runs fine.
LIBPY="/OrcaSlicer/deps/build/destdir/usr/local/libpython/lib"
LIBPY2="/OrcaSlicer/build/src/Release/python/lib"
LOG="${LOG:-/tmp/gui-session.log}"
export DISPLAY="$DISP" HOME=/root
export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
# The ladders read the document back through the MCP socket, and the offer ladder's only
# instrument is the [OFFER] keytrace. Neither is on by default, and a session launched without
# them comes up looking perfectly healthy: the window is there, status says app up, and every
# ladder then dies on "Connection refused" — which reads as a dead app rather than a rig that was
# started without its instrument. The script that launches the rig is where they belong.
export ORCA_CAD_MCP="${ORCA_CAD_MCP:-/tmp/mcp.sock}"
export ORCA_CAD_KEYTRACE="${ORCA_CAD_KEYTRACE:-1}"
export LD_LIBRARY_PATH="$LIBPY:$LIBPY2:${LD_LIBRARY_PATH:-}"
mkdir -p /root/.config # startup dies in boost::filesystem::create_directory without this
# Skip zombies. This container accumulates <defunct> instances of the app across runs, and pgrep
# matches them, so the naive "first match" reported a dead pid as if the session were healthy.
app_pid() {
local p
for p in $(pgrep -f "$(basename "$BIN")" 2>/dev/null); do
[ "$(awk "{print \$3}" "/proc/$p/stat" 2>/dev/null)" = "Z" ] && continue
echo "$p"; return 0
done
return 1
}
status() {
echo "display : $(pgrep -f "Xvfb $DISP" >/dev/null && echo up || echo DOWN)"
echo "wm : $(pgrep -x openbox >/dev/null && echo up || echo DOWN)"
if pgrep -x x11vnc >/dev/null; then echo "vnc : up on :5900"
elif ! command -v x11vnc >/dev/null; then echo "vnc : n/a (x11vnc not installed here)"
else echo "vnc : DOWN"; fi
local p; p="$(app_pid || true)"
echo "app : ${p:-DOWN}"
# WHICH binary is on screen, not just that something is. A pid alone cannot tell you whether
# you are looking at the build you just linked or one from last week, and that is precisely
# the question every rig verification is asking.
[ -n "${p:-}" ] && echo "binary : $(readlink -f "/proc/$p/exe" 2>/dev/null || echo unknown)"
[ -n "${p:-}" ] && echo "windows : $(xdotool search --name . getwindowname %@ 2>/dev/null | paste -sd'|' -)"
return 0
}
[ "${1:-}" = "--status" ] && { status; exit 0; }
# --- desktop: Xvfb, a window manager, and the VNC server ------------------------------------
# openbox is REQUIRED: without it xdotool windowactivate aborts with "windowmanager claims not to
# support _NET_ACTIVE_WINDOW" and dialogs never take focus.
pgrep -f "Xvfb $DISP" >/dev/null || { nohup Xvfb "$DISP" -screen 0 "${GEOM}x24" -nolisten tcp >/tmp/xvfb.log 2>&1 & sleep 3; }
pgrep -x openbox >/dev/null || { nohup openbox >/tmp/openbox.log 2>&1 & sleep 1; }
if ! pgrep -x x11vnc >/dev/null && command -v x11vnc >/dev/null; then
AUTH=()
[ -f /root/.vnc/passwd ] && AUTH=(-rfbauth /root/.vnc/passwd)
nohup x11vnc -display "$DISP" -rfbport 5900 "${AUTH[@]}" -forever -shared -noxdamage \
>/tmp/x11vnc.log 2>&1 &
sleep 2
fi
# --- app ------------------------------------------------------------------------------------
# Kill by BASENAME, not by "$BIN". The app enforces a single instance, so an older copy launched
# from a DIFFERENT path (the packaged build/package/bin/ one, say, when BIN points at the freshly
# linked build/src/Release/ one) survives a path-matched pkill, keeps the instance lock, and the
# new process exits seconds after loading fonts — leaving no error anywhere. app_pid() below has
# always matched by basename, so status then reported that stale process as a healthy session:
# the launch looked green while the window on screen was days old. Measured 2026-08-02, where it
# nearly passed a UI change against a Jul-30 binary. The killer and the reporter must agree on
# what counts as "the app".
pkill -9 -f "$(basename "$BIN")" 2>/dev/null || true
sleep 2
nohup "$BIN" >"$LOG" 2>&1 &
echo "launched $(basename "$BIN") pid $!"
# Wait for the main window rather than sleeping a fixed amount: cold starts vary a lot under
# software GL, and a fixed sleep either wastes time or races.
for _ in $(seq 1 40); do
xdotool search --name "Untitled" >/dev/null 2>&1 && break
sleep 1
done
# --- first-run dialogs ----------------------------------------------------------------------
# Click the titlebar close box, but only on geometry we have just read for a window that still
# exists, and only if the resulting point is inside the screen. Every variable is unset first so a
# failed read cannot leave the previous dialog's numbers behind — that is the whole bug.
screen_w="${GEOM%x*}"; screen_h="${GEOM#*x}"
close_dialog() {
local name="$1" id X Y WIDTH HEIGHT cx cy
id="$(xdotool search --name "$name" 2>/dev/null | head -1 || true)"
[ -z "$id" ] && return 1
unset X Y WIDTH HEIGHT
eval "$(xdotool getwindowgeometry --shell "$id" 2>/dev/null || true)"
# All four must be present and numeric: an empty or stale read is how the stray click happened.
for v in "${X:-}" "${Y:-}" "${WIDTH:-}" "${HEIGHT:-}"; do
[[ "$v" =~ ^-?[0-9]+$ ]] || { echo " $name: unreadable geometry, not clicking"; return 1; }
done
cx=$((X + WIDTH - 11)); cy=$((Y - 31)) # openbox decoration: close box above the frame
if [ "$cx" -lt 0 ] || [ "$cy" -lt 0 ] || [ "$cx" -ge "$screen_w" ] || [ "$cy" -ge "$screen_h" ]; then
echo " $name: close box at ${cx},${cy} is off-screen, not clicking"; return 1
fi
echo " $name: closing via titlebar at ${cx},${cy}"
xdotool mousemove "$cx" "$cy" click 1
sleep 2
return 0
}
# "Restore" is not a first-RUN dialog, it is a second-run one: killing the app mid-session leaves
# unsaved items behind, and the next launch asks whether to restore them. It sits over the tab bar
# with a modal grab, so every synthetic click afterwards lands on it and the ladder reports
# geometry that never got drawn — that is the "success with no log" shape twice already.
for name in "Setup Wizard" "New version" "Restore"; do
for _ in 1 2 3; do close_dialog "$name" || break; done
done
# --- main window ----------------------------------------------------------------------------
main="$(xdotool search --name "Untitled" 2>/dev/null | head -1 || true)"
if [ -n "$main" ]; then
xdotool windowmove "$main" 0 0 windowsize "$main" "$screen_w" "$screen_h" 2>/dev/null || true
xdotool windowactivate "$main" 2>/dev/null || true
sleep 2
fi
echo "--- session ---"
status
+95
View File
@@ -0,0 +1,95 @@
# Deps-only base image for fast iteration on Orca.
# Identical system+pinned-dependency setup to scripts/Dockerfile, but STOPS after
# `build_linux.sh -dr` (no slicer/AppImage build). Produces an image with the pinned
# deps baked at /OrcaSlicer/deps/build/destdir, so the slicer can be rebuilt
# incrementally via scripts/CAD/build-gui-incremental.sh without re-running the long deps build.
#
# Build once (rebuild only when deps/ changes, e.g. OCCT module flags):
# docker build -t snapmaker-deps -f scripts/Dockerfile.deps .
FROM docker.io/ubuntu:24.04
LABEL maintainer="Orca CAD iteration base"
# Disable interactive package configuration
RUN apt-get update && \
echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
# Add a deb-src
RUN echo deb-src http://archive.ubuntu.com/ubuntu \
$(cat /etc/*release | grep VERSION_CODENAME | cut -d= -f2) main universe>> /etc/apt/sources.list
RUN apt-get update && apt-get install -y \
autoconf \
build-essential \
cmake \
curl \
eglexternalplatform-dev \
extra-cmake-modules \
file \
git \
gstreamer1.0-plugins-bad \
gstreamer1.0-libav \
libcairo2-dev \
libcurl4-openssl-dev \
libdbus-1-dev \
libglew-dev \
libglu1-mesa-dev \
libgstreamer1.0-dev \
libgstreamerd-3-dev \
libgstreamer-plugins-base1.0-dev \
libgstreamer-plugins-good1.0-dev \
libgtk-3-dev \
libsecret-1-dev \
libsoup2.4-dev \
libssl3 \
libssl-dev \
libtool \
libudev-dev \
libwayland-dev \
libwebkit2gtk-4.1-dev \
libxkbcommon-dev \
locales \
locales-all \
m4 \
pkgconf \
sudo \
wayland-protocols \
wget
ENV LC_ALL=en_US.utf8
RUN locale-gen $LC_ALL
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
COPY ./ OrcaSlicer
WORKDIR OrcaSlicer
# System dependencies
RUN ./build_linux.sh -u
# Pinned dependencies in ./deps (OCCT 7.6 with ModelingAlgorithms enabled, OpenCV,
# OpenVDB, Boost, wxWidgets, ...). This is the long step; it is baked into the image.
# -j 12, not the default all-cores: on 2026-08-21 an unbounded deps build plus an unbounded
# app build put 42 GB of cc1plus on a 62 GB box and OOM-killed the host for 2h28m. 12 keeps
# the compile fast while leaving the machine usable. Pair with `docker build --memory`.
RUN ./build_linux.sh -dr -j 12
# Compatibility symlink. This deps tree installs into deps/build/OrcaSlicer_dep/, while the
# orcacad_buildcache volume was configured against the previous image, whose prefix was
# deps/build/destdir/. Those paths are baked as absolute strings into the app's CMakeCache, so
# without this symlink a rebuild on the new image reconfigures from scratch — hours of compile
# to change one directory name. Same tree, two names.
RUN ln -sfn /OrcaSlicer/deps/build/OrcaSlicer_dep /OrcaSlicer/deps/build/destdir
# The rig's GUI runtime. This used to arrive for free because orcacad-deps was layered on
# snapmaker-deps; that lineage is Trap 1 in docs/rig_build_traps.md (a baked project(Snapmaker_Orca)
# tree) and building from this Dockerfile is what removes it — along with the X stack the rig
# needs. scripts/CAD/start-headless-gui.sh requires Xvfb and openbox (without a window manager `xdotool
# windowactivate` aborts with "windowmanager claims not to support..."), drives the UI with
# xdotool, and captures to /shots with scrot/ImageMagick. Same set snapmaker-deps carries.
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
imagemagick \
openbox \
scrot \
xauth \
xdotool \
xvfb \
&& rm -rf /var/lib/apt/lists/*
+70
View File
@@ -0,0 +1,70 @@
# Adds assimp to the orcacad-deps image.
#
# WHY THIS EXISTS. OrcaSlicer mainline gained `find_package(assimp REQUIRED)` in
# src/libslic3r/CMakeLists.txt (glTF/GLB/FBX import for texture-to-colour), and the baked
# orcacad-deps image predates it: the image's deps/ tree has no Assimp directory at all, and
# nothing named assimp exists anywhere in it. On the host, deps/build/dep_Assimp-prefix has
# only `patch` and `update` stamps -- no build, no install -- so the dependency was fetched
# and then never built, in the image or out of it.
#
# The consequence was that scripts/CAD/run-kernel-tests.sh failed at CMake CONFIGURE time,
# before a single source file compiled, so THIS FORK'S KERNEL SUITE COULD NOT RUN AT ALL.
# Every kernel change ported here was parity-checked against Snapmaker and never independently
# tested (w80c). The Snapmaker fork does not hit this: its base requires neither
# assimp nor OpenCV.
#
# WHY A LAYER AND NOT A FULL DEPS REBUILD. Rebuilding every dependency takes hours and would
# rewrite artifacts that are currently working. This adds exactly the one missing package on
# top of the existing image, and it is a Dockerfile rather than a `docker commit` so that what
# was done is reviewable and repeatable instead of being an undocumented mutation.
#
# The flags below are copied from the project's own recipe (deps/Assimp/Assimp.cmake) plus the
# standard superbuild arguments from orcaslicer_add_cmake_project (deps/CMakeLists.txt:158) and
# DEP_CMAKE_OPTS (deps/deps-linux.cmake). Keep them in step with that recipe: this file is a
# stand-in for the superbuild, not an independent opinion about how to build assimp.
#
# BUILD (from the repo root, tarball already in deps/DL_CACHE/Assimp/):
# docker build -f scripts/Dockerfile.deps-assimp -t orcacad-deps .
#
# VERIFY:
# docker run --rm orcacad-deps sh -c \
# 'ls /OrcaSlicer/deps/build/destdir/usr/local/lib/cmake/assimp*'
# scripts/CAD/run-kernel-tests.sh
#
FROM orcacad-deps
# v5.4.3 is the version deps/Assimp/Assimp.cmake selects for CMake >= 3.22 (the image has
# 3.28.3). The SHA256 is that recipe's URL_HASH, verified against the cached tarball before
# this file was written -- an unverified archive is not a dependency, it is whatever was
# sitting in the cache.
ARG ASSIMP_SHA256=66dfbaee288f2bc43172440a55d0235dfc7bf885dda6435c038e8000e79582cb
COPY deps/DL_CACHE/Assimp/v5.4.3.tar.gz /tmp/assimp.tar.gz
RUN set -eux; \
echo "${ASSIMP_SHA256} /tmp/assimp.tar.gz" | sha256sum -c -; \
mkdir -p /tmp/assimp-src && \
tar -xzf /tmp/assimp.tar.gz -C /tmp/assimp-src --strip-components=1; \
DESTDIR=/OrcaSlicer/deps/build/destdir/usr/local; \
cmake -S /tmp/assimp-src -B /tmp/assimp-build -G Ninja \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$DESTDIR" \
-DCMAKE_PREFIX_PATH="$DESTDIR" \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DBUILD_SHARED_LIBS=OFF \
-DASSIMP_BUILD_USE_CCACHE=OFF \
-DASSIMP_BUILD_TESTS=OFF \
-DASSIMP_BUILD_SAMPLES=OFF \
-DASSIMP_BUILD_ASSIMP_TOOLS=OFF \
-DASSIMP_INSTALL_PDB=OFF \
-DASSIMP_NO_EXPORT=ON \
-DASSIMP_BUILD_ALL_IMPORTERS_BY_DEFAULT=OFF \
-DASSIMP_BUILD_GLTF_IMPORTER=ON \
-DASSIMP_BUILD_OBJ_IMPORTER=ON \
-DASSIMP_BUILD_FBX_IMPORTER=ON \
-DASSIMP_BUILD_ZLIB=ON \
-DASSIMP_WARNINGS_AS_ERRORS=OFF \
-DBUILD_WITH_STATIC_CRT=OFF; \
cmake --build /tmp/assimp-build --target install -- -j"$(nproc)"; \
rm -rf /tmp/assimp-src /tmp/assimp-build /tmp/assimp.tar.gz; \
test -n "$(ls "$DESTDIR"/lib/cmake/assimp* 2>/dev/null)"
-258
View File
@@ -1,258 +0,0 @@
#!/usr/bin/env python3
"""
Assign deterministic, globally-unique setting_id to OrcaSlicer system profiles.
Policy (see AGENTS.md "Critical Constraints"):
* A preset's setting_id is a pure function of its identity:
setting_id = base62_16( uuid5(NAMESPACE, "<vendor>/<type>/<name>") )
The same value is recomputed on the fly by the C++ app
(Slic3r::generate_preset_setting_id); the two MUST stay byte-identical. The rule
(generate_preset_setting_id, below) is also imported by the validator
(orca_extra_profile_check.py). Uniqueness is therefore automatic: two presets
collide only if they share vendor + type + name, which the validator flags.
* Bambu (BBL) owns the authoritative "G*" id space and is the only reserved vendor:
its ids are never rewritten (preserves backward-compat with Bambu-synced presets).
Every other vendor - including OrcaFilamentLibrary and Custom - follows the
deterministic rule.
* Only instantiated presets (instantiation == "true") carry a setting_id; base /
template profiles do not.
Only setting_id is rewritten. filament_id is deliberately left untouched: it is a
per-material id, shared across a filament's nozzle variants and inherited from base
templates, so it must not be made per-file unique.
Run from anywhere: python3 scripts/assign_vendor_setting_ids.py
The script is idempotent: a second run over an unchanged tree produces no diff.
"""
import json
import os
import re
import sys
import uuid
# Deterministic preset setting_id rule. Imported by the validator
# (orca_extra_profile_check.py) and kept byte-identical to the C++
# Slic3r::generate_preset_setting_id. Dedicated namespace, distinct from the cloud
# namespace (f47ac10b-...) so the two id spaces never coincide; this constant is baked
# into both languages - never change it.
NAMESPACE = uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f")
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
ID_LENGTH = 16
def generate_preset_setting_id(vendor, type_name, name):
"""Deterministic 16-char base62 setting_id for a preset.
input = f"{vendor}/{type_name}/{name}"; u = uuid5(NAMESPACE, input);
id = the low ID_LENGTH base62 digits of int(u.bytes, "big"), most-significant first.
"""
u = uuid.uuid5(NAMESPACE, f"{vendor}/{type_name}/{name}")
n = int.from_bytes(u.bytes, "big")
digits = []
for _ in range(ID_LENGTH):
digits.append(ALPHABET[n % 62])
n //= 62
return "".join(reversed(digits))
PROFILES_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "resources", "profiles"))
# Bambu (BBL) is the only reserved vendor: it keeps its authoritative "G*" cloud ids.
RESERVED_VENDORS = {"BBL"}
PROFILE_SUBDIRS = ("filament", "process", "machine")
def iter_profile_files(vendor_dir):
"""Yield (json path, type) under a vendor, in a deterministic order.
type is the subdir name ("filament"/"process"/"machine"), which matches
Preset::get_type_string() on the C++ side.
"""
for sub in PROFILE_SUBDIRS:
base = os.path.join(vendor_dir, sub)
if not os.path.isdir(base):
continue
for root, dirs, files in os.walk(base):
dirs.sort() # deterministic traversal across filesystems
for name in sorted(files):
if name.endswith(".json"):
yield os.path.join(root, name), sub
def read_profile(path):
"""Return (setting_id, instantiation, name) as present (or None)."""
try:
with open(path, "rb") as f:
data = json.loads(f.read())
except (ValueError, OSError):
return None, None, None
if not isinstance(data, dict):
return None, None, None
return data.get("setting_id"), data.get("instantiation"), data.get("name")
def list_vendors():
return sorted(
d for d in os.listdir(PROFILES_DIR)
if os.path.isdir(os.path.join(PROFILES_DIR, d))
)
_JSON_STR = r'"(?:[^"\\]|\\.)*"'
def remove_key_line(text, key):
"""Remove a top-level `"key": "..."` member, preserving formatting.
Handles both the common case (member has a trailing comma) and the member
being the LAST in its object (consume the preceding comma instead, so no
dangling comma is left). Returns (new_text, count).
"""
# Member followed by a comma (not the last in the object).
trailing = re.compile(
r'[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR + r'[ \t]*,[ \t]*\r?\n'
)
new, n = trailing.subn("", text, count=1)
if n:
return new, n
# Member is the last one: drop the preceding comma and the member itself.
leading = re.compile(
r',[ \t]*\r?\n[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR
)
return leading.subn("", text, count=1)
def _remove_key_in_tree(key, should_remove):
"""Remove `key` from files where should_remove(sid, inst, text) is True."""
removed = 0
for vendor in list_vendors():
for path, _type in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
with open(path, "rb") as f:
text = f.read().decode("utf-8")
sid, inst, _name = read_profile(path)
if not should_remove(sid, inst, text):
continue
new_text, n = remove_key_line(text, key)
if n == 0:
raise RuntimeError(f"Could not locate {key} line to remove: {path}")
json.loads(new_text) # fail loudly if removal broke the JSON
with open(path, "wb") as f:
f.write(new_text.encode("utf-8"))
removed += 1
return removed
def remove_misspelled_settings_id():
"""Delete the misspelled "settings_id" key (extra "s") wherever it appears.
The app never reads that key, so those presets effectively had no setting_id
and get a correct one assigned by the normal pass; here we drop the junk key.
"""
return _remove_key_in_tree(
"settings_id", lambda sid, inst, text: '"settings_id"' in text
)
def strip_base_setting_ids():
"""Remove setting_id from every base profile (instantiation != "true").
Convention: only instantiated, user-selectable presets carry a setting_id;
base/template profiles do not. Applied across all vendors.
"""
return _remove_key_in_tree(
"setting_id", lambda sid, inst, text: bool(sid) and inst != "true"
)
def replace_id_value(text, key, new_value):
"""Replace the first top-level `"key": "..."` value, preserving all formatting."""
pattern = re.compile(r'("' + re.escape(key) + r'"\s*:\s*)"(?:[^"\\]|\\.)*"')
repl = lambda m: m.group(1) + json.dumps(new_value, ensure_ascii=False)
new_text, n = pattern.subn(repl, text, count=1)
return new_text, n
def insert_setting_id(text, new_id):
"""Insert a `"setting_id"` line into a preset that lacks one.
Placed just before `filament_id` (or, failing that, `instantiation`) so it
matches the canonical key order, reusing that anchor line's indentation and
line ending. Only setting_id is added; filament_id is left untouched.
"""
for key in ("filament_id", "instantiation"):
m = re.search(r'^([ \t]*)"' + key + r'"[ \t]*:.*?(\r?\n)', text, re.MULTILINE)
if m:
line = f'{m.group(1)}"setting_id": {json.dumps(new_id, ensure_ascii=False)},{m.group(2)}'
return text[:m.start()] + line + text[m.start():], 1
return text, 0
def rewrite_file(path, new_id, has_setting_id):
"""Set the preset's setting_id to new_id (replacing or inserting as needed).
filament_id is intentionally left untouched. Uses binary IO so the file's
original line endings (LF or CRLF) and exact formatting are preserved
byte-for-byte apart from the changed/added line. The result is re-parsed to
guarantee it is still valid JSON.
"""
with open(path, "rb") as f:
text = f.read().decode("utf-8")
if has_setting_id:
text, n = replace_id_value(text, "setting_id", new_id)
else:
text, n = insert_setting_id(text, new_id)
if n == 0:
raise RuntimeError(f"Could not set setting_id on {path}")
json.loads(text) # fail loudly if the edit broke the JSON
with open(path, "wb") as f:
f.write(text.encode("utf-8"))
return True
def main():
# 0. Drop the misspelled "settings_id" key wherever it appears.
typos = remove_misspelled_settings_id()
# 1. Strip setting_id from base profiles everywhere (only instantiated presets keep one).
stripped = strip_base_setting_ids()
# 2. Assign the deterministic setting_id to every instantiated preset of every
# non-reserved vendor.
changed = added = 0
vendors_touched = []
for vendor in list_vendors():
if vendor in RESERVED_VENDORS:
continue
vendor_changed = 0
for path, type_name in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
sid, inst, name = read_profile(path)
if inst != "true":
continue
if not name:
raise RuntimeError(f"instantiated preset has no \"name\": {path}")
new_id = generate_preset_setting_id(vendor, type_name, name)
if sid == new_id:
continue # already correct - idempotent
rewrite_file(path, new_id, has_setting_id=sid is not None)
changed += 1
vendor_changed += 1
if sid is None:
added += 1
if vendor_changed:
vendors_touched.append((vendor, vendor_changed))
print(f"Misspelled settings_id removed : {typos}")
print(f"Base setting_ids stripped : {stripped}")
print(f"Reserved vendors : {sorted(RESERVED_VENDORS)}")
print(f"Vendors updated : {len(vendors_touched)}")
for v, n in vendors_touched:
print(f" {v} ({n} files)")
print(f"Files rewritten : {changed} (of which newly assigned: {added})")
return 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -122,6 +122,9 @@ sed "/name: OrcaSlicer/{
\1 git_commit_hash: \"$GIT_COMMIT_HASH\"|
}" > "$MANIFEST_DOCKER"
# ---------- pack deps/ ----------
./scripts/flatpak/make_deps_tar.sh
# ---------- run build in Docker ----------
DOCKER="${DOCKER:-docker}"
+8
View File
@@ -0,0 +1,8 @@
@echo off
REM Runs check_profile.ps1, the Windows twin of check_profile.sh, from cmd.
REM Arguments are passed straight through, so anything the .ps1 takes works here:
REM scripts\check_profile.bat -Vendor Elegoo validate_custom
REM -ExecutionPolicy Bypass is needed because a Windows client defaults to Restricted,
REM which refuses to run a checked-out .ps1 at all.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0check_profile.ps1" %*
exit /b %ERRORLEVEL%
+693
View File
@@ -0,0 +1,693 @@
<#
.SYNOPSIS
Local twin of the "Check profiles" CI job (.github/workflows/check_profiles.yml), for Windows.
.DESCRIPTION
The Windows counterpart of scripts/check_profile.sh, and kept deliberately close to it.
Runs the same five checks, in the same order, with the same validator flags, and with the
same semantics: every check runs even after an earlier one fails (the workflow's
continue-on-error), then the script exits non-zero once at the end.
profile_tool scripts/orca_profile_tool.py check
validate_system validator -p <profiles> -l <level>
validate_slice validator -p <profiles> -s -l <level>
validate_filament_subtypes validator -p <profiles> -l <level> -f
validate_custom validator against every released custom-preset fixture
profile_tool is the only check that is not the validator binary; it makes the static checks
the validator cannot, because the validator loads the tree the way the slicer does and so
never sees a profile no <vendor>.json indexes, a preset name two files claim, or a file
normalize and update-index would still rewrite.
Everything that has to be downloaded - the profile validator and the custom-preset fixture
archives - lands under <repo>\.test\check_profiles and is reused on the next run. That
directory also holds one log per check plus a copy of the comment CI would post on the PR.
resources\profiles\user, which the validator creates as its data dir but a CI checkout never
has, is moved aside for the duration of the run and restored on exit. Only one run per work
dir at a time.
-Vendor narrows a run to one vendor while working on that vendor's profiles - the one
deliberate divergence from CI, which always checks the whole tree. A check that cannot be
narrowed is left out of the run and reported as skipped.
x64 and ARM64 hosts are both supported. A locally built validator is chosen by the machine
type in its PE header rather than by the name of its build tree, so build\ (x64) and
build-arm64\ side by side resolve correctly; the published nightly is x64 only and runs
under emulation on ARM64.
.PARAMETER ProfilesDir
Profile tree to validate (default: resources\profiles).
.PARAMETER Vendor
Check only this vendor, named after its <Vendor>.json (e.g. "Co Print"). validate_custom is
narrowed with it too, by keeping only that vendor's presets in each fixture tree. The one
check it cannot narrow is validate_slice for a vendor that ships no printers; the summary
reports that one as skipped, and naming it explicitly still runs it. profile_tool keeps its
two cross-vendor checks (setting_id and filament_id) tree-wide, so a scoped run can still
fail on another vendor's files.
.PARAMETER Validator
OrcaSlicer_profile_validator.exe to use; also $env:ORCA_PROFILE_VALIDATOR. Default: the local
build*\ Release build (then RelWithDebInfo, MinSizeRel, Debug) for this architecture, else
the nightly release build is downloaded.
.PARAMETER Download
Ignore local builds and use the downloaded nightly validator.
.PARAMETER Refresh
Re-download the validator and fixtures instead of using the cache.
.PARAMETER WorkDir
Downloads, logs and fixture trees (default: .test\check_profiles). Point it somewhere short,
such as D:\t, if a fixture tree trips Windows' 260-character path limit.
.PARAMETER LogLevel
Validator log level (default: 2, as in CI).
.PARAMETER Checks
Checks to run, by name (default: all of them, in the order listed above).
.EXAMPLE
scripts\check_profile.bat
.EXAMPLE
powershell -ExecutionPolicy Bypass -File scripts\check_profile.ps1 validate_system validate_slice
.EXAMPLE
scripts\check_profile.bat -Vendor Elegoo
#>
# PositionalBinding is off so that the check names are the only positional arguments; left on,
# a bare "validate_system" would bind to whichever named parameter came next in this block.
[CmdletBinding(PositionalBinding = $false)]
param(
[Alias('p')] [string] $ProfilesDir,
[Alias('v')] [string] $Vendor,
[string] $Validator,
[string] $WorkDir,
[Alias('l')] [int] $LogLevel = 2,
[switch] $Download,
[switch] $Refresh,
[Alias('h')] [switch] $Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]] $Checks
)
$ErrorActionPreference = 'Stop'
# Windows PowerShell 5.1 still negotiates TLS 1.0/1.1, which github.com refuses.
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$ValidatorReleaseUrl = 'https://github.com/OrcaSlicer/OrcaSlicer/releases/download/nightly-builds'
$FixtureReleaseUrl = 'https://github.com/OrcaSlicer/OrcaSlicer-profile-validator/releases/download/fixture-archive'
$RepoRoot = Split-Path -Parent $PSScriptRoot
# PROCESSOR_ARCHITECTURE reports the architecture of the *shell*, so a 32-bit PowerShell on a
# 64-bit OS says x86; ARCHITEW6432 is the machine's own in that case.
$HostArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
$HostArch = switch ($HostArch) {
'ARM64' { 'arm64' }
'AMD64' { 'x64' }
default { 'x86' }
}
$AllChecks = @('profile_tool', 'validate_system', 'validate_slice', 'validate_filament_subtypes', 'validate_custom')
$script:LogWriter = $null
$script:Python = ''
# ---------------------------------------------------------------------------- helpers
function Die([string] $Message) {
Write-Host "check_profile.ps1: $Message" -ForegroundColor Red
exit 2
}
# UTF-8 without a BOM, so a log reads the same here as the artifact CI uploads.
function New-LogWriter([string] $Path) {
New-Object IO.StreamWriter($Path, $false, (New-Object Text.UTF8Encoding($false)))
}
# Output of the check being run: shown, and kept in that check's log.
function Write-CheckLog([string] $Text) {
Write-Host $Text
if ($script:LogWriter) { $script:LogWriter.WriteLine($Text) }
}
# Runs a program and returns its exit code, streaming stdout and stderr into the current check's
# log. -OutFile sends that output to a file of its own instead, silently.
function Invoke-Tool {
param([string] $Exe, [string[]] $Arguments, [string] $OutFile)
# Under 'Stop', 2>&1 turns every stderr line of a native command into a terminating error.
# Assigning the preference here scopes it to this function, so it undoes itself on return.
$ErrorActionPreference = 'Continue'
$writer = if ($OutFile) { New-LogWriter $OutFile } else { $null }
try {
& $Exe @Arguments 2>&1 | ForEach-Object {
if ($writer) { $writer.WriteLine("$_") } else { Write-CheckLog "$_" }
}
return $LASTEXITCODE
} catch {
if ($writer) { $writer.WriteLine("$_") } else { Write-CheckLog "$_" }
return 1
} finally {
if ($writer) { $writer.Dispose() }
}
}
# The first $Limit characters of a log, as CI truncates them for the PR comment.
function Get-LogHead([string] $Path, [int] $Limit) {
$text = if (Test-Path -LiteralPath $Path) { [IO.File]::ReadAllText($Path) } else { '' }
if (-not $text) { return 'No output captured' }
if ($text.Length -gt $Limit) { return $text.Substring(0, $Limit) }
return $text
}
# ---------------------------------------------------------------------------- arguments
if ($Help) { Get-Help $PSCommandPath -Detailed; exit 0 }
foreach ($name in $Checks) {
if ($name -like '-*') { Die "unknown option '$name' (try -Help)" }
if ($AllChecks -notcontains $name) { Die "unknown check '$name' (try -Help)" }
}
# A check named on the command line always runs; only the default set is narrowed (see the run
# loop below).
$NamedChecks = [bool] $Checks
if (-not $Checks) { $Checks = $AllChecks }
if (-not $ProfilesDir) { $ProfilesDir = Join-Path $RepoRoot 'resources\profiles' }
if (-not (Test-Path -LiteralPath $ProfilesDir -PathType Container)) { Die "profile directory not found: $ProfilesDir" }
$ProfilesDir = (Resolve-Path -LiteralPath $ProfilesDir).Path
# A vendor neither tool knows is not an error to them: the static checks load nothing of their own
# and still report success, so a typo would otherwise be three green checks and one baffling slice
# failure. The match has to be made on the names themselves rather than with a Test-Path - the
# validator compares the <Vendor>.json stem case-sensitively, while Windows' case-insensitive
# filesystem would let "creality" pass a path test and then match no vendor. A vendor is a
# <name>.json with a sibling <name> directory; that pair is also what tells one apart from
# blacklist.json, which sits in the same folder.
if ($Vendor) {
$found = $false
$suggestion = ''
foreach ($file in (Get-ChildItem -LiteralPath $ProfilesDir -Filter '*.json' -File)) {
$name = [IO.Path]::GetFileNameWithoutExtension($file.Name)
if (-not (Test-Path -LiteralPath (Join-Path $ProfilesDir $name) -PathType Container)) { continue }
if ($name -ceq $Vendor) { $found = $true; break }
if ($name -eq $Vendor) { $suggestion = $name }
}
if (-not $found) {
if ($suggestion) { Die "unknown vendor '$Vendor'; vendor names are case-sensitive, did you mean '$suggestion'?" }
Die "unknown vendor '$Vendor': no such vendor in $ProfilesDir"
}
}
# The validator's -v and orca_profile_tool.py check's --vendor both take that stem; an unscoped
# run passes neither, so the checks below splat these in either way.
$VendorArgs = if ($Vendor) { @('-v', $Vendor) } else { @() }
$VendorPyArgs = if ($Vendor) { @('--vendor', $Vendor) } else { @() }
if (-not $WorkDir) { $WorkDir = Join-Path $RepoRoot '.test\check_profiles' }
$LogDir = Join-Path $WorkDir 'logs'
try { New-Item -ItemType Directory -Force -Path $LogDir | Out-Null } catch { Die "cannot create ${LogDir}: $_" }
$WorkDir = (Resolve-Path -LiteralPath $WorkDir).Path
$LogDir = Join-Path $WorkDir 'logs'
if (-not $Validator) { $Validator = $env:ORCA_PROFILE_VALIDATOR }
# ------------------------------------------------------------------- clean profile tree
# The validator points its data dir at the profile tree, so it creates - and, with -g, fills -
# <profiles>\user. A CI checkout never has that directory, and anything left in it from an
# earlier local run would be loaded as user presets and validated too. Move it aside for the
# duration of the run so what gets checked is what CI checks.
$script:StashedUserDir = ''
function Push-UserPresets {
$user = Join-Path $ProfilesDir 'user'
if (-not (Test-Path -LiteralPath $user -PathType Container)) { return }
$script:StashedUserDir = Join-Path $WorkDir "user-presets-$PID"
Remove-Item -LiteralPath $script:StashedUserDir -Recurse -Force -ErrorAction SilentlyContinue
try { Move-Item -LiteralPath $user -Destination $script:StashedUserDir }
catch { $script:StashedUserDir = ''; Die "cannot move $user aside: $_" }
Write-Host "moved $user aside for the run (restored on exit)"
}
function Pop-UserPresets {
# The validator leaves an empty user\default\{filament,machine,process} skeleton behind.
# Prune it directory by directory, never wholesale: one that holds a real file survives and
# is reported instead of being deleted. Runs even when nothing was stashed, so a tree that
# had no user\ before the run does not gain one.
$user = Join-Path $ProfilesDir 'user'
Remove-EmptyDirs $user
if (-not $script:StashedUserDir) { return }
if (Test-Path -LiteralPath $user) {
Write-Host "$user is not empty; your presets stay in $($script:StashedUserDir)"
} else {
Move-Item -LiteralPath $script:StashedUserDir -Destination $user
}
$script:StashedUserDir = ''
}
function Remove-EmptyDirs([string] $Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return }
Get-ChildItem -LiteralPath $Path -Recurse -Directory -Force |
Sort-Object { $_.FullName.Length } -Descending |
ForEach-Object {
if (-not (Get-ChildItem -LiteralPath $_.FullName -Force)) {
Remove-Item -LiteralPath $_.FullName -Force
}
}
if (-not (Get-ChildItem -LiteralPath $Path -Force)) { Remove-Item -LiteralPath $Path -Force }
}
# ---------------------------------------------------------------------------- downloads
# Cached; -Refresh, or -Force, pulls a new copy. Release assets never change, so caching them is
# safe; the fixture manifest is the index that grows with every release, and CI re-reads it on
# every run.
function Save-Asset {
param([string] $Url, [string] $Dest, [switch] $Force)
if (-not $Refresh -and -not $Force -and (Test-Path -LiteralPath $Dest -PathType Leaf) -and
(Get-Item -LiteralPath $Dest).Length -gt 0) {
return
}
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Dest) | Out-Null
Write-Host "downloading $(Split-Path -Leaf $Dest) ..."
# Invoke-WebRequest spends most of a large download repainting its progress bar.
$ProgressPreference = 'SilentlyContinue'
$part = "$Dest.part"
for ($attempt = 1; ; $attempt++) {
try {
Invoke-WebRequest -Uri $Url -OutFile $part -UseBasicParsing
break
} catch {
if ($attempt -ge 3) {
Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue
throw "download failed: $Url"
}
}
}
Move-Item -LiteralPath $part -Destination $Dest -Force
}
function Get-Sha256([string] $Path) {
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
}
# Directories a built validator can sit in, best first: build_win.bat names its trees build,
# build-dbginfo, build-minsize and build-dbg, each optionally -clang and -arm64 suffixed, and
# CMake puts the binary in src\<config>. Each pattern is matched both directly under a build root
# and one level down (build\x64), for trees laid out the way the macOS ones are.
function Get-ValidatorSearchDirs {
foreach ($config in 'Release', 'RelWithDebInfo', 'MinSizeRel', 'Debug') {
Join-Path $RepoRoot "build*\src\$config"
Join-Path $RepoRoot "build*\*\src\$config"
}
Join-Path $RepoRoot 'build*\src'
Join-Path $RepoRoot 'build*\*\src'
}
# The machine type from the PE header, which is what actually decides whether an .exe can run
# here - the name of the build tree only says what it was meant to be.
function Get-ExeArch([string] $Path) {
try {
$stream = [IO.File]::OpenRead($Path)
try {
$reader = New-Object IO.BinaryReader($stream)
$stream.Position = 0x3C # e_lfanew: offset of the PE header
$stream.Position = $reader.ReadInt32()
if ($reader.ReadUInt32() -ne 0x00004550) { return '' } # "PE\0\0"
switch ($reader.ReadUInt16()) {
0x8664 { 'x64' }
0xAA64 { 'arm64' }
0x014C { 'x86' }
default { '' }
}
} finally { $stream.Dispose() }
} catch { '' }
}
# First locally built OrcaSlicer_profile_validator.exe, in the order above. An x86 build, and on
# ARM64 an x64 one, is kept only as a fallback: it runs, but emulated. An ARM64 build on an x64
# host does not run at all and is never offered.
function Find-LocalValidator {
$emulated = ''
foreach ($pattern in (Get-ValidatorSearchDirs)) {
foreach ($dir in @(Resolve-Path -Path $pattern -ErrorAction SilentlyContinue)) {
$candidate = Join-Path $dir.Path 'OrcaSlicer_profile_validator.exe'
if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
$arch = Get-ExeArch $candidate
if (-not $arch -or $arch -eq $HostArch) { return $candidate }
if (-not $emulated -and ($arch -eq 'x86' -or $HostArch -eq 'arm64')) { $emulated = $candidate }
}
}
if ($emulated) { Write-Host "no $HostArch build found, falling back to $emulated (emulated)" }
return $emulated
}
# The nightly release build, same one CI uses. Windows ships the bare .exe, x64 only.
function Save-NightlyValidator {
if ($HostArch -ne 'x64') {
Write-Host "the nightly Windows validator is x64; it runs here under emulation"
}
$exe = Join-Path $WorkDir 'validator\OrcaSlicer_profile_validator.exe'
Save-Asset -Url "$ValidatorReleaseUrl/OrcaSlicer_profile_validator_Windows_nightly.exe" -Dest $exe
return $exe
}
function Resolve-Validator {
if ($Validator) {
if (-not (Test-Path -LiteralPath $Validator -PathType Leaf)) { Die "validator not found: $Validator" }
return (Resolve-Path -LiteralPath $Validator).Path
}
if (-not $Download) {
$local = Find-LocalValidator
if ($local) {
Write-Host "using locally built validator: $local"
return $local
}
}
try { $downloaded = Save-NightlyValidator } catch { Die "could not obtain a profile validator: $_" }
Write-Host "using downloaded validator: $downloaded"
return $downloaded
}
# python3 is rarely on PATH on Windows: the py launcher is the reliable way in, and a bare
# `python` may be the Store stub, which prints an advert and exits non-zero. Probe each for the
# interpreter it actually resolves to, and use that.
function Resolve-Python {
$ErrorActionPreference = 'Continue'
if ($script:Python) { return $script:Python }
foreach ($candidate in 'py -3', 'python', 'python3') {
$words = $candidate -split ' '
$exe = Get-Command $words[0] -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $exe) { continue }
$leading = @($words | Select-Object -Skip 1)
$found = & $exe.Source @leading -c 'import sys; print(sys.executable)' 2>$null
if ($LASTEXITCODE -eq 0 -and $found) {
$script:Python = "$found"
return $script:Python
}
}
Die 'no Python 3 found; install it (or the py launcher) and re-run'
}
# The fixtures name every preset "<vendor>_<parent preset>_orca_test", after the "name" inside
# <Vendor>.json rather than the file stem -Vendor takes - BBL.json is "Bambulab", iQ.json is
# "innovatiQ". Falls back to the stem for a vendor file with no readable name.
function Get-VendorDisplayName {
$name = ''
try { $name = (Get-Content -LiteralPath (Join-Path $ProfilesDir "$Vendor.json") -Raw -ErrorAction Stop | ConvertFrom-Json).name } catch { }
if ($name) { return "$name" }
return $Vendor
}
# Unpack just the presets one vendor's profiles own, and return how many that was. Each fixture
# preset was generated from a single system preset and inherits it by name - none inherits another
# user preset - so the selection is self-contained.
function Expand-VendorPresets([string] $Zip, [string] $Tree, [string] $Prefix) {
# .NET rather than Expand-Archive: a fixture holds around 20,000 entries, and unpacking every
# one of them to keep a twentieth costs more than the validation this is speeding up.
Add-Type -AssemblyName System.IO.Compression.FileSystem
$kept = 0
$archive = [IO.Compression.ZipFile]::OpenRead($Zip)
try {
foreach ($entry in $archive.Entries) {
# A directory entry has an empty Name, so it never matches and is never created.
if (-not $entry.Name.StartsWith($Prefix, [StringComparison]::Ordinal)) { continue }
$dest = Join-Path $Tree $entry.FullName.Replace('/', [IO.Path]::DirectorySeparatorChar)
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($dest)) | Out-Null
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
$kept++
}
} finally {
$archive.Dispose()
}
return $kept
}
# ---------------------------------------------------------------------------- checks
$CheckBodies = @{
profile_tool = {
Invoke-Tool -Exe (Resolve-Python) -Arguments (@((Join-Path $RepoRoot 'scripts\orca_profile_tool.py'), 'check', '--profiles', $ProfilesDir) + $VendorPyArgs)
}
validate_system = {
Invoke-Tool -Exe $Validator -Arguments (@('-p', $ProfilesDir) + $VendorArgs + @('-l', "$LogLevel"))
}
# Slices a two-colour cube through every printer so all custom g-code (incl.
# change_filament_gcode) is expanded - catches undefined-placeholder / invalid-flow bugs the
# static checks cannot see.
validate_slice = {
Invoke-Tool -Exe $Validator -Arguments (@('-p', $ProfilesDir) + $VendorArgs + @('-s', '-l', "$LogLevel"))
}
validate_filament_subtypes = {
Invoke-Tool -Exe $Validator -Arguments (@('-p', $ProfilesDir) + $VendorArgs + @('-l', "$LogLevel", '-f'))
}
# Every released fixture is a snapshot of user presets saved by that OrcaSlicer version; each
# is unpacked over the current system profiles and validated, so a profile change that would
# break an existing user's presets fails here.
#
# Under -Vendor only that vendor's presets are unpacked, which is what makes the validator's
# -v usable here: -v filters the system vendors but never the user presets, so on a whole-tree
# snapshot it reports every other vendor's presets as unresolvable parents - thousands of
# errors saying nothing about the vendor under test. Teaching -v to filter user presets too is
# the deeper fix, but this runs against whatever validator is to hand, the published nightly
# included, so the picking has to happen here. It picks on the "<vendor display name>_"
# filename prefix that generate_custom_presets() (the validator's -g mode) gave every preset in
# these fixtures; a fixture cut from a renamed generator would surface as the "checked nothing"
# warning below. Presets whose source had no vendor carry no prefix - a few of those still name
# one in the middle, like "PET @BBL A1_orca_test" - and only an unscoped run covers them.
validate_custom = {
$fixturesDir = Join-Path $WorkDir 'profile-fixtures'
$outputDir = Join-Path $WorkDir 'custom-preset-validation'
New-Item -ItemType Directory -Force -Path $fixturesDir, $outputDir | Out-Null
$manifestPath = Join-Path $fixturesDir 'manifest.json'
Save-Asset -Url "$FixtureReleaseUrl/manifest.json" -Dest $manifestPath -Force
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
$entries = if ($manifest -is [array]) { $manifest } else { $manifest.fixtures }
$fixtures = @($entries | Where-Object { $_.version -and $_.asset })
if (-not $fixtures) {
Write-CheckLog "No custom preset fixtures found in $FixtureReleaseUrl/manifest.json"
return 1
}
$vendorPrefix = if ($Vendor) { "$(Get-VendorDisplayName)_" } else { '' }
$totalKept = 0
$status = 0
$failedLogs = @()
$summary = @('## Custom Preset Fixture Validation', '', '| Version | Status | Log |', '| --- | --- | --- |')
foreach ($fixture in $fixtures) {
$version = $fixture.version
$asset = $fixture.asset
$fixtureZip = Join-Path $fixturesDir $asset
$profileTree = Join-Path $outputDir "profiles-$version"
$logPath = Join-Path $outputDir "$version.log"
$assetUrl = "$FixtureReleaseUrl/$([uri]::EscapeDataString($asset))"
Save-Asset -Url $assetUrl -Dest $fixtureZip
$expected = $fixture.asset_sha256
if ($expected -and $expected -ne '<sha256>' -and (Get-Sha256 $fixtureZip) -ne $expected.ToUpperInvariant()) {
# A cached zip can be stale or truncated; the release asset itself is immutable,
# so deleting it forces Save-Asset to pull a fresh copy.
Write-Host "checksum mismatch for $asset, re-downloading"
Remove-Item -LiteralPath $fixtureZip -Force
Save-Asset -Url $assetUrl -Dest $fixtureZip
$actual = Get-Sha256 $fixtureZip
if ($actual -ne $expected.ToUpperInvariant()) {
Write-CheckLog "${asset}: expected $expected, got $actual"
return 1
}
}
Write-Host "validating custom presets from $version ..."
Remove-Item -LiteralPath $profileTree -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $profileTree | Out-Null
# Piped rather than copied through <profiles>\*, so a vendor directory whose name
# holds a wildcard character is still copied by its literal path. Under -Vendor the
# validator opens only that vendor and OrcaFilamentLibrary and skips the other sixty-odd
# (PresetBundle::load_system_presets_from_json), so the rest are left out of the copy:
# ~3s a fixture that was going on files nothing opens.
Get-ChildItem -LiteralPath $ProfilesDir -Force |
Where-Object { -not $Vendor -or -not $_.PSIsContainer -or $_.Name -eq $Vendor -or $_.Name -eq 'OrcaFilamentLibrary' } |
Copy-Item -Destination $profileTree -Recurse -Force
Remove-Item -LiteralPath (Join-Path $profileTree 'user') -Recurse -Force -ErrorAction SilentlyContinue
if ($Vendor) {
$kept = Expand-VendorPresets $fixtureZip $profileTree $vendorPrefix
$totalKept += $kept
Write-CheckLog " $kept $Vendor preset file(s)"
} else {
Expand-Archive -LiteralPath $fixtureZip -DestinationPath $profileTree -Force
}
$result = Invoke-Tool -Exe $Validator -Arguments (@('-p', $profileTree) + $VendorArgs + @('-l', "$LogLevel")) -OutFile $logPath
if ($result -eq 0) {
$summary += "| $version | PASS | $version.log |"
# Only failures are worth keeping; each tree is a full copy of resources\profiles.
Remove-Item -LiteralPath $profileTree -Recurse -Force
} else {
$summary += "| $version | FAIL | $version.log |"
$failedLogs += $logPath
$status = 1
}
}
# A vendor added after the newest fixture was cut appears in none of them: nothing failed,
# but nothing was checked either.
if ($Vendor -and $totalKept -eq 0) {
Write-CheckLog "no $Vendor presets in any fixture; validate_custom checked nothing"
}
[IO.File]::WriteAllLines((Join-Path $outputDir 'summary.md'), [string[]] $summary)
$summary | ForEach-Object { Write-CheckLog $_ }
if ($failedLogs) {
Write-CheckLog ''
Write-CheckLog '## Failed Fixture Logs'
foreach ($logPath in $failedLogs) {
Write-CheckLog ''
Write-CheckLog "### $([IO.Path]::GetFileNameWithoutExtension($logPath))"
Write-CheckLog '```'
Write-CheckLog (Get-LogHead $logPath 12000)
Write-CheckLog '```'
}
}
return $status
}
}
# Heading CI puts above this check's log in the PR comment.
$CommentHeadings = @{
profile_tool = '### Profile Check Failed (orca_profile_tool.py)'
validate_system = '### System Profile Validation Failed'
validate_slice = '### Slice Validation Failed (custom g-code expansion)'
validate_filament_subtypes = '### Filament Subtype Validation Failed'
validate_custom = '### Custom Preset Validation Failed'
}
# ---------------------------------------------------------------------------- run
function Invoke-Check([string] $Name) {
$log = Join-Path $LogDir "$Name.log"
Write-Host ''
Write-Host "==> $Name$(if ($Vendor) { " ($Vendor)" })" -ForegroundColor Cyan
$script:LogWriter = New-LogWriter $log
try {
$result = & $CheckBodies[$Name] | Select-Object -Last 1
} catch {
Write-CheckLog "$_"
$result = 1
} finally {
$script:LogWriter.Dispose()
$script:LogWriter = $null
}
if ([int] $result -eq 0) {
Write-Host " $Name passed" -ForegroundColor Green
return $true
}
Write-Host " $Name failed (exit $result)" -ForegroundColor Red
return $false
}
# The fixture trees under the work dir are shared scratch space keyed by fixture version, so a
# second run would delete a tree the first one is validating.
$LockDir = Join-Path $WorkDir '.lock'
try { New-Item -ItemType Directory -Path $LockDir -ErrorAction Stop | Out-Null }
catch { Die "another run is using $WorkDir (pass -WorkDir, or remove $LockDir if no run is active)" }
# The validator writes UTF-8, but PowerShell decodes a child process's output using the console
# code page, which mangles the accented and CJK preset names in its messages.
$PreviousOutputEncoding = [Console]::OutputEncoding
try {
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
Push-UserPresets
if ($Checks | Where-Object { $_ -ne 'profile_tool' }) { $Validator = Resolve-Validator }
# An empty printer set is a failure to the sweep, so validate_slice is recorded as skipped
# rather than run for a vendor that ships no printers (the filament-only OrcaFilamentLibrary);
# naming the check explicitly still runs it.
$results = [ordered] @{}
$skipReasons = @{}
foreach ($name in $AllChecks) {
if ($Checks -notcontains $name) { continue }
if ($name -eq 'validate_slice' -and -not $NamedChecks -and $Vendor -and
-not (Test-Path -LiteralPath (Join-Path (Join-Path $ProfilesDir $Vendor) 'machine') -PathType Container)) {
$results[$name] = 'skip'
$skipReasons[$name] = "$Vendor ships no printers"
} else {
$results[$name] = if (Invoke-Check $name) { 'pass' } else { 'fail' }
}
}
Write-Host ''
Write-Host '==> summary' -ForegroundColor Cyan
foreach ($name in $results.Keys) {
switch ($results[$name]) {
'pass' { Write-Host " PASS $name" -ForegroundColor Green }
'skip' { Write-Host " SKIP $name ($($skipReasons[$name]))" -ForegroundColor Yellow }
default { Write-Host " FAIL $name ($(Join-Path $LogDir "$name.log"))" -ForegroundColor Red }
}
}
$failed = @($results.Keys | Where-Object { $results[$_] -eq 'fail' })
if (-not $failed) {
Remove-Item -LiteralPath (Join-Path $WorkDir 'pr_comment.md') -Force -ErrorAction SilentlyContinue
Write-Host ''
if (@($results.Values) -contains 'skip') {
Write-Host "Every check that ran passed, but CI runs the skipped ones too. Logs: $LogDir" -ForegroundColor Green
} else {
Write-Host "All checks passed. Logs: $LogDir" -ForegroundColor Green
}
exit 0
}
# The comment check_profiles_comment.yml would post when something fails.
$comment = @(
# Marker matched by check_profiles_comment.yml to delete prior comments.
'<!-- profile-validation-comment -->'
'## :x: Profile Validation Errors'
''
foreach ($name in $failed) {
$CommentHeadings[$name]
''
'```'
Get-LogHead (Join-Path $LogDir "$name.log") 30000
'```'
''
}
'---'
'*Fix the errors above and push a new commit. To reproduce this run locally: `scripts/check_profile.sh`, or `scripts\check_profile.bat` on Windows.*'
)
$commentPath = Join-Path $WorkDir 'pr_comment.md'
[IO.File]::WriteAllLines($commentPath, [string[]] $comment)
Write-Host ''
Write-Host "One or more profile checks failed. Logs: $LogDir" -ForegroundColor Red
Write-Host "The comment CI would post: $commentPath"
exit 1
} finally {
Pop-UserPresets
Remove-Item -LiteralPath $LockDir -Force -ErrorAction SilentlyContinue
[Console]::OutputEncoding = $PreviousOutputEncoding
}
+650
View File
@@ -0,0 +1,650 @@
#!/usr/bin/env bash
#
# Local twin of the "Check profiles" CI job (.github/workflows/check_profiles.yml).
#
# Runs the same five checks, in the same order, with the same validator flags, and with the
# same semantics: every check runs even after an earlier one fails (the workflow's
# continue-on-error), then the script exits non-zero once at the end.
#
# Everything that has to be downloaded - the profile validator and the custom-preset fixture
# archives - lands under <repo>/.test/check_profiles/ and is reused on the next run. That
# directory also holds one log per check plus a copy of the comment CI would post on the PR.
#
# resources/profiles/user, which the validator creates as its data dir but a CI checkout never
# has, is moved aside for the duration of the run and restored on exit. Only one run per work
# dir at a time.
#
# -v/--vendor narrows a run to one vendor while working on that vendor's profiles - the one
# deliberate divergence from CI, which always checks the whole tree. A check that cannot be
# narrowed is left out of the run and reported as skipped; see usage().
#
# Usage: scripts/check_profile.sh [OPTION]... [CHECK]...
# The check_* functions run through run_check, which dispatches on the check name, so
# ShellCheck cannot see that they (and what they call) are used.
# shellcheck disable=SC2329
set -uo pipefail
VALIDATOR_RELEASE_URL="https://github.com/OrcaSlicer/OrcaSlicer/releases/download/nightly-builds"
FIXTURE_RELEASE_URL="https://github.com/OrcaSlicer/OrcaSlicer-profile-validator/releases/download/fixture-archive"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
HOST_ARCH="$(uname -m)"
PROFILES_DIR="${REPO_ROOT}/resources/profiles"
WORK_DIR="${REPO_ROOT}/.test/check_profiles"
VALIDATOR="${ORCA_PROFILE_VALIDATOR:-}"
# Vendor to check, named after its <Vendor>.json - empty means every vendor, which is exactly what
# both the validator's -v and orca_profile_tool.py check's --vendor take an empty value to mean.
# So the flag is passed unconditionally below rather than kept in an array bash 3.2 cannot expand
# empty under `set -u`.
VENDOR=""
LOG_LEVEL=2
PREFER_DOWNLOAD=0
REFRESH=0
ALL_CHECKS=(profile_tool validate_system validate_slice validate_filament_subtypes validate_custom)
CHECKS=()
# "<check><TAB>pass|fail" per check that ran, plus "<check><TAB>skip<TAB>why" for one a vendor
# scope left out; a string rather than an array because bash 3.2 (still the /bin/bash on macOS)
# cannot expand an empty array under `set -u`.
RESULTS=""
usage() {
cat <<EOF
Run the profile checks from .github/workflows/check_profiles.yml locally.
Usage: scripts/check_profile.sh [OPTION]... [CHECK]...
Checks (default: all, in this order):
profile_tool scripts/orca_profile_tool.py check
validate_system validator -p <profiles> -l <level>
validate_slice validator -p <profiles> -s -l <level>
validate_filament_subtypes validator -p <profiles> -l <level> -f
validate_custom validator against every released custom-preset fixture
Options:
-p, --profiles DIR profile tree to validate (default: resources/profiles)
-v, --vendor NAME check only this vendor, named after its <Vendor>.json (e.g. "Co Print")
--validator BIN OrcaSlicer_profile_validator to use; also \$ORCA_PROFILE_VALIDATOR.
Default: the local build*/ Release build (then RelWithDebInfo, then
Debug) for this architecture, else the nightly release build is
downloaded for this platform
--download ignore local builds and use the downloaded nightly validator
--refresh re-download the validator and fixtures instead of using the cache
--work-dir DIR downloads, logs and fixture trees (default: .test/check_profiles)
-l, --log-level N validator log level (default: ${LOG_LEVEL}, as in CI)
-h, --help show this help
Note: profile_tool is the only check that is not the validator binary; it makes the static
checks the validator cannot, because the validator loads the tree the way the slicer does
and so never sees a profile no <vendor>.json indexes, a preset name two files claim, or a
file normalize and update-index would still rewrite.
Note: --vendor narrows validate_custom too, by keeping only that vendor's presets in each
fixture tree. The one check it cannot narrow is validate_slice for a vendor that ships no
printers; the summary reports that one as skipped, and naming it explicitly still runs it.
profile_tool keeps its two cross-vendor checks (setting_id and filament_id) tree-wide, so a
scoped run can still fail on another vendor's files.
EOF
}
msg() { printf '%s\n' "$*" >&2; }
die() { printf 'check_profile.sh: %s\n' "$*" >&2; exit 2; }
if [ -t 1 ]; then
C_RED=$'\033[91m'; C_GREEN=$'\033[92m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
else
C_RED=''; C_GREEN=''; C_BOLD=''; C_RESET=''
fi
# ---------------------------------------------------------------------------- arguments
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-p|--profiles) [ $# -ge 2 ] || die "$1 needs a directory"; PROFILES_DIR="$2"; shift 2 ;;
-v|--vendor) [ $# -ge 2 ] || die "$1 needs a vendor name"; VENDOR="$2"; shift 2 ;;
--validator) [ $# -ge 2 ] || die "$1 needs a path"; VALIDATOR="$2"; shift 2 ;;
--work-dir) [ $# -ge 2 ] || die "$1 needs a directory"; WORK_DIR="$2"; shift 2 ;;
-l|--log-level) [ $# -ge 2 ] || die "$1 needs a number"; LOG_LEVEL="$2"; shift 2 ;;
--download) PREFER_DOWNLOAD=1; shift ;;
--refresh) REFRESH=1; shift ;;
-*) die "unknown option '$1' (try --help)" ;;
*)
known=0
for check in "${ALL_CHECKS[@]}"; do
[ "$1" = "${check}" ] && known=1
done
[ "${known}" -eq 1 ] || die "unknown check '$1' (try --help)"
CHECKS[${#CHECKS[@]}]="$1"
shift
;;
esac
done
# A check named on the command line always runs; only the default set is narrowed (see the run
# loop below).
NAMED_CHECKS="${#CHECKS[@]}"
[ "${NAMED_CHECKS}" -gt 0 ] || CHECKS=("${ALL_CHECKS[@]}")
[ -d "${PROFILES_DIR}" ] || die "profile directory not found: ${PROFILES_DIR}"
PROFILES_DIR="$(cd -- "${PROFILES_DIR}" && pwd)"
# A vendor neither tool knows is not an error to them: the static checks load nothing of their own
# and still report success, so a typo would otherwise be three green checks and one baffling slice
# failure. The match has to be made on the names themselves rather than with a -f test - the
# validator compares the <Vendor>.json stem case-sensitively, while a case-insensitive filesystem
# (macOS, Windows) would let "creality" pass a file test and then match no vendor. A vendor is a
# <name>.json with a sibling <name>/ directory; that pair is also what tells one apart from
# blacklist.json, which sits in the same folder.
if [ -n "${VENDOR}" ]; then
wanted="$(printf '%s' "${VENDOR}" | tr '[:upper:]' '[:lower:]')"
found=""
suggestion=""
for file in "${PROFILES_DIR}"/*.json; do
name="${file##*/}"; name="${name%.json}"
[ -d "${PROFILES_DIR}/${name}" ] || continue
if [ "${name}" = "${VENDOR}" ]; then
found=1
break
fi
[ "$(printf '%s' "${name}" | tr '[:upper:]' '[:lower:]')" = "${wanted}" ] && suggestion="${name}"
done
if [ -z "${found}" ]; then
[ -z "${suggestion}" ] || die "unknown vendor '${VENDOR}'; vendor names are case-sensitive, did you mean '${suggestion}'?"
die "unknown vendor '${VENDOR}': no such vendor in ${PROFILES_DIR}"
fi
fi
LOG_DIR="${WORK_DIR}/logs"
mkdir -p "${LOG_DIR}" || die "cannot create ${LOG_DIR}"
WORK_DIR="$(cd -- "${WORK_DIR}" && pwd)"
LOG_DIR="${WORK_DIR}/logs"
wants() {
local check
for check in "${CHECKS[@]}"; do
[ "${check}" = "$1" ] && return 0
done
return 1
}
# ------------------------------------------------------------------- clean profile tree
# The validator points its data dir at the profile tree, so it creates - and, with -g, fills -
# <profiles>/user. A CI checkout never has that directory, and anything left in it from an
# earlier local run would be loaded as user presets and validated too. Move it aside for the
# duration of the run so what gets checked is what CI checks.
STASHED_USER_DIR=""
stash_user_presets() {
[ -d "${PROFILES_DIR}/user" ] || return 0
STASHED_USER_DIR="${WORK_DIR}/user-presets-$$"
rm -rf "${STASHED_USER_DIR}"
mv "${PROFILES_DIR}/user" "${STASHED_USER_DIR}" || { STASHED_USER_DIR=""; die "cannot move ${PROFILES_DIR}/user aside"; }
msg "moved ${PROFILES_DIR}/user aside for the run (restored on exit)"
}
restore_user_presets() {
# The validator leaves an empty user/default/{filament,machine,process} skeleton behind.
# Prune it with rmdir, never rm -rf: a directory that holds a real file survives and is
# reported instead of being deleted. Runs even when nothing was stashed, so a tree that had
# no user/ before the run does not gain one.
find "${PROFILES_DIR}/user" -depth -type d -exec rmdir {} + 2>/dev/null
[ -n "${STASHED_USER_DIR}" ] || return 0
if [ -d "${PROFILES_DIR}/user" ]; then
msg "${PROFILES_DIR}/user is not empty; your presets stay in ${STASHED_USER_DIR}"
STASHED_USER_DIR=""
return 0
fi
mv "${STASHED_USER_DIR}" "${PROFILES_DIR}/user"
STASHED_USER_DIR=""
}
# The fixture trees under the work dir are shared scratch space keyed by fixture version, so a
# second run would delete a tree the first one is validating.
LOCK_DIR="${WORK_DIR}/.lock"
mkdir "${LOCK_DIR}" 2>/dev/null ||
die "another run is using ${WORK_DIR} (pass --work-dir, or remove ${LOCK_DIR} if no run is active)"
cleanup() {
restore_user_presets
rmdir "${LOCK_DIR}" 2>/dev/null
}
# Installed only once the lock is ours, so a refused start never releases someone else's.
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
stash_user_presets
# ---------------------------------------------------------------------------- downloads
# fetch URL DEST [force] - cached; --refresh, or a non-empty third argument, forces a new
# download. Release assets never change, so caching them is safe; the fixture manifest is the
# index that grows with every release, and CI re-reads it on every run.
fetch() {
local url="$1" dest="$2" force="${3:-}"
if [ "${REFRESH}" -eq 0 ] && [ -z "${force}" ] && [ -s "${dest}" ]; then
return 0
fi
mkdir -p "$(dirname -- "${dest}")" || return 1
msg "downloading $(basename -- "${dest}") ..."
if ! curl -fsSL --retry 3 -o "${dest}.part" "${url}"; then
rm -f "${dest}.part"
msg "download failed: ${url}"
return 1
fi
mv -f "${dest}.part" "${dest}"
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | cut -d' ' -f1
else
return 1
fi
}
# Directories a built validator can sit in, best first: every Ninja Multi-Config Release tree,
# then RelWithDebInfo, then Debug, then a single-config generator's plain src/. Each pattern is
# matched both directly under a build root (build/) and one level down (build/arm64, build/x64),
# and unmatched globs are dropped by the caller's -d test.
validator_search_dirs() {
local config
for config in Release RelWithDebInfo Debug; do
printf '%s\n' "${REPO_ROOT}"/build*/src/"${config}" "${REPO_ROOT}"/build*/*/src/"${config}"
done
printf '%s\n' "${REPO_ROOT}"/build*/src "${REPO_ROOT}"/build*/*/src
}
# A build tree named for the other architecture (build/x86_64 on an arm64 host, build/arm64 on
# an x64 one) is a last resort: on Linux it will not run at all, on macOS it goes via Rosetta.
# Takes a repo-relative path - an absolute one would also match an arch name in the checkout path.
is_foreign_arch_dir() {
case "${HOST_ARCH}" in
arm64|aarch64) case "$1" in *x86_64*|*x86-64*|*x64*|*amd64*) return 0 ;; esac ;;
x86_64|amd64) case "$1" in *arm64*|*aarch64*) return 0 ;; esac ;;
esac
return 1
}
# First locally built OrcaSlicer_profile_validator, in the order above. macOS puts it in an .app
# bundle, Linux and Windows next to the other binaries.
find_local_validator() {
local dir candidate foreign=""
while IFS= read -r dir; do
[ -d "${dir}" ] || continue
for candidate in \
"${dir}/OrcaSlicer_profile_validator" \
"${dir}/OrcaSlicer_profile_validator.exe" \
"${dir}/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator"; do
[ -f "${candidate}" ] && [ -x "${candidate}" ] || continue
if ! is_foreign_arch_dir "${dir#"${REPO_ROOT}"/}"; then
printf '%s\n' "${candidate}"
return 0
fi
[ -n "${foreign}" ] || foreign="${candidate}"
done
done <<EOF
$(validator_search_dirs)
EOF
[ -n "${foreign}" ] || return 1
msg "no ${HOST_ARCH} build found, falling back to ${foreign}"
printf '%s\n' "${foreign}"
}
# The nightly release build, same one CI uses. Linux ships the bare binary, macOS a .dmg
# holding the signed .app, Windows an .exe.
download_validator() {
local dest="${WORK_DIR}/validator" binary dmg app mounted app_src
case "$(uname -s)" in
Linux*)
case "${HOST_ARCH}" in
arm64|aarch64) msg "the nightly Linux validator is x86_64; build it locally for ${HOST_ARCH}" ;;
esac
binary="${dest}/OrcaSlicer_profile_validator"
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Linux_Ubuntu2404_nightly" "${binary}" || return 1
chmod +x "${binary}" || return 1
;;
Darwin*)
dmg="${dest}/OrcaSlicer_profile_validator.dmg"
app="${dest}/OrcaSlicer_profile_validator.app"
binary="${app}/Contents/MacOS/OrcaSlicer_profile_validator"
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Mac_universal_nightly.dmg" "${dmg}" || return 1
if [ ! -x "${binary}" ] || [ "${REFRESH}" -eq 1 ]; then
mounted="${dest}/mnt"
rm -rf "${mounted}" "${app}"
mkdir -p "${mounted}" || return 1
hdiutil attach -nobrowse -readonly -mountpoint "${mounted}" "${dmg}" >/dev/null || return 1
app_src="$(find "${mounted}" -maxdepth 1 -name '*.app' -print 2>/dev/null | head -n 1)"
if [ -n "${app_src}" ]; then
cp -R "${app_src}" "${app}"
fi
hdiutil detach "${mounted}" >/dev/null 2>&1
rmdir "${mounted}" 2>/dev/null
[ -x "${binary}" ] || { msg "no validator app inside ${dmg}"; return 1; }
fi
;;
MINGW*|MSYS*|CYGWIN*)
binary="${dest}/OrcaSlicer_profile_validator.exe"
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Windows_nightly.exe" "${binary}" || return 1
chmod +x "${binary}" || return 1
;;
*)
msg "no nightly validator published for $(uname -s); build it (-DORCA_TOOLS=ON) and pass --validator"
return 1
;;
esac
printf '%s\n' "${binary}"
}
resolve_validator() {
if [ -n "${VALIDATOR}" ]; then
[ -x "${VALIDATOR}" ] || die "validator not executable: ${VALIDATOR}"
return 0
fi
if [ "${PREFER_DOWNLOAD}" -eq 0 ]; then
VALIDATOR="$(find_local_validator)"
if [ -n "${VALIDATOR}" ]; then
msg "using locally built validator: ${VALIDATOR}"
return 0
fi
fi
VALIDATOR="$(download_validator)" || die "could not obtain a profile validator"
msg "using downloaded validator: ${VALIDATOR}"
}
# ---------------------------------------------------------------------------- checks
check_profile_tool() {
python3 "${REPO_ROOT}/scripts/orca_profile_tool.py" check --profiles "${PROFILES_DIR}" --vendor "${VENDOR}"
}
check_validate_system() {
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -l "${LOG_LEVEL}"
}
# Slices a two-colour cube through every printer so all custom g-code (incl. change_filament_gcode)
# is expanded - catches undefined-placeholder / invalid-flow bugs the static checks cannot see.
check_validate_slice() {
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -s -l "${LOG_LEVEL}"
}
check_validate_filament_subtypes() {
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -l "${LOG_LEVEL}" -f
}
# The fixtures name every preset "<vendor>_<parent preset>_orca_test", after the "name" inside
# <Vendor>.json rather than the file stem --vendor takes - BBL.json is "Bambulab", iQ.json is
# "innovatiQ". Falls back to the stem for a vendor file with no readable name.
vendor_display_name() {
local name
name="$(VENDOR_JSON="${PROFILES_DIR}/${VENDOR}.json" python3 - <<'PY'
import json
import os
try:
with open(os.environ["VENDOR_JSON"], encoding="utf-8") as fh:
print(json.load(fh).get("name", ""))
except Exception:
pass
PY
)"
printf '%s\n' "${name:-${VENDOR}}"
}
# Every released fixture is a snapshot of user presets saved by that OrcaSlicer version; each is
# unpacked over the current system profiles and validated, so a profile change that would break
# an existing user's presets fails here.
#
# Under --vendor only that vendor's presets are unpacked, which is what makes the validator's -v
# usable here: -v filters the system vendors but never the user presets, so on a whole-tree
# snapshot it reports every other vendor's presets as unresolvable parents - thousands of errors
# saying nothing about the vendor under test. Teaching -v to filter user presets too is the deeper
# fix, but this runs against whatever validator is to hand, the published nightly included, so the
# picking has to happen here. It picks on the "<vendor display name>_" filename prefix that
# generate_custom_presets() (the validator's -g mode) gave every preset in these fixtures; a
# fixture cut from a renamed generator would surface as the "checked nothing" warning below.
# Presets whose source had no vendor carry no prefix - a few of those still name one in the
# middle, like "PET @BBL A1_orca_test" - and only an unscoped run covers them.
check_validate_custom() {
local fixtures_dir="${WORK_DIR}/profile-fixtures"
local output_dir="${WORK_DIR}/custom-preset-validation"
local summary="${output_dir}/summary.md"
local status=0 failed_logs="" vendor_prefix="" total_kept=0
local version asset expected_sha256 asset_url fixture_zip profile_tree log_path actual_sha256 result kept
command -v unzip >/dev/null 2>&1 || { msg "unzip is required for validate_custom"; return 1; }
[ -z "${VENDOR}" ] || vendor_prefix="$(vendor_display_name)_"
mkdir -p "${fixtures_dir}" "${output_dir}" || return 1
fetch "${FIXTURE_RELEASE_URL}/manifest.json" "${fixtures_dir}/manifest.json" force || return 1
MANIFEST_PATH="${fixtures_dir}/manifest.json" python3 - > "${fixtures_dir}/fixtures.tsv" <<'PY'
import json
import os
with open(os.environ["MANIFEST_PATH"], encoding="utf-8") as fh:
manifest = json.load(fh)
if isinstance(manifest, dict):
entries = manifest.get("fixtures", [])
else:
entries = manifest
for entry in entries:
version = entry.get("version", "")
asset = entry.get("asset", "")
sha256 = entry.get("asset_sha256", "")
if not version or not asset:
continue
print(f"{version}\t{asset}\t{sha256}")
PY
if [ ! -s "${fixtures_dir}/fixtures.tsv" ]; then
echo "No custom preset fixtures found in ${FIXTURE_RELEASE_URL}/manifest.json"
return 1
fi
{
echo "## Custom Preset Fixture Validation"
echo ""
echo "| Version | Status | Log |"
echo "| --- | --- | --- |"
} > "${summary}"
while IFS=$'\t' read -r version asset expected_sha256; do
[ -n "${version}" ] || continue
fixture_zip="${fixtures_dir}/${asset}"
profile_tree="${output_dir}/profiles-${version}"
log_path="${output_dir}/${version}.log"
asset_url="${FIXTURE_RELEASE_URL}/$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "${asset}")"
fetch "${asset_url}" "${fixture_zip}" || return 1
if [ -n "${expected_sha256}" ] && [ "${expected_sha256}" != "<sha256>" ]; then
actual_sha256="$(sha256_of "${fixture_zip}")"
if [ -z "${actual_sha256}" ]; then
msg "no sha256 tool available, skipping checksum of ${asset}"
elif [ "${actual_sha256}" != "${expected_sha256}" ]; then
# A cached zip can be stale or truncated; the release asset itself is immutable,
# so deleting it forces fetch to pull a fresh copy.
msg "checksum mismatch for ${asset}, re-downloading"
rm -f "${fixture_zip}"
fetch "${asset_url}" "${fixture_zip}" || return 1
actual_sha256="$(sha256_of "${fixture_zip}")"
[ "${actual_sha256}" = "${expected_sha256}" ] || { msg "${asset}: expected ${expected_sha256}, got ${actual_sha256}"; return 1; }
fi
fi
msg "validating custom presets from ${version} ..."
rm -rf "${profile_tree}"
mkdir -p "${profile_tree}" || return 1
if [ -n "${VENDOR}" ]; then
# -v has the validator open this vendor and OrcaFilamentLibrary and skip the rest
# (PresetBundle::load_system_presets_from_json), and unzip's * spans '/', so one
# pattern reaches every preset type. Copying and unpacking only those turns a ~5s
# setup per fixture into ~0.5s. Naming the vendor twice, when it is the library
# itself, is not an error to cp; exit 11 is unzip's "nothing matched", which is the
# fixture-predates-the-vendor case the warning below reports.
cp -a "${PROFILES_DIR}"/*.json "${PROFILES_DIR}/${VENDOR}" \
"${PROFILES_DIR}/OrcaFilamentLibrary" "${profile_tree}/" || return 1
unzip -q "${fixture_zip}" "user/*/${vendor_prefix}*" -d "${profile_tree}"
result=$?
[ "${result}" -eq 0 ] || [ "${result}" -eq 11 ] || return 1
kept="$(find "${profile_tree}/user" -type f 2>/dev/null | wc -l | tr -d ' ')"
total_kept=$((total_kept + kept))
msg " ${kept} ${VENDOR} preset file(s)"
else
cp -a "${PROFILES_DIR}/." "${profile_tree}/" || return 1
rm -rf "${profile_tree}/user"
unzip -q "${fixture_zip}" -d "${profile_tree}" || return 1
fi
"${VALIDATOR}" -p "${profile_tree}" -v "${VENDOR}" -l "${LOG_LEVEL}" > "${log_path}" 2>&1
result=$?
if [ "${result}" -eq 0 ]; then
echo "| ${version} | PASS | ${version}.log |" >> "${summary}"
# Only failures are worth keeping; each tree is a full copy of resources/profiles.
rm -rf "${profile_tree}"
else
echo "| ${version} | FAIL | ${version}.log |" >> "${summary}"
failed_logs="${failed_logs}${log_path}"$'\n'
status=1
fi
done < "${fixtures_dir}/fixtures.tsv"
# A vendor added after the newest fixture was cut appears in none of them: nothing failed, but
# nothing was checked either.
if [ -n "${VENDOR}" ] && [ "${total_kept}" -eq 0 ]; then
msg "no ${VENDOR} presets in any fixture; validate_custom checked nothing"
fi
cat "${summary}"
if [ -n "${failed_logs}" ]; then
echo ""
echo "## Failed Fixture Logs"
while IFS= read -r log_path; do
[ -n "${log_path}" ] || continue
echo ""
echo "### $(basename "${log_path}" .log)"
echo '```'
head -c 12000 "${log_path}" || echo "No output captured"
echo '```'
done <<EOF
${failed_logs}
EOF
fi
return "${status}"
}
# Heading CI puts above this check's log in the PR comment.
comment_heading() {
case "$1" in
profile_tool) echo "### Profile Check Failed (orca_profile_tool.py)" ;;
validate_system) echo "### System Profile Validation Failed" ;;
validate_slice) echo "### Slice Validation Failed (custom g-code expansion)" ;;
validate_filament_subtypes) echo "### Filament Subtype Validation Failed" ;;
validate_custom) echo "### Custom Preset Validation Failed" ;;
esac
}
# ---------------------------------------------------------------------------- run
run_check() {
local name="$1"
local log="${LOG_DIR}/${name}.log"
local result
printf '\n%s==> %s%s%s\n' "${C_BOLD}" "${name}" "${VENDOR:+ (${VENDOR})}" "${C_RESET}"
"check_${name}" 2>&1 | tee "${log}"
result="${PIPESTATUS[0]}"
if [ "${result}" -eq 0 ]; then
printf '%s %s passed%s\n' "${C_GREEN}" "${name}" "${C_RESET}"
RESULTS="${RESULTS}${name}"$'\t'"pass"$'\n'
else
printf '%s %s failed (exit %s)%s\n' "${C_RED}" "${name}" "${result}" "${C_RESET}"
RESULTS="${RESULTS}${name}"$'\t'"fail"$'\n'
fi
}
if wants validate_system || wants validate_slice || wants validate_filament_subtypes || wants validate_custom; then
resolve_validator
fi
# An empty printer set is a failure to the sweep, so validate_slice is recorded as skipped rather
# than run for a vendor that ships no printers (the filament-only OrcaFilamentLibrary); naming the
# check explicitly still runs it.
for check in "${ALL_CHECKS[@]}"; do
wants "${check}" || continue
if [ "${check}" = validate_slice ] && [ "${NAMED_CHECKS}" -eq 0 ] && [ -n "${VENDOR}" ] &&
[ ! -d "${PROFILES_DIR}/${VENDOR}/machine" ]; then
RESULTS="${RESULTS}${check}"$'\t'"skip"$'\t'"${VENDOR} ships no printers"$'\n'
else
run_check "${check}"
fi
done
# Summary, plus the comment check_profiles_comment.yml would post when something fails.
failed=0
skipped=0
printf '\n%s==> summary%s\n' "${C_BOLD}" "${C_RESET}"
while IFS=$'\t' read -r name result why; do
[ -n "${name}" ] || continue
if [ "${result}" = "pass" ]; then
printf '%s PASS%s %s\n' "${C_GREEN}" "${C_RESET}" "${name}"
elif [ "${result}" = "skip" ]; then
printf '%s SKIP%s %s (%s)\n' "${C_BOLD}" "${C_RESET}" "${name}" "${why}"
skipped=1
else
printf '%s FAIL%s %s (%s)\n' "${C_RED}" "${C_RESET}" "${name}" "${LOG_DIR}/${name}.log"
failed=1
fi
done <<EOF
${RESULTS}
EOF
if [ "${failed}" -eq 0 ]; then
rm -f "${WORK_DIR}/pr_comment.md"
if [ "${skipped}" -eq 0 ]; then
printf '\n%sAll checks passed.%s Logs: %s\n' "${C_GREEN}" "${C_RESET}" "${LOG_DIR}"
else
printf '\n%sEvery check that ran passed%s, but CI runs the skipped ones too. Logs: %s\n' \
"${C_GREEN}" "${C_RESET}" "${LOG_DIR}"
fi
exit 0
fi
{
# Marker matched by check_profiles_comment.yml to delete prior comments.
echo "<!-- profile-validation-comment -->"
echo "## :x: Profile Validation Errors"
echo ""
while IFS=$'\t' read -r name result; do
[ "${result}" = "fail" ] || continue
comment_heading "${name}"
echo ""
echo '```'
head -c 30000 "${LOG_DIR}/${name}.log" || echo "No output captured"
echo '```'
echo ""
done <<INNER
${RESULTS}
INNER
echo "---"
# Single-quoted on purpose: the backticks below are markdown, not command substitution.
# shellcheck disable=SC2016
echo '*Fix the errors above and push a new commit. To reproduce this run locally: `scripts/check_profile.sh`, or `scripts\check_profile.bat` on Windows.*'
} > "${WORK_DIR}/pr_comment.md"
printf '\n%sOne or more profile checks failed.%s Logs: %s\n' "${C_RED}" "${C_RESET}" "${LOG_DIR}"
printf 'The comment CI would post: %s\n' "${WORK_DIR}/pr_comment.md"
exit 1
+1
View File
@@ -1,3 +1,4 @@
builddir
.flatpak-builder
*.docker.yml
deps.tar*
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Check a Flatpak manifest for `type: dir` sources at or before the orca_deps
# module.
#
# Usage: check_manifest_cacheable.sh [manifest]
# Defaults to com.orcaslicer.OrcaSlicer.yml next to this script.
#
# Exits 0 when none are found, 1 when any are, listing them as file:line, and
# 2 when the manifest is missing or has no orca_deps module.
set -euo pipefail
anchor=orca_deps
manifest=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/com.orcaslicer.OrcaSlicer.yml}
module_re='^ - name: '
dir_re='(^|[-{,[:space:]])type:[[:space:]]*dir([,}[:space:]]|$)'
if [ ! -f "$manifest" ]; then
echo "$manifest: no such file" >&2
exit 2
fi
anchor_start=$(grep -n "${module_re}${anchor}[[:space:]]*$" "$manifest" | cut -d: -f1 || true)
if [ -z "$anchor_start" ]; then
echo "$manifest: no module named '$anchor'; this check needs updating" >&2
exit 2
fi
# First module header after the anchor, or EOF if the anchor is last.
anchor_end=$(grep -n "$module_re" "$manifest" | cut -d: -f1 |
awk -v s="$anchor_start" '$1 > s { print $1; exit }')
[ -n "$anchor_end" ] || anchor_end=$(awk 'END { print NR + 1 }' "$manifest")
violations=$(awk -v e="$anchor_end" -v f="$manifest" -v mre="$module_re" -v dre="$dir_re" '
{ sub(/\r$/, "") }
/^[[:space:]]*#/ { next }
$0 ~ mre { module = $3 }
NR < e && $0 ~ dre {
printf "%s:%d: %s (module %s)\n", f, NR, $0, module
}' "$manifest")
if [ -n "$violations" ]; then
printf '%s\n' "$violations" >&2
echo >&2
echo "flatpak-builder cannot checksum a directory, so each of these makes" >&2
echo "$anchor and every module after it rebuild from scratch on every run." >&2
echo "See the $anchor sources in this manifest for the tarball used instead." >&2
exit 1
fi
echo "manifest OK: no 'type: dir' source at or before $anchor"
+29 -4
View File
@@ -144,6 +144,7 @@ modules:
env:
BUILD_DIR: deps/build_flatpak
build-commands:
- tar xf deps.tar
- |
cmake -S deps -B $BUILD_DIR \
-DFLATPAK=ON \
@@ -169,10 +170,10 @@ modules:
- /libpython/include
sources:
# OrcaSlicer deps/ directory (avoids copying .git from worktree)
- type: dir
path: ../../deps
dest: deps
# flatpak-builder cannot checksum a directory, so a `type: dir` here would
# rebuild every dependency on every run. Generated by make_deps_tar.sh.
- type: file
path: deps.tar
# ---------------------------------------------------------------
# Pre-downloaded dependency archives
@@ -222,6 +223,12 @@ modules:
sha256: 51afe0db79af8386e2027d56d685177135581e0ee82ade9d7f2caff8deab5ec5
dest: external-packages/OpenCSG
# SolveSpace libslvs (2D sketch constraint solver, Design tab)
- type: file
url: https://github.com/JacobStoren/SolveSpaceLib/archive/4d8704523e4bf212fadf5189f92484244f670fea.zip
sha256: 1c4bdde9c3c6ef20ea4b50b73601de56769f2eb131b36927d7c6489f102e6c30
dest: external-packages/SLVS
# Blosc 1.17.0 (tamasmeszaros fork)
- type: file
url: https://github.com/tamasmeszaros/c-blosc/archive/refs/heads/v1.17.0_tm.zip
@@ -377,6 +384,17 @@ modules:
- cmake --build build_flatpak --target generate_system_cache -j$FLATPAK_BUILDER_N_JOBS
- ./scripts/build_preset_cache.sh -n -b build_flatpak /app/share/OrcaSlicer/profiles
# Built (not run) here via the action's run-tests, then shipped to a separate
# test job. Only the test sources compile; nothing installs to /app.
test-commands:
- cmake . -B build_flatpak -DBUILD_TESTS=ON
# A suite missing from this list fails the leg loudly, since ctest registers a
# <target>_NOT_BUILT test for it. (tests/all is a Ninja subdirectory target and
# this build uses the default Makefile generator, so it is not available here.)
- cmake --build build_flatpak -j"${FLATPAK_BUILDER_N_JOBS:-$(nproc)}" --target
libslic3r_tests fff_print_tests sla_print_tests libnest2d_tests slic3rutils_tests
filament_group_tests
cleanup:
- /include
@@ -413,6 +431,10 @@ modules:
- type: dir
path: ../../localization
dest: localization
# For the post-build unit-test step (BUILD_TESTS=ON); not built by the app.
- type: dir
path: ../../tests
dest: tests
- type: file
path: ../../CMakeLists.txt
@@ -426,6 +448,9 @@ modules:
- type: file
path: ../build_preset_cache.sh
dest: scripts
- type: file
path: ../run_unit_tests.sh
dest: scripts
# AppData metainfo for GNOME Software & Co.
- type: file
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Pack deps/ into scripts/flatpak/deps.tar for the Flatpak manifest's orca_deps
# module.
#
# Usage: make_deps_tar.sh
# Requires GNU tar. On macOS, brew install gnu-tar.
#
# The archive is byte-reproducible. Member order, mtimes, ownership and the
# group/other write bits are pinned, so identical deps/ contents always produce
# an identical file. deps/build* and deps/DL_CACHE are excluded.
#
# Prints the output path, size and sha256.
set -euo pipefail
if [ "$#" -ne 0 ]; then
echo "usage: ${0##*/}" >&2
exit 2
fi
tar_bin=$(command -v gtar || command -v tar || true)
tar_version=$([ -n "$tar_bin" ] && "$tar_bin" --version 2>/dev/null || true)
case $tar_version in
*"GNU tar"*) ;;
*) echo "${0##*/}: needs GNU tar; on macOS run 'brew install gnu-tar'" >&2
exit 2 ;;
esac
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
repo_root=$(cd "$script_dir/../.." && pwd)
out=$script_dir/deps.tar
tmp=$out.tmp
trap 'rm -f "$tmp"' EXIT
"$tar_bin" --format=gnu \
--sort=name \
--mtime=@0 \
--owner=0 --group=0 --numeric-owner \
--mode=go-w \
--exclude='deps/build*' \
--exclude='deps/DL_CACHE' \
-cf "$tmp" \
-C "$repo_root" deps
mv -f "$tmp" "$out"
if command -v sha256sum >/dev/null 2>&1; then
sum=$(sha256sum "$out" | cut -d' ' -f1)
elif command -v shasum >/dev/null 2>&1; then
sum=$(shasum -a 256 "$out" | cut -d' ' -f1)
else
sum=unavailable
fi
echo "Wrote $out ($(du -h "$out" | cut -f1), sha256 $sum)"
+4 -3
View File
@@ -8,8 +8,9 @@ flatpak install flathub org.gnome.Platform//50 org.gnome.Sdk//50 org.freedesktop
##
# in OrcaSlicer folder, run following command to build Orca
# # First time build
# flatpak-builder --state-dir=.flatpak-builder --keep-build-dirs --user --force-clean build-dir scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
# # Subsequent builds (only rebuilding OrcaSlicer)
# # First time build
# ./scripts/flatpak/make_deps_tar.sh && flatpak-builder --state-dir=.flatpak-builder --keep-build-dirs --user --force-clean build-dir scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
# # Subsequent builds (only rebuilding OrcaSlicer; run make_deps_tar.sh first if deps/ changed)
# flatpak-builder --state-dir=.flatpak-builder --keep-build-dirs --user build-dir scripts/flatpak/com.orcaslicer.OrcaSlicer.yml --build-only=OrcaSlicer
-632
View File
@@ -1,632 +0,0 @@
import os
import json
import argparse
from pathlib import Path
from assign_vendor_setting_ids import generate_preset_setting_id
OBSOLETE_KEYS = {
"acceleration", "scale", "rotate", "duplicate", "duplicate_grid",
"bed_size", "print_center", "g0", "wipe_tower_per_color_wipe",
"support_sharp_tails", "support_remove_small_overhangs", "support_with_sheath",
"tree_support_collision_resolution", "tree_support_with_infill",
"max_volumetric_speed", "max_print_speed", "support_closing_radius",
"remove_freq_sweep", "remove_bed_leveling", "remove_extrusion_calibration",
"support_transition_line_width", "support_transition_speed", "bed_temperature",
"bed_temperature_initial_layer", "can_switch_nozzle_type", "can_add_auxiliary_fan",
"extra_flush_volume", "spaghetti_detector", "adaptive_layer_height",
"z_hop_type", "z_lift_type", "bed_temperature_difference", "long_retraction_when_cut",
"retraction_distance_when_cut", "extruder_type", "internal_bridge_support_thickness",
"extruder_clearance_max_radius", "top_area_threshold", "reduce_wall_solid_infill",
"filament_load_time", "filament_unload_time", "smooth_coefficient",
"overhang_totally_speed", "silent_mode", "overhang_speed_classic"
}
# Utility functions for printing messages in different colors.
def print_error(msg):
print(f"\033[91m[ERROR]\033[0m {msg}") # Red
def print_warning(msg):
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
def print_info(msg):
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
def print_success(msg):
print(f"\033[92m[SUCCESS]\033[0m {msg}") # Green
# Add helper function for duplicate key detection.
def no_duplicates_object_pairs_hook(pairs):
seen = {}
for key, value in pairs:
if key in seen:
raise ValueError(f"Duplicate key detected: {key}")
seen[key] = value
return seen
# NOTE: currently Orca expects compatible_printers to be a defined in every instantiation profile, inheritation is not supported in Profile page
def check_filament_compatible_printers(vendor, vendor_folder):
"""
Checks JSON files in the vendor folder for missing or empty 'compatible_printers'
when 'instantiation' is flagged as true.
In the OrcaFilamentLibrary 'compatible_printers' is optional: a profile without it is generic and
offered on every printer, while a profile that lists printers supersedes the generic one there.
Parameters:
vendor (str): The vendor name the folder belongs to.
vendor_folder (str or Path): The directory to search for JSON profile files.
Returns:
int: The number of profiles with missing or empty 'compatible_printers'.
"""
error = 0
vendor_path = Path(vendor_folder)
if not vendor_path.exists():
return 0
profiles = {}
# Use rglob to recursively find .json files.
for file_path in vendor_path.rglob("*.json"):
if file_path.name == 'filaments_color_codes.json': # Ignore non-profile file
continue
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
# Use custom hook to detect duplicates.
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
except ValueError as ve:
print_error(f"Duplicate key error in {file_path}: {ve}")
error += 1
continue
except Exception as e:
print_error(f"Error processing {file_path}: {e}")
error += 1
continue
profile_name = data['name']
if profile_name in profiles:
print_error(f"Duplicated profile {profile_name}: {file_path}")
error += 1
continue
profiles[profile_name] = {
'file_path': file_path,
'content': data,
}
def get_property(profile, key):
content = profile['content']
if key in content:
return content[key]
return None
def get_inherit_property(profile, key):
content = profile['content']
if key in content:
return content[key]
if 'inherits' in content:
inherits = content['inherits']
if inherits not in profiles:
raise ValueError(f"Parent profile not found: {inherits}, referenced in {profile['file_path']}")
return get_inherit_property(profiles[inherits], key)
return None
for profile in profiles.values():
instantiation = str(profile['content'].get("instantiation", "")).lower() == "true"
if instantiation and vendor != 'OrcaFilamentLibrary':
try:
compatible_printers = get_property(profile, "compatible_printers")
if not compatible_printers or (isinstance(compatible_printers, list) and not compatible_printers):
print_error(f"'compatible_printers' missing in {profile['file_path']}")
error += 1
except ValueError as ve:
print_error(f"Unable to parse {profile['file_path']}: {ve}")
error += 1
continue
return error
def load_available_filament_profiles(profiles_dir, vendor_name):
"""
Load all available filament profiles from a vendor's directory.
Parameters:
profiles_dir (Path): The directory containing vendor profile directories
vendor_name (str): The name of the vendor directory
Returns:
set: A set of filament profile names
"""
profiles = set()
vendor_path = profiles_dir / vendor_name / "filament"
if not vendor_path.exists():
return profiles
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
data = json.load(fp)
if "name" in data:
profiles.add(data["name"])
except Exception as e:
print_error(f"Error loading filament profile {file_path}: {e}")
return profiles
def check_machine_default_materials(profiles_dir, vendor_name):
"""
Checks if default materials referenced in machine profiles exist in
the vendor's filament library or in the global OrcaFilamentLibrary.
Parameters:
profiles_dir (Path): The base profiles directory
vendor_name (str): The vendor name to check
Returns:
int: Number of missing filament references found
int: the number of warnings found (0 or 1)
"""
error_count = 0
machine_dir = profiles_dir / vendor_name / "machine"
if not machine_dir.exists():
print_warning(f"No machine profiles found for vendor: {vendor_name}")
return 0, 1
# Load available filament profiles
vendor_filaments = load_available_filament_profiles(profiles_dir, vendor_name)
global_filaments = load_available_filament_profiles(profiles_dir, "OrcaFilamentLibrary")
all_available_filaments = vendor_filaments.union(global_filaments)
# Check each machine profile
for file_path in machine_dir.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
data = json.load(fp)
default_materials = None
if "default_materials" in data:
default_materials = data["default_materials"]
elif "default_filament_profile" in data:
default_materials = data["default_filament_profile"]
if default_materials:
if isinstance(default_materials, list):
for material in default_materials:
if material not in all_available_filaments:
print_error(f"Missing filament profile: '{material}' referenced in {file_path.relative_to(profiles_dir)}")
error_count += 1
else:
# Handle semicolon-separated list of materials in a string
if ";" in default_materials:
for material in default_materials.split(";"):
material = material.strip()
if material and material not in all_available_filaments:
print_error(f"Missing filament profile: '{material}' referenced in {file_path.relative_to(profiles_dir)}")
error_count += 1
else:
# Single material in a string
if default_materials not in all_available_filaments:
print_error(f"Missing filament profile: '{default_materials}' referenced in {file_path.relative_to(profiles_dir)}")
error_count += 1
except Exception as e:
print_error(f"Error processing machine profile {file_path}: {e}")
error_count += 1
return error_count, 0
def check_name_consistency(profiles_dir, vendor_name):
"""
Make sure filament profile names match in both vendor json and subpath files.
Filament profiles work only if the name in <vendor>.json matches the name in sub_path file,
or if it's one of the sub_path file's `renamed_from`.
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
Returns:
int: Number of errors found
int: Number of warnings found (0 or 1)
"""
error_count = 0
vendor_dir = profiles_dir / vendor_name
vendor_file = profiles_dir / (vendor_name + ".json")
if not vendor_file.exists():
print_warning(f"No profiles found for vendor: {vendor_name} at {vendor_file}")
return 0, 1
try:
with open(vendor_file, 'r', encoding='UTF-8') as fp:
data = json.load(fp)
except Exception as e:
print_error(f"Error loading vendor profile {vendor_file}: {e}")
return 1, 0
for section in ['filament_list', 'machine_model_list', 'machine_list', 'process_list']:
if section not in data:
continue
for child in data[section]:
name_in_vendor = child['name']
sub_path = child['sub_path']
sub_file = vendor_dir / sub_path
if not sub_file.exists():
print_error(f"Missing sub profile: '{sub_path}' declared in {vendor_file.relative_to(profiles_dir)}")
error_count += 1
continue
try:
with open(sub_file, 'r', encoding='UTF-8') as fp:
sub_data = json.load(fp)
except Exception as e:
print_error(f"Error loading profile {sub_file}: {e}")
error_count += 1
continue
name_in_sub = sub_data['name']
if name_in_sub == name_in_vendor:
continue
# if 'renamed_from' in sub_data:
# renamed_from = [n.strip() for n in sub_data['renamed_from'].split(';')]
# if name_in_vendor in renamed_from:
# continue
print_error(f"{section} name mismatch: required '{name_in_vendor}' in {vendor_file.relative_to(profiles_dir)} but found '{name_in_sub}' in {sub_file.relative_to(profiles_dir)}")
error_count += 1
return error_count, 0
def check_filament_id(vendor, vendor_folder):
"""
Make sure filament_id is not longer than 8 characters, otherwise AMS won't work properly
"""
if vendor not in ('BBL', 'OrcaFilamentLibrary'):
return 0
error = 0
vendor_path = Path(vendor_folder)
if not vendor_path.exists():
return 0
# Use rglob to recursively find .json files.
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
# Use custom hook to detect duplicates.
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
except ValueError as ve:
print_error(f"Duplicate key error in {file_path}: {ve}")
error += 1
continue
except Exception as e:
print_error(f"Error processing {file_path}: {e}")
error += 1
continue
if 'filament_id' not in data:
continue
filament_id = data['filament_id']
if len(filament_id) > 8:
error += 1
print_error(f"Filament id too long \"{filament_id}\": {file_path}")
return error
def check_obsolete_keys(profiles_dir, vendor_name):
"""
Check for obsolete keys in all filament profiles for a vendor.
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
obsolete_keys (set): Set of obsolete key names to check
Returns:
int: Number of obsolete keys found
"""
error_count = 0
vendor_path = profiles_dir / vendor_name / "filament"
if not vendor_path.exists():
return 0
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, "r", encoding="UTF-8") as fp:
data = json.load(fp)
except Exception as e:
print_warning(f"Error reading profile {file_path.relative_to(profiles_dir)}: {e}")
error_count += 1
continue
for key in data.keys():
if key in OBSOLETE_KEYS:
print_warning(f"Obsolete key: '{key}' found in {file_path.relative_to(profiles_dir)}")
error_count += 1
return error_count
CONFLICT_KEYS = [
['extruder_clearance_radius', 'extruder_clearance_max_radius'],
]
VECTOR_KEYS = {
"filament_type",
}
def check_vector_type_keys(profiles_dir, vendor_name):
"""
Check that properties expected to be vectors (JSON arrays) are not stored as scalars.
For example, `filament_type` must be a list like ["PA-CF"], not a string "PA-CF".
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
Returns:
int: Number of errors found
"""
error_count = 0
vendor_path = profiles_dir / vendor_name
if not vendor_path.exists():
return 0
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, "r", encoding="UTF-8") as fp:
data = json.load(fp)
except Exception as e:
print_error(f"Error processing {file_path.relative_to(profiles_dir)}: {e}")
error_count += 1
continue
if not isinstance(data, dict):
continue
for key in VECTOR_KEYS:
if key in data and not isinstance(data[key], list):
print_error(
f"'{key}' must be an array in {file_path.relative_to(profiles_dir)}, "
f"got {type(data[key]).__name__}: {data[key]!r}"
)
error_count += 1
return error_count
def check_conflict_keys(profiles_dir, vendor_name):
"""
Check for keys that could not be specified at the same time,
due to option renaming & backward compatibility reasons.
For example, `extruder_clearance_max_radius` and `extruder_clearance_radius` cannot co-exist
otherwise slicer won't know which one to use.
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
Returns:
int: Number of errors found
int: Number of warnings found
"""
error_count = 0
warn_count = 0
vendor_path = profiles_dir / vendor_name
if not vendor_path.exists():
print_warning(f"No machine profiles found for vendor: {vendor_name}")
return 0, 1
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
# Use custom hook to detect duplicates.
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
except ValueError as ve:
print_error(f"Duplicate key error in {file_path.relative_to(profiles_dir)}: {ve}")
error_count += 1
continue
except Exception as e:
print_error(f"Error processing {file_path.relative_to(profiles_dir)}: {e}")
error_count += 1
continue
for key_sets in CONFLICT_KEYS:
if sum([1 if k in data else 0 for k in key_sets]) > 1:
print_error(f"Conflict keys {key_sets} co-exist in {file_path.relative_to(profiles_dir)}")
error_count += 1
return error_count, warn_count
# Bambu (BBL) keeps its authoritative "G*" cloud ids, which are NOT produced by the
# deterministic formula, so BBL is exempt from the formula match (Rule 2) only. It is
# still checked for presence, uniqueness, base-no-id and the typo key like every other
# vendor. Every other vendor (incl. OrcaFilamentLibrary and Custom) must also match the
# formula.
SETTING_ID_FORMULA_EXEMPT_VENDORS = {"BBL"}
PROFILE_SUBDIRS = ("filament", "process", "machine")
def check_setting_id_uniqueness(profiles_dir):
"""
Validate setting_id across every vendor (see scripts/assign_vendor_setting_ids.py):
1. Every instantiated preset must HAVE a setting_id. (all vendors)
2. A stored setting_id must equal generate_preset_setting_id(vendor, type, name); a stale
value means the JSON was edited without rerunning assign_vendor_setting_ids.py.
(all vendors EXCEPT the formula-exempt ones, e.g. BBL)
3. Base profiles (instantiation != "true") must not carry a setting_id. (all vendors)
4. setting_id must be globally unique - no two files may share one. (all vendors)
5. No profile may use the misspelled key "settings_id". (all vendors)
Formula-exempt vendors (BBL) keep their authoritative ids, so only Rule 2 is skipped
for them; they are still held to presence, uniqueness, base-no-id and the typo check.
"""
errors = 0
owners = {} # setting_id -> list of relative_path (every vendor)
for vendor_dir in sorted(profiles_dir.iterdir()):
if not vendor_dir.is_dir():
continue
vendor = vendor_dir.name
formula_exempt = vendor in SETTING_ID_FORMULA_EXEMPT_VENDORS
for sub in PROFILE_SUBDIRS:
base = vendor_dir / sub
if not base.is_dir():
continue
for file_path in base.rglob("*.json"):
try:
data = json.loads(file_path.read_bytes())
except (ValueError, OSError):
continue
if not isinstance(data, dict):
continue
rel = file_path.relative_to(profiles_dir)
# Rule 5: catch the misspelled "settings_id" key.
if "settings_id" in data:
errors += 1
print_error(
f'profile {rel} uses the misspelled key "settings_id" '
f'(should be "setting_id"); run assign_vendor_setting_ids.py'
)
sid = data.get("setting_id")
instantiated = data.get("instantiation") == "true"
if not instantiated:
# Rule 3: base/template profiles must not carry a setting_id.
if sid:
errors += 1
print_error(
f'base profile {rel} (instantiation != "true") must not have a '
f'setting_id ("{sid}"); run assign_vendor_setting_ids.py'
)
continue
# Rule 1: every instantiated preset must have a setting_id.
if not sid:
errors += 1
print_error(
f"instantiated preset {rel} is missing a setting_id; "
f"run assign_vendor_setting_ids.py"
)
continue
# Rule 2: the stored id must match the deterministic rule. BBL keeps its
# authoritative G* ids and is exempt from this check only.
if not formula_exempt:
expected = generate_preset_setting_id(vendor, sub, data.get("name", ""))
if sid != expected:
errors += 1
print_error(
f'setting_id "{sid}" in {rel} does not match the expected '
f'"{expected}" for {vendor}/{sub}/{data.get("name", "")}; '
f"run assign_vendor_setting_ids.py"
)
continue
# Rule 4: collect for the global-uniqueness check below.
owners.setdefault(sid, []).append(rel)
# Rule 4: a setting_id shared by two files is an error. For managed vendors this means
# a duplicate vendor/type/name; for formula-exempt vendors (BBL) a copy-pasted id.
for sid, locs in sorted(owners.items()):
if len(locs) < 2:
continue
errors += 1
print_error(
f'setting_id "{sid}" is shared by {len(locs)} files ({sorted(map(str, locs))}); '
f"setting_id must be globally unique"
)
return errors
def main():
parser = argparse.ArgumentParser(
description="Check 3D printer profiles for common issues",
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument("--vendor", type=str, help="Specify a single vendor to check")
parser.add_argument("--check-filaments", action="store_true", help="Check 'compatible_printers' in filament profiles")
parser.add_argument("--check-materials", action="store_true", help="Check default materials in machine profiles")
parser.add_argument("--check-obsolete-keys", action="store_true", help="Warn if obsolete keys are found in filament profiles")
args = parser.parse_args()
print_info("Checking profiles ...")
script_dir = Path(__file__).resolve().parent
profiles_dir = script_dir.parent / "resources" / "profiles"
checked_vendor_count = 0
errors_found = 0
warnings_found = 0
def run_checks(vendor_name):
nonlocal errors_found, warnings_found, checked_vendor_count
vendor_path = profiles_dir / vendor_name
if args.check_filaments or not (args.check_materials and not args.check_filaments):
errors_found += check_filament_compatible_printers(vendor_name, vendor_path / "filament")
if args.check_materials:
new_errors, new_warnings = check_machine_default_materials(profiles_dir, vendor_name)
errors_found += new_errors
warnings_found += new_warnings
if args.check_obsolete_keys:
warnings_found += check_obsolete_keys(profiles_dir, vendor_name)
new_errors, new_warnings = check_name_consistency(profiles_dir, vendor_name)
errors_found += new_errors
warnings_found += new_warnings
new_errors, new_warnings = check_conflict_keys(profiles_dir, vendor_name)
errors_found += new_errors
warnings_found += new_warnings
errors_found += check_vector_type_keys(profiles_dir, vendor_name)
errors_found += check_filament_id(vendor_name, vendor_path / "filament")
checked_vendor_count += 1
if args.vendor:
run_checks(args.vendor)
else:
for vendor_dir in profiles_dir.iterdir():
if not vendor_dir.is_dir() or vendor_dir.name == "OrcaFilamentLibrary":
continue
run_checks(vendor_dir.name)
# Global (cross-vendor) check: setting_id must be unique and stay in-namespace.
# Runs once over the whole tree regardless of the --vendor filter.
errors_found += check_setting_id_uniqueness(profiles_dir)
# ✨ Output finale in stile "compilatore"
print("\n==================== SUMMARY ====================")
print_info(f"Checked vendors : {checked_vendor_count}")
if errors_found > 0:
print_error(f"Files with errors : {errors_found}")
else:
print_success("Files with errors : 0")
if warnings_found > 0:
print_warning(f"Files with warnings : {warnings_found}")
else:
print_success("Files with warnings : 0")
print("=================================================")
if errors_found > 0 or warnings_found > 0 :
print_warning('Issue(s) found, try `orca_filament_lib.py --fix` to fix common issues automatically')
exit(-1 if errors_found > 0 else 0)
if __name__ == "__main__":
main()
-310
View File
@@ -1,310 +0,0 @@
import os
import json
import argparse
from collections import defaultdict
def create_ordered_profile(profile_dict, priority_fields=['name', 'type']):
"""Create a new dictionary with priority fields first"""
ordered_profile = {}
# Add priority fields first
for field in priority_fields:
if field in profile_dict:
ordered_profile[field] = profile_dict[field]
# Add remaining fields
for key, value in profile_dict.items():
if key not in priority_fields:
ordered_profile[key] = value
return ordered_profile
def topological_sort(filaments):
# Build a graph of dependencies
graph = defaultdict(list)
in_degree = defaultdict(int)
name_to_filament = {f['name']: f for f in filaments}
all_names = set(name_to_filament.keys())
# Create the dependency graph
processed_files = set()
for filament in filaments:
if 'inherits' in filament:
parent = filament['inherits']
child = filament['name']
# Only create dependency if parent exists
if parent in all_names:
graph[parent].append(child)
in_degree[child] += 1
if parent not in in_degree:
in_degree[parent] = 0
processed_files.add(child)
processed_files.add(parent)
# Initialize queue with nodes having no dependencies (now sorted)
queue = sorted([name for name, degree in in_degree.items() if degree == 0])
result = []
# Process the queue
while queue:
current = queue.pop(0)
result.append(name_to_filament[current])
processed_files.add(current)
# Process children (now sorted)
children = sorted(graph[current])
for child in children:
in_degree[child] -= 1
if in_degree[child] == 0:
queue.append(child)
# Add remaining files that weren't part of inheritance tree (now sorted)
remaining = sorted(all_names - processed_files)
for name in remaining:
result.append(name_to_filament[name])
return result
def update_profile_library(vendor="",profile_type="filament"):
# change current working directory to the relative path(..\resources\profiles) compare to script location
os.chdir(os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles'))
# Collect current profile entries
if vendor:
vendors = [vendor]
else:
profiles_dir = os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles')
vendors = [f[:-5] for f in os.listdir(profiles_dir) if f.lower().endswith('.json')]
for vendor in vendors:
current_profiles = []
base_dir = vendor
# Orca expects machine_model to be in the machine folder
if profile_type == 'machine_model':
profile_dir = os.path.join(base_dir, 'machine')
else:
profile_dir = os.path.join(base_dir, profile_type)
for root, dirs, files in os.walk(profile_dir):
for file in files:
if file.lower().endswith('.json'):
full_path = os.path.join(root, file)
# Get relative path from base directory
sub_path = os.path.relpath(full_path, base_dir).replace('\\', '/')
try:
with open(full_path, 'r', encoding='utf-8') as f:
_profile = json.load(f)
if _profile.get('type') != profile_type:
continue
name = _profile.get('name')
inherits = _profile.get('inherits')
if name:
entry = {
"name": name,
"sub_path": sub_path
}
if inherits:
entry['inherits'] = inherits
current_profiles.append(entry)
else:
print(f"Warning: Missing 'name' in {full_path}")
except Exception as e:
print(f"Error reading {full_path}: {str(e)}")
continue
# Sort profiles based on inheritance
sorted_profiles = topological_sort(current_profiles)
# Remove the inherits field as it's not needed in the final JSON
for p in sorted_profiles:
p.pop('inherits', None)
# Update library file
lib_path = f'{vendor}.json'
profile_section = profile_type+'_list'
try:
with open(lib_path, 'r+', encoding='utf-8') as f:
library = json.load(f)
library[profile_section] = sorted_profiles
f.seek(0)
json.dump(library, f, indent="\t", ensure_ascii=False)
f.write('\n')
f.truncate()
print(f"Profile library for {vendor} updated successfully!")
except Exception as e:
print(f"Error updating library file: {str(e)}")
def clean_up_profile(vendor="", profile_type="", force=False):
# change current working directory to the relative path(..\resources\profiles) compare to script location
os.chdir(os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles'))
# Collect current profile entries
if vendor:
vendors = [vendor]
else:
profiles_dir = os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles')
vendors = [f[:-5] for f in os.listdir(profiles_dir) if f.lower().endswith('.json')]
for vendor in vendors:
current_profiles = []
base_dir = vendor
# Orca expects machine_model to be in the machine folder
if profile_type == 'machine_model':
profile_dir = os.path.join(base_dir, 'machine')
else:
profile_dir = os.path.join(base_dir, profile_type)
for root, dirs, files in os.walk(profile_dir):
for file in files:
if file.lower().endswith('.json'):
if file == 'filaments_color_codes.json': # Ignore non-profile file
continue
full_path = os.path.join(root, file)
# Get relative path from base directory
sub_path = os.path.relpath(full_path, base_dir).replace('\\', '/')
try:
with open(full_path, 'r+', encoding='utf-8') as f:
_profile = json.load(f)
need_update = False
if not _profile.get('type') or _profile.get('type') == "":
need_update = True
name = _profile.get('name')
inherits = _profile.get('inherits')
if profile_type == "machine_model" or profile_type == "machine":
if "nozzle" in name or "Nozzle" in name:
_profile['type'] = "machine"
else:
_profile['type'] = "machine_model"
else:
_profile['type'] = profile_type
print(f"Added type: {_profile['type']} to {file}")
fields_to_remove = ['version', 'is_custom_defined']
for field in fields_to_remove:
if _profile.get(field):
# remove version field
del _profile[field]
print(f"Removed {field} field from {file}")
need_update = True
# Handle `extruder_clearance_radius`.
if 'extruder_clearance_radius' in _profile and 'extruder_clearance_max_radius' in _profile:
# BBS renamed `extruder_clearance_radius` to `extruder_clearance_max_radius`
# however some of their profiles have both options exists with different value, which
# could cause very bad consequence such as toolhead collision.
# Here we make sure only one of these options exist, and if both present, we keep
# the one with greater value.
need_update = True
if float(_profile['extruder_clearance_max_radius']) > float(_profile['extruder_clearance_radius']):
del _profile['extruder_clearance_radius']
else:
del _profile['extruder_clearance_max_radius']
# Convert filament fields to arrays if not already
if profile_type == 'filament':
fields_to_arrayify = ['filament_cost', 'filament_density', 'filament_type', "temperature_vitrification", "filament_max_volumetric_speed", "filament_vendor"]
for field in fields_to_arrayify:
if field in _profile and not isinstance(_profile[field], list):
original_value = _profile[field]
_profile[field] = [original_value]
print(f"Converted {field} to array in {file}")
need_update = True
# remove following fields from filament profile
fields_to_remove = ['initial_layer_print_speed', 'outer_wall_speed', 'inner_wall_speed', 'infill_speed', 'top_surface_speed', 'travel_speed']
for field in fields_to_remove:
if field in _profile:
del _profile[field]
print(f"Removed {field} field from {file}")
need_update = True
if need_update or force:
# write back to file
f.seek(0)
ordered_profile = create_ordered_profile(_profile, ['type', 'name', 'renamed_from', 'inherits', 'from', 'setting_id', 'filament_id', 'instantiation'])
json.dump(ordered_profile, f, indent="\t", ensure_ascii=False)
f.write('\n')
f.truncate()
print(f"Updated profile: {full_path}")
except Exception as e:
print(f"Error reading {full_path}: {str(e)}")
continue
# For each JSON file, it will:
# - Replace "BBL X1C" with "System" in the name field
# - Empty the compatible_printers array
# - Ensure setting_id starts with 'O'
def rename_filament_system(vendor="OrcaFilamentLibrary"):
# change current working directory to the relative path
os.chdir(os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles'))
base_dir = vendor
filament_dir = os.path.join(base_dir, 'filament')
for root, dirs, files in os.walk(filament_dir):
for file in files:
if file.lower().endswith('.json'):
full_path = os.path.join(root, file)
try:
with open(full_path, 'r', encoding='utf-8') as f:
data = json.load(f)
modified = False
# Update name if it contains "BBL X1C"
if 'name' in data and "BBL X1C" in data['name']:
data['name'] = data['name'].replace("BBL X1C", "System")
modified = True
# Empty compatible_printers if exists
if 'compatible_printers' in data:
data['compatible_printers'] = []
modified = True
# Update setting_id if needed
if 'setting_id' in data and not data['setting_id'].startswith('O'):
data['setting_id'] = 'O' + data['setting_id']
modified = True
if modified:
with open(full_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent="\t", ensure_ascii=False)
f.write('\n')
print(f"Updated {full_path}")
except Exception as e:
print(f"Error processing {full_path}: {str(e)}")
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Update filament library for specified vendor')
parser.add_argument('-v', '--vendor', type=str, default="",
help='Vendor name (default: "" which means all vendors)')
parser.add_argument('-u', '--update', action='store_true', help='update vendor.json')
parser.add_argument('-p', '--profile_type', type=str, choices=['machine_model', 'process', 'filament', 'machine'], help='profile type (default: "" which means all types)')
parser.add_argument('-f', '--fix', action='store_true', help='Fix errors like missing type field, and clean up the profile')
parser.add_argument('--force', action='store_true', help='Force update the profile files, for --fix option')
args = parser.parse_args()
if args.fix:
if(args.profile_type):
clean_up_profile(args.vendor, args.profile_type, args.force)
else:
clean_up_profile(args.vendor, 'machine_model', args.force)
clean_up_profile(args.vendor, 'process', args.force)
clean_up_profile(args.vendor, 'filament', args.force)
clean_up_profile(args.vendor, 'machine', args.force)
if args.update:
update_profile_library(args.vendor, 'machine_model')
update_profile_library(args.vendor, 'process')
update_profile_library(args.vendor, 'filament')
update_profile_library(args.vendor, 'machine')
# else:
# rename_filament_system(args.vendor)
+2419
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -7,8 +7,9 @@
#
# Usage: run_unit_tests.sh [TEST_DIR] [BUILD_CONFIG]
# TEST_DIR directory containing the built tests (default: build/tests)
# BUILD_CONFIG configuration to run; required for multi-config generators
# (Windows/macOS), harmless/omitted for single-config (Linux).
# BUILD_CONFIG configuration to run; required for multi-config generators, which all
# build scripts use (build_linux.sh too: Ninja Multi-Config). Without it,
# tests registered with plain add_test() lose their labels and report "Not Run".
ROOT_DIR="$(dirname "$0")/.."
@@ -17,8 +18,9 @@ cd "${ROOT_DIR}" || exit 1
TEST_DIR="${1:-build/tests}"
BUILD_CONFIG="${2:-}"
# Run the whole suite, excluding tests tagged [NotWorking].
# Run the whole suite, excluding tests tagged [NotWorking] and tests labelled RequiresApp,
# which run the built orca-slicer binary that this directory does not contain.
# --no-tests=error fails the job if the filter matches nothing (instead of passing green).
args=(--test-dir "${TEST_DIR}" -LE "NotWorking" --no-tests=error --output-junit "$(pwd)/ctest_results.xml" --output-on-failure -j)
args=(--test-dir "${TEST_DIR}" -LE "NotWorking|RequiresApp" --no-tests=error --output-junit "$(pwd)/ctest_results.xml" --output-on-failure -j)
[ -n "${BUILD_CONFIG}" ] && args+=(--build-config "${BUILD_CONFIG}")
ctest "${args[@]}"
+969
View File
@@ -0,0 +1,969 @@
<#
.SYNOPSIS
Tests build_win.bat's option handling and the commands it generates.
.DESCRIPTION
Cases run the script with --dry-run, so nothing is configured, built or
deleted and the suite finishes in seconds. Each case asserts on the exit
code and on the command lines the script echoes.
Adding a case means adding one row to $cases. A bare string starts a new
group. Defaults: ExpectExit is 0 and --dry-run is appended, so a row only
states what is unusual about it.
Name what the case proves, in words
Args arguments, as an array
ExpectExit expected exit code (default 0)
DryRun append --dry-run (default $true)
First regex the first output line must match
Env environment for this case only
Contains literal strings the output must have
NotContains literal strings it must not have
Match regexes; each must match at least one output line
NotMatch regexes; none may match any output line
NotExists paths that must not exist after the case runs
DateStampedZip require a bundle date from during this case's invocation
.PARAMETER Name
Run only the cases whose name matches this regex. Headings with no
matching case are not printed, and a pattern that matches nothing is a
failure rather than an empty pass.
.EXAMPLE
powershell -File scripts/test_build_win.ps1
.EXAMPLE
powershell -File scripts/test_build_win.ps1 -Name solution
#>
[CmdletBinding()]
param(
[string] $Script,
[string] $Name
)
$ErrorActionPreference = 'Stop'
if (-not $Script) {
$here = $PSScriptRoot
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
$Script = Join-Path (Split-Path -Parent $here) 'build_win.bat'
}
if (-not (Test-Path $Script)) { throw "build_win.bat not found at $Script" }
# cmd resumes a batch file by byte offset after `call :label`, and with LF
# endings that offset lands wrong and the label lookup fails. .gitattributes
# pins CRLF; this catches a checkout or an editor that did not honour it.
if ((Get-Content -Raw $Script) -match "(?<!`r)`n") {
throw "$Script has LF line endings; cmd needs CRLF to resume after call :label"
}
$Script = (Resolve-Path $Script).Path
# Read the long options out of the script itself, so this cannot go stale when
# an option is added. Field order is: add_arg <var> <type> <short> <long>.
$longFlags = @(
Select-String -Path $Script -Pattern '^call :add_arg \S+ \S+ \S+ (\S+) ' |
ForEach-Object { '--' + $_.Matches[0].Groups[1].Value }
)
if ($longFlags.Count -lt 20) { throw "only found $($longFlags.Count) options in $Script; the parser above is wrong" }
# A winget that always fails, so the prerequisite failure path runs without
# touching the machine. It has to be an .exe: a .bat invoked without `call`
# transfers control and never comes back, which would end the script instead.
# where.exe returns 1 when its patterns match nothing and never prompts.
$fixtures = Join-Path ([IO.Path]::GetTempPath()) 'build_win_test_fixtures'
New-Item -ItemType Directory -Force -Path $fixtures | Out-Null
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $fixtures 'winget.exe') -Force
$stubPath = "$fixtures;C:\Windows\system32;C:\Windows"
# Stand-in ninjas that only report a version, so the 1.12 boundary in the
# progress format can be exercised on a machine whose real ninja is newer.
# A dry run skips the dev shell, so PATH here is what the script sees.
$ninjaPaths = @{}
foreach ($v in @{ old = '1.11.1'; new = '1.12.0' }.GetEnumerator()) {
$d = Join-Path $fixtures "ninja-$($v.Key)"
New-Item -ItemType Directory -Force -Path $d | Out-Null
Set-Content -Path (Join-Path $d 'ninja.bat') -Encoding ascii -Value @('@echo off', "echo $($v.Value)")
$ninjaPaths[$v.Key] = "$d;$env:PATH"
}
# A clang-cl earlier on PATH than the Visual Studio one, which is what the
# compiler used to resolve to. Nothing runs it; the script only locates it.
$clangDir = Join-Path $fixtures 'clang'
New-Item -ItemType Directory -Force -Path $clangDir | Out-Null
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $clangDir 'clang-cl.exe') -Force
$clangOnPath = "$clangDir;$env:PATH"
# A ccache that only has to exist. Nothing runs it; the script only locates it.
$cacheDir = Join-Path $fixtures 'cache'
New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $cacheDir 'ccache.exe') -Force
$ccacheOnPath = "$cacheDir;$env:PATH"
# ProgramFiles(x86) is where the script looks for vswhere, so an empty one
# stands in for a machine whose Visual Studio has no clang toolset.
$noVs = Join-Path $fixtures 'no-vs'
New-Item -ItemType Directory -Force -Path $noVs | Out-Null
# A build directory that already holds a classic solution, for the case where
# what is on disk disagrees with what the generator would write.
$slnDir = Join-Path $fixtures 'sln'
New-Item -ItemType Directory -Force -Path $slnDir | Out-Null
Set-Content -Path (Join-Path $slnDir 'OrcaSlicer.sln') -Value '' -Encoding ascii
$cases = @(
'argument handling'
@{ Name = 'no arguments prints help'; Args = @(); DryRun = $false
Contains = @('Usage: build_win.bat [options]', '--clang-cl') }
@{ Name = "--help lists all $($longFlags.Count) options the script defines"; Args = @('--help'); DryRun = $false
Contains = $longFlags }
@{ Name = 'help is grouped and shows usage, examples and environment'; Args = @('--help'); DryRun = $false
Contains = @('Usage: build_win.bat [options]', 'Actions:', 'Build configuration:', 'Toolchain:',
'How much gets rebuilt:', 'Paths and extra arguments:', 'Diagnostics:',
'Examples:', 'Environment:') }
@{ Name = 'the environment section shows what to set'; Args = @('--help'); DryRun = $false
Contains = @('ORCA_DEPS_CMAKE_ARGS', 'ORCA_SLICER_CMAKE_ARGS', 'ORCA_UPDATER_SIG_KEY', 'NINJA_STATUS',
'set ORCA_SLICER_CMAKE_ARGS=-DSLIC3R_BUILD_SANDBOXES=ON', '(PowerShell)', 'debugscript') }
@{ Name = 'section headers do not widen the flag column'; Args = @('--help'); DryRun = $false
Match = @('^ -d, --deps +Download') }
# Windows Terminal opens at 120 columns and wraps at 120, so 119 is the
# limit. Anyone still on the old conhost gets 80 and will see wrapping.
@{ Name = 'every help line fits a 120 column console'; Args = @('--help'); DryRun = $false
NotMatch = @('^.{120,}$') }
@{ Name = 'an unknown long option is rejected'; Args = @('--nonsense'); ExpectExit = 1
Contains = @('Failed to find arg') }
@{ Name = 'an unknown short option is rejected'; Args = @('-Z'); ExpectExit = 1
Contains = @('Failed to find arg') }
@{ Name = 'a bare argument is rejected'; Args = @('deps'); ExpectExit = 1
Contains = @('Unknown argument') }
@{ Name = 'an unknown architecture is rejected'; Args = @('-d', '--arch', 'sparc'); ExpectExit = 1
Contains = @('Unknown architecture') }
@{ Name = 'a string option without a value is rejected'; Args = @('-d', '--arch'); ExpectExit = 1
Contains = @('requires a value') }
@{ Name = 'short options can be bundled'; Args = @('-dx')
Contains = @('-G "Ninja Multi-Config"', '--target deps') }
'generator and compiler selection'
@{ Name = 'deps default to the Visual Studio generator'; Args = @('-d')
Contains = @('-G "Visual Studio', '-A x64', '--target deps')
NotContains = @('Ninja', 'clang-cl') }
@{ Name = '-x selects Ninja without changing compiler'; Args = @('-d', '-x')
Contains = @('-G "Ninja Multi-Config"')
NotContains = @('clang-cl', '-A x64') }
@{ Name = '-l -x builds with clang-cl under Ninja'; Args = @('-d', '-l', '-x')
Contains = @('-G "Ninja Multi-Config"')
Match = @('-DCMAKE_C_COMPILER="[^"]+/clang-cl\.exe"', '-DCMAKE_CXX_COMPILER="[^"]+/clang-cl\.exe"') }
# PATH order used to decide the compiler. VsDevCmd appends the Visual
# Studio LLVM directory to the end of PATH, so a standalone LLVM already
# there was resolved instead, and an old one failed the compiler check.
@{ Name = 'the compiler is resolved from Visual Studio, not PATH'; Args = @('-s', '-l', '-x')
Env = @{ PATH = $clangOnPath }
Match = @('^Compiler: .*/VC/Tools/Llvm/[^/]+/bin/clang-cl\.exe$') }
@{ Name = 'msvc names no compiler, having resolved none'; Args = @('-s')
NotContains = @('Compiler: ') }
@{ Name = '-l without -x names none either, the toolset picks it'; Args = @('-s', '-l')
NotContains = @('Compiler: ') }
# An empty ProgramFiles(x86) puts vswhere out of reach, which is a machine
# whose Visual Studio has no clang toolset.
@{ Name = 'without a Visual Studio clang the one on PATH is used and named'; Args = @('-s', '-l', '-x')
Env = @{ 'ProgramFiles(x86)' = $noVs; PATH = $clangOnPath }
Contains = @('Visual Studio has no clang-cl')
Match = @('^Compiler: .*/clang/clang-cl\.exe$') }
@{ Name = 'no clang-cl anywhere stops before configuring'; Args = @('-s', '-l', '-x'); ExpectExit = 1
Env = @{ 'ProgramFiles(x86)' = $noVs; PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('No clang-cl found', '--install-vs ide -l')
NotContains = @('cmake -B') }
# Only a configure passes the compiler to CMake, so an action that does
# not configure resolves none, and cannot start needing one installed.
@{ Name = 'packing resolves no compiler'; Args = @('-p', '-l', '-x')
Contains = @('Packing the dependencies')
NotContains = @('Compiler: ') }
@{ Name = '--no-configure resolves none either'; Args = @('-s', '-l', '-x', '--no-configure')
Contains = @('cmake --build "build-clang"')
NotContains = @('Compiler: ') }
@{ Name = '-l alone uses the ClangCL toolset on the VS generator'; Args = @('-d', '-l')
Contains = @('-G "Visual Studio', '-T ClangCL')
NotContains = @('-DCMAKE_C_COMPILER') }
# --msvc and --msbuild name the defaults, so a caller can be explicit and a
# contradictory pair can be caught rather than silently resolved.
@{ Name = '--msvc is the compiler default spelled out'; Args = @('-d', '--msvc')
Contains = @('-G "Visual Studio', '-A x64')
NotContains = @('clang-cl') }
@{ Name = '--msbuild is the generator default spelled out'; Args = @('-d', '--msbuild')
Contains = @('-G "Visual Studio')
NotContains = @('Ninja') }
@{ Name = '--msvc with -x gives Ninja driving cl'; Args = @('-d', '--msvc', '-x')
Contains = @('-G "Ninja Multi-Config"')
NotContains = @('clang-cl') }
@{ Name = '--msbuild with -l gives the VS generator and the ClangCL toolset'; Args = @('-d', '--msbuild', '-l')
Contains = @('-G "Visual Studio', '-T ClangCL') }
# A developer with a standalone LLVM points at it, and the path is passed
# with forward slashes so CMake cannot read a backslash as an escape.
@{ Name = '--clang-path names the compiler, quoted for its spaces'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
Contains = @('-DCMAKE_C_COMPILER="C:/Program Files/LLVM/bin/clang-cl.exe"',
'-DCMAKE_CXX_COMPILER="C:/Program Files/LLVM/bin/clang-cl.exe"') }
@{ Name = '--clang-path beats the Visual Studio clang'; Args = @('-s', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
Contains = @('Compiler: C:/Program Files/LLVM/bin/clang-cl.exe') }
@{ Name = '--clang-path is a clang request on its own'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
Contains = @('deps/build-clang') }
@{ Name = '--clang-path needs Ninja to take effect'; Args = @('-d', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe'); ExpectExit = 1
Contains = @('needs the Ninja generator') }
# `exist` is true for a directory too, and a directory would reach CMake
# as the compiler.
@{ Name = '--clang-path must name the exe, not its folder'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM'); ExpectExit = 1
Contains = @('is a directory') }
@{ Name = 'a clang-cl that is not there is caught early'; Args = @('-d', '-x', '--clang-path', 'C:\nope\clang-cl.exe'); ExpectExit = 1
Contains = @('No clang-cl at')
NotContains = @('cmake -S deps') }
@{ Name = '--clang-path contradicting --msvc is rejected'; Args = @('-d', '-x', '--msvc', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe'); ExpectExit = 1
Contains = @('select different compilers') }
@{ Name = '--clang-cl and --msvc together are rejected'; Args = @('-d', '-l', '--msvc'); ExpectExit = 1
Contains = @('select different compilers') }
@{ Name = '--ninja and --msbuild together are rejected'; Args = @('-d', '-x', '--msbuild'); ExpectExit = 1
Contains = @('select different generators') }
# One option with a value, driven by a table, rather than a flag per
# release. Adding a release should not need a new flag.
@{ Name = '--vs 2019 pins that release and skips autodetect'; Args = @('-d', '--vs', '2019')
Contains = @('-G "Visual Studio 16 2019"')
NotContains = @('Detecting Visual Studio') }
@{ Name = '--vs 2022 pins that release'; Args = @('-d', '--vs', '2022')
Contains = @('-G "Visual Studio 17 2022"') }
@{ Name = '--vs 2026 pins that release'; Args = @('-d', '--vs', '2026')
Contains = @('-G "Visual Studio 18 2026"') }
@{ Name = 'an unknown release is rejected and the known ones listed'; Args = @('-d', '--vs', '2015'); ExpectExit = 1
Contains = @('Unknown Visual Studio release', '2019, 2022, 2026') }
@{ Name = '--vs and --ninja together are rejected'; Args = @('-d', '--vs', '2022', '-x'); ExpectExit = 1
Contains = @('select different generators') }
@{ Name = 'without --vs the release is autodetected'; Args = @('-d')
Contains = @('Detecting Visual Studio') }
'architecture'
@{ Name = 'x64 is the default'; Args = @('-d')
Contains = @('Configuration: Release, x64')
NotContains = @('build-arm64') }
@{ Name = 'arm64 sets the generator platform and deps tree'; Args = @('-d', '--arch', 'arm64')
Contains = @('-A ARM64', 'deps/build-arm64') }
@{ Name = 'the architecture is matched case-insensitively'; Args = @('-d', '--arch', 'ARM64')
Contains = @('-A ARM64', 'deps/build-arm64') }
@{ Name = 'arm64 under Ninja has no -A but keeps the arm64 tree'; Args = @('-d', '--arch', 'arm64', '-x', '-l')
Contains = @('deps/build-clang-arm64')
Match = @('-DCMAKE_C_COMPILER="[^"]+/clang-cl\.exe"')
NotContains = @('-A ') }
'build configurations'
# One option with a value, driven by a table, so Release is named rather
# than being whatever is left when no flag is passed.
@{ Name = 'release is the default'; Args = @('-s')
Contains = @('-DCMAKE_BUILD_TYPE=Release', 'cmake -B "build" ') }
@{ Name = '--config release is the default spelled out'; Args = @('-s', '--config', 'release')
Contains = @('-DCMAKE_BUILD_TYPE=Release', 'cmake -B "build" ') }
@{ Name = '--config debug builds into build-dbg'; Args = @('-s', '--config', 'debug')
Contains = @('-DCMAKE_BUILD_TYPE=Debug', 'cmake -B "build-dbg" ') }
@{ Name = '--config relwithdebinfo builds into build-dbginfo'; Args = @('-s', '--config', 'relwithdebinfo')
Contains = @('-DCMAKE_BUILD_TYPE=RelWithDebInfo', 'cmake -B "build-dbginfo" ') }
@{ Name = '--config minsizerel builds into build-minsize'; Args = @('-s', '--config', 'minsizerel')
Contains = @('-DCMAKE_BUILD_TYPE=MinSizeRel', 'cmake -B "build-minsize" ') }
# Batch variable names are case-insensitive, so the table lookup is too.
@{ Name = 'the configuration name is matched case-insensitively'; Args = @('-s', '--config', 'RelWithDebInfo')
Contains = @('-DCMAKE_BUILD_TYPE=RelWithDebInfo') }
@{ Name = 'an unknown configuration is rejected and the known ones listed'; Args = @('-s', '--config', 'bogus'); ExpectExit = 1
Contains = @('Unknown configuration', 'release, debug, relwithdebinfo, minsizerel') }
# Release, RelWithDebInfo and MinSizeRel all link the /MD dependencies, so
# one tree serves all three. Debug is /MDd and cannot share.
@{ Name = 'minsizerel builds against the release deps'; Args = @('-d', '-s', '--config', 'minsizerel')
Contains = @('cmake -S deps -B "deps/build"', 'cmake -B "build-minsize" ')
NotContains = @('deps/build-minsize') }
@{ Name = 'relwithdebinfo builds against them too'; Args = @('-d', '-s', '-l', '--config', 'relwithdebinfo')
Contains = @('cmake -S deps -B "deps/build-clang"', 'cmake -B "build-dbginfo-clang" ')
NotContains = @('deps/build-dbginfo') }
@{ Name = 'debug keeps a dependency tree of its own'; Args = @('-d', '--config', 'debug')
Contains = @('cmake -S deps -B "deps/build-dbg"') }
# The build tree and the deps tree now have different names, so the
# derivation from the binary directory name cannot work and it is named.
@{ Name = 'a shared deps tree is named outright'; Args = @('-s', '-l', '--config', 'relwithdebinfo')
Match = @('-DDEP_BUILD_DIR="[A-Za-z]:\\.*\\deps\\build-clang"') }
@{ Name = 'configuration and arch combine into build-dbg-arm64'; Args = @('-s', '--config', 'debug', '--arch', 'arm64')
Contains = @('cmake -B "build-dbg-arm64" ') }
'what gets built'
@{ Name = 'the deps target defaults to deps'; Args = @('-d')
Contains = @('--target deps') }
@{ Name = '-t overrides the deps target'; Args = @('-d', '-t', 'dep_Boost')
Contains = @('--target dep_Boost') }
@{ Name = 'unit tests are off unless asked for'; Args = @('-s')
Contains = @('-DBUILD_TESTS=OFF') }
@{ Name = '--tests turns the unit tests on'; Args = @('-s', '--tests')
Contains = @('-DBUILD_TESTS=ON') }
@{ Name = '-a enables ASAN for the slicer'; Args = @('-s', '-a')
Contains = @('-DSLIC3R_ASAN=ON') }
@{ Name = '--no-pch turns the precompiled header off'; Args = @('-s', '--no-pch')
Contains = @('-DSLIC3R_PCH=OFF') }
@{ Name = '--no-pch says so in the banner'; Args = @('-s', '--no-pch')
Contains = @('Precompiled header: off') }
@{ Name = 'the precompiled header is on unless asked'; Args = @('-s')
NotContains = @('SLIC3R_PCH') }
@{ Name = '--no-pch works without a cache'; Args = @('-s', '--no-pch')
NotContains = @('COMPILER_LAUNCHER') }
@{ Name = 'the slicer build runs gettext'; Args = @('-s')
Contains = @('run_gettext.bat') }
# tools\7z.exe needs a 7z.dll beside it, which the repo does not carry,
# so the pack falls back to the bsdtar Windows ships. Either is correct;
# what matters is that one is chosen and handed the right names.
@{ Name = '-p packs the deps tree with whichever archiver is usable'; Args = @('-d', '-p')
Match = @('^\+ .*(7z\.exe a|tar\.exe -a -c -f) OrcaSlicer_dep_win-\S+\.zip OrcaSlicer_dep$') }
# The bundle is only good for what built it, so it carries the same three
# axes as the tree. Release x64 on cl keeps the historical plain name.
@{ Name = 'the bundle name carries compiler and deps flavour'; Args = @('-p', '-l', '--config', 'debug', '--arch', 'arm64')
Contains = @('OrcaSlicer_dep_win-ARM64-clang-dbg_') }
@{ Name = 'a relwithdebinfo pack is the release bundle'; Args = @('-p', '--config', 'relwithdebinfo')
Contains = @('OrcaSlicer_dep_win-x64_')
NotContains = @('-dbg', 'dbginfo') }
@{ Name = 'a plain release bundle keeps its old name'; Args = @('-p')
Contains = @('OrcaSlicer_dep_win-x64_')
NotContains = @('-clang', '-Release') }
@{ Name = 'the bundle is stamped with today, not a shuffled date'; Args = @('-p')
DateStampedZip = $true }
# powershell.exe is not in System32 itself, so a trimmed PATH used to
# leave the stamp empty and the bundle named OrcaSlicer_dep_win-x64_.zip.
@{ Name = 'the bundle is stamped even with a bare PATH'; Args = @('-p')
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
DateStampedZip = $true }
@{ Name = '-p packs without rebuilding'; Args = @('-p')
Match = @('^\+ .*(7z\.exe a|tar\.exe -a -c -f) ')
NotContains = @('cmake -S deps') }
@{ Name = 'deps and slicer build in one invocation'; Args = @('-d', '-s', '-x', '-l')
Contains = @('cmake -S deps', 'cmake -B "build-clang" ') }
'the compiler cache'
@{ Name = '--cache needs clang-cl and Ninja'; Args = @('-s', '--cache', 'ccache'); ExpectExit = 1
Contains = @('needs clang-cl and Ninja') }
# cl.exe is out of scope, since ccache refuses every compile under /Zi.
@{ Name = '--cache under Ninja still needs clang-cl'; Args = @('-s', '-x', '--cache', 'ccache'); ExpectExit = 1
Contains = @('needs clang-cl and Ninja') }
@{ Name = 'an unknown --cache value is rejected'; Args = @('-s', '-x', '--cache', 'nope'); ExpectExit = 1
Contains = @('Expected ccache, sccache or off') }
# A bare PATH, since the machine running the tests may have sccache installed.
@{ Name = 'a --cache tool that is not there is caught early'; Args = @('-s', '-l', '-x', '--cache', 'sccache'); ExpectExit = 1
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('is not on PATH') }
@{ Name = '--cache takes any casing'; Args = @('-s', '-l', '-x', '--cache', 'CCACHE')
Env = @{ PATH = $ccacheOnPath }
Contains = @('ccache.exe') }
@{ Name = '--cache off asks for no launcher'; Args = @('-s', '-x', '--cache', 'off')
NotContains = @('COMPILER_LAUNCHER') }
@{ Name = 'no --cache asks for no launcher'; Args = @('-s', '-x')
NotContains = @('COMPILER_LAUNCHER') }
@{ Name = '--cache turns the precompiled header off'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Contains = @('-DSLIC3R_PCH=OFF', 'COMPILER_LAUNCHER') }
# Without it the objects name the build directory and only that tree can use them.
@{ Name = '--cache asks for relative debug paths'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Contains = @('-DSLIC3R_RELATIVE_DEBUG_PATHS=ON') }
@{ Name = 'no --cache leaves the debug paths alone'; Args = @('-s', '-l', '-x')
NotContains = @('SLIC3R_RELATIVE_DEBUG_PATHS') }
# The resolved path, not the bare name, so PATH cannot change it later.
@{ Name = '--cache names the resolved path in the banner'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Match = @('^Compiler cache: .*/ccache\.exe$') }
@{ Name = '--cache reaches the dependency configure too'; Args = @('-d', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Contains = @('-DCMAKE_C_COMPILER_LAUNCHER=') }
# Nothing records a launcher without a configure, so the tool is not needed.
# Reaching the cmake check on a bare PATH is what proves it was skipped.
@{ Name = '--no-configure asks for no cache tool'; Args = @('-s', '-l', '-x', '--no-configure', '--cache', 'ccache'); ExpectExit = 1
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('CMake was not found')
NotContains = @('is not on PATH') }
'the developer loop'
@{ Name = '--slicer-target builds one target'; Args = @('-s', '--slicer-target', 'libslic3r')
Contains = @('--config Release --target libslic3r') }
@{ Name = '--no-configure skips the slicer configure'; Args = @('-s', '--no-configure')
Contains = @('cmake --build "build"')
NotContains = @('cmake -B "build" ') }
@{ Name = '--no-configure skips the deps configure'; Args = @('-d', '--no-configure')
Contains = @('cmake --build "deps/build"')
NotContains = @('cmake -S deps') }
@{ Name = '--no-gettext skips the translation step'; Args = @('-s', '--no-gettext')
Contains = @('cmake --build "build"')
NotContains = @('run_gettext') }
# Installing copies the whole tree again for a layout only releases need,
# so it is asked for rather than assumed.
@{ Name = 'nothing is installed unless asked'; Args = @('-s')
Contains = @('run_gettext')
NotContains = @('--target install') }
@{ Name = '-i adds the install step'; Args = @('-s', '-i')
Contains = @('--target install') }
# install(TARGETS OrcaSlicer) has no OPTIONAL, so this would fail partway
# through a build instead of before it.
@{ Name = 'installing a tree with no executable is refused'; Args = @('-s', '-i', '--slicer-target', 'glad'); ExpectExit = 1
Contains = @('--install needs the executable')
NotContains = @('cmake --build') }
# Without -s there is no install step, so there is nothing to refuse.
@{ Name = 'the same flags without a slicer build are left alone'; Args = @('-d', '-i', '--slicer-target', 'glad')
Contains = @('cmake -S deps')
NotContains = @('--install needs the executable') }
@{ Name = 'naming the executable target is allowed'; Args = @('-s', '-i', '--slicer-target', 'OrcaSlicer')
Contains = @('--target install') }
# Ninja counts compilers, so -j goes on the command line. MSBuild's -j
# counts projects while /MP still runs one cl per core inside each, so the
# cap goes to CL_MPCount in the environment instead. A dry run can only
# show that no -j is passed.
@{ Name = '-j on Ninja passes -j to cmake'; Args = @('-s', '-x', '-j', '4')
Contains = @('Parallel jobs: 4', '--config Release -j 4') }
@{ Name = '-j on Ninja reaches the deps build'; Args = @('-d', '-x', '-j', '2')
Contains = @('--target deps -j 2') }
@{ Name = '-j on MSBuild does not pass -j, which would count projects'; Args = @('-s', '-j', '4')
Contains = @('Parallel jobs: 4')
NotContains = @('-j 4') }
@{ Name = '-j on MSBuild leaves the deps build without -j too'; Args = @('-d', '-j', '2')
Contains = @('--target deps')
NotContains = @('-j 2') }
@{ Name = 'no -j means no job limit'; Args = @('-s')
NotContains = @('parallel jobs', '-j ') }
@{ Name = 'a non-numeric -j is rejected'; Args = @('-s', '-j', 'abc'); ExpectExit = 1
Contains = @('Invalid --jobs value') }
@{ Name = 'a zero -j is rejected'; Args = @('-s', '-j', '0'); ExpectExit = 1
Contains = @('Invalid --jobs value') }
@{ Name = 'the loop options combine into a single build command'; Args = @('-s', '-x', '--no-configure', '--no-gettext', '--slicer-target', 'libslic3r_tests', '-j', '8')
Contains = @('--target libslic3r_tests -j 8')
NotContains = @('cmake -B "build" ', 'run_gettext', '--target install') }
'one tree per configuration, compiler and architecture'
# CMake resets its cache and carries on when the compiler changes under an
# existing tree, leaving stamps from the old toolchain. Give each its own.
@{ Name = 'clang builds land in their own trees'; Args = @('-d', '-s', '-l')
Contains = @('cmake -S deps -B "deps/build-clang"', 'cmake -B "build-clang" ') }
@{ Name = 'MSVC keeps the historical plain names'; Args = @('-d', '-s', '--msvc')
Contains = @('cmake -S deps -B "deps/build"', 'cmake -B "build" ')
NotContains = @('build-clang') }
@{ Name = 'configuration, compiler and arch all name the tree'; Args = @('-d', '-s', '-l', '--config', 'debug', '--arch', 'arm64')
Contains = @('cmake -S deps -B "deps/build-dbg-clang-arm64"', 'cmake -B "build-dbg-clang-arm64" ') }
'locating the dependency tree'
# Without --deps-dir nothing is passed, so CMakeLists derives the path from
# the binary directory name as it always has.
@{ Name = 'the deps path is left to CMake by default'; Args = @('-s')
NotContains = @('-DDEP_BUILD_DIR') }
@{ Name = '--deps-dir tells the slicer where the deps are'; Args = @('-s', '--deps-dir', 'D:\orca-deps')
Contains = @('-DDEP_BUILD_DIR="D:\orca-deps"') }
@{ Name = '--deps-dir also redirects the deps build'; Args = @('-d', '--deps-dir', 'D:\orca-deps')
Contains = @('cmake -S deps -B "D:\orca-deps"', 'cmake --build "D:\orca-deps"')
NotContains = @('deps/build') }
# Paths are quoted throughout, so one with spaces survives the whole run.
@{ Name = 'a deps path with spaces survives'; Args = @('-d', '-s', '--deps-dir', 'C:\Program Files\deps')
Contains = @('-B "C:\Program Files\deps"', '-DDEP_BUILD_DIR="C:\Program Files\deps"') }
@{ Name = '--deps-dir also redirects the pack'; Args = @('-p', '--deps-dir', 'D:\orca-deps')
Contains = @('cd /d "D:\orca-deps"') }
@{ Name = 'pack uses an absolute default path'; Args = @('-p')
Match = @('^\+ cd /d "[A-Za-z]:\\.*\\deps\\build"') }
# CMakeLists derives DEP_BUILD_DIR from the build directory's name, so
# pointing the build elsewhere has to name the deps tree outright.
@{ Name = '--build-dir moves the slicer build'; Args = @('-s', '-l', '-x', '--build-dir', 'out/build/x64-clang')
Contains = @('cmake -B "out/build/x64-clang" ') }
@{ Name = '--build-dir still names the deps tree'; Args = @('-s', '-l', '-x', '--build-dir', 'out/build/x64-clang')
Match = @('-DDEP_BUILD_DIR="[A-Za-z]:\\.*\\deps\\build-clang"') }
@{ Name = '--deps-dir wins over the derived tree'; Args = @('-s', '--build-dir', 'out/build/x64-clang', '--deps-dir', 'D:\orca-deps')
Contains = @('-DDEP_BUILD_DIR="D:\orca-deps"') }
# A value ending in a backslash escapes the closing quote it is spliced
# into, so cmake would receive D:\tree" and swallow the next argument.
@{ Name = 'a trailing backslash is trimmed off a path'; Args = @('-s', '--build-dir', 'D:\tree\')
Contains = @('cmake -B "D:\tree" ') }
@{ Name = 'a build directory with spaces survives'; Args = @('-s', '--build-dir', 'C:\Program Files\tree')
Contains = @('cmake -B "C:\Program Files\tree" ') }
@{ Name = 'the default build names no deps tree'; Args = @('-s')
NotContains = @('DEP_BUILD_DIR') }
'saying what mode and toolchain are in play'
@{ Name = 'a dry run announces itself before the first command'; Args = @('-u')
First = '^Dry run: printing commands without running them\.$' }
@{ Name = 'the announcement leads even a full build'; Args = @('-ds')
First = '^Dry run: ' }
# Autodetect only runs for the Visual Studio generator, and only when no
# release was pinned, so it needs a Visual Studio on the machine.
@{ Name = 'the detected Visual Studio names its release year'; Args = @('-s')
Match = @('^Detected Visual Studio \d+ \(20\d\d\)$') }
@{ Name = 'pinning a release skips detection'; Args = @('-s', '--vs', '2022')
NotContains = @('Detected Visual Studio') }
'an action has to be asked for'
# Neither can happen without building the slicer, so they stand alone the
# way --install-vs does.
@{ Name = '--run-tests is an action on its own'; Args = @('--run-tests')
Contains = @('-DBUILD_TESTS=ON', 'ctest --test-dir')
NotContains = @('Nothing to do') }
@{ Name = '--tests is too'; Args = @('--tests')
Contains = @('-DBUILD_TESTS=ON')
NotContains = @('Nothing to do', 'ctest --test-dir') }
# Naming an action means that action, not a fuller build.
@{ Name = 'they do not add a slicer build to one already asked for'; Args = @('-d', '--tests')
Contains = @('cmake -S deps')
NotContains = @('cmake -B "build"') }
@{ Name = 'shaping options alone are not an action'; Args = @('--config', 'debug'); ExpectExit = 1
Contains = @('Nothing to do.')
NotContains = @('Build completed') }
@{ Name = '-j alone is not an action either'; Args = @('-j', '4'); ExpectExit = 1
Contains = @('Nothing to do.') }
@{ Name = '--install-vs counts as an action on its own'; Args = @('--install-vs', 'ide')
NotContains = @('Nothing to do.') }
'diagnostics'
@{ Name = '-v asks cmake for the command lines'; Args = @('-s', '-v')
Contains = @('--verbose') }
@{ Name = '-v applies to the deps build too'; Args = @('-d', '-v')
Contains = @('--target deps', '--verbose') }
# ninja's own default shows neither a percentage nor a time. %w and %W
# need ninja 1.12, and an unknown placeholder is fatal, so the format is
# chosen from the version rather than hardcoded.
@{ Name = '-v names the ninja progress format'; Args = @('-s', '-x', '-v')
Match = @('^Ninja progress format: \[.*%p.*\]') }
@{ Name = 'a ninja older than 1.12 gets the format it understands'; Args = @('-s', '-x', '-v')
Env = @{ PATH = $ninjaPaths['old'] }
Contains = @('Ninja progress format: [%s/%t %p :: %e]') }
@{ Name = 'ninja 1.12 gets elapsed and remaining'; Args = @('-s', '-x', '-v')
Env = @{ PATH = $ninjaPaths['new'] }
Contains = @('Ninja progress format: [%f/%t %p :: %w / %W]') }
@{ Name = 'a format you set yourself is left alone'; Args = @('-s', '-x', '-v')
Env = @{ NINJA_STATUS = '[mine] ' }
Contains = @('Ninja progress format: [mine]') }
@{ Name = 'MSBuild builds mention no ninja format'; Args = @('-s', '-v')
NotContains = @('Ninja progress format') }
@{ Name = 'builds are quiet without -v'; Args = @('-s')
NotContains = @('--verbose') }
'installing prerequisites'
# -u installs CMake, Perl and Git. Visual Studio is a separate ask, because
# most people already have it, and which one you want is a real choice.
@{ Name = '-u alone installs no Visual Studio'; Args = @('-u')
Contains = @('Kitware.CMake', 'StrawberryPerl', 'Git.Git')
NotContains = @('Microsoft.VisualStudio') }
# Asking for Visual Studio is asking to install prerequisites, so it does
# not also need -u; requiring both meant --install-vs alone did nothing.
@{ Name = '--install-vs works without -u'; Args = @('--install-vs', 'ide')
Contains = @('id=Microsoft.VisualStudio.Community', 'Kitware.CMake', 'Git.Git') }
@{ Name = '--install-vs buildtools asks for the build tools'; Args = @('-u', '--install-vs', 'buildtools')
Contains = @('id=Microsoft.VisualStudio.BuildTools')
NotContains = @('VC.CoreIde') }
@{ Name = '--install-vs ide asks for Community with the IDE component'; Args = @('-u', '--install-vs', 'ide')
Contains = @('id=Microsoft.VisualStudio.Community', 'VC.CoreIde') }
@{ Name = 'an unknown edition is rejected and the known ones listed'; Args = @('-u', '--install-vs', 'bogus'); ExpectExit = 1
Contains = @('Unknown Visual Studio edition', 'buildtools, ide') }
@{ Name = '--vs picks which release to install'; Args = @('-u', '--vs', '2022', '--install-vs', 'buildtools')
Contains = @('id=Microsoft.VisualStudio.2022.BuildTools') }
@{ Name = '-l adds the clang compiler and the MSBuild toolset'; Args = @('-u', '--install-vs', 'buildtools', '-l')
Contains = @('VC.Llvm.Clang ', 'VC.Llvm.ClangToolset') }
# CMake 4.x breaks Boost.Context on ARM64, so the installer pins 3.31 there
# and leaves x64 on the current release.
@{ Name = 'CMake is pinned to 3.31 when installing for arm64'; Args = @('-u', '--arch', 'arm64')
Contains = @('Kitware.CMake --version 3.31.8') }
@{ Name = 'CMake is not pinned for x64'; Args = @('-u', '--arch', 'x64')
Contains = @('Kitware.CMake')
NotContains = @('--version') }
@{ Name = 'installing for arm64 asks for the ARM64 toolset'; Args = @('--install-vs', 'buildtools', '--arch', 'arm64')
Contains = @('Microsoft.VisualStudio.Component.VC.Tools.ARM64') }
@{ Name = 'an x64 install asks only for the x64 toolset'; Args = @('--install-vs', 'buildtools')
Contains = @('Microsoft.VisualStudio.Component.VC.Tools.x86.x64')
NotContains = @('VC.Tools.ARM64') }
'dry run changes nothing'
# clean_tree resolves the path before removing it, so the line echoed is
# absolute. It is the last thing printed before a directory goes.
@{ Name = '-c echoes the deps rmdir rather than running it'; Args = @('-d', '-c')
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\.*\\deps\\build"$') }
@{ Name = '-c removes the tree --deps-dir named, not the default one'; Args = @('-d', '-c', '--deps-dir', 'D:\orca-deps')
Match = @('^\+ rmdir /S /Q "D:\\orca-deps"$')
NotContains = @('\deps\build') }
@{ Name = '-c echoes the slicer rmdir rather than running it'; Args = @('-s', '-c')
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\.*\\build"$') }
@{ Name = 'cleaning a slicer build leaves the deps tree alone'; Args = @('-s', '-c')
NotMatch = @('rmdir.*\\deps\\') }
@{ Name = 'cleaning both builds removes both trees'; Args = @('-d', '-s', '-c')
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\[^"]*\\deps\\build"$',
'^\+ rmdir /S /Q "[A-Za-z]:\\(?!.*\\deps\\)[^"]*\\build"$') }
# Nothing below should be reachable. They are a floor under a bug that
# hands clean_tree a path far shorter than it looks.
@{ Name = 'a configuration whose lookup comes back empty is rejected'; Args = @('-d', '-c', '--config', 'release '); ExpectExit = 1
Contains = @('Unknown configuration')
NotContains = @('rmdir') }
@{ Name = 'a drive root is refused'; Args = @('-d', '-c', '--deps-dir', 'D:\'); ExpectExit = 1
Contains = @('that is a drive root')
NotContains = @('+ rmdir') }
@{ Name = 'the repository itself is refused'; Args = @('-d', '-c', '--deps-dir', '.'); ExpectExit = 1
Contains = @('that is the repository itself')
NotContains = @('+ rmdir') }
@{ Name = '-k echoes the taskkills rather than running them'; Args = @('-k')
Contains = @('+ taskkill /F /IM MSBuild.exe', '+ taskkill /F /IM cl.exe') }
@{ Name = '-k also covers the Ninja toolchain'; Args = @('-k')
Contains = @('+ taskkill /F /IM ninja.exe', '+ taskkill /F /IM clang-cl.exe') }
@{ Name = '-k announces the dry run like every other action'; Args = @('-k')
First = '^Dry run: ' }
@{ Name = '-u echoes the winget installs rather than running them'; Args = @('-u')
Contains = @('+ winget install', 'Kitware.CMake')
NotContains = @('cmake -S deps') }
# Not a dry run: winget is a stub that fails, so nothing is installed.
@{ Name = 'a failed install is reported, not claimed as success'; Args = @('-u')
DryRun = $false; ExpectExit = 1
Env = @{ PATH = $stubPath }
Contains = @('Failed to install:', 'CMake', 'Perl', 'Git')
NotContains = @('Installed the prerequisites') }
# The reason belongs inside the frame. Every command this path ran had
# already succeeded, so naming the last one would point at the wrong thing.
@{ Name = 'a failed install names the reason, not the last command'; Args = @('-u')
DryRun = $false; ExpectExit = 1
Env = @{ PATH = $stubPath }
Contains = @('####', 'Failed to install:')
NotContains = @('Failed: winget') }
@{ Name = 'a dry run does not claim the install happened'; Args = @('-u')
Contains = @('Dry run: nothing was installed.')
NotContains = @('are in place') }
@{ Name = '-u with a build defers that build, not a fuller one'; Args = @('-u', '-d')
Contains = @('build_win.bat -d')
NotContains = @('build_win.bat -ds') }
@{ Name = '-u on its own suggests the whole build'; Args = @('-u')
Contains = @('build_win.bat -ds') }
@{ Name = 'a dry run only echoes, it never configures'; Args = @('-d')
Contains = @('+ cmake')
NotContains = @('CMake Error', 'Configuring done') }
'extra configure arguments'
# --deps-args and --slicer-args are declared "rawstring", so a value that
# looks like an option is allowed through. Plain string options still
# reject one, since there it almost always means a forgotten value.
@{ Name = '--deps-args reaches the deps configure'; Args = @('-d', '--deps-args', 'FOO')
Contains = @('-DCMAKE_BUILD_TYPE=Release FOO') }
@{ Name = '--slicer-args reaches the slicer configure'; Args = @('-s', '--slicer-args', 'BAZ')
Contains = @('BAZ') }
@{ Name = '--deps-args accepts a value that starts with a dash'; Args = @('-d', '--deps-args', '-DFOO')
Contains = @('-DFOO') }
@{ Name = 'a plain string option still rejects a dash-leading value'; Args = @('-d', '--deps-target', '--tests'); ExpectExit = 1
Contains = @('looks like another option') }
# cmd splits arguments on "=" as well as spaces, so an unquoted -D reaches
# the script as two arguments however it was invoked.
@{ Name = 'an unquoted value containing = is rejected'; Args = @('-d', '--deps-args', 'FOO=BAR'); ExpectExit = 1
Contains = @('Unknown argument') }
# The environment overrides exist for that reason; nothing tokenises them.
@{ Name = 'ORCA_DEPS_CMAKE_ARGS reaches the deps configure'; Args = @('-d')
Env = @{ ORCA_DEPS_CMAKE_ARGS = '-DFOO=BAR -DBAZ=QUX' }
Contains = @('-DFOO=BAR -DBAZ=QUX') }
@{ Name = 'ORCA_SLICER_CMAKE_ARGS reaches the slicer configure'; Args = @('-s')
Env = @{ ORCA_SLICER_CMAKE_ARGS = '-DWANTED=1' }
Contains = @('-DWANTED=1') }
@{ Name = 'the deps override does not leak into the slicer configure'; Args = @('-s')
Env = @{ ORCA_DEPS_CMAKE_ARGS = '-DDEPSONLY=1' }
NotContains = @('-DDEPSONLY=1') }
@{ Name = 'the help points at the environment for a spaced argument'; Args = @('--help'); DryRun = $false
Contains = @('Neither form supports a value containing an ampersand') }
@{ Name = 'the help lists the environment overrides'; Args = @('--help'); DryRun = $false
Contains = @('Environment:', 'ORCA_DEPS_CMAKE_ARGS', 'ORCA_SLICER_CMAKE_ARGS') }
'running the unit tests'
@{ Name = '--tests builds them without running them'; Args = @('-s', '--tests')
Contains = @('-DBUILD_TESTS=ON')
NotContains = @('ctest') }
@{ Name = '--run-tests builds and runs them'; Args = @('-s', '--run-tests')
Contains = @('-DBUILD_TESTS=ON', 'ctest --test-dir "build/tests" -C Release --output-on-failure') }
@{ Name = '--run-tests follows the build type and directory'; Args = @('-s', '--run-tests', '--config', 'debug')
Contains = @('ctest --test-dir "build-dbg/tests" -C Debug') }
@{ Name = 'no tests are run by default'; Args = @('-s')
NotContains = @('ctest') }
'failures are reported'
@{ Name = 'a missing cmake is caught and exits non-zero'; Args = @('-d'); ExpectExit = 1
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('CMake was not found') }
@{ Name = 'packing does not need cmake, only an archiver'; Args = @('-p')
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
NotContains = @('CMake was not found') }
# Not a dry run: a cd to a missing drive is a real failure inside a
# parenthesised block, which is where exit /b silently loses its code.
# Without the jump to :die this exits 0 and a failed build reads as a
# successful one.
@{ Name = 'a failure inside a build block reaches the caller'; Args = @('-p', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('Exit code 1.', '####')
NotContains = @('Build completed', 'Try') }
# The retry follows the stage that failed. Offering it for the whole run
# would clean a dependency tree that was not at fault.
@{ Name = 'a failure names a retry scoped to the stage that failed'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
NotContains = @('build_win.bat -ds') }
# CMake's own failure here is hundreds of lines about package resolution.
@{ Name = 'a missing dependency tree is named, not left to CMake'; Args = @('-s', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('Dependencies not found at', 'Build them with build_win.bat -d --deps-dir "Z:\nope"')
NotContains = @('cmake -B', 'Try') }
# Every other suggestion carries the flags that reproduce the run; a bare
# -d would point at the MSVC tree after a clang build.
@{ Name = 'the missing-deps hint names this toolchain'; Args = @('-s', '-l', '-x', '--deps-dir', 'deps/not-built')
DryRun = $false; ExpectExit = 1
Contains = @('Build them with build_win.bat -d -l -x --deps-dir "deps/not-built"')
NotExists = @('deps/not-built') }
# A dry run configures nothing, so it must not depend on which trees happen
# to exist on the machine running the suite.
@{ Name = 'a dry run does not check for the deps tree'; Args = @('-s', '--deps-dir', 'Z:\nope')
Contains = @('cmake -B "build"')
NotContains = @('Dependencies not found') }
# -d is about to build them, so there is nothing to report yet.
@{ Name = 'building the deps in the same run skips the check'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
NotContains = @('Dependencies not found') }
# A configure fails before any compiler runs, so -v has nothing to show.
@{ Name = 'a configure failure is not offered a verbose rebuild'; Args = @('-d', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('-c discard that tree')
NotContains = @('-v show the failing') }
# A bare --no-configure succeeds on a machine that already has a usable
# build tree, so name one that cannot exist instead.
@{ Name = 'a build failure is'; Args = @('-s', '--no-configure', '--build-dir', 'deps/no-such-tree')
DryRun = $false; ExpectExit = 1
Contains = @('-v show the failing')
NotExists = @('deps/no-such-tree') }
# --build-dir names the slicer tree, which a deps failure has nothing to do
# with. --deps-dir stays, because that is the tree that failed.
@{ Name = 'a deps retry leaves out the slicer tree'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope', '--build-dir', 'D:\b')
DryRun = $false; ExpectExit = 1
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
NotContains = @('--build-dir') }
@{ Name = 'an unknown configuration stays a single line'; Args = @('-s', '--config', 'bogus'); ExpectExit = 1
Contains = @('Unknown configuration')
NotContains = @('####', 'Try') }
@{ Name = 'a bad --jobs value is not framed either'; Args = @('-s', '-j', 'x'); ExpectExit = 1
Contains = @('Invalid --jobs value')
NotContains = @('####') }
'the summary says what to do next'
# Every suggested command carries the flags that reproduce this run.
@{ Name = 'a deps build points at the slicer build'; Args = @('-d', '-l')
Contains = @('Build the slicer build_win.bat -s -l')
NotContains = @('Run it') }
@{ Name = 'a ninja slicer build offers a single target'; Args = @('-s', '-l', '-x')
Contains = @('Rebuild after edits build_win.bat -s -l -x --no-configure', 'Rebuild one target')
NotContains = @('Solution', 'Open in Visual Studio') }
@{ Name = 'a visual studio build names the solution instead'; Args = @('-s')
Contains = @('Solution ', 'Open in Visual Studio build\OrcaSlicer.sln',
'Rebuild after edits build_win.bat -s --no-configure')
NotContains = @('Rebuild one target') }
@{ Name = 'the configuration and architecture come back'; Args = @('-s', '-l', '-x', '--config', 'debug', '--arch', 'arm64')
Contains = @('build_win.bat -s -l -x --config debug --arch arm64 --no-configure') }
@{ Name = 'the tree overrides come back quoted'; Args = @('-s', '--deps-dir', 'D:\d', '--build-dir', 'D:\b')
Contains = @('--deps-dir "D:\d" --build-dir "D:\b"') }
@{ Name = 'a pinned visual studio release comes back'; Args = @('-s', '--vs', '2022')
Contains = @('build_win.bat -s --vs 2022 --no-configure') }
# Autodetection writes what it found into the same variable, so a detected
# release must not come back as though it had been asked for.
@{ Name = 'a detected release does not'; Args = @('-s')
NotContains = @('--vs') }
@{ Name = 'the binary is named in the build tree it was built in'; Args = @('-s', '-l', '-x')
Contains = @('build-clang\src\Release\orca-slicer.exe') }
@{ Name = 'installing names the installed copy instead'; Args = @('-s', '-l', '-x', '-i')
Contains = @('build-clang\OrcaSlicer\orca-slicer.exe') }
# -i changes where the binary lands, so a rebuild that dropped it would
# leave the path above pointing at a stale copy.
@{ Name = 'the rebuild suggestion keeps -i'; Args = @('-s', '-l', '-x', '-i')
Contains = @('Rebuild after edits build_win.bat -s -l -x -i --no-configure') }
# A deps retry has no install step to repeat.
@{ Name = 'a deps retry drops it'; Args = @('-d', '-s', '-i', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
NotContains = @('-d -i') }
# Naming a target builds it and its dependencies, not its dependents, so
# the binary on disk is whatever the last full build left there.
@{ Name = 'a single-target build does not claim the whole binary'; Args = @('-s', '-l', '-x', '--slicer-target', 'glad')
Contains = @('Target glad', 'Relink the binary build_win.bat -s -l -x --no-configure')
NotContains = @('Run it', 'orca-slicer.exe', 'Rebuild after edits') }
# The executable has a target of its own, and naming that one does relink.
@{ Name = 'naming the executable target still claims the binary'; Args = @('-s', '-l', '-x', '--slicer-target', 'OrcaSlicer')
Contains = @('Run it', 'orca-slicer.exe', 'Rebuild after edits')
NotContains = @('Target OrcaSlicer', 'Relink the binary') }
@{ Name = '--run-tests offers the ctest line'; Args = @('-s', '-l', '-x', '--run-tests')
Contains = @('Re-run the tests ctest --test-dir build-clang/tests -C Release') }
@{ Name = 'packing names the bundle and how to use it'; Args = @('-p', '-l')
Contains = @('Bundle ', 'Share the bundle') }
# The line supplies its own tree, so the one this run used must not ride
# along and contradict it.
@{ Name = 'the bundle line names one tree, not two'; Args = @('-s', '-p', '-l', '-x', '--deps-dir', 'D:\shared')
Contains = @('then build_win.bat -s -l -x --deps-dir <path>')
NotContains = @('--deps-dir "D:\shared" --deps-dir') }
@{ Name = 'installing prerequisites suggests the build that follows'; Args = @('-u', '-l')
Contains = @('Restart this shell', 'build_win.bat -ds -l')
NotContains = @('Run it') }
# Everything below the header line is worked out the same way in either
# run, which is why a dry run can cover it.
@{ Name = 'a dry run does not claim a build happened'; Args = @('-s', '-l', '-x')
Contains = @('Dry run: nothing was built.')
NotContains = @('Build completed in') }
# --no-configure is the iteration loop and still gets the block; four
# lines after a rebuild is not enough to be worth suppressing.
@{ Name = '--no-configure still gets the summary'; Args = @('-s', '-l', '-x', '--no-configure')
Contains = @('Next', 'Rebuild after edits') }
'pointing at the solution'
# The extension follows the generator, so these two pin the release and a
# build directory that cannot already hold a solution of either kind.
@{ Name = 'the 2026 generator gets the XML solution'; Args = @('-s', '--vs', '2026', '--build-dir', 'D:\tree')
Contains = @('Solution D:\tree\OrcaSlicer.slnx', 'Open in Visual Studio D:\tree\OrcaSlicer.slnx') }
@{ Name = 'the releases before it get the classic one'; Args = @('-s', '--vs', '2022', '--build-dir', 'D:\tree')
Contains = @('Solution D:\tree\OrcaSlicer.sln', 'Open in Visual Studio D:\tree\OrcaSlicer.sln') }
@{ Name = 'a solution already on disk wins over the generator'; Args = @('-s', '--vs', '2026', '--build-dir', $slnDir)
Match = @('^ Solution .*\\OrcaSlicer\.sln$') }
# Extension-agnostic from here: these cases are about the directory, and
# the release is whatever is installed.
@{ Name = 'the VS generator says where the solution is'; Args = @('-s')
Match = @('^ Solution .*\\build\\OrcaSlicer\.slnx?$') }
@{ Name = 'the solution path follows the configuration'; Args = @('-s', '--config', 'debug')
Match = @('^ Solution .*\\build-dbg\\OrcaSlicer\.slnx?$') }
@{ Name = 'the solution line survives an install'; Args = @('-s', '-i')
Contains = @(' Solution ') }
# The path is resolved, not pasted onto the repository root, so it is
# right whether --build-dir came absolute or with forward slashes.
@{ Name = 'a moved build still prints one real path'; Args = @('-s', '--build-dir', 'out/build/x64-clang')
Match = @('^ Solution [A-Za-z]:\\[^/]+\\OrcaSlicer\.slnx?$') }
@{ Name = 'an absolute --build-dir is not glued onto the repo root'; Args = @('-s', '--build-dir', 'D:\tree')
Match = @('^ Solution D:\\tree\\OrcaSlicer\.slnx?$') }
)
function Invoke-BuildScript {
param([string[]] $Arguments, [hashtable] $Environment)
$saved = @{}
if ($Environment) {
foreach ($key in $Environment.Keys) {
$saved[$key] = [Environment]::GetEnvironmentVariable($key)
Set-Item -Path "env:$key" -Value $Environment[$key]
}
}
try {
# 'Stop' turns a native command's stderr into a terminating error, and
# a case that exercises a real failure writes to stderr. Let the output
# through and judge the run by its exit code instead. The assignment is
# scoped to this function, so the rest of the suite keeps 'Stop'.
$ErrorActionPreference = 'Continue'
if ($Arguments.Count -eq 0) {
$out = & $Script 2>&1 | Out-String
} else {
$out = & $Script @Arguments 2>&1 | Out-String
}
return [pscustomobject]@{ Output = $out; Exit = $LASTEXITCODE }
} finally {
foreach ($key in $saved.Keys) {
if ($null -eq $saved[$key]) { Remove-Item -Path "env:$key" -ErrorAction SilentlyContinue }
else { Set-Item -Path "env:$key" -Value $saved[$key] }
}
}
}
$knownFields = @(
'Name', 'Args', 'ExpectExit', 'DryRun', 'First', 'Env',
'Contains', 'NotContains', 'Match', 'NotMatch', 'NotExists', 'DateStampedZip'
)
function Test-Case {
param([hashtable] $Case)
# Read fields with the indexer, not dot notation. A hashtable exposes its
# own members too, so $Case.Contains returns the Contains *method* whenever
# the case has no key by that name.
$argv = @($Case['Args'])
if (-not $Case.ContainsKey('DryRun') -or $Case['DryRun']) { $argv += '--dry-run' }
$expect = 0
if ($Case.ContainsKey('ExpectExit')) { $expect = $Case['ExpectExit'] }
$started = Get-Date
$result = Invoke-BuildScript -Arguments $argv -Environment $Case['Env']
$finished = Get-Date
$problems = @()
# A misspelled field is silently ignored by the checks below, which
# leaves the case asserting nothing at all and passing.
foreach ($field in $Case.Keys) {
if ($knownFields -notcontains $field) { $problems += "unknown field '$field'" }
}
if ($result.Exit -ne $expect) { $problems += "exit $($result.Exit), expected $expect" }
foreach ($needle in $Case['Contains']) {
if (-not $result.Output.Contains($needle)) { $problems += "missing '$needle'" }
}
foreach ($needle in $Case['NotContains']) {
if ($result.Output.Contains($needle)) { $problems += "unexpected '$needle'" }
}
$lines = $result.Output -split "`r?`n"
if ($Case['First'] -and $lines[0] -notmatch $Case['First']) {
$problems += "first line was '$($lines[0])'"
}
foreach ($pattern in $Case['Match']) {
if (@($lines | Where-Object { $_ -match $pattern }).Count -eq 0) {
$problems += "no line matching /$pattern/"
}
}
if ($Case['DateStampedZip']) {
# Bound the accepted dates to this invocation so crossing midnight is
# valid without allowing an unrelated past or future date.
$dateStamps = @($started.ToString('yyyyMMdd'), $finished.ToString('yyyyMMdd')) | Select-Object -Unique
$pattern = '_(' + ($dateStamps -join '|') + ')\.zip$'
if (@($lines | Where-Object { $_ -match $pattern }).Count -eq 0) {
$problems += "no line matching /$pattern/"
}
}
foreach ($pattern in $Case['NotMatch']) {
foreach ($line in @($lines | Where-Object { $_ -match $pattern })) {
$problems += "line matches /$pattern/: $line"
}
}
# Output cannot show what a run did not create.
foreach ($path in $Case['NotExists']) {
$full = Join-Path (Split-Path -Parent $Script) $path
if (Test-Path $full) {
$problems += "created '$path'"
}
}
return ,$problems
}
$pass = 0
$failed = @()
# Held back so a filtered run does not print headings for groups it skipped.
$heading = $null
foreach ($case in $cases) {
if ($case -is [string]) {
$heading = $case
continue
}
if ($Name -and $case['Name'] -notmatch $Name) { continue }
if ($heading) {
Write-Host ''
Write-Host $heading -ForegroundColor Cyan
$heading = $null
}
$problems = Test-Case -Case $case
if ($problems.Count -eq 0) {
$pass++
Write-Host (' ok ' + $case['Name'])
} else {
$failed += $case['Name']
Write-Host (' FAIL ' + $case['Name']) -ForegroundColor Red
foreach ($problem in $problems) { Write-Host (' ' + $problem) -ForegroundColor Red }
Write-Host (' args: ' + (@($case['Args']) -join ' '))
}
}
Remove-Item -Recurse -Force $fixtures -ErrorAction SilentlyContinue
Write-Host ''
# A pattern that matched nothing has proved nothing, so do not report it as
# a clean run.
if ($Name -and $pass -eq 0 -and $failed.Count -eq 0) {
Write-Host "no case matched /$Name/" -ForegroundColor Red
exit 1
}
Write-Host "$pass passed, $($failed.Count) failed"
if ($failed.Count -gt 0) {
foreach ($name in $failed) { Write-Host " failed: $name" -ForegroundColor Red }
exit 1
}
exit 0
+114 -1
View File
@@ -5,10 +5,15 @@ Inserts/deletes/modifies random lane data in Moonraker database,
then reads back and displays with colored output.
"""
import requests
try:
import requests
except ImportError:
requests = None # only needed for live-printer operations, not --check-ofl-map
import random
import argparse
import json
import os
import re
import time
import sys
@@ -16,6 +21,11 @@ import sys
DEFAULT_HOST = "192.168.88.9"
DEFAULT_PORT = 7125
NAMESPACE = "lane_data"
# Repo-relative paths for the offline generic-map check
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MOONRAKER_AGENT_CPP = os.path.join(REPO_ROOT, "src", "slic3r", "Utils", "MoonrakerPrinterAgent.cpp")
OFL_FILAMENT_DIR = os.path.join(REPO_ROOT, "resources", "profiles", "OrcaFilamentLibrary", "filament")
LANE_KEYS = [f"lane{i}" for i in range(1, 9)] # lane1-lane8
MATERIALS = ["PLA", "ABS", "PETG", "ASA", "ASA Sparkle", "TPU", ""]
@@ -30,6 +40,95 @@ MATERIAL_TEMPS = {
"": {"nozzle": None, "bed": None},
}
def parse_cpp_type_map():
"""Extract the normalized-type -> OFL generic family table from MoonrakerPrinterAgent.cpp.
Reads MoonrakerPrinterAgent::map_filament_type_to_generic_id's type_to_ofl_family
initializer so the check tracks the C++ normalization without a duplicated list.
"""
with open(MOONRAKER_AGENT_CPP, encoding="utf-8") as f:
src = f.read()
m = re.search(r"type_to_ofl_family\s*=\s*\{(.*?)\n\s*\};", src, re.DOTALL)
if not m:
raise RuntimeError(f"type_to_ofl_family table not found in {MOONRAKER_AGENT_CPP}")
pairs = re.findall(r'\{\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\}', m.group(1))
if not pairs:
raise RuntimeError("type_to_ofl_family table parsed empty")
return dict(pairs)
def load_ofl_presets():
"""Map preset name -> parsed JSON for every OrcaFilamentLibrary filament profile."""
presets = {}
for root, _dirs, files in os.walk(OFL_FILAMENT_DIR):
for fn in files:
if not fn.endswith(".json"):
continue
try:
with open(os.path.join(root, fn), encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
continue
name = data.get("name")
if isinstance(name, str) and name:
presets[name] = data
return presets
def resolve_ofl_filament_id(presets, name):
"""Follow the inherits chain (within OFL) until a filament_id is declared."""
seen = set()
while name and name not in seen:
seen.add(name)
preset = presets.get(name)
if preset is None:
return None
fid = preset.get("filament_id")
if fid:
return fid
name = preset.get("inherits")
return None
def check_ofl_generic_map():
"""Assert every material type the C++ normalization handles resolves to a shipped
OrcaFilamentLibrary generic preset carrying a filament_id.
Expectations are derived from the shipped profiles, not pinned id literals, so the
check stays valid across filament_id re-mints.
"""
print("Checking C++ generic-type map against shipped OrcaFilamentLibrary presets...")
try:
type_map = parse_cpp_type_map()
except (OSError, RuntimeError) as e:
print(f" FAIL: {e}")
return False
presets = load_ofl_presets()
if not presets:
print(f" FAIL: no OFL filament profiles found under {OFL_FILAMENT_DIR}")
return False
errors = []
for family in sorted(set(type_map.values())):
preset_name = f"Generic {family} @System"
if preset_name not in presets:
errors.append(f"{preset_name}: no such OFL preset")
continue
if str(presets[preset_name].get("instantiation", "")).lower() != "true":
errors.append(f"{preset_name}: not instantiated — the C++ runtime lookup "
f"only sees presets loaded into the PresetBundle")
continue
fid = resolve_ofl_filament_id(presets, preset_name)
if not fid:
errors.append(f"{preset_name}: no filament_id resolvable through inherits")
continue
aliases = ", ".join(sorted(t for t, fam in type_map.items() if fam == family))
print(f" {fid:10s} {preset_name:32s} <- {aliases}")
if errors:
for e in errors:
print(f" FAIL: {e}")
print(f"OFL generic map check FAILED ({len(errors)} error(s))")
return False
print(f"OFL generic map check passed: {len(type_map)} type aliases, "
f"{len(set(type_map.values()))} OFL generic presets\n")
return True
def test_connection(host, port, api_key=None, verbose=False):
"""Test basic connectivity to Moonraker."""
url = f"http://{host}:{port}/server/info"
@@ -404,11 +503,25 @@ def main():
help="Only read and display current lane data")
parser.add_argument("--load", metavar="FILE",
help="Load lane data from JSON file and overwrite printer lanes")
parser.add_argument("--check-ofl-map", action="store_true",
help="Only run the offline check that the C++ generic-type map "
"resolves against shipped OrcaFilamentLibrary presets")
parser.add_argument("--verbose", "-v", action="store_true",
help="Verbose output for debugging")
args = parser.parse_args()
# Offline check first: the C++ type normalization must resolve against shipped
# OFL presets (no printer needed).
if not check_ofl_generic_map():
return 1
if args.check_ofl_map:
return 0
if requests is None:
print("The 'requests' module is required for live printer operations (pip install requests).")
return 1
print(f"\nConnecting to Moonraker at {args.host}:{args.port}...")
# First test basic connectivity
View File
File diff suppressed because it is too large Load Diff
+942
View File
@@ -0,0 +1,942 @@
#!/usr/bin/env python3
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
normalize, trim, update-index and check commands, and the subcommand dispatch that
reaches them (stdlib unittest, no external deps).
The id halves are covered by test_filament_id.py and test_setting_id.py.
Run from the repo root: python -m unittest discover -s scripts/tests -v
"""
import contextlib
import io
import json
import os
import re
import shutil
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import orca_profile_tool as apt # noqa: E402
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
REAL_PROFILES = os.path.join(REPO_ROOT, "resources", "profiles")
class Tree:
"""A throwaway resources/profiles-shaped directory built one file at a time.
Nothing is written implicitly: index entries are added by index(), so a test
can produce exactly the mismatch it is about (a file no list references, a
list naming a file that is not there, a preset whose name disagrees with the
index).
"""
def __init__(self):
self.dir = tempfile.mkdtemp(prefix="profile_tool_test_")
self.profiles = os.path.join(self.dir, "profiles")
os.makedirs(self.profiles)
def cleanup(self):
shutil.rmtree(self.dir, ignore_errors=True)
def index_path(self, vendor):
return os.path.join(self.profiles, vendor + ".json")
def add_vendor(self, vendor):
for sub in apt.PROFILE_SUBDIRS:
os.makedirs(os.path.join(self.profiles, vendor, sub), exist_ok=True)
if not os.path.exists(self.index_path(vendor)):
self.write_index(vendor, {"name": vendor, "version": "01.00.00.00"})
return self
def write_index(self, vendor, index):
with open(self.index_path(vendor), "w", encoding="utf-8", newline="\n") as f:
json.dump(index, f, indent=4, ensure_ascii=False)
f.write("\n")
def read_index(self, vendor):
with open(self.index_path(vendor), encoding="utf-8-sig") as f:
return json.load(f)
def index(self, vendor, section, name, sub_path):
index = self.read_index(vendor)
index.setdefault(section + "_list", []).append(
{"name": name, "sub_path": sub_path})
self.write_index(vendor, index)
def path(self, vendor, rel):
return os.path.join(self.profiles, vendor, rel.replace("/", os.sep))
def write(self, vendor, rel, data):
"""Write a preset at <vendor>/<rel>; returns its path."""
self.add_vendor(vendor)
path = self.path(vendor, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
f.write("\n")
return path
def write_raw(self, vendor, rel, raw):
self.add_vendor(vendor)
path = self.path(vendor, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(raw)
return path
def read(self, vendor, rel):
with open(self.path(vendor, rel), encoding="utf-8-sig") as f:
return json.load(f)
def raw(self, vendor, rel):
with open(self.path(vendor, rel), "rb") as f:
return f.read()
def bytes_map(self):
"""Every file in the tree -> its bytes, for "nothing was written" asserts."""
out = {}
for root, dirs, files in os.walk(self.profiles):
dirs.sort()
for name in sorted(files):
path = os.path.join(root, name)
with open(path, "rb") as f:
out[os.path.relpath(path, self.profiles)] = f.read()
return out
class TreeCase(unittest.TestCase):
def setUp(self):
self.t = Tree()
self.addCleanup(self.t.cleanup)
def run_command(self, *argv):
"""main() against this tree, capturing stdout."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = apt.main([*argv, "--profiles", self.t.profiles])
return rc, buf.getvalue()
# ---------------------------------------------------------------------------
# normalize
# ---------------------------------------------------------------------------
class TestObsoleteKeys(unittest.TestCase):
def test_obsolete_keys_match_the_loader_ignore_set(self):
path = os.path.join(REPO_ROOT, "src", "libslic3r", "PrintConfig.cpp")
with open(path, encoding="utf-8") as f:
source = f.read()
match = re.search(
r"void PrintConfigDef::handle_legacy\(.*?"
r"static\s+std::set<std::string>\s+ignore\s*=\s*\{(.*?)\};",
source, re.DOTALL)
self.assertIsNotNone(match, "Could not locate the loader's obsolete-key set")
keys = re.sub(r"//[^\n]*|/\*.*?\*/", "", match.group(1), flags=re.DOTALL)
self.assertEqual(apt.OBSOLETE_KEYS, set(re.findall(r'"([^"\n]+)"', keys)))
class TestNormalize(TreeCase):
def test_a_missing_type_is_filled_in_from_the_directory(self):
self.t.write("V", "filament/A.json", {"name": "A"})
self.t.write("V", "process/B.json", {"name": "B"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
self.assertEqual(self.t.read("V", "process/B.json")["type"], "process")
def test_the_machine_folder_splits_on_the_preset_name(self):
# Orca keeps machine models in machine/ next to the nozzle variants that
# are machines; only the name tells them apart.
self.t.write("V", "machine/M.json", {"name": "V Printer"})
self.t.write("V", "machine/N.json", {"name": "V Printer 0.4 nozzle"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.read("V", "machine/M.json")["type"], "machine_model")
self.assertEqual(self.t.read("V", "machine/N.json")["type"], "machine")
def test_dropped_keys_go_and_filament_vectors_are_arrayified(self):
self.t.write("V", "filament/A.json", {
"type": "filament", "name": "A", "version": "1.2.3",
"is_custom_defined": "1", "filament_type": "PLA",
"filament_vendor": "AV", "travel_speed": 200,
"filament_load_time": ["15"], "filament_unload_time": "0"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
data = self.t.read("V", "filament/A.json")
self.assertNotIn("version", data)
self.assertNotIn("is_custom_defined", data)
self.assertNotIn("travel_speed", data) # a process setting, not a filament one
self.assertNotIn("filament_load_time", data)
self.assertNotIn("filament_unload_time", data)
self.assertEqual(data["filament_type"], ["PLA"])
self.assertEqual(data["filament_vendor"], ["AV"])
def test_obsolete_keys_are_removed_from_every_profile_type(self):
for sub in ("filament", "process", "machine"):
with self.subTest(profile_type=sub):
expected = {"type": sub, "name": "A"}
self.t.write("V", f"{sub}/A.json", {
**expected, "silent_mode": "", "adaptive_layer_height": "0",
"anisotropic_surfaces": "1", "filament_load_time": ["0"],
"filament_unload_time": "0"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.read("V", f"{sub}/A.json"), expected)
before = self.t.bytes_map()
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertIn("0 profile(s) normalized", out)
self.assertEqual(self.t.bytes_map(), before)
def test_the_larger_extruder_clearance_wins(self):
# Keeping the smaller one would licence a toolhead collision.
self.t.write("V", "machine/M.json", {
"type": "machine", "name": "M 0.4 nozzle",
"extruder_clearance_radius": "45", "extruder_clearance_max_radius": "68"})
self.t.write("V", "machine/N.json", {
"type": "machine", "name": "N 0.4 nozzle",
"extruder_clearance_radius": "68", "extruder_clearance_max_radius": "45"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
kept = self.t.read("V", "machine/M.json")
self.assertNotIn("extruder_clearance_radius", kept)
self.assertEqual(kept["extruder_clearance_max_radius"], "68")
kept = self.t.read("V", "machine/N.json")
self.assertNotIn("extruder_clearance_max_radius", kept)
self.assertEqual(kept["extruder_clearance_radius"], "68")
def test_a_rewritten_file_leads_with_its_identifying_keys(self):
self.t.write("V", "filament/A.json", {
"filament_cost": [20], "name": "A", "instantiation": "true",
"inherits": "base", "version": "1"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
keys = list(self.t.read("V", "filament/A.json"))
self.assertEqual(keys[:4], ["type", "name", "inherits", "instantiation"])
def test_a_conforming_tree_is_left_byte_identical(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
# These used to be misclassified as obsolete: one is active, the other
# is a legacy alias that still supplies the toolhead clearance on load.
self.t.write("V", "machine/M.json", {
"type": "machine", "name": "M", "extruder_type": ["Direct Drive"],
"extruder_clearance_max_radius": "68", "machine_load_filament_time": "15",
"machine_unload_filament_time": "10"})
self.t.write("V", "process/P.json", {
"type": "process", "name": "P", "travel_speed": "200",
"top_surface_fill_order": "outward"})
before = self.t.bytes_map()
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.bytes_map(), before)
def test_force_rewrites_even_a_conforming_file(self):
self.t.write_raw("V", "filament/A.json",
b'{"name":"A","type":"filament"}')
rc, out = self.run_command("normalize", "--force")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.raw("V", "filament/A.json"),
b'{\n\t"type": "filament",\n\t"name": "A"\n}\n')
def test_dry_run_writes_nothing(self):
self.t.write("V", "filament/A.json", {"name": "A", "bed_temperature": ["60"]})
before = self.t.bytes_map()
rc, out = self.run_command("normalize", "--dry-run")
self.assertEqual(rc, 0, out)
self.assertIn("would be", out)
self.assertEqual(self.t.bytes_map(), before)
def test_profile_type_confines_the_run(self):
self.t.write("V", "filament/A.json", {"name": "A"})
self.t.write("V", "process/B.json", {"name": "B"})
rc, out = self.run_command("normalize", "--profile-type", "filament")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
self.assertNotIn("type", self.t.read("V", "process/B.json"))
def test_an_unreadable_profile_is_reported_not_swallowed(self):
self.t.write_raw("V", "filament/A.json", b"{ not json")
rc, out = self.run_command("normalize")
self.assertEqual(rc, 1, out)
self.assertIn("ERROR", out)
self.assertEqual(self.t.raw("V", "filament/A.json"), b"{ not json")
def test_a_directory_without_an_index_is_not_a_bundle(self):
# resources/profiles also holds non-bundle entries (blacklist.json, the
# untracked user/ directory); only a directory WITH an index is a vendor.
stray = os.path.join(self.t.profiles, "user", "filament")
os.makedirs(stray)
with open(os.path.join(stray, "A.json"), "wb") as f:
f.write(b'{"name": "A"}')
self.t.write("V", "filament/A.json", {"name": "A"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertFalse(os.path.exists(os.path.join(self.t.profiles, "user.json")))
with open(os.path.join(stray, "A.json"), "rb") as f:
self.assertEqual(f.read(), b'{"name": "A"}')
# ---------------------------------------------------------------------------
# trim
# ---------------------------------------------------------------------------
class TestTrim(TreeCase):
def bundle(self):
self.t.write("V", "filament/Listed.json",
{"type": "filament", "name": "Listed"})
self.t.index("V", "filament", "Listed", "filament/Listed.json")
return self.t
def test_an_unindexed_preset_is_removed(self):
self.bundle().write("V", "filament/Orphan.json",
{"type": "filament", "name": "Orphan"})
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
self.assertTrue(os.path.exists(self.t.path("V", "filament/Listed.json")))
self.assertFalse(os.path.exists(self.t.path("V", "filament/Orphan.json")))
def test_a_dotted_sub_path_still_names_its_file(self):
# Index entries are hand-written; "filament/./X.json" is the same file.
self.bundle()
self.t.write("V", "filament/Dotted.json",
{"type": "filament", "name": "Dotted"})
self.t.index("V", "filament", "Dotted", "filament/./Dotted.json")
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
self.assertTrue(os.path.exists(self.t.path("V", "filament/Dotted.json")))
def test_an_unparsable_file_is_kept_and_reported(self):
self.bundle().write_raw("V", "filament/Broken.json", b"{ not json")
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
self.assertIn("WARNING", out)
self.assertTrue(os.path.exists(self.t.path("V", "filament/Broken.json")))
def test_a_data_file_is_not_a_preset(self):
self.bundle().write("V", "filament/filaments_color_codes.json",
{"data": [], "total": 0})
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
self.assertTrue(os.path.exists(
self.t.path("V", "filament/filaments_color_codes.json")))
def test_an_inherited_base_is_kept_and_reported(self):
# Neither file loads -- the loader only reads indexed sub_paths -- but
# deleting the parent destroys the only record of what the indexed child
# was written against, so that is a maintainer's call, not trim's.
self.bundle()
self.t.write("V", "machine/base.json",
{"type": "machine", "name": "V base"})
self.t.write("V", "machine/mid.json",
{"type": "machine", "name": "V mid", "inherits": "V base"})
self.t.write("V", "machine/M.json",
{"type": "machine", "name": "M 0.4 nozzle", "inherits": "V mid"})
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
# ... and the chain is followed: mid rescues base in a second pass.
self.assertTrue(os.path.exists(self.t.path("V", "machine/mid.json")))
self.assertTrue(os.path.exists(self.t.path("V", "machine/base.json")))
self.assertIn("inherited from", out)
def test_a_stale_copy_of_an_indexed_profile_is_removed(self):
# "inherits" resolves by preset name, so the indexed base is the parent the
# child actually gets; the unindexed twin is a leftover the loader never
# reaches, and being named in an inherits does not earn it a reprieve.
self.bundle()
self.t.write("V", "machine/HSN/base.json",
{"type": "machine", "name": "V base"})
self.t.index("V", "machine", "V base", "machine/HSN/base.json")
self.t.write("V", "machine/base.json",
{"type": "machine", "name": "V base"})
self.t.write("V", "machine/M.json",
{"type": "machine", "name": "M 0.4 nozzle", "inherits": "V base"})
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
self.assertTrue(os.path.exists(self.t.path("V", "machine/HSN/base.json")))
self.assertFalse(os.path.exists(self.t.path("V", "machine/base.json")))
self.assertNotIn("WARNING", out)
self.assertIn('machine/HSN/base.json is the profile named "V base"', out)
def test_dry_run_deletes_nothing(self):
self.bundle().write("V", "filament/Orphan.json",
{"type": "filament", "name": "Orphan"})
before = self.t.bytes_map()
rc, out = self.run_command("trim", "--dry-run")
self.assertEqual(rc, 0, out)
self.assertIn("would be removed", out)
self.assertEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# update-index
# ---------------------------------------------------------------------------
class TestUpdateIndex(TreeCase):
def test_every_profile_on_disk_lands_in_its_own_section(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
self.t.write("V", "machine/M.json", {"type": "machine", "name": "M"})
self.t.write("V", "machine/MM.json", {"type": "machine_model", "name": "MM"})
rc, out = self.run_command("update-index")
self.assertEqual(rc, 0, out)
index = self.t.read_index("V")
self.assertEqual(index["filament_list"],
[{"name": "A", "sub_path": "filament/A.json"}])
self.assertEqual(index["process_list"],
[{"name": "B", "sub_path": "process/B.json"}])
self.assertEqual(index["machine_list"],
[{"name": "M", "sub_path": "machine/M.json"}])
self.assertEqual(index["machine_model_list"],
[{"name": "MM", "sub_path": "machine/MM.json"}])
def test_parents_are_listed_before_their_children(self):
# The loader resolves inherits in one pass over the list.
for name, parent in (("C", "B"), ("A", None), ("B", "A")):
data = {"type": "filament", "name": name}
if parent:
data["inherits"] = parent
self.t.write("V", f"filament/{name}.json", data)
rc, out = self.run_command("update-index")
self.assertEqual(rc, 0, out)
self.assertEqual([e["name"] for e in self.t.read_index("V")["filament_list"]],
["A", "B", "C"])
# inherits is ordering input only; it never lands in the index.
for entry in self.t.read_index("V")["filament_list"]:
self.assertEqual(sorted(entry), ["name", "sub_path"])
def test_a_profile_with_no_usable_type_is_reported_not_dropped(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.t.write("V", "filament/B.json", {"name": "B"})
rc, out = self.run_command("update-index")
self.assertEqual(rc, 1, out)
self.assertIn("cannot be indexed", out)
self.assertIn("filament/B.json", out)
def test_two_profiles_claiming_one_name_leave_the_index_alone(self):
# The bundle holds one profile per name, so a rebuild would pick a winner by
# directory order and drop the other without a word.
self.t.write("V", "machine/base.json", {"type": "machine", "name": "base"})
self.t.write("V", "machine/HSN/base.json", {"type": "machine", "name": "base"})
self.t.index("V", "machine", "base", "machine/HSN/base.json")
before = self.t.bytes_map()
rc, out = self.run_command("update-index")
self.assertEqual(rc, 1, out)
self.assertIn('2 profiles are named "base"', out)
self.assertIn("machine/base.json", out)
self.assertIn("machine/HSN/base.json", out)
self.assertEqual(self.t.bytes_map(), before)
def test_profile_type_rebuilds_only_that_section(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
rc, out = self.run_command("update-index", "--profile-type", "filament")
self.assertEqual(rc, 0, out)
index = self.t.read_index("V")
self.assertEqual([e["name"] for e in index["filament_list"]], ["A"])
self.assertNotIn("process_list", index)
def test_an_up_to_date_index_is_left_byte_identical(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.run_command("update-index")
before = self.t.bytes_map()
rc, out = self.run_command("update-index")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.bytes_map(), before)
def test_dry_run_writes_nothing(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
before = self.t.bytes_map()
rc, out = self.run_command("update-index", "--dry-run")
self.assertEqual(rc, 0, out)
self.assertIn("would be rebuilt", out)
self.assertEqual(self.t.bytes_map(), before)
def test_a_json_file_with_no_bundle_is_never_touched(self):
# resources/profiles/blacklist.json is a .json with no directory beside
# it. Enumerating vendors by stem once wrote four empty *_list keys into it.
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
stray = os.path.join(self.t.profiles, "blacklist.json")
with open(stray, "wb") as f:
f.write(b'{"filament": ["GFSA03"]}')
rc, out = self.run_command("update-index")
self.assertEqual(rc, 0, out)
with open(stray, "rb") as f:
self.assertEqual(f.read(), b'{"filament": ["GFSA03"]}')
# ---------------------------------------------------------------------------
# check
# ---------------------------------------------------------------------------
class TestCheck(TreeCase):
def bundle(self):
"""A bundle that passes every per-vendor check."""
self.t.write("V", "filament/A.json", {
"type": "filament", "name": "A", "instantiation": "true",
"filament_id": "OFaaaaaa", "filament_type": ["PLA"],
"filament_vendor": ["AV"], "compatible_printers": ["M 0.4 nozzle"],
"setting_id": apt.generate_preset_setting_id("V", "filament", "A")})
self.t.index("V", "filament", "A", "filament/A.json")
return self.t
def per_vendor_errors(self, *argv):
"""Run the per-vendor checks alone, which is what --vendor narrows."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = apt.check_filament_compatible_printers(self.t.profiles, "V")
name_errors, _warn = apt.check_name_consistency(self.t.profiles, "V")
errors += name_errors
errors += apt.check_vector_type_keys(self.t.profiles, "V")
errors += apt.check_filament_id_length(self.t.profiles, "V")
conflict, _warn = apt.check_conflict_keys(self.t.profiles, "V")
errors += conflict
materials, _warn = apt.check_machine_default_materials(self.t.profiles, "V")
errors += materials
return errors, buf.getvalue()
def test_a_clean_bundle_reports_nothing(self):
self.bundle()
errors, out = self.per_vendor_errors()
self.assertEqual(errors, 0, out)
def test_an_instantiated_filament_needs_compatible_printers(self):
self.bundle().write("V", "filament/B.json", {
"type": "filament", "name": "B", "instantiation": "true"})
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("'compatible_printers' missing", out)
def test_a_library_filament_may_leave_compatible_printers_empty(self):
# The shared library is exempt from that rule and nothing else.
self.t.write(apt.OFL, "filament/A.json",
{"type": "filament", "name": "A", "instantiation": "true"})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = apt.check_filament_compatible_printers(self.t.profiles, apt.OFL)
self.assertEqual(errors, 0, buf.getvalue())
def test_the_library_is_checked_like_any_other_bundle(self):
# A file the library's own index does not reference must fail plain
# `check`, now that the per-vendor pass no longer skips it.
self.t.write(apt.OFL, "filament/Stray.json",
{"type": "filament", "name": "Stray"})
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertIn(f"{apt.OFL}/filament/Stray.json: no {apt.OFL}.json list "
f"references it", out)
def test_a_duplicate_key_is_an_error(self):
self.bundle().write_raw("V", "filament/B.json",
b'{"type":"filament","name":"B","name":"B2"}')
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("Duplicate key", out)
def test_the_index_and_the_file_must_agree_on_the_name(self):
self.bundle()
self.t.write("V", "filament/C.json", {"type": "filament", "name": "Other"})
self.t.index("V", "filament", "C", "filament/C.json")
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("name mismatch", out)
def test_an_index_entry_with_no_file_is_an_error(self):
self.bundle()
self.t.index("V", "filament", "Gone", "filament/Gone.json")
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("Missing sub profile", out)
def test_a_vector_option_may_not_be_a_scalar(self):
self.bundle().write("V", "filament/B.json", {
"type": "filament", "name": "B", "filament_type": "PLA"})
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("must be an array", out)
def test_renamed_and_old_option_may_not_co_exist(self):
self.bundle().write("V", "machine/M.json", {
"type": "machine", "name": "M 0.4 nozzle",
"extruder_clearance_radius": "45", "extruder_clearance_max_radius": "68"})
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("Conflict keys", out)
def test_the_length_rule_only_binds_indexed_presets(self):
# A file the index never loads cannot break AMS matching, and some
# bundles ship such orphans from before the rule existed.
self.bundle().write("V", "filament/Long.json", {
"type": "filament", "name": "Long", "filament_id": "OFtoolongforams"})
errors, out = self.per_vendor_errors()
self.assertEqual(errors, 0, out)
self.t.index("V", "filament", "Long", "filament/Long.json")
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("Filament id too long", out)
def test_obsolete_key_warnings_exclude_active_and_renamed_options(self):
self.bundle().write("V", "filament/B.json", {
"type": "filament", "name": "B", "silent_mode": "0",
"anisotropic_surfaces": "0", "extruder_type": ["Direct Drive"],
"extruder_clearance_max_radius": "68"})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
warnings = apt.check_obsolete_keys(self.t.profiles, "V")
self.assertEqual(warnings, 2)
self.assertIn("Obsolete key", buf.getvalue())
def test_obsolete_key_warnings_run_without_a_flag(self):
self.t.write("V", "filament/A.json", {
"type": "filament", "name": "A", "silent_mode": "0"})
self.run_command("update-index")
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out) # normalization also rejects the obsolete key
self.assertIn("Obsolete key: 'silent_mode' found in V/filament/A.json", out)
self.assertIn("Files with warnings : 1", out)
def test_a_default_material_must_exist_somewhere(self):
self.bundle().write("V", "machine/M.json", {
"type": "machine", "name": "M 0.4 nozzle",
"default_materials": "A;Nope"})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors, _warn = apt.check_machine_default_materials(self.t.profiles, "V")
self.assertEqual(errors, 1)
self.assertIn("'Nope'", buf.getvalue())
def test_a_default_material_fails_check_without_a_flag(self):
# The reference check is part of the default run, not an opt-in: a
# dangling name has to fail plain `check`.
self.bundle()
self.t.write("V", "machine/M.json", {
"type": "machine", "name": "M 0.4 nozzle",
"default_filament_profile": ["A", "Nope"]})
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertIn("Missing filament profile: 'Nope'", out)
def test_the_stray_user_directory_is_not_a_vendor(self):
# A local validator run leaves resources/profiles/user/ behind; an
# unscoped check must not count it as a bundle and warn about it.
self.bundle()
for sub in apt.PROFILE_SUBDIRS:
os.makedirs(os.path.join(self.t.profiles, apt.USER_DIR, "default", sub))
_rc, out = self.run_command("check")
self.assertIn("Checked vendors : 1", out)
self.assertNotIn("user", out)
def names(self, vendor="V"):
"""The preset name check for one bundle, which is what --vendor narrows."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = apt.check_preset_name_uniqueness(self.t.profiles, vendor)
return errors, buf.getvalue()
def test_one_bundle_may_not_hold_two_profiles_of_a_name(self):
self.bundle().write("V", "filament/dup.json", {
"type": "filament", "name": "A", "instantiation": "false"})
errors, out = self.names()
self.assertEqual(errors, 1, out)
self.assertIn('V has 2 filament profiles named "A"', out)
def test_an_unindexed_twin_counts_as_a_duplicate(self):
# The case this check was written for: a stale copy of a base profile in
# machine/, which no per-vendor check walked, one index edit away from
# silently deciding which of the two a whole bundle inherits from.
self.bundle()
self.t.write("V", "machine/HSN/base.json",
{"type": "machine", "name": "V base"})
self.t.index("V", "machine", "V base", "machine/HSN/base.json")
self.t.write("V", "machine/base.json", {"type": "machine", "name": "V base"})
errors, out = self.names()
self.assertEqual(errors, 1, out)
self.assertIn("machine/HSN/base.json", out)
self.assertIn("machine/base.json", out)
def test_one_name_in_two_types_is_not_a_clash(self):
self.bundle()
self.t.write("V", "process/same.json", {"type": "process", "name": "A"})
errors, out = self.names()
self.assertEqual(errors, 0, out)
def test_a_name_is_per_bundle_not_global(self):
# fdm_machine_common exists in 60 shipped bundles; the name is scoped to the
# bundle that resolves it, so sharing one across vendors is not a clash.
for vendor in ("V", "W"):
self.t.write(vendor, "machine/common.json",
{"type": "machine", "name": "fdm_machine_common"})
self.t.index(vendor, "machine", "fdm_machine_common", "machine/common.json")
for vendor in ("V", "W"):
errors, out = self.names(vendor)
self.assertEqual(errors, 0, out)
def coverage(self, vendor="V"):
"""The index-coverage check for one bundle: (errors, gaps, output)."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors, gaps = apt.check_index_coverage(self.t.profiles, vendor)
return errors, gaps, buf.getvalue()
def test_a_file_no_list_references_is_an_error(self):
self.bundle().write("V", "filament/Stray.json",
{"type": "filament", "name": "Stray"})
errors, gaps, out = self.coverage()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["unindexed"], 1)
self.assertIn("no V.json list references it", out)
def test_a_file_with_no_type_is_its_own_category(self):
# update-index cannot place it, so "add it to the index" is not the remedy.
self.bundle().write("V", "filament/Stray.json", {"name": "Stray"})
errors, gaps, out = self.coverage()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["unindexable"], 1)
self.assertIn("declares no profile type", out)
def test_an_unparsable_unlisted_file_is_reported_too(self):
self.bundle().write_raw("V", "filament/Broken.json", b"{ not json")
errors, gaps, out = self.coverage()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["unindexable"], 1)
def test_a_dotted_sub_path_still_counts_as_listed(self):
self.bundle()
self.t.write("V", "filament/Dotted.json",
{"type": "filament", "name": "Dotted"})
self.t.index("V", "filament", "Dotted", "filament/./Dotted.json")
errors, _gaps, out = self.coverage()
self.assertEqual(errors, 0, out)
def test_a_data_file_is_not_expected_in_the_index(self):
self.bundle().write("V", "filament/filaments_color_codes.json",
{"data": [], "total": 0})
errors, _gaps, out = self.coverage()
self.assertEqual(errors, 0, out)
def test_a_bundle_with_no_index_is_left_to_the_name_check(self):
# Every file unlisted because there is no list at all is one problem, not
# one per file; check_name_consistency reports the missing index.
self.t.write("W", "filament/A.json", {"type": "filament", "name": "A"})
os.remove(self.t.index_path("W"))
errors, _gaps, out = self.coverage("W")
self.assertEqual(errors, 0, out)
def test_the_remedy_is_printed_once_not_once_per_file(self):
self.bundle()
for n in range(5):
self.t.write("V", f"filament/Stray{n}.json",
{"type": "filament", "name": f"Stray{n}"})
self.t.write("V", "filament/NoType.json", {"name": "NoType"})
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertEqual(out.count("update-index\" to add them"), 1, out)
self.assertEqual(out.count("or delete them"), 1, out)
self.assertIn("5 unreferenced file(s)", out)
self.assertIn("1 unreferenced file(s)", out)
def test_setting_id_uniqueness_is_tree_wide(self):
# Two presets sharing vendor/type/name mint one id, so the collision
# only shows up in a pass that has seen the whole tree.
shared = apt.generate_preset_setting_id("V", "filament", "A")
for rel in ("filament/A.json", "filament/nested/A.json"):
self.t.write("V", rel, {"type": "filament", "name": "A",
"instantiation": "true", "setting_id": shared})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = apt.check_setting_id_uniqueness(self.t.profiles)
self.assertGreater(errors, 0)
self.assertIn("globally unique", buf.getvalue())
def test_a_base_profile_must_not_carry_a_setting_id(self):
self.t.write("V", "filament/base.json", {
"type": "filament", "name": "base", "instantiation": "false",
"setting_id": apt.generate_preset_setting_id("V", "filament", "base")})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = apt.check_setting_id_uniqueness(self.t.profiles)
self.assertEqual(errors, 1)
self.assertIn("must not have a", buf.getvalue())
# ---------------------------------------------------------------------------
# check: normalize and update-index would change nothing
# ---------------------------------------------------------------------------
class TestNormalized(TreeCase):
"""The pass that holds a bundle to the shape normalize and update-index write."""
def normalize(self):
"""Put the tree in that shape, the way a contributor is told to."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
apt.main(["normalize", "--profiles", self.t.profiles])
apt.main(["update-index", "--profiles", self.t.profiles])
return buf.getvalue()
def normalized(self, vendor="V"):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors, gaps = apt.check_normalized(self.t.profiles, vendor)
return errors, gaps, buf.getvalue()
def test_a_bundle_the_two_commands_just_wrote_reports_nothing(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
self.normalize()
errors, _gaps, out = self.normalized()
self.assertEqual(errors, 0, out)
def test_a_profile_fix_would_rewrite_is_an_error(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.normalize()
# version belongs to the bundle, in <vendor>.json, never to a preset.
data = self.t.read("V", "filament/A.json")
data["version"] = "01.00.00.00"
self.t.write("V", "filament/A.json", data)
errors, gaps, out = self.normalized()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["unnormalized"], 1, out)
self.assertIn("V/filament/A.json: normalize would remove version", out)
def test_an_index_update_index_would_rebuild_is_an_error(self):
for name, parent in (("B", "A"), ("A", None)):
data = {"type": "filament", "name": name}
if parent:
data["inherits"] = parent
self.t.write("V", f"filament/{name}.json", data)
self.normalize()
# Parents-first is what lets the loader resolve inherits in one pass; a
# hand-edited list that puts the child first still names every file.
index = self.t.read_index("V")
index["filament_list"].reverse()
self.t.write_index("V", index)
errors, gaps, out = self.normalized()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["stale_index"], 1, out)
self.assertIn("V.json: update-index would rebuild filament_list", out)
def test_an_unbuildable_index_is_left_to_the_checks_that_name_it(self):
# update-index refuses to rebuild a bundle where two files claim one name,
# so "would be rebuilt" on top of the duplicate-name error would be noise.
self.t.write("V", "machine/base.json", {"type": "machine", "name": "base"})
self.normalize()
self.t.write("V", "machine/HSN/base.json", {"type": "machine", "name": "base"})
errors, gaps, out = self.normalized()
self.assertEqual(errors, 0, out)
self.assertEqual(gaps["stale_index"], 0, out)
def test_a_bundle_with_no_index_still_has_its_files_checked(self):
self.t.write("V", "filament/A.json",
{"type": "filament", "name": "A", "is_custom_defined": "0"})
os.remove(self.t.index_path("V"))
errors, gaps, out = self.normalized()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["unnormalized"], 1, out)
self.assertEqual(gaps["stale_index"], 0, out)
def test_the_shared_base_bundle_is_covered_too(self):
# normalize and update-index own the shape of every bundle, the shared
# library included.
self.t.write(apt.OFL, "filament/A.json",
{"type": "filament", "name": "A", "version": "01.00.00.00"})
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertIn(f"{apt.OFL}/filament/A.json: normalize would remove version", out)
def test_each_remedy_is_printed_once_for_the_whole_run(self):
for n in range(3):
self.t.write("V", f"filament/A{n}.json",
{"type": "filament", "name": f"A{n}",
"version": "01.00.00.00"})
self.t.write("W", "filament/B.json", {"type": "filament", "name": "B"})
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertIn("3 profile file(s) above are not what", out)
self.assertEqual(out.count('normalize" writes: run it and commit'), 1, out)
self.assertIn("2 vendor index(es) above are not what", out)
self.assertEqual(out.count('update-index" writes: run it and commit'), 1, out)
# ---------------------------------------------------------------------------
# CLI dispatch
# ---------------------------------------------------------------------------
class TestDispatch(TreeCase):
def test_each_command_reaches_its_own_writer(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
for command, expected in (("normalize", "normalized"),
("trim", "unreferenced"),
("update-index", "vendor index")):
with self.subTest(command=command):
rc, out = self.run_command(command, "--dry-run")
self.assertEqual(rc, 0, out)
self.assertIn(expected, out)
def test_an_option_belongs_to_one_command_only(self):
for argv in (["trim", "--force"],
["update-index", "--filament-id"],
["check", "--profile-type", "filament"]):
with self.subTest(argv=argv):
with self.assertRaises(SystemExit) as cm, \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
apt.main([*argv, "--profiles", self.t.profiles])
self.assertEqual(cm.exception.code, 2)
def test_an_unknown_vendor_stops_the_run(self):
self.t.write("V", "filament/A.json", {"name": "A"})
before = self.t.bytes_map()
rc, out = self.run_command("normalize", "--vendor", "Nope")
self.assertEqual(rc, 1)
self.assertIn("unknown vendor", out)
self.assertEqual(self.t.bytes_map(), before)
def test_an_empty_vendor_means_every_vendor(self):
self.t.write("V", "filament/A.json", {"name": "A"})
rc, out = self.run_command("normalize", "--vendor", "")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
# ---------------------------------------------------------------------------
# the real tree
# ---------------------------------------------------------------------------
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
class TestRealTree(unittest.TestCase):
def test_check_passes(self):
# The exact CI invocation, return code included.
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = apt.main(["check"])
self.assertEqual(rc, 0, buf.getvalue())
def test_the_shipped_tree_needs_no_fix(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = apt.normalize_profiles(REAL_PROFILES, dry_run=True)
self.assertEqual(errors, 0, buf.getvalue())
self.assertEqual(changed, 0, buf.getvalue())
def test_the_shipped_indexes_need_no_rebuild(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = apt.update_profile_indexes(REAL_PROFILES, dry_run=True)
self.assertEqual(errors, 0, buf.getvalue())
self.assertEqual(changed, 0, buf.getvalue())
def test_no_shipped_bundle_is_a_stray_json_file(self):
# blacklist.json has no directory beside it, so it is not a vendor.
self.assertNotIn("blacklist", apt.list_vendor_names(REAL_PROFILES))
if __name__ == "__main__":
unittest.main()
+899
View File
@@ -0,0 +1,899 @@
#!/usr/bin/env python3
"""Tests for the setting_id half of scripts/orca_profile_tool.py (stdlib unittest, no
external deps).
Run from the repo root: python -m unittest discover -s scripts/tests -v
"""
import contextlib
import io
import json
import os
import shutil
import sys
import tempfile
import unittest
import uuid
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import orca_profile_tool as afi # noqa: E402
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
REAL_PROFILES = os.path.join(REPO_ROOT, "resources", "profiles")
# ---------------------------------------------------------------------------
# helpers: synthetic profile trees
# ---------------------------------------------------------------------------
def preset(name, instantiation=True, setting_id=None, settings_id=None,
filament_id=None, type_name="filament", **extra):
"""A preset in the canonical key order the shipped profiles use."""
data = {"type": type_name, "name": name, "from": "system"}
if setting_id is not None:
data["setting_id"] = setting_id
if settings_id is not None:
data["settings_id"] = settings_id
if filament_id is not None:
data["filament_id"] = filament_id
data["instantiation"] = "true" if instantiation else "false"
data.update(extra)
return data
class SettingTree:
"""A throwaway resources/profiles-shaped directory of setting_id-bearing bundles.
A bundle is a `<vendor>/` directory plus the sibling `<vendor>.json` index
that makes list_vendor_names() see it; presets live under filament/,
process/ and machine/, the three subdirs generate_setting_ids walks.
write() also registers filament presets in the index's filament_list, the
way a shipped bundle does. generate_setting_ids never reads that list, but
generate_filament_ids does: without it the filament half of the tool is a
no-op on this tree, and the tests that assert --setting-id leaves
filament_ids alone would hold for the wrong reason.
"""
def __init__(self):
self.dir = tempfile.mkdtemp(prefix="setting_id_test_")
self.profiles = os.path.join(self.dir, "profiles")
os.makedirs(self.profiles)
def cleanup(self):
shutil.rmtree(self.dir, ignore_errors=True)
def index_path(self, vendor):
return os.path.join(self.profiles, vendor + ".json")
def add_vendor(self, vendor):
"""Create the bundle dir and its index; idempotent, keeps the list."""
for sub in afi.PROFILE_SUBDIRS:
os.makedirs(os.path.join(self.profiles, vendor, sub), exist_ok=True)
if not os.path.exists(self.index_path(vendor)):
self._write_index(vendor, {"name": vendor, "version": "01.00.00.00",
"filament_list": []})
def _write_index(self, vendor, index):
with open(self.index_path(vendor), "w", encoding="utf-8",
newline="\n") as f:
json.dump(index, f, indent=4, ensure_ascii=False)
def register(self, vendor, subdir, name):
"""Add a filament preset to the bundle index's filament_list."""
with open(self.index_path(vendor), encoding="utf-8") as f:
index = json.load(f)
sub_path = os.path.join(subdir, name + ".json").replace(os.sep, "/")
index["filament_list"].append({"name": name, "sub_path": sub_path})
self._write_index(vendor, index)
def path(self, vendor, subdir, name):
return os.path.join(self.profiles, vendor, subdir, name + ".json")
def write(self, vendor, subdir, data, name=None):
"""Write a preset as indented LF JSON; returns its path."""
self.add_vendor(vendor)
file_name = name if name is not None else data["name"]
path = self.path(vendor, subdir, file_name)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
f.write("\n")
if subdir.split(os.sep)[0] == "filament" and data.get("name"):
self.register(vendor, subdir, file_name)
return path
def write_raw(self, vendor, subdir, name, raw):
"""Write exact bytes (BOM, CRLF, tabs, broken JSON); returns its path."""
self.add_vendor(vendor)
path = self.path(vendor, subdir, name)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(raw)
return path
def read(self, vendor, subdir, name):
with open(self.path(vendor, subdir, name), encoding="utf-8-sig") as f:
return json.load(f)
def raw(self, vendor, subdir, name):
with open(self.path(vendor, subdir, name), "rb") as f:
return f.read()
def bytes_map(self):
"""relative path -> bytes, for every file in the tree."""
out = {}
for root, dirs, files in os.walk(self.profiles):
dirs.sort()
for name in sorted(files):
path = os.path.join(root, name)
with open(path, "rb") as f:
out[os.path.relpath(path, self.profiles)] = f.read()
return out
# -- pipeline wrappers ---------------------------------------------------
def run(self, vendors=None, dry_run=False):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = afi.generate_setting_ids(self.profiles, vendors, dry_run)
return changed, errors, buf.getvalue()
def run_filament_ids(self, vendors=None, dry_run=False):
"""The OTHER half of --generate."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = afi.generate_filament_ids(self.profiles, vendors, dry_run)
return changed, errors, buf.getvalue()
class SettingTreeCase(unittest.TestCase):
def setUp(self):
self.t = SettingTree()
self.addCleanup(self.t.cleanup)
# ---------------------------------------------------------------------------
# mint: the C++/Python byte-identity contract
# ---------------------------------------------------------------------------
class TestSettingIdMint(unittest.TestCase):
# Copied verbatim from tests/libslic3r/test_preset_setting_id.cpp: the C++
# generate_preset_setting_id() recomputes these ids on the fly, so the two
# implementations must stay byte-identical.
GOLDEN = [
("Afinia", "filament", "Afinia ABS @Afinia H400", "TL34qSVkppBvMvgH"),
("Afinia", "process", "0.20mm Standard @Afinia H400", "FzmtNsy7XQvpd7w0"),
("Afinia", "machine", "Afinia H400 0.4 nozzle", "r4FZagW0S8uoaJPd"),
("Anycubic", "filament", "Generic PLA @Anycubic Kobra 2", "YIWGGLQ8Oepd30Fv"),
("Creality", "process", "0.16mm Optimal @Creality Ender-3 V3", "2Nrbq8PxssUPBLza"),
("Elegoo", "machine", "Elegoo Neptune 4 0.4 nozzle", "69QdWuRQwAZk9rFu"),
]
def test_golden_vectors(self):
for vendor, type_name, name, expected in self.GOLDEN:
with self.subTest(name=name):
self.assertEqual(
afi.generate_preset_setting_id(vendor, type_name, name), expected)
def test_namespace_and_length_are_frozen(self):
# Baked into the C++ side and into every shipped profile; never change it.
self.assertEqual(afi.NAMESPACE,
uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f"))
self.assertEqual(afi.SETTING_ID_LENGTH, 16)
self.assertEqual(
afi.ALPHABET,
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
self.assertEqual(len(afi.ALPHABET), 62)
def test_format_is_sixteen_alphabet_chars(self):
for vendor, type_name, name in [("Creality", "filament", "CR PLA @Ender"),
("Elegoo", "machine", "Elegoo Neptune 4"),
("拓竹", "filament", "拓竹 PLA @P1")]:
with self.subTest(name=name):
sid = afi.generate_preset_setting_id(vendor, type_name, name)
self.assertEqual(len(sid), afi.SETTING_ID_LENGTH)
self.assertTrue(set(sid) <= set(afi.ALPHABET), sid)
def test_is_the_low_base62_digits_of_the_uuid5(self):
# Independent re-implementation of the whole rule, key layout included.
for vendor, type_name, name, _expected in self.GOLDEN:
u = uuid.uuid5(afi.NAMESPACE, f"{vendor}/{type_name}/{name}")
n = int.from_bytes(u.bytes, "big")
digits = ""
for _ in range(afi.SETTING_ID_LENGTH):
digits = afi.ALPHABET[n % 62] + digits
n //= 62
self.assertEqual(afi.generate_preset_setting_id(vendor, type_name, name),
digits)
def test_deterministic(self):
a = afi.generate_preset_setting_id("VendorX", "filament", "My PLA")
self.assertEqual(a, afi.generate_preset_setting_id("VendorX", "filament", "My PLA"))
def test_every_identity_component_changes_the_id(self):
base = afi.generate_preset_setting_id("VendorX", "filament", "My PLA")
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorY", "filament", "My PLA"))
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorX", "process", "My PLA"))
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorX", "filament", "My PETG"))
def test_key_is_a_flat_slash_join(self):
# The mint key is "<vendor>/<type>/<name>" with no escaping, so a "/" in
# a component shifts the split — harmless in practice (vendor is a
# directory name and type is one of PROFILE_SUBDIRS), but it is what the
# C++ side does too and the two must agree byte for byte.
self.assertEqual(afi.generate_preset_setting_id("A/B", "filament", "C"),
afi.generate_preset_setting_id("A", "B/filament", "C"))
class TestBase62Tail(unittest.TestCase):
def test_hand_computed_digits(self):
self.assertEqual(afi._base62_tail(0, 4), "0000")
self.assertEqual(afi._base62_tail(61, 1), "z") # last alphabet char
self.assertEqual(afi._base62_tail(62, 2), "10") # 1*62 + 0
self.assertEqual(afi._base62_tail(3843, 2), "zz") # 61*62 + 61
self.assertEqual(afi._base62_tail(3907, 3), "111") # 62^2 + 62 + 1
def test_keeps_only_the_low_digits(self):
self.assertEqual(afi._base62_tail(62, 1), "0") # high digit dropped
self.assertEqual(afi._base62_tail(3907, 2), "11")
# ---------------------------------------------------------------------------
# assignment
# ---------------------------------------------------------------------------
class TestAssignment(SettingTreeCase):
def test_instantiation_is_read_exactly_as_the_validator_reads_it(self):
# check_setting_id_uniqueness tests `instantiation == "true"` strictly.
# Anything looser here would hand an id to a preset the validator calls
# a base profile, and the two would fight over it on every run.
for name, value in [("Boolean", True), ("Capitalised", "True"),
("Numeric", 1), ("Absent", None)]:
with self.subTest(instantiation=value):
data = {"type": "filament", "name": name, "from": "system"}
if value is not None:
data["instantiation"] = value
self.t.write("VendorA", "filament", data)
before = self.t.bytes_map()
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (0, 0), out)
self.assertEqual(self.t.bytes_map(), before)
def test_a_bundle_without_an_index_is_still_assigned(self):
# setting_id is a per-file property, and the validator walks every
# directory. A bundle whose index has not landed yet must be fixable,
# or the validator flags files this tool refuses to touch.
path = os.path.join(self.t.profiles, "Noindex", "process", "Q.json")
os.makedirs(os.path.dirname(path))
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(preset("Q", type_name="process"), f, indent=4)
self.assertFalse(os.path.exists(self.t.index_path("Noindex")))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
with open(path, encoding="utf-8") as f:
self.assertEqual(json.load(f)["setting_id"],
afi.generate_preset_setting_id("Noindex", "process", "Q"))
def test_assigns_across_every_profile_subdir(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorA", "process",
preset("0.20mm Standard @P1", type_name="process"))
self.t.write("VendorA", "machine",
preset("P1 0.4 nozzle", type_name="machine"))
# os.walk recursion: a preset in a nested directory is walked too.
self.t.write("VendorA", os.path.join("filament", "nested"),
preset("A PETG @P1"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (4, 0), out)
for subdir, type_name, name in [
("filament", "filament", "A PLA @P1"),
("process", "process", "0.20mm Standard @P1"),
("machine", "machine", "P1 0.4 nozzle"),
(os.path.join("filament", "nested"), "filament", "A PETG @P1")]:
self.assertEqual(
self.t.read("VendorA", subdir, name)["setting_id"],
afi.generate_preset_setting_id("VendorA", type_name, name),
msg=name)
def test_type_comes_from_the_subdirectory_not_the_type_field(self):
# The subdir name is the type name (Preset::get_type_string()); a stale
# "type" field inside the file does not enter the id.
self.t.write("VendorA", "process", preset("Odd @P1", type_name="filament"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(self.t.read("VendorA", "process", "Odd @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "process", "Odd @P1"))
def test_idempotent(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorA", "process",
preset("0.20mm Standard @P1", type_name="process"))
self.t.write("VendorA", "filament", preset("A PLA @base", instantiation=False,
setting_id="LEFTOVER00000000"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (3, 0), out)
after_first = self.t.bytes_map()
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (0, 0), out)
self.assertEqual(self.t.bytes_map(), after_first)
def test_stale_value_is_replaced_in_place(self):
path = self.t.write("VendorA", "filament",
preset("A PLA @P1", setting_id="0000000000000000",
filament_id="OFabc123"))
before = self.t.raw("VendorA", "filament", "A PLA @P1")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
want = afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1")
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
want)
raw = self.t.raw("VendorA", "filament", "A PLA @P1")
self.assertEqual(raw.count(b'"setting_id"'), 1) # replaced, not appended
self.assertEqual(raw, before.replace(b'"0000000000000000"',
b'"%s"' % want.encode()))
self.assertTrue(os.path.isfile(path))
def test_missing_value_is_inserted_before_filament_id(self):
self.t.write("VendorA", "filament",
preset("A PLA @P1", filament_id="OFabc123"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
text = self.t.raw("VendorA", "filament", "A PLA @P1").decode("utf-8")
want = afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1")
self.assertIn(f'"setting_id": "{want}"', text)
self.assertLess(text.index('"setting_id"'), text.index('"filament_id"'))
def test_base_profiles_are_stripped(self):
self.t.write("VendorA", "filament",
preset("A PLA @base", instantiation=False,
setting_id="0000000000000000", filament_id="OFabc123"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
data = self.t.read("VendorA", "filament", "A PLA @base")
self.assertNotIn("setting_id", data)
self.assertEqual(data["filament_id"], "OFabc123") # nothing else touched
self.assertEqual(data["instantiation"], "false")
def test_base_profile_without_instantiation_key_is_stripped(self):
# No "instantiation" key at all == not instantiated (str(None) != "true").
data = {"type": "filament", "name": "A PLA @root", "from": "system",
"setting_id": "0000000000000000"}
self.t.write("VendorA", "filament", data)
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertNotIn("setting_id", self.t.read("VendorA", "filament", "A PLA @root"))
def test_misspelled_settings_id_is_dropped(self):
self.t.write("VendorA", "filament",
preset("A PLA @base", instantiation=False,
settings_id="0000000000000000"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
data = self.t.read("VendorA", "filament", "A PLA @base")
self.assertNotIn("settings_id", data)
self.assertNotIn("setting_id", data)
def test_typo_drop_and_assignment_are_one_file_change(self):
self.t.write("VendorA", "filament",
preset("A PLA @P1", settings_id="0000000000000000",
filament_id="OFabc123"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out) # ONE counted file change
data = self.t.read("VendorA", "filament", "A PLA @P1")
self.assertNotIn("settings_id", data)
self.assertEqual(data["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
def test_reserved_vendor_keeps_instantiated_ids_and_loses_base_ones(self):
# BBL owns the authoritative "G*" cloud id space: its instantiated
# presets are never rewritten, its base declarations still are stripped.
self.t.write("BBL", "filament",
preset("Bambu ABS @BBL A1", setting_id="GFSB00_07"))
self.t.write("BBL", "filament", preset("Bambu ABS @P1 no id"))
self.t.write("BBL", "filament",
preset("Bambu ABS @base", instantiation=False,
setting_id="GFSB00_00"))
kept = self.t.raw("BBL", "filament", "Bambu ABS @BBL A1")
kept_idless = self.t.raw("BBL", "filament", "Bambu ABS @P1 no id")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out) # only the base profile
self.assertEqual(self.t.raw("BBL", "filament", "Bambu ABS @BBL A1"), kept)
self.assertEqual(self.t.raw("BBL", "filament", "Bambu ABS @P1 no id"),
kept_idless)
self.assertNotIn("setting_id",
self.t.read("BBL", "filament", "Bambu ABS @base"))
def test_reserved_vendors_misspelled_key_is_corrected_not_dropped(self):
# A reserved vendor's id is authoritative, so there is no formula to
# fall back on. Dropping the typo and stopping there would leave the
# preset with no setting_id at all and no way for the tool to give it
# one - a validator error nothing can clear. Fix the key, keep the value.
self.t.write("BBL", "filament",
preset("Bambu PLA @P1", settings_id="GFSA00_01"))
# A base profile still just loses the key; it may not carry an id.
self.t.write("BBL", "filament",
preset("Bambu PLA @base", instantiation=False,
settings_id="GFSA00_00"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (2, 0), out)
fixed = self.t.read("BBL", "filament", "Bambu PLA @P1")
self.assertNotIn("settings_id", fixed)
self.assertEqual(fixed["setting_id"], "GFSA00_01")
base = self.t.read("BBL", "filament", "Bambu PLA @base")
self.assertNotIn("settings_id", base)
self.assertNotIn("setting_id", base)
# Idempotent: the corrected file is what the next run expects to see.
self.assertEqual(self.t.run()[:2], (0, 0))
def test_a_managed_vendors_misspelled_key_is_still_replaced_by_the_mint(self):
self.t.write("VendorA", "filament",
preset("A PLA @P1", settings_id="whatever"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
data = self.t.read("VendorA", "filament", "A PLA @P1")
self.assertNotIn("settings_id", data)
self.assertEqual(data["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
def test_reserved_vendors_constant(self):
self.assertEqual(afi.RESERVED_VENDORS, {"BBL"})
self.assertEqual(afi.PROFILE_SUBDIRS, ("filament", "process", "machine"))
# ---------------------------------------------------------------------------
# --vendor narrowing
# ---------------------------------------------------------------------------
class TestVendorNarrowing(SettingTreeCase):
def setUp(self):
super().setUp()
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorB", "filament", preset("B PLA @P1"))
def test_restricts_writes_to_the_named_vendor(self):
before = self.t.bytes_map()
changed, errors, out = self.t.run(vendors=["VendorA"])
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
self.assertEqual(self.t.raw("VendorB", "filament", "B PLA @P1"),
before[os.path.join("VendorB", "filament", "B PLA @P1.json")])
# ... and the vendor left out is written by a later run, unchanged in kind.
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(self.t.read("VendorB", "filament", "B PLA @P1")["setting_id"],
afi.generate_preset_setting_id("VendorB", "filament", "B PLA @P1"))
def test_unknown_vendor_reports_and_writes_nothing(self):
before = self.t.bytes_map()
changed, errors, out = self.t.run(vendors=["Nope"])
self.assertEqual((changed, errors), (0, 1))
self.assertIn("Nope", out)
self.assertEqual(self.t.bytes_map(), before)
def test_unknown_vendor_blocks_the_known_ones_too(self):
before = self.t.bytes_map()
changed, errors, _out = self.t.run(vendors=["VendorA", "Nope"])
self.assertEqual((changed, errors), (0, 1))
self.assertEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# --dry-run
# ---------------------------------------------------------------------------
class TestDryRun(SettingTreeCase):
def test_writes_nothing_and_previews_the_real_run(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorA", "process",
preset("0.20mm Standard @P1", type_name="process"))
self.t.write("VendorA", "filament",
preset("A PLA @base", instantiation=False,
setting_id="0000000000000000"))
before = self.t.bytes_map()
dry_changed, dry_errors, out = self.t.run(dry_run=True)
self.assertEqual((dry_changed, dry_errors), (3, 0), out)
self.assertIn("would", out)
self.assertEqual(self.t.bytes_map(), before) # nothing written
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (dry_changed, dry_errors), out)
self.assertNotEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# byte preservation
# ---------------------------------------------------------------------------
class TestBytePreservation(SettingTreeCase):
CRLF_TEXT = (
'{\r\n'
'\t"type": "filament",\r\n'
'\t"name": "CRLF PLA @P1",\r\n'
'\t"from": "system",\r\n'
'\t"filament_id": "OFabc123",\r\n'
'\t"instantiation": "true",\r\n'
'\t"filament_type": [\r\n'
'\t\t"PLA"\r\n'
'\t]\r\n'
'}\r\n'
)
def test_crlf_and_tab_indentation_survive(self):
self.t.write_raw("VendorA", "filament", "CRLF PLA @P1",
self.CRLF_TEXT.encode("utf-8"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
raw = self.t.raw("VendorA", "filament", "CRLF PLA @P1")
self.assertEqual(raw.count(b"\n"), raw.count(b"\r\n")) # still CRLF-only
want = afi.generate_preset_setting_id("VendorA", "filament", "CRLF PLA @P1")
inserted = ('\t"setting_id": "%s",\r\n' % want).encode("utf-8")
# Every original byte survives: dropping the inserted line restores the file.
self.assertEqual(raw.replace(inserted, b"", 1),
self.CRLF_TEXT.encode("utf-8"))
def test_bom_survives(self):
raw_in = b"\xef\xbb\xbf" + json.dumps(
preset("BOM PLA @P1", filament_id="OFabc123"),
indent=4, ensure_ascii=False).encode("utf-8") + b"\n"
self.t.write_raw("VendorA", "filament", "BOM PLA @P1", raw_in)
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
raw = self.t.raw("VendorA", "filament", "BOM PLA @P1")
self.assertTrue(raw.startswith(b"\xef\xbb\xbf"))
self.assertEqual(raw.count(b"\xef\xbb\xbf"), 1)
want = afi.generate_preset_setting_id("VendorA", "filament", "BOM PLA @P1")
self.assertEqual(self.t.read("VendorA", "filament", "BOM PLA @P1")["setting_id"],
want)
self.assertEqual(
raw.replace((' "setting_id": "%s",\n' % want).encode("utf-8"), b"", 1),
raw_in)
def test_non_ascii_name_round_trips(self):
name = "拓竹 PLA @P1 0.4 nozzle"
raw_in = (json.dumps(preset(name, filament_id="OFabc123"), indent=4,
ensure_ascii=False).encode("utf-8") + b"\n")
self.t.write_raw("VendorA", "filament", name, raw_in)
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
raw = self.t.raw("VendorA", "filament", name)
self.assertIn(name.encode("utf-8"), raw) # not escaped to \uXXXX
data = self.t.read("VendorA", "filament", name)
self.assertEqual(data["name"], name)
self.assertEqual(data["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", name))
def test_surrounding_formatting_is_untouched_on_a_strip(self):
text = ('{\r\n'
'\t"type": "filament",\r\n'
'\t"name": "Odd @base",\r\n'
'\t"setting_id": "0000000000000000",\r\n'
'\t"instantiation": "false",\r\n'
'\t"compatible_printers": []\r\n'
'}\r\n')
self.t.write_raw("VendorA", "filament", "Odd @base", text.encode("utf-8"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(
self.t.raw("VendorA", "filament", "Odd @base").decode("utf-8"),
text.replace('\t"setting_id": "0000000000000000",\r\n', "", 1))
# ---------------------------------------------------------------------------
# byte-preserving key edits
# ---------------------------------------------------------------------------
class TestInsertAnchor(unittest.TestCase):
WITH_BOTH = ('{\n "name": "K @P1",\n "filament_id": "OFabc123",\n'
' "instantiation": "true"\n}\n')
WITH_INSTANTIATION = ('{\n "name": "K @P1",\n'
' "instantiation": "true"\n}\n')
def test_inserts_before_filament_id(self):
text, n = afi.insert_setting_id(self.WITH_BOTH, "0123456789abcdef")
self.assertEqual(n, 1)
json.loads(text)
self.assertEqual(text, self.WITH_BOTH.replace(
' "filament_id"',
' "setting_id": "0123456789abcdef",\n "filament_id"', 1))
def test_falls_back_to_instantiation(self):
text, n = afi.insert_setting_id(self.WITH_INSTANTIATION, "0123456789abcdef")
self.assertEqual(n, 1)
json.loads(text)
self.assertIn('"setting_id": "0123456789abcdef",\n "instantiation"', text)
def test_falls_back_to_name(self):
# Last-resort anchor: every preset has a name, so the insert does not
# depend on filament_id having been written first — a dry run, which
# writes none, must reach the same verdict as the real run.
text, n = afi.insert_setting_id('{\n "name": "K",\n "x": 1\n}\n',
"0123456789abcdef")
self.assertEqual(n, 1)
json.loads(text)
self.assertIn('"name": "K",\n "setting_id": "0123456789abcdef",', text)
def test_no_anchor_returns_zero(self):
src = '{\n "type": "filament"\n}\n'
text, n = afi.insert_setting_id(src, "0123456789abcdef")
self.assertEqual(n, 0)
self.assertEqual(text, src)
class TestKeyLineHelpers(unittest.TestCase):
def test_delete_trailing_comma_form(self):
text = ('{\n "name": "K",\n "setting_id": "OLD0000000000000",\n'
' "instantiation": "false"\n}\n')
out, n = afi.delete_key_line(text, "setting_id")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out), {"name": "K", "instantiation": "false"})
self.assertEqual(out, text.replace(
' "setting_id": "OLD0000000000000",\n', "", 1))
def test_delete_last_property_form_consumes_the_preceding_comma(self):
text = ('{\n "name": "K",\n "instantiation": "false",\n'
' "setting_id": "OLD0000000000000"\n}\n')
out, n = afi.delete_key_line(text, "setting_id")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out), {"name": "K", "instantiation": "false"})
self.assertEqual(out, '{\n "name": "K",\n "instantiation": "false"\n}\n')
def test_delete_requires_the_exact_old_value(self):
text = ('{\n "name": "K",\n "setting_id": "OLD0000000000000",\n'
' "instantiation": "false"\n}\n')
out, n = afi.delete_key_line(text, "setting_id", old_value="OTHER")
self.assertEqual((out, n), (text, 0))
_out, n = afi.delete_key_line(text, "setting_id",
old_value="OLD0000000000000")
self.assertEqual(n, 1)
def test_delete_missing_key_returns_zero(self):
text = '{\n "name": "K"\n}\n'
self.assertEqual(afi.delete_key_line(text, "setting_id"), (text, 0))
def test_delete_does_not_confuse_the_two_spellings(self):
text = ('{\n "name": "K",\n "settings_id": "TYPO000000000000",\n'
' "setting_id": "REAL000000000000",\n'
' "instantiation": "true"\n}\n')
out, n = afi.delete_key_line(text, "settings_id")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out)["setting_id"], "REAL000000000000")
def test_replace_refuses_a_stale_old_value(self):
text = '{\n "setting_id": "OLD0000000000000"\n}\n'
out, n = afi.replace_key_value(text, "setting_id", "NEW0000000000000",
old_value="NOTTHIS000000000")
self.assertEqual((out, n), (text, 0))
out, n = afi.replace_key_value(text, "setting_id", "NEW0000000000000",
old_value="OLD0000000000000")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out)["setting_id"], "NEW0000000000000")
def test_insert_key_line_prefers_before_over_after(self):
text = ('{\n "name": "K",\n "filament_id": "OFabc123",\n'
' "instantiation": "true"\n}\n')
out, n = afi.insert_key_line(text, "setting_id", "V",
before=("filament_id", "instantiation"),
after=("name",))
self.assertEqual(n, 1)
self.assertLess(out.index('"setting_id"'), out.index('"filament_id"'))
self.assertGreater(out.index('"setting_id"'), out.index('"name"'))
# The `before` tuple's own order decides, not the order in the file.
out, _n = afi.insert_key_line(text, "setting_id", "V",
before=("instantiation", "filament_id"))
self.assertGreater(out.index('"setting_id"'), out.index('"filament_id"'))
def test_insert_key_line_falls_back_to_after(self):
text = '{\n "name": "K",\n "from": "system"\n}\n'
out, n = afi.insert_key_line(text, "setting_id", "V",
before=("filament_id",), after=("name",))
self.assertEqual(n, 1)
self.assertEqual(out, '{\n "name": "K",\n "setting_id": "V",\n'
' "from": "system"\n}\n')
def test_insert_key_line_without_any_anchor(self):
text = '{\n "from": "system"\n}\n'
self.assertEqual(
afi.insert_key_line(text, "setting_id", "V", before=("filament_id",),
after=("name",)),
(text, 0))
# ---------------------------------------------------------------------------
# error paths: reported and counted, never raised
# ---------------------------------------------------------------------------
class TestErrorPaths(SettingTreeCase):
def test_unparsable_profile_is_reported_and_the_run_continues(self):
broken = b'{\n "name": "Broken @P1",\n oops\n}\n'
self.t.write_raw("VendorA", "filament", "Broken @P1", broken)
self.t.write("VendorA", "filament", preset("Good @P1"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("Broken @P1", out)
self.assertEqual(self.t.raw("VendorA", "filament", "Broken @P1"), broken)
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
def test_non_object_top_level_is_reported(self):
raw = b'[\n {"name": "K"}\n]\n'
self.t.write_raw("VendorA", "filament", "List @P1", raw)
self.t.write("VendorA", "filament", preset("Good @P1"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("List @P1", out)
self.assertEqual(self.t.raw("VendorA", "filament", "List @P1"), raw)
def test_nameless_instantiated_preset_is_reported(self):
nameless = {"type": "filament", "from": "system", "instantiation": "true"}
self.t.write("VendorA", "filament", nameless, name="Nameless")
self.t.write("VendorA", "filament", preset("Good @P1"))
before = self.t.raw("VendorA", "filament", "Nameless")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("Nameless", out)
self.assertEqual(self.t.raw("VendorA", "filament", "Nameless"), before)
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
def test_a_nameless_preset_still_gets_its_misspelled_key_dropped(self):
# The nameless-preset error must not abandon an edit already queued for
# the same file: leaving "settings_id" behind would keep the validator
# red with no way for this tool to clear it.
nameless = {"type": "filament", "from": "system", "settings_id": "JUNK123",
"instantiation": "true"}
self.t.write("VendorA", "filament", nameless, name="Nameless")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertNotIn("settings_id", self.t.read("VendorA", "filament", "Nameless"))
self.assertNotIn("setting_id", self.t.read("VendorA", "filament", "Nameless"))
self.assertIn('misspelled "settings_id" dropped : 1', out)
def test_an_unanchorable_file_is_reported_not_raised(self):
# One oddly formatted profile must not abort the pass over all the
# others, and it must fail the same way in a dry run as in a real one.
raw = b'{ "type":"filament", "name":"Flat @P1", "instantiation":"true" }\n'
self.t.write_raw("VendorA", "filament", "Flat @P1", raw)
self.t.write("VendorA", "filament", preset("Good @P1"))
dry_changed, dry_errors, dry_out = self.t.run(dry_run=True)
changed, errors, out = self.t.run()
self.assertEqual((dry_changed, dry_errors), (changed, errors), dry_out)
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("Flat @P1", out)
self.assertEqual(self.t.raw("VendorA", "filament", "Flat @P1"), raw)
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
def test_nameless_base_preset_is_fine(self):
# Only instantiated presets need an identity; a nameless base profile is
# simply left alone.
nameless = {"type": "filament", "from": "system", "instantiation": "false"}
self.t.write("VendorA", "filament", nameless, name="Nameless base")
before = self.t.bytes_map()
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (0, 0), out)
self.assertEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# the real tree
# ---------------------------------------------------------------------------
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
class TestRealTree(unittest.TestCase):
def test_shipped_tree_needs_no_setting_id_change(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = afi.generate_setting_ids(REAL_PROFILES, dry_run=True)
self.assertEqual((changed, errors), (0, 0), buf.getvalue())
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
class TestCli(SettingTreeCase):
def main(self, argv):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main(argv)
return rc, buf.getvalue()
def test_setting_id_only_run_touches_no_filament_id(self):
# "OFZZZZZZ" is not the mint of the preset's own triple, so the filament
# half of --generate has real work waiting on this tree (proven at the
# end): leaving the id alone is the narrowing's doing, not an idle tree.
self.t.write("VendorA", "filament",
preset("A PLA @P1", filament_id="OFZZZZZZ",
filament_vendor=["AV"], filament_type=["PLA"]))
self.t.write("VendorA", "machine",
preset("P1 0.4 nozzle", type_name="machine"))
rc, out = self.main(["generate-id", "--setting-id",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
filament = self.t.read("VendorA", "filament", "A PLA @P1")
self.assertEqual(filament["filament_id"], "OFZZZZZZ")
self.assertEqual(filament["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
machine = self.t.read("VendorA", "machine", "P1 0.4 nozzle")
self.assertNotIn("filament_id", machine)
self.assertEqual(machine["setting_id"],
afi.generate_preset_setting_id("VendorA", "machine", "P1 0.4 nozzle"))
# ... and the skipped half does re-mint that id when it is allowed to run.
changed, errors, out = self.t.run_filament_ids()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(
self.t.read("VendorA", "filament", "A PLA @P1")["filament_id"],
afi.generate_filament_id("AV", "PLA", "A PLA"))
def test_dry_run_setting_id_writes_nothing(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
before = self.t.bytes_map()
rc, out = self.main(["generate-id", "--setting-id", "--dry-run",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
self.assertIn("1 file(s) would change", out) # there WAS one to write
self.assertEqual(self.t.bytes_map(), before)
# the real run then writes exactly it
rc, out = self.main(["generate-id", "--setting-id",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
self.assertIn("1 file(s) changed", out)
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament",
"A PLA @P1"))
def test_setting_id_without_a_command_is_a_usage_error(self):
with contextlib.redirect_stderr(io.StringIO()), \
self.assertRaises(SystemExit) as cm:
afi.main(["--setting-id", "--profiles", self.t.profiles])
self.assertEqual(cm.exception.code, 2)
if __name__ == "__main__":
unittest.main()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""
Generate resources/printers/bambu_filament_ids.json: the map from Orca's
content-addressed filament_id ("OF" + 6 base62 chars, see orca_profile_tool.py)
to Bambu Lab's own AMS/RFID catalog id ("GF..." etc.) for the subset of filament
products Bambu ships.
The map is generated from BambuStudio's OWN shipped BBL bundle, never from
Orca's: Orca's BBL bundle is a fork of Bambu's, tuned and extended
independently, so it is not the source of truth for Bambu's catalog ids.
src/slic3r/Utils/BBLPrinterAgent.cpp loads it at runtime and translates an id
only where it crosses to or from a Bambu printer, so the correspondence never
has to be hand-maintained. See docs/HLSD/filament_id.md.
One row per BambuStudio filament PRODUCT: one named spool product = one
"@base"-declared filament_id, shared by every per-printer/per-nozzle
instantiation of it (BambuStudio follows the same one-product-one-id shape
Orca's own filament_id policy does). A row's key is the OF id that product's
(filament_vendor, filament_type, filament) triple mints the id Orca carries
for it wherever it ships it, since the id is a function of the triple alone.
Map format:
{
"source": "https://github.com/bambulab/BambuStudio",
"bambustudio_commit": "66e405477",
"generated": "2026-09-04",
"filaments": {
"OFhuaUQB": {"bambu_id": "GFB00", "vendor": "Bambu Lab", "type": "ABS", "name": "Bambu ABS"}
}
}
Run from anywhere: python3 scripts/update_bambu_filament_ids.py
(default) shallow-clone BambuStudio's master branch (sparse: just
the BBL filament bundle) and regenerate the map
--bambustudio-dir DIR
read DIR (a BambuStudio resources/profiles checkout)
instead of cloning
--ref REF clone this BambuStudio ref instead of master
--output PATH write here instead of resources/printers/bambu_filament_ids.json
After writing, an informational drift report is printed: BambuStudio filaments
Orca ships nothing with the same identity for, and a count of Orca's own BBL
filaments that matched no BambuStudio row. Neither blocks the write; both are
for a human to read.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from orca_profile_tool import ( # noqa: E402
BAMBU_MAP_PATH,
OFL,
PROFILES_DIR,
analyze_tree,
base_name,
generate_filament_id,
load_vendor_filaments,
print_error,
print_info,
print_success,
resolve_filament_id,
resolve_triple,
)
BAMBUSTUDIO_REPO = "https://github.com/bambulab/BambuStudio"
# ---------------------------------------------------------------------------
# Row derivation
# ---------------------------------------------------------------------------
def derive_rows(bs_filaments):
"""One row per BambuStudio product, keyed by the OF id its triple mints."""
by_filament = {} # filament_name -> (bambu_id, triple)
for rec in bs_filaments.values():
if not rec["instantiation"]:
continue
bambu_id, _src, _entry = resolve_filament_id(rec["name"], bs_filaments, {})
if not bambu_id:
continue
filament_name = base_name(rec["name"])
triple = resolve_triple(rec["name"], bs_filaments, {})
prev = by_filament.setdefault(filament_name, (bambu_id, triple))
if prev != (bambu_id, triple):
raise SystemExit(f"BambuStudio filament {filament_name!r} is not one "
f"product: {prev} vs {(bambu_id, triple)}")
rows, seen = {}, {}
for filament_name, (bambu_id, triple) in sorted(by_filament.items()):
if bambu_id in seen:
raise SystemExit(f"Bambu id {bambu_id} is shared by "
f"{seen[bambu_id]!r} and {filament_name!r}")
seen[bambu_id] = filament_name
rows[generate_filament_id(*triple)] = {
"bambu_id": bambu_id, "vendor": triple[0], "type": triple[1], "name": triple[2]}
return rows
# ---------------------------------------------------------------------------
# Drift report (informational only)
# ---------------------------------------------------------------------------
def drift_report(rows, orca_analysis):
"""Two triple-identity comparisons between the map just derived and Orca's
own BBL bundle (never id-based: the two bundles assign filament_id
independently, so only the (vendor, type, name) identity is comparable).
Returns print-ready lines: one per BambuStudio row triple with no
same-triple filament in Orca's BBL bundle (upstream ships it, we ship
nothing with that identity there sometimes a genuinely missing product,
sometimes a renamed one), then a count of Orca's own BBL filaments that
matched no BambuStudio row (Orca-only products, e.g. a name-drifted
duplicate of one already counted in the first list).
"""
row_triples = {(r["vendor"], r["type"], r["name"]) for r in rows.values()}
bbl_filaments = orca_analysis["vendors"].get("BBL", {})
ofl_filaments = orca_analysis["vendors"].get(OFL, {})
orca_filaments = {}
for rec in bbl_filaments.values():
if not rec["instantiation"]:
continue
triple = resolve_triple(rec["name"], bbl_filaments, ofl_filaments)
orca_filaments.setdefault(base_name(rec["name"]), triple)
lines = []
for triple in sorted(row_triples - set(orca_filaments.values())):
lines.append(f'upstream ships {triple[2]!r} ({triple[0]}/{triple[1]}), '
"we ship nothing with that identity")
orca_only = [name for name, triple in orca_filaments.items()
if triple not in row_triples]
lines.append(f"Orca BBL filaments with no row: {len(orca_only)} Orca-only product(s)")
return lines
# ---------------------------------------------------------------------------
# Map IO
# ---------------------------------------------------------------------------
def write_map(path, rows, commit, date):
"""Write the map: sorted keys, indent 2, LF, trailing newline."""
payload = {
"source": BAMBUSTUDIO_REPO,
"bambustudio_commit": commit,
"generated": date,
"filaments": rows,
}
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(payload, f, indent=2, ensure_ascii=False, sort_keys=True)
f.write("\n")
# ---------------------------------------------------------------------------
# BambuStudio fetch
# ---------------------------------------------------------------------------
def run(command, cwd=None):
"""Run a command, streaming its output; raise SystemExit on failure."""
print("+ " + " ".join(command))
try:
subprocess.run(command, cwd=cwd, check=True)
except subprocess.CalledProcessError as e:
raise SystemExit(f"command failed ({e.returncode}): {' '.join(command)}") from e
def run_out(command, cwd=None):
"""Run a command and return its stripped stdout; raise SystemExit on failure."""
try:
result = subprocess.run(command, cwd=cwd, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
raise SystemExit(
f"command failed ({e.returncode}): {' '.join(command)}\n{e.stderr}") from e
return result.stdout.strip()
def fetch_bambustudio(ref, workdir):
run(["git", "clone", "--depth=1", "--filter=blob:none", "--sparse", "--branch", ref,
BAMBUSTUDIO_REPO + ".git", workdir])
run(["git", "-C", workdir, "sparse-checkout", "set", "--no-cone",
"resources/profiles/BBL.json", "resources/profiles/BBL/filament"])
return os.path.join(workdir, "resources", "profiles"), run_out(["git", "-C", workdir, "rev-parse", "--short", "HEAD"])
def local_dir_commit(dir_path):
"""git rev-parse --short HEAD of dir_path, or "local" when it is not a repo."""
result = subprocess.run(["git", "-C", dir_path, "rev-parse", "--short", "HEAD"],
capture_output=True, text=True)
return result.stdout.strip() if result.returncode == 0 else "local"
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(argv=None):
parser = argparse.ArgumentParser(
description="Regenerate resources/printers/bambu_filament_ids.json from "
"BambuStudio's shipped BBL bundle.")
source = parser.add_mutually_exclusive_group()
source.add_argument("--bambustudio-dir", metavar="DIR",
help="a BambuStudio resources/profiles directory to read "
"instead of cloning")
source.add_argument("--ref", default="master",
help='BambuStudio git ref to shallow-clone when '
'--bambustudio-dir is not given (default: "master")')
parser.add_argument("--output", default=BAMBU_MAP_PATH,
help="output path (default: resources/printers/bambu_filament_ids.json)")
args = parser.parse_args(argv)
workdir = None
if args.bambustudio_dir:
profiles_dir = args.bambustudio_dir
commit = local_dir_commit(profiles_dir)
else:
workdir = tempfile.mkdtemp(prefix="bambustudio_")
profiles_dir, commit = fetch_bambustudio(args.ref, workdir)
try:
bs_filaments, errors = load_vendor_filaments(profiles_dir, "BBL")
if errors:
for e in errors:
print_error(e)
raise SystemExit("unreadable BambuStudio filament profile(s)")
rows = derive_rows(bs_filaments)
write_map(args.output, rows, commit, datetime.date.today().isoformat())
print_success(f"wrote {len(rows)} row(s) to {args.output} (BambuStudio @ {commit})")
for line in drift_report(rows, analyze_tree(PROFILES_DIR)):
print_info(line)
finally:
if workdir:
shutil.rmtree(workdir, ignore_errors=True)
return 0
if __name__ == "__main__":
sys.exit(main())