This commit is contained in:
Lam Wei Lun
2026-09-21 17:01:46 +08:00
3240 changed files with 299310 additions and 103072 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)"
+12 -8
View File
@@ -21,8 +21,10 @@
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.
archives - lands under a per-user cache directory (%LOCALAPPDATA%\orca-profile-check) and
is reused on the next run. Being outside the checkout, that directory is shared by every
worktree on the machine. It 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
@@ -38,8 +40,7 @@
under emulation on ARM64.
.PARAMETER ProfilesDir
Profile tree to validate (default: resources\profiles). profile_tool always looks at the
tree next to the script, so this only redirects the validator checks.
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
@@ -61,8 +62,8 @@
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.
Downloads, logs and fixture trees (default: %LOCALAPPDATA%\orca-profile-check). 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).
@@ -213,7 +214,10 @@ if ($Vendor) {
$VendorArgs = if ($Vendor) { @('-v', $Vendor) } else { @() }
$VendorPyArgs = if ($Vendor) { @('--vendor', $Vendor) } else { @() }
if (-not $WorkDir) { $WorkDir = Join-Path $RepoRoot '.test\check_profiles' }
if (-not $WorkDir) {
# Per-user cache dir, so every worktree on the machine shares one set of downloads.
$WorkDir = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'orca-profile-check'
}
$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
@@ -440,7 +444,7 @@ function Expand-VendorPresets([string] $Zip, [string] $Tree, [string] $Prefix) {
$CheckBodies = @{
profile_tool = {
Invoke-Tool -Exe (Resolve-Python) -Arguments (@((Join-Path $RepoRoot 'scripts\orca_profile_tool.py'), 'check') + $VendorPyArgs)
Invoke-Tool -Exe (Resolve-Python) -Arguments (@((Join-Path $RepoRoot 'scripts\orca_profile_tool.py'), 'check', '--profiles', $ProfilesDir) + $VendorPyArgs)
}
validate_system = {
+23 -13
View File
@@ -7,8 +7,9 @@
# 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.
# archives - lands under a per-user cache directory and is reused on the next run. Being outside
# the checkout, that directory is shared by every worktree on the machine. It 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
@@ -33,9 +34,20 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
HOST_ARCH="$(uname -m)"
HOST_OS="$(uname -s)"
case "${HOST_OS}" in
Darwin*) HOST_OS=Darwin ;;
MINGW*|MSYS*|CYGWIN*) HOST_OS=Windows ;;
Linux*) HOST_OS=Linux ;;
esac
PROFILES_DIR="${REPO_ROOT}/resources/profiles"
WORK_DIR="${REPO_ROOT}/.test/check_profiles"
case "${HOST_OS}" in
Darwin) DEFAULT_WORK_DIR="${HOME}/Library/Caches/orca-profile-check" ;;
Windows) DEFAULT_WORK_DIR="${LOCALAPPDATA:-${HOME}/AppData/Local}/orca-profile-check" ;;
*) DEFAULT_WORK_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/orca-profile-check" ;;
esac
WORK_DIR="${DEFAULT_WORK_DIR}"
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.
@@ -75,16 +87,14 @@ Options:
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)
--work-dir DIR downloads, logs and fixture trees (default: ${DEFAULT_WORK_DIR})
-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. It always looks at the tree next to
the script (<repo>/resources/profiles); --profiles only redirects the validator checks,
because validating another tree's ids needs that tree's own filament_id snapshot too.
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
@@ -306,8 +316,8 @@ EOF
# 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_OS}" in
Linux)
case "${HOST_ARCH}" in
arm64|aarch64) msg "the nightly Linux validator is x86_64; build it locally for ${HOST_ARCH}" ;;
esac
@@ -315,7 +325,7 @@ download_validator() {
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Linux_Ubuntu2404_nightly" "${binary}" || return 1
chmod +x "${binary}" || return 1
;;
Darwin*)
Darwin)
dmg="${dest}/OrcaSlicer_profile_validator.dmg"
app="${dest}/OrcaSlicer_profile_validator.app"
binary="${app}/Contents/MacOS/OrcaSlicer_profile_validator"
@@ -334,13 +344,13 @@ download_validator() {
[ -x "${binary}" ] || { msg "no validator app inside ${dmg}"; return 1; }
fi
;;
MINGW*|MSYS*|CYGWIN*)
Windows)
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"
msg "no nightly validator published for ${HOST_OS}; build it (-DORCA_TOOLS=ON) and pass --validator"
return 1
;;
esac
@@ -366,7 +376,7 @@ resolve_validator() {
# ---------------------------------------------------------------------------- checks
check_profile_tool() {
python3 "${REPO_ROOT}/scripts/orca_profile_tool.py" check --vendor "${VENDOR}"
python3 "${REPO_ROOT}/scripts/orca_profile_tool.py" check --profiles "${PROFILES_DIR}" --vendor "${VENDOR}"
}
check_validate_system() {
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -223,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
@@ -325,12 +331,14 @@ modules:
sha256: deedcabe339165214a3637df4c86a507aef0d793cf8774ff68735f4737e8ddbc
dest: external-packages/FFMPEG
# libdatachannel v0.22.2
# libdatachannel v0.24.5
# The Git checkout includes the submodules that are missing from the
# GitHub source archive. DataChannel.cmake consumes it as SOURCE_DIR.
- type: git
url: https://github.com/paullouisageneau/libdatachannel.git
tag: v0.22.2
commit: b3390b4e01e97071dd054684870c4bb5221794bf
dest: deps/build_flatpak/dep_DataChannel-prefix/src/dep_DataChannel
tag: v0.24.5
commit: 443f6934d9007eb7076ab7825ba330f355fcbead
dest: external-packages/DataChannel
# ---------------------------------------------------------------
# Fallback archives for deps normally provided by the GNOME SDK.
@@ -385,7 +393,7 @@ 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
# Built (not run) here when CI injects 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
+88 -287
View File
@@ -10,23 +10,21 @@ commands:
normalize rewrite profile files into their canonical shape
trim delete profile files no <vendor>.json list references
update-index regenerate the *_list sections of <vendor>.json
update-snapshot re-record scripts/filament_id_snapshot.json
options shared by several commands:
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all
(every command but update-snapshot)
--profile-type TYPE one of machine_model/process/filament/machine; repeatable
(normalize, trim, update-index)
--dry-run report what would change and write nothing (every command
that writes)
--profiles DIR act on another profile tree (default: resources/profiles);
check and update-snapshot then need --snapshot PATH too,
since the snapshot describes resources/profiles alone
--profiles DIR act on another profile tree (default: resources/profiles)
After adding, renaming or deleting profile files, run:
normalize -> trim -> update-index -> generate-id -> update-snapshot -> check
Each step feeds the next: normalize writes the "type" update-index files a
profile by, and trim judges against the index update-index is about to rebuild.
normalize -> update-index -> generate-id -> check
normalize supplies missing types; update-index registers presets before id
generation.
Use trim only for deliberate cleanup, previewed with --dry-run: it judges against
the current index and can delete newly added, unindexed presets.
Run from anywhere; "python scripts/orca_profile_tool.py --help" repeats this list
and "... <command> --help" documents one command in full.
@@ -53,8 +51,8 @@ filament_id policy (see docs/HLSD/filament_id.md):
filament_id = "OF" + base62_6( uuid5(FILAMENT_ID_NAMESPACE,
"filament_product/<filament_vendor>/<filament_type>/<filament_name>") )
8 chars total, which satisfies the AMS length limit. Nobody invents ids by
hand, and nothing but the triple feeds the mint — not the rest of the tree,
not the snapshot. Two products whose triples mint one id (a base62
hand, and nothing but the triple feeds the mint — not the rest of the tree.
Two products whose triples mint one id (a base62
collision; odds ~1e-5 over the whole tree) is an error --check reports and
--generate refuses to write; the remedy is a rename so the triples differ,
never a salted or hand-picked second id.
@@ -68,12 +66,6 @@ filament_id policy (see docs/HLSD/filament_id.md):
the app applies at the printer boundary), the QD_* ids a Qidi box composes at
runtime, and the P+7-hex ids CreatePresetsDialog.cpp gives user-created
filaments all fail the format rule like any other stray value.
* scripts/filament_id_snapshot.json is the sanctioned-state snapshot: one
entry per id, carrying the product triple it is minted from and the
"Vendor/Filament" presets claiming it. It must exactly equal the tree-derived
state at all times, so any id/claim/triple change shows up as a reviewable
diff to that file (the maintainer gate). It sanctions state, never
exceptions: no check consults it to excuse a preset from the rules above.
setting_id policy (see AGENTS.md "Critical Constraints"):
* setting_id is a PRESET id, a pure function of the preset's identity:
@@ -121,13 +113,15 @@ FILAMENT_ID_LENGTH = 6 # base62 digits after the "OF" prefix -> 8 chars total
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
PROFILES_DIR = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "resources", "profiles"))
SNAPSHOT_PATH = os.path.join(SCRIPTS_DIR, "filament_id_snapshot.json")
# The single source of truth for the map path; update_bambu_filament_ids.py
# imports this rather than recomputing it.
BAMBU_MAP_PATH = os.path.normpath(
os.path.join(SCRIPTS_DIR, "..", "resources", "printers", "bambu_filament_ids.json"))
OFL = "OrcaFilamentLibrary"
# The validator's data dir, created under resources/profiles by a local run;
# not a vendor bundle, so an unscoped pass leaves it alone.
USER_DIR = "user"
# Bambu (BBL) is the only vendor exempt from the setting_id rule: it keeps its
# authoritative "G*" cloud ids. No vendor is exempt from the filament_id rule.
@@ -146,7 +140,9 @@ PROFILE_TYPES = ("machine_model", "process", "filament", "machine")
# Data files that sit under a vendor bundle but are not presets: no name, no type.
NON_PROFILE_FILES = {"filaments_color_codes.json", "cli_config.json"}
# Settings dropped from PrintConfig.cpp. Reported by "check --obsolete-keys".
# Mirror PrintConfigDef::handle_legacy's ignore set in PrintConfig.cpp; a test
# checks parity. Used by normalize and check. Active options and
# legacy aliases that the loader migrates do not belong here.
OBSOLETE_KEYS = {
"acceleration", "scale", "rotate", "duplicate", "duplicate_grid",
"bed_size", "print_center", "g0", "wipe_tower_per_color_wipe",
@@ -158,10 +154,11 @@ OBSOLETE_KEYS = {
"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",
"retraction_distance_when_cut", "internal_bridge_support_thickness",
"top_area_threshold", "reduce_wall_solid_infill",
"filament_load_time", "filament_unload_time", "smooth_coefficient",
"overhang_totally_speed", "silent_mode", "overhang_speed_classic"
"overhang_totally_speed", "silent_mode", "overhang_speed_classic",
"anisotropic_surfaces",
}
# Keys renamed at some point, whose old and new spellings must never co-exist:
@@ -185,7 +182,6 @@ _JSON_STR = r'"(?:[^"\\]|\\.)*"'
GENERATE_CMD = "python scripts/orca_profile_tool.py generate-id"
SETTING_ID_CMD = '"python scripts/orca_profile_tool.py generate-id --setting-id"'
UPDATE_HINT = 'run "python scripts/orca_profile_tool.py update-snapshot" and commit the diff for maintainer review'
BAMBU_MAP_HINT = 'regenerate the map with "python scripts/update_bambu_filament_ids.py" and commit the diff for maintainer review'
NORMALIZE_HINT = 'try "python scripts/orca_profile_tool.py normalize" to fix common issues automatically'
@@ -238,8 +234,8 @@ def _base62_tail(n, length):
"""The low `length` base62 digits of n, most-significant first.
The shared tail of both id rules. Its output bytes are pinned by the C++
golden vectors (tests/libslic3r/test_preset_setting_id.cpp) and by the
filament_id snapshot — never change it.
golden vectors (tests/libslic3r/test_preset_setting_id.cpp) and by every
filament_id in the tree — never change it.
"""
digits = []
for _ in range(length):
@@ -475,9 +471,8 @@ def resolve_triple(name, filaments, ofl_filaments):
def analyze_tree(profiles_dir):
"""Load every vendor bundle and derive the full filament_id state.
Returns a dict with the tree-derived snapshot sections plus the working data
the checks and the assign pass need. All claims are "Vendor/Filament" strings
over INSTANTIATED system filaments, tree-wide including OFL and BBL.
Returns a dict of the tree-derived state the checks and the assign pass need,
tree-wide including OFL and BBL.
"""
profiles_dir = str(profiles_dir)
vendor_names = list_vendor_names(profiles_dir)
@@ -499,11 +494,6 @@ def analyze_tree(profiles_dir):
rec["id_source"] = src
vendors[vendor] = filaments
# id -> set of "Vendor/Filament" claims over instantiated presets. Every id
# occurring in the tree is a key; ids only ever DECLARED (e.g. on a root
# none of whose descendants instantiate) keep an empty claim list, so that
# the snapshot exactly equals the tree-derived state.
ids = {}
vendor_ids = {} # vendor -> set of ids occurring there (declared or effective)
declared_ids = {} # vendor -> set of ids DECLARED in that vendor's own files
missing_effective = [] # (vendor, name, file) instantiated presets resolving no id
@@ -524,7 +514,6 @@ def analyze_tree(profiles_dir):
fid = rec["filament_id"]
occurring.add(fid)
declared_ids.setdefault(vendor, set()).add(fid)
ids.setdefault(fid, set())
declarer_triples.append((vendor, rec, fid, triple))
triples.setdefault(fid, set()).add(triple)
filament_triples.setdefault(
@@ -537,11 +526,10 @@ def analyze_tree(profiles_dir):
missing_effective.append((vendor, rec["name"], rec["file"]))
continue
occurring.add(eff)
ids.setdefault(eff, set()).add(f"{vendor}/{base_name(rec['name'])}")
if not rec.get("filament_id") and OF_ID_RE.match(eff):
inherited.append((vendor, rec, eff, triple))
# Cross-bundle triple divergence (check 4, warning only): the same filament
# Cross-bundle triple divergence (check 3, warning only): the same filament
# name declared in several bundles with different triples cannot converge
# on one id until the divergence is fixed.
name_bundles = {}
@@ -556,7 +544,6 @@ def analyze_tree(profiles_dir):
return {
"vendors": vendors,
"read_errors": read_errors,
"ids": {fid: sorted(claims) for fid, claims in ids.items()},
"vendor_ids": vendor_ids,
"declared_ids": declared_ids,
"missing_effective": sorted(missing_effective),
@@ -570,57 +557,16 @@ def analyze_tree(profiles_dir):
}
# ---------------------------------------------------------------------------
# Snapshot IO
# ---------------------------------------------------------------------------
def snapshot_from_analysis(analysis):
"""One entry per id, in id order: the product triple it is minted from and
the "Vendor/Filament" claims on it. Requires exactly one declared triple per
id (update_snapshot refuses any other state; check 3 rejects it anyway)."""
ids = {}
for fid, claims in sorted(analysis["ids"].items()):
[(vendor, ftype, filament_name)] = analysis["triples"][fid]
ids[fid] = {"filaments": sorted(claims), "name": filament_name,
"filament_type": ftype, "filament_vendor": vendor}
return {"ids": ids}
def snapshot_triple(entry):
return [entry["filament_vendor"], entry["filament_type"], entry["name"]]
def load_snapshot(path):
"""Return the snapshot dict, or None when the file does not exist."""
if not os.path.exists(path):
return None
data = load_json(path)
data.setdefault("ids", {})
return data
def write_snapshot(path, obj):
"""Deterministic serialization: snapshot_from_analysis order, indent 1, LF,
trailing newline."""
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(obj, f, indent=1, ensure_ascii=False)
f.write("\n")
# ---------------------------------------------------------------------------
# filament_id validation
# ---------------------------------------------------------------------------
def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
map_path=BAMBU_MAP_PATH):
def check_filament_ids(profiles_dir=PROFILES_DIR, map_path=BAMBU_MAP_PATH):
"""Validate filament_id state across every vendor. Returns the error count.
1. Format: every id occurring in the tree (declared or effective) must
match ^OF[0-9A-Za-z]{6}$. No exceptions: not the snapshot, not BBL.
2. Snapshot equality, both directions: every id in the tree, the filaments
claiming it and the triple its declarers resolve must equal the snapshot
entry exactly (the snapshot diff is the maintainer gate).
3. Identity: the id is a function of the triple alone, and there is no
match ^OF[0-9A-Za-z]{6}$. No exceptions, not even BBL.
2. Identity: the id is a function of the triple alone, and there is no
second acceptable value. (a) A declared id must equal the one id the
declarer's own triple mints; (b) the id an instantiated preset inherits
must equal the one ITS own triple mints — how it inherits it (a root, a
@@ -628,31 +574,22 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
filament resolves an effective id at all (an id-less one is a hard load
error in C++); (d) no two products mint one id (a base62 collision,
resolved by renaming one of them).
4. Triple integrity: (a) every declarer resolves non-empty filament_vendor
3. Triple integrity: (a) every declarer resolves non-empty filament_vendor
and filament_type; (b) declarers of one (bundle, filament) resolve
identical triples; cross-bundle divergence on the same filament name is a
warning only.
5. Bambu catalog map: resources/printers/bambu_filament_ids.json must parse,
4. Bambu catalog map: resources/printers/bambu_filament_ids.json must parse,
carry source/bambustudio_commit/generated, key only OF-format ids, map
each Bambu id at most once, and for every row whose key the tree claims,
the tree's triple for that id must equal the row's (vendor, type, name).
Nothing is grandfathered: the snapshot sanctions state, never exceptions.
"""
_utf8_console()
errors = 0
analysis = analyze_tree(profiles_dir)
snapshot = load_snapshot(snapshot_path)
if snapshot is None:
print_error(f"filament_id snapshot not found at {snapshot_path}; {UPDATE_HINT}")
return 1
for msg in analysis["read_errors"]:
print_error(msg)
errors += 1
snap_ids = snapshot["ids"]
tree_ids = analysis["ids"]
# -- 1. format ----------------------------------------------------------
for vendor in sorted(analysis["vendor_ids"]):
for fid in sorted(analysis["vendor_ids"][vendor]):
@@ -663,47 +600,7 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
f'filament ids must come from "{GENERATE_CMD}"')
errors += 1
# -- 2. snapshot equality (both directions) -----------------------------
tree_triples = analysis["triples"]
for fid in sorted(tree_ids):
entry = snap_ids.get(fid)
if entry is None:
print_error(
f'filament_id "{fid}" is not sanctioned by '
f"scripts/filament_id_snapshot.json; {UPDATE_HINT}")
errors += 1
continue
for claim in tree_ids[fid]:
if claim not in entry["filaments"]:
print_error(
f'filament_id "{fid}" claim "{claim}" is not sanctioned by '
f"scripts/filament_id_snapshot.json; {UPDATE_HINT}")
errors += 1
# Every tree id has at least one declarer; the snapshot records one
# triple per id, so a divergent declarer is a mismatch in both directions.
sanctioned = snapshot_triple(entry)
for t in tree_triples[fid]:
if t != sanctioned:
print_error(
f'filament_id "{fid}" triple "{"/".join(t)}" is not sanctioned by '
f'scripts/filament_id_snapshot.json, which records '
f'"{"/".join(sanctioned)}"; {UPDATE_HINT}')
errors += 1
for fid in sorted(snap_ids):
if fid not in tree_ids:
print_error(
f'filament_id stability: snapshot id "{fid}" vanished from the tree; '
f"{UPDATE_HINT}")
errors += 1
continue
for claim in snap_ids[fid]["filaments"]:
if claim not in tree_ids[fid]:
print_error(
f'filament_id stability: snapshot claim "{claim}" of id "{fid}" '
f"vanished from the tree; {UPDATE_HINT}")
errors += 1
# -- 3. identity: the id is a function of the triple alone ---------------
# -- 2. identity: the id is a function of the triple alone ---------------
# One triple, one id: a declaration must carry exactly the mint of its
# triple, and there is no second acceptable value — not a salt, not a
# hand-picked one, not whatever another preset of the product carries. Two
@@ -719,10 +616,9 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
f'filament_id "{fid}" declared by "{rec["name"]}" ({rec["file"]}) does '
f'not match the mint of its triple "{"/".join(triple)}": expected '
f'"{want}"; paste the expected id, or fix the triple and run '
f'"{GENERATE_CMD} --vendor {vendor}" (preview with --dry-run), then '
f"--update-snapshot")
f'"{GENERATE_CMD} --vendor {vendor}" (preview with --dry-run)')
errors += 1
# (3b) An inherited id is held to the same single value, and every preset
# (2b) An inherited id is held to the same single value, and every preset
# missing it is listed — a variant under a wrong root as much as a preset
# riding another product's root. Nothing is folded into the declarer's
# error: the report names each preset whose id is wrong.
@@ -747,7 +643,7 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
f'run "{GENERATE_CMD}" (expected id for filament '
f'"{vendor}/{base_name(name)}": "{expected}")')
errors += 1
# (3d) The mint is injective over the tree's products, or two of them are
# (2d) The mint is injective over the tree's products, or two of them are
# indistinguishable to every device that matches on the id.
for fid, ts in sorted(analysis["collisions"].items()):
print_error(
@@ -756,7 +652,7 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
f"of them so their triples differ")
errors += 1
# -- 4. triple integrity ---------------------------------------------------
# -- 3. triple integrity ---------------------------------------------------
for vendor, rec, fid, triple in sorted(
analysis["declarer_triples"], key=lambda x: (x[0], x[1]["file"])):
if triple[0] and triple[1]:
@@ -789,7 +685,7 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
f"({detail}); bundles of one product converge on one id only once "
f"their triples agree")
# -- 6. Bambu catalog map --------------------------------------------------
# -- 4. Bambu catalog map --------------------------------------------------
try:
bambu_map = load_json(map_path)
if not isinstance(bambu_map, dict):
@@ -830,7 +726,7 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
errors += 1
else:
bambu_id_owners[bambu_id] = fid
claimed = tree_triples.get(fid)
claimed = analysis["triples"].get(fid)
if not claimed:
continue # a product BambuStudio ships that the tree does not (yet)
row_triple = [row.get("vendor", ""), row.get("type", ""), row.get("name", "")]
@@ -1050,13 +946,12 @@ def load_available_filament_profiles(profiles_dir, vendor):
def check_machine_default_materials(profiles_dir, vendor):
"""Every default material a machine names must exist, in the bundle or in OFL.
Returns (errors, warnings); the warning is the bundle having no machine/ at all.
Returns (errors, warnings); a bundle with no machine/ has nothing to check.
"""
error_count = 0
machine_dir = Path(profiles_dir) / vendor / "machine"
if not machine_dir.exists():
print_warning(f"No machine profiles found for vendor: {vendor}")
return 0, 1
return 0, 0
available = (load_available_filament_profiles(profiles_dir, vendor)
| load_available_filament_profiles(profiles_dir, OFL))
@@ -1246,7 +1141,7 @@ def check_filament_id_length(profiles_dir, vendor):
def check_obsolete_keys(profiles_dir, vendor):
"""Warn about settings PrintConfig.cpp no longer defines. Returns the count."""
"""Warn about settings PrintConfig.cpp explicitly discards. Returns the count."""
warn_count = 0
profiles_path = Path(profiles_dir)
vendor_path = profiles_path / vendor / "filament"
@@ -1397,16 +1292,17 @@ def check_normalized(profiles_dir, vendor):
# check
# ---------------------------------------------------------------------------
def check_profiles(profiles_dir=PROFILES_DIR, vendors=None, snapshot_path=SNAPSHOT_PATH,
materials=False, obsolete_keys=False):
def check_profiles(profiles_dir=PROFILES_DIR, vendors=None):
"""Validate the whole profile tree. Returns the error count.
The per-vendor checks honour `vendors`; the setting_id and filament_id checks are
cross-vendor properties a narrowed run cannot answer, so they always cover the
whole tree. With no `vendors`, OrcaFilamentLibrary is left out of the per-vendor
pass: it is the shared base bundle, its filaments are generic by design, and they
are checked through the vendors that inherit them. Naming it explicitly checks it.
The normalization pass covers it either way - see the comment on that loop.
whole tree. With no `vendors`, every bundle is checked except the `user` directory
a local validator run leaves behind, being its data dir rather than a bundle;
naming it explicitly checks it. OrcaFilamentLibrary is checked like any other
bundle, its only exemption being that a library filament may leave
compatible_printers empty - what check_filament_compatible_printers applies. The
normalization pass takes its own vendor list - see the comment on that loop.
"""
print_info("Checking profiles ...")
errors_found = 0
@@ -1416,19 +1312,17 @@ def check_profiles(profiles_dir=PROFILES_DIR, vendors=None, snapshot_path=SNAPSH
if vendors:
checked = list(vendors)
else:
checked = [v for v in list_profile_dirs(profiles_dir) if v != OFL]
checked = [v for v in list_profile_dirs(profiles_dir) if v != USER_DIR]
for vendor in checked:
errors_found += check_preset_name_uniqueness(profiles_dir, vendor)
errors_found += check_filament_compatible_printers(profiles_dir, vendor)
if materials:
new_errors, new_warnings = check_machine_default_materials(profiles_dir, vendor)
errors_found += new_errors
warnings_found += new_warnings
new_errors, new_warnings = check_machine_default_materials(profiles_dir, vendor)
errors_found += new_errors
warnings_found += new_warnings
if obsolete_keys:
warnings_found += check_obsolete_keys(profiles_dir, vendor)
warnings_found += check_obsolete_keys(profiles_dir, vendor)
new_errors, new_warnings = check_name_consistency(profiles_dir, vendor)
errors_found += new_errors
@@ -1445,12 +1339,11 @@ def check_profiles(profiles_dir=PROFILES_DIR, vendors=None, snapshot_path=SNAPSH
errors_found += new_errors
remedies.update(gaps)
# normalize and update-index know nothing of the OrcaFilamentLibrary exemption
# above - that bundle sits out the per-vendor pass because its filaments are
# generic by design, which says nothing about the shape of its files - so this pass
# takes its own vendor list. Unscoped that is the bundles with an index, exactly
# what those two commands take; a --vendor is passed through as given, so a bundle
# whose index has not landed yet still has its files held to what normalize writes.
# normalize and update-index judge file and index shape, not the preset-content
# rules above, so this pass takes its own vendor list. Unscoped that is the
# bundles with an index, exactly what those two commands take; a --vendor is
# passed through as given, so a bundle whose index has not landed yet still has
# its files held to what normalize writes.
for vendor in (vendors or list_vendor_names(profiles_dir)):
new_errors, gaps = check_normalized(profiles_dir, vendor)
errors_found += new_errors
@@ -1463,7 +1356,7 @@ def check_profiles(profiles_dir=PROFILES_DIR, vendors=None, snapshot_path=SNAPSH
# Cross-vendor checks: setting_id uniqueness and the whole filament_id state,
# both validated over the entire tree regardless of --vendor.
errors_found += check_setting_id_uniqueness(profiles_dir)
errors_found += check_filament_ids(profiles_dir, snapshot_path)
errors_found += check_filament_ids(profiles_dir)
print("\n==================== SUMMARY ====================")
print_info(f"Checked vendors : {len(checked)}")
@@ -1481,69 +1374,6 @@ def check_profiles(profiles_dir=PROFILES_DIR, vendors=None, snapshot_path=SNAPSH
return errors_found
# ---------------------------------------------------------------------------
# update-snapshot
# ---------------------------------------------------------------------------
def update_snapshot(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH, dry_run=False):
"""Regenerate the snapshot from the tree.
Refuses to sanction a tree it could not read whole, and an id declared under
more than one triple: neither state can be recorded truthfully, so writing it
would only hide the mistake until CI. It does not judge the ids themselves —
the snapshot records state and check judges it, so an id that is not a mint
lands in the diff and fails check 1.
Idempotent: a second run over an unchanged tree changes nothing. Returns 0
on success.
"""
analysis = analyze_tree(profiles_dir)
# A tree that could not be read whole cannot be sanctioned: the snapshot
# would silently drop the unreadable bundle's ids and claims, and the diff
# would read as a deliberate removal.
refusals = len(analysis["read_errors"])
for msg in analysis["read_errors"]:
print_error(msg)
for fid, ts in sorted(analysis["triples"].items()):
if len(ts) > 1:
print_error(
f'refusing to sanction filament_id "{fid}": declared under {len(ts)} '
f'triples ({"; ".join("/".join(t) for t in ts)}); one id names one '
f"product (check 3)")
refusals += 1
if refusals:
return 1
new_snap = snapshot_from_analysis(analysis)
old_snap = load_snapshot(snapshot_path)
old_ids = old_snap["ids"] if old_snap else {}
# Diff summary.
added_ids = sorted(set(new_snap["ids"]) - set(old_ids))
removed_ids = sorted(set(old_ids) - set(new_snap["ids"]))
added_claims = sum(
len(set(entry["filaments"]) - set(old_ids.get(fid, {}).get("filaments", [])))
for fid, entry in new_snap["ids"].items())
removed_claims = sum(
len(set(entry["filaments"]) - set(new_snap["ids"].get(fid, {}).get("filaments", [])))
for fid, entry in old_ids.items())
changed = new_snap != (old_snap or {"ids": {}})
if changed and not dry_run:
write_snapshot(snapshot_path, new_snap)
print_info(f"snapshot ids : {len(new_snap['ids'])} (+{len(added_ids)} / -{len(removed_ids)})")
print_info(f"claims added : {added_claims}")
print_info(f"claims removed : {removed_claims}")
if changed and dry_run:
print_success(f"dry run: {snapshot_path} would be rewritten; nothing written")
elif changed:
print_success(f"snapshot written to {snapshot_path}")
else:
print_success("snapshot already up to date; nothing changed")
return 0
# ---------------------------------------------------------------------------
# Byte-preserving profile edits
# ---------------------------------------------------------------------------
@@ -1687,9 +1517,9 @@ def generate_filament_ids(profiles_dir=PROFILES_DIR, vendors=None, dry_run=False
* an instantiated filament that resolves no id at all gets one inserted
into its root(s): the id-less presets of the SAME filament its members
inherit, or the member itself (a parent of another filament cannot carry
this filament's id — check 3).
this filament's id — check 2).
A declaration is left alone exactly when it already equals the one id its
triple mints (check 3). Two products minting one id (check 3d) are reported
triple mints (check 2). Two products minting one id (check 2d) are reported
and left unwritten: nothing salts past a collision, a rename resolves it.
`vendors` restricts what is WRITTEN; the id is a function of the triple
@@ -1697,9 +1527,7 @@ def generate_filament_ids(profiles_dir=PROFILES_DIR, vendors=None, dry_run=False
reports whatever it was not allowed to touch. `changed_paths`, when a set is
passed, collects the files that changed. A file whose layout offers no
anchor for the edit is reported and counted as an error, so one odd profile
cannot abort the pass over all the others. Never reads or touches the
snapshot — run --update-snapshot afterwards and review the diff. Returns
(files_changed, errors).
cannot abort the pass over all the others. Returns (files_changed, errors).
"""
_utf8_console()
analysis = analyze_tree(profiles_dir)
@@ -1954,9 +1782,9 @@ def run_generate_id(profiles_dir, vendors, filament_id, setting_id, dry_run):
do_filament = filament_id or not setting_id
do_setting = setting_id or not filament_id
changed = set() # one file the two passes both touch is still one file
filament_files = errors = 0
errors = 0
if do_filament:
filament_files, e = generate_filament_ids(profiles_dir, vendors, dry_run, changed)
_n, e = generate_filament_ids(profiles_dir, vendors, dry_run, changed)
errors += e
if do_setting:
_n, e = generate_setting_ids(profiles_dir, vendors, dry_run, changed)
@@ -1968,11 +1796,6 @@ def run_generate_id(profiles_dir, vendors, filament_id, setting_id, dry_run):
print_error(f"{summary}; {errors} error(s)")
else:
print_success(summary)
if filament_files and not dry_run:
# A filament_id write may or may not move the sanctioned state (an id repaired
# back to the value the snapshot already records does not), so regenerate and
# let the diff - empty or not - say.
print_warning(f"now {UPDATE_HINT}")
return 1 if errors else 0
@@ -2061,6 +1884,10 @@ def _normalize_profile(data, sub):
del data[field]
changes.append(f"remove {field}")
for field in sorted(OBSOLETE_KEYS.intersection(data)):
del data[field]
changes.append(f"remove {field}")
# BBS renamed extruder_clearance_radius to extruder_clearance_max_radius, but some
# profiles carry both with different values, and the slicer cannot tell which one
# to obey - a toolhead collision waiting to happen. Keep the larger one only.
@@ -2306,8 +2133,9 @@ def build_index_sections(profiles_dir, vendor, profile_types=None):
one message each, for the caller to report. `sections` is None when two files claim
one preset name: the bundle can only hold one profile under a name, so rebuilding
would pick a winner by directory order and quietly drop the other, and the index has
to be left alone instead. Deleting the stale copy is trim's job, which is why it
runs before this.
to be left alone instead. Identify the intended preset and delete or rename the
duplicate before retrying. Use trim only for deliberate unindexed-file cleanup,
previewed with --dry-run.
"""
vendor_dir = os.path.join(profiles_dir, vendor)
sections = {}
@@ -2360,8 +2188,8 @@ def build_index_sections(profiles_dir, vendor, profile_types=None):
problems.append(f'{vendor}.json: {len(subs)} profiles are named "{name}" '
f'({", ".join(sorted(subs))}); only one can be indexed under '
f"that name, so delete or rename the others - "
f'"python scripts/orca_profile_tool.py trim" removes an '
f"unindexed copy")
f"preview unindexed-file cleanup with "
f'"python scripts/orca_profile_tool.py trim --dry-run"')
return (None if clashes else sections), problems
@@ -2429,13 +2257,13 @@ examples:
preview exactly that; writes nothing
orca_profile_tool.py generate-id --setting-id --vendor Elegoo
setting_id only, and only in that bundle
orca_profile_tool.py update-snapshot
re-record the sanctioned filament_id state after a generate-id run
after adding, renaming or deleting profile files, run in this order:
normalize -> trim -> update-index -> generate-id -> update-snapshot -> check
each step feeds the next: normalize writes the "type" update-index files a
profile by, and trim judges against the index update-index is about to rebuild.
normalize -> update-index -> generate-id -> check
normalize supplies missing types; update-index registers presets before id
generation.
Use trim only for deliberate cleanup, previewed with --dry-run: it judges against
the current index and can delete newly added, unindexed presets.
"""
@@ -2461,12 +2289,6 @@ def build_parser():
dry_run_opt.add_argument("--dry-run", "--dryrun", dest="dry_run", action="store_true",
help="report what would change and write nothing")
snapshot_opt = argparse.ArgumentParser(add_help=False)
snapshot_opt.add_argument("--snapshot", default=None, metavar="PATH",
help="the sanctioned filament_id state of that tree "
"(default: scripts/filament_id_snapshot.json, which "
"describes resources/profiles and no other tree)")
parser = argparse.ArgumentParser(
prog="orca_profile_tool.py", allow_abbrev=False,
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -2483,23 +2305,18 @@ def build_parser():
name, parents=parents, help=help_text, description=description,
allow_abbrev=False, formatter_class=argparse.RawDescriptionHelpFormatter)
check_cmd = add(
"check", [vendor_opt, snapshot_opt, profiles_opt],
add(
"check", [vendor_opt, profiles_opt],
"validate the whole profile tree -- what CI runs",
"Validate the whole profile tree: preset name uniqueness, index coverage\n"
"both ways, compatible_printers, conflicting and vector-typed keys,\n"
"filament_id length, that normalize and update-index would leave every\n"
"bundle alone, and the tree-wide setting_id and filament_id state.\n"
"Exits nonzero on errors.\n"
"both ways, compatible_printers, default-material references, obsolete,\n"
"conflicting and vector-typed keys, filament_id length, that normalize and\n"
"update-index would leave every bundle alone, and the tree-wide setting_id\n"
"and filament_id state. Exits nonzero on errors.\n"
"\n"
"--vendor narrows the per-vendor checks only: setting_id uniqueness and the\n"
"filament_id state are cross-vendor properties a narrowed run cannot answer,\n"
"so they always cover the whole tree.")
check_cmd.add_argument("--materials", action="store_true",
help="also check that every default material a machine names "
"exists")
check_cmd.add_argument("--obsolete-keys", action="store_true", dest="obsolete_keys",
help="also warn about settings the slicer no longer defines")
generate_cmd = add(
"generate-id", [vendor_opt, dry_run_opt, profiles_opt],
@@ -2533,6 +2350,9 @@ def build_parser():
"loader only ever reads the sub_paths listed there, so an unindexed preset\n"
"never loads.\n"
"\n"
"Use only for deliberate cleanup, previewed with --dry-run. Newly added,\n"
"unindexed presets can be deleted too; omit trim from the authoring workflow.\n"
"\n"
"Assets and data files are kept, a file that cannot be parsed is kept and\n"
"reported, and so is one a surviving profile inherits from that no indexed\n"
"profile provides -- but a stale copy of an indexed profile goes, since\n"
@@ -2546,12 +2366,9 @@ def build_parser():
"A profile is indexed under the section its own \"type\" names, so run\n"
"normalize first: it is what writes a missing type. Two files claiming one\n"
"preset name leave that index alone, because a rebuild could only keep one\n"
"of them; run trim first, which is what clears a stale copy.")
add("update-snapshot", [dry_run_opt, snapshot_opt, profiles_opt],
"re-record scripts/filament_id_snapshot.json",
"Re-record the sanctioned filament_id state after a generate-id run, and\n"
"commit the diff for maintainer review.")
"of them. Identify the intended preset and delete or rename the duplicate.\n"
"Use trim only for deliberate unindexed-file cleanup, previewed with\n"
"--dry-run; it can also delete newly authored presets.")
return parser
@@ -2578,30 +2395,14 @@ def main(argv=None):
return 1
profile_types = tuple(getattr(args, "profile_type", []) or ()) or None
snapshot_path = getattr(args, "snapshot", None)
if snapshot_path is None:
if (args.command in ("check", "update-snapshot")
and os.path.abspath(profiles_dir) != os.path.abspath(PROFILES_DIR)):
# The repo snapshot is the sanctioned state of resources/profiles alone:
# checking another tree against it is meaningless, and re-recording one
# into it would overwrite the tracked file with a foreign tree's state.
parser.error(f"{args.command} reads and writes the sanctioned state of the "
f"tree it is given, so --profiles needs --snapshot PATH for "
f"that tree too")
snapshot_path = SNAPSHOT_PATH
if args.command == "check":
errors = check_profiles(profiles_dir, vendors, snapshot_path,
materials=args.materials, obsolete_keys=args.obsolete_keys)
errors = check_profiles(profiles_dir, vendors)
return 1 if errors else 0
if args.command == "generate-id":
return run_generate_id(profiles_dir, vendors, args.filament_id, args.setting_id,
args.dry_run)
if args.command == "update-snapshot":
return update_snapshot(profiles_dir, snapshot_path, dry_run=args.dry_run)
if args.command == "normalize":
_changed, errors = normalize_profiles(profiles_dir, vendors, profile_types,
force=args.force, dry_run=args.dry_run)
+51 -269
View File
@@ -55,13 +55,12 @@ def preset(name, filament_id=None, inherits=None, instantiation=True,
class SyntheticTree:
"""A throwaway resources/profiles-shaped directory plus a snapshot path."""
"""A throwaway resources/profiles-shaped directory."""
def __init__(self):
self.dir = tempfile.mkdtemp(prefix="filament_id_test_")
self.profiles = os.path.join(self.dir, "profiles")
os.makedirs(self.profiles)
self.snapshot = os.path.join(self.dir, "filament_id_snapshot.json")
def cleanup(self):
shutil.rmtree(self.dir, ignore_errors=True)
@@ -110,16 +109,6 @@ class SyntheticTree:
with open(idx_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=4, ensure_ascii=False)
def remove_preset(self, vendor, name):
os.remove(self.preset_path(vendor, name))
idx_path = os.path.join(self.profiles, vendor + ".json")
with open(idx_path, encoding="utf-8") as f:
index = json.load(f)
index["filament_list"] = [
e for e in index["filament_list"] if e["name"] != name]
with open(idx_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=4, ensure_ascii=False)
def bytes_map(self):
"""{relative path -> file bytes} over every .json in the tree."""
raw = {}
@@ -135,17 +124,11 @@ class SyntheticTree:
# -- pipeline wrappers ---------------------------------------------------
def update_snapshot(self, dry_run=False):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.update_snapshot(self.profiles, self.snapshot, dry_run)
return rc, buf.getvalue()
def check(self, map_path=None):
buf = io.StringIO()
kwargs = {} if map_path is None else {"map_path": map_path}
with contextlib.redirect_stdout(buf):
errors = afi.check_filament_ids(self.profiles, self.snapshot, **kwargs)
errors = afi.check_filament_ids(self.profiles, **kwargs)
return errors, buf.getvalue()
# assign() and remint() are the same one pass over the tree — every filament
@@ -167,8 +150,6 @@ class SyntheticTree:
def cli(self, *argv):
"""Run main() against this tree, capturing stdout."""
flags = [*argv, "--profiles", self.profiles]
if argv and argv[0] in ("check", "update-snapshot"):
flags += ["--snapshot", self.snapshot]
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main(flags)
@@ -178,10 +159,10 @@ class SyntheticTree:
def make_clean_tree(apla_id="AX01", generic_id="OGFL99"):
"""Baseline tree: OFL base+generic, a vendor filament, a clean tuned generic.
apla_id/generic_id default to arbitrary non-OF placeholders (sanctioned by
the snapshot below) since most tests only need "already assigned, don't
touch" and never run the checks. TestAssign and the check tests pass real
OF-format ids instead (OfCleanTreeCase).
apla_id/generic_id default to arbitrary non-OF placeholders since most tests
only need "already assigned, don't touch" and never run the checks.
TestAssign and the check tests pass real OF-format ids instead
(OfCleanTreeCase).
"""
t = SyntheticTree()
t.add_vendor(OFL, [
@@ -199,8 +180,6 @@ def make_clean_tree(apla_id="AX01", generic_id="OGFL99"):
preset("Generic PLA @P1", inherits="Generic PLA @System",
compatible_printers=["P1 0.4 nozzle"]),
])
rc, _out = t.update_snapshot()
assert rc == 0
return t
@@ -212,10 +191,9 @@ class SyntheticTreeCase(unittest.TestCase):
class OfCleanTreeCase(unittest.TestCase):
"""Like SyntheticTreeCase, but the baseline filament/generic already carry
real OF-format ids (check 1 now rejects "AX01"/"OGFL99" unconditionally,
with no snapshot exemption), so an otherwise-untouched tree still passes
check_filament_ids. Tests that specifically need a non-OF baseline to
remint (TestRemint, TestUpdateSnapshot) keep using SyntheticTreeCase
real OF-format ids (check 1 rejects "AX01"/"OGFL99"), so an
otherwise-untouched tree passes check_filament_ids. Tests that specifically
need a non-OF baseline to remint (TestRemint) keep using SyntheticTreeCase
instead.
"""
def setUp(self):
@@ -231,7 +209,7 @@ class OfCleanTreeCase(unittest.TestCase):
class TestMint(unittest.TestCase):
def test_namespace_literal(self):
# Frozen: derived from the setting_id namespace; baked into the snapshot.
# Frozen: derived from the setting_id namespace; baked into every shipped id.
self.assertEqual(afi.FILAMENT_ID_NAMESPACE,
uuid.UUID("c4d3ff49-4c32-5534-a3e3-00894157ab97"))
@@ -450,22 +428,10 @@ class TestChecks(OfCleanTreeCase):
self.assertIn('is not a minted "OF" id', out)
self.assertIn("BOGUS_9", out)
def test_check2_new_claim_needs_snapshot_update(self):
self.t.write_preset("VendorA", preset("ANEW @P2", inherits="APLA @base",
compatible_printers=["P2"]))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn('claim "VendorA/ANEW" is not sanctioned', out)
self.assertIn("update-snapshot", out)
def test_check2_vanished_claim_is_stability_error(self):
self.t.remove_preset("VendorA", "APLA @P1")
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn("stability", out)
self.assertIn('"VendorA/APLA"', out)
def test_check2_triple_change_needs_snapshot_update(self):
def test_check2_triple_change_needs_a_remint(self):
# Correcting a triple changes the product's identity: the old id is no
# longer its mint, reported on the root and again under the variant
# inheriting it, until generate-id re-mints it.
apla_id = afi.generate_filament_id("AVendor", "PLA", "APLA")
self.t.write_preset("VendorA", preset("APLA @base", filament_id=apla_id,
instantiation=False,
@@ -473,26 +439,15 @@ class TestChecks(OfCleanTreeCase):
filament_type="PETG"),
register=False)
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn('triple "AVendor/PETG/APLA" is not sanctioned', out)
self.assertIn('which records "AVendor/PLA/APLA"', out)
# Sanctioning the new triple is not enough: the old id is no longer its
# mint (check 3, nothing grandfathered) — the identity fix is a re-mint,
# reported on the root and again under the variant inheriting it.
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 2, out)
self.assertIn("does not match the mint of its triple", out)
self.assertIn('"APLA @P1" (VendorA/filament/APLA @P1.json) inherits filament_id', out)
_changed, errors, out = self.t.remint(["VendorA"])
self.assertEqual(errors, 0, out)
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
def test_check3_of_id_must_match_triple_mint(self):
def test_check2_of_id_must_match_triple_mint(self):
self.t.write_preset("VendorA", preset("BNEW @base", filament_id="OFZZZZZZ",
instantiation=False,
filament_vendor="BV", filament_type="PLA"))
@@ -503,39 +458,20 @@ class TestChecks(OfCleanTreeCase):
self.assertIn("does not match the mint of its triple", out)
self.assertIn(afi.generate_filament_id("BV", "PLA", "BNEW"), out)
def test_check3_no_grandfathering_of_a_wrong_declaration(self):
# Sanctioning the tree does not excuse a declaration from its mint.
self.t.write_preset("VendorA", preset("CNEW @base", filament_id="OFZZZZZZ",
instantiation=False,
filament_vendor="CV", filament_type="PLA"))
self.t.write_preset("VendorA", preset("CNEW @P1", inherits="CNEW @base",
compatible_printers=["P1"]))
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 2, out) # the declaration, and the variant inheriting it
self.assertIn("does not match the mint of its triple", out)
self.assertIn('"CNEW @P1" (VendorA/filament/CNEW @P1.json) inherits filament_id', out)
def test_check3_inherited_id_must_be_the_mint_of_own_triple(self):
def test_check2_inherited_id_must_be_the_mint_of_own_triple(self):
# A preset of another filament inheriting APLA's root takes APLA's id,
# which is not the mint of ITS triple (AVendor/PLA/Tuned PLA).
self.t.write_preset("VendorA", preset("Tuned PLA @P1", inherits="APLA @base",
compatible_printers=["P1"]))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertEqual(errors, 1, out)
self.assertIn('"Tuned PLA @P1" (VendorA/filament/Tuned PLA @P1.json) inherits '
'filament_id "%s"' % afi.generate_filament_id("AVendor", "PLA", "APLA"),
out)
self.assertIn('mints "%s"' % afi.generate_filament_id("AVendor", "PLA", "Tuned PLA"),
out)
# ... and sanctioning the tree does not excuse it either.
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 1, out)
def test_check3_lists_every_preset_inheriting_a_wrong_id(self):
def test_check2_lists_every_preset_inheriting_a_wrong_id(self):
# A wrong declaration is reported under every preset inheriting it, its
# own product's variant and another product alike: each one's effective
# id is not the mint of its own triple, and each is listed. Nothing is
@@ -552,11 +488,10 @@ class TestChecks(OfCleanTreeCase):
self.assertIn('"DNEW @P1" (VendorA/filament/DNEW @P1.json) inherits filament_id', out)
self.assertIn('"Other DNEW @P1" (VendorA/filament/Other DNEW @P1.json) inherits '
'filament_id', out)
# The unsanctioned id (check 2), the declaration (3a), and both presets
# inheriting it (3b).
self.assertEqual(errors, 4, out)
# The declaration (2a), and both presets inheriting it (2b).
self.assertEqual(errors, 3, out)
def test_check3_reports_an_inherited_mismatch_even_when_its_own_product_misdeclares_the_id(self):
def test_check2_reports_an_inherited_mismatch_even_when_its_own_product_misdeclares_the_id(self):
# "Tuned PLA @P1" inherits APLA's root, so it carries APLA's id: wrong
# for its own product however the declarations around it are fixed.
# That "Tuned PLA @base" — its own product — misdeclares that same id
@@ -573,11 +508,11 @@ class TestChecks(OfCleanTreeCase):
'not match the mint of its triple', out)
self.assertIn('"Tuned PLA @P1" (VendorA/filament/Tuned PLA @P1.json) inherits '
'filament_id', out)
# The unsanctioned claim and triple (check 2), the declaration (3a) and
# the inherited id (3b): four distinct errors, nothing folded away.
self.assertEqual(errors, 4, out)
# The declaration (2a) and the inherited id (2b): two distinct errors,
# nothing folded away.
self.assertEqual(errors, 2, out)
def test_check3_reports_a_collision_between_two_products(self):
def test_check2_reports_a_collision_between_two_products(self):
# Two products whose triples mint one id is a base62 collision. There
# is no salted or hand-picked second id to fall back on: the check
# names both products, and the remedy is a rename so the triples differ.
@@ -601,13 +536,12 @@ class TestChecks(OfCleanTreeCase):
self.assertIn("V/PLA/X", out)
self.assertIn("W/ABS/Y", out)
# Each declaration is the mint of its own triple, so the collision is
# the only identity error — no product is pushed off its id — and the
# unsanctioned id (check 2) is the only other one.
# the only error — no product is pushed off its id.
self.assertNotIn("does not match the mint", out)
self.assertNotIn("inherits filament_id", out)
self.assertEqual(errors, 2, out)
self.assertEqual(errors, 1, out)
def test_check3_renamed_tuned_generic_is_an_identity_error(self):
def test_check2_renamed_tuned_generic_is_an_identity_error(self):
# Riding the OFL generic under another base name: same rule, same error.
self.t.write_preset("VendorA", preset("Tuned PLA @P1",
inherits="Generic PLA @System",
@@ -617,7 +551,7 @@ class TestChecks(OfCleanTreeCase):
self.assertIn("Tuned PLA @P1", out)
self.assertIn("inherits filament_id", out)
def test_check3_own_key_on_an_instantiated_preset_is_fine(self):
def test_check2_own_key_on_an_instantiated_preset_is_fine(self):
# Where the id comes from is irrelevant: a variant may carry the key.
apla_id = afi.generate_filament_id("AVendor", "PLA", "APLA")
self.t.write_preset("VendorA", preset("APLA @P1", filament_id=apla_id,
@@ -627,7 +561,7 @@ class TestChecks(OfCleanTreeCase):
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
def test_check3_inheriting_a_real_filament_of_another_product_is_fine(self):
def test_check2_inheriting_a_real_filament_of_another_product_is_fine(self):
# A branded product may inherit the OFL generic (an instantiated
# preset) for its settings; it declares its own triple's id.
fid = afi.generate_filament_id("BV", "PLA", "Branded PLA")
@@ -635,8 +569,6 @@ class TestChecks(OfCleanTreeCase):
inherits="Generic PLA @System",
filament_vendor="BV",
compatible_printers=["P1"]))
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
# With a wrong key it is a plain mint mismatch: the parent plays no
@@ -670,22 +602,16 @@ class TestChecks(OfCleanTreeCase):
self.assertIn(f'filament_id "{fid}"', out)
self.assertIn('is not a minted "OF" id', out)
def test_check3c_unresolvable_instantiated_filament(self):
def test_check2c_unresolvable_instantiated_filament(self):
self.t.write_preset("VendorA", preset("DNEW @P1", compatible_printers=["P1"]))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn("resolves no filament_id", out)
self.assertIn("hard load error", out)
def test_missing_snapshot_is_an_error(self):
os.remove(self.t.snapshot)
errors, out = self.t.check()
self.assertEqual(errors, 1)
self.assertIn("snapshot not found", out)
class TestCheck5(OfCleanTreeCase):
def test_5a_empty_vendor_is_hard_error(self):
class TestCheck3(OfCleanTreeCase):
def test_3a_empty_vendor_is_hard_error(self):
fid = afi.generate_filament_id("", "PLA", "NVPLA")
self.t.write_preset("VendorA", preset("NVPLA @base", filament_id=fid,
instantiation=False,
@@ -693,17 +619,11 @@ class TestCheck5(OfCleanTreeCase):
self.t.write_preset("VendorA", preset("NVPLA @P1", inherits="NVPLA @base",
compatible_printers=["P1"]))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn("resolves empty filament_vendor", out)
self.assertIn('filament_vendor "Generic"', out)
# No grandfathering: sanctioning the tree does not silence check 4a.
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 1, out)
self.assertIn("resolves empty filament_vendor", out)
self.assertIn('filament_vendor "Generic"', out)
def test_5b_divergent_filament_triples(self):
def test_3b_divergent_filament_triples(self):
id1 = afi.generate_filament_id("MV", "PLA", "MPLA")
id2 = afi.generate_filament_id("MV", "PETG", "MPLA")
self.t.write_preset("VendorA", preset("MPLA @base1", filament_id=id1,
@@ -715,18 +635,12 @@ class TestCheck5(OfCleanTreeCase):
filament_vendor="MV",
filament_type="PETG"))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertEqual(errors, 1, out)
self.assertIn("divergent triples", out)
self.assertIn("MV/PLA/MPLA", out)
self.assertIn("MV/PETG/MPLA", out)
# No grandfathering: sanctioning the tree does not silence check 4b.
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 1, out)
self.assertIn("divergent triples", out)
def test_5_cross_bundle_divergence_is_warning_only(self):
def test_3_cross_bundle_divergence_is_warning_only(self):
fid = afi.generate_filament_id("BV", "PETG", "APLA")
self.t.add_vendor("VendorB", [
preset("APLA @base", filament_id=fid, instantiation=False,
@@ -734,8 +648,6 @@ class TestCheck5(OfCleanTreeCase):
preset("APLA @PB", inherits="APLA @base",
compatible_printers=["PB 0.4 nozzle"]),
])
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
self.assertIn("[WARNING]", out)
@@ -743,7 +655,7 @@ class TestCheck5(OfCleanTreeCase):
self.assertIn('"APLA"', out)
class TestCheck6(OfCleanTreeCase):
class TestCheck4(OfCleanTreeCase):
def _write_map(self, rows):
path = os.path.join(self.t.dir, "bambu_filament_ids.json")
ubfi.write_map(path, rows, "testcommit", "2026-09-04")
@@ -845,104 +757,6 @@ class TestCheck6(OfCleanTreeCase):
self.assertIn('declares no "bambu_id"', out)
# ---------------------------------------------------------------------------
# --update-snapshot
# ---------------------------------------------------------------------------
class TestUpdateSnapshot(SyntheticTreeCase):
def test_idempotent_and_deterministic(self):
with open(self.t.snapshot, "rb") as f:
first = f.read()
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 0)
self.assertIn("nothing changed", out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), first)
self.assertTrue(first.endswith(b"\n"))
self.assertNotIn(b"\r", first)
snap = json.loads(first.decode("utf-8"))
self.assertEqual(list(snap), ["ids"]) # state only, no exception lists
self.assertEqual(list(snap["ids"]), sorted(snap["ids"]))
self.assertEqual(snap["ids"]["AX01"], {
"filaments": ["VendorA/APLA"], "name": "APLA",
"filament_type": "PLA", "filament_vendor": "AVendor"})
self.assertEqual(snap["ids"]["OGFL99"], {
"filaments": ["OrcaFilamentLibrary/Generic PLA", "VendorA/Generic PLA"],
"name": "Generic PLA", "filament_type": "PLA", "filament_vendor": "Generic"})
# Key order is part of the on-disk format.
self.assertEqual(list(snap["ids"]["AX01"]),
["filaments", "name", "filament_type", "filament_vendor"])
def test_refuses_a_tree_it_could_not_read(self):
# A bundle that does not parse contributes no ids, so sanctioning the
# rest would record the loss as a deliberate removal.
with open(self.t.snapshot, "rb") as f:
before = f.read()
with open(os.path.join(self.t.profiles, "VendorA",
"filament", "APLA @base.json"), "w",
encoding="utf-8") as f:
f.write("{ not json")
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 1, out)
self.assertIn("unreadable filament profile", out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), before)
def test_refuses_an_id_declared_under_two_triples(self):
# VendorB re-declares APLA's id for a different product: one id, two
# triples. No single entry can describe it, and check 3 rejects it anyway.
self.t.add_vendor("VendorB", [
preset("BPLA @base", filament_id="AX01", instantiation=False,
filament_vendor="BVendor", filament_type="PLA"),
preset("BPLA @P1", inherits="BPLA @base", compatible_printers=["P1"]),
])
with open(self.t.snapshot, "rb") as f:
before = f.read()
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 1)
self.assertIn('refusing to sanction filament_id "AX01": declared under 2 triples '
'(AVendor/PLA/APLA; BVendor/PLA/BPLA)', out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), before) # nothing written on refusal
def test_records_an_id_it_cannot_defend_and_lets_check_reject_it(self):
# update-snapshot records state, it does not judge ids: a foreign id
# lands in the diff a maintainer reviews, and fails check 1 straight
# after. Sanctioning it does not grandfather it.
self.t.write_preset("VendorA", preset("CNEW @base", filament_id="GFX99",
instantiation=False,
filament_vendor="CV",
filament_type="PLA"))
self.t.write_preset("VendorA", preset("CNEW @P1", inherits="CNEW @base",
compatible_printers=["P1"]))
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 0, out)
with open(self.t.snapshot, encoding="utf-8") as f:
self.assertIn("GFX99", json.load(f)["ids"])
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn('is not a minted "OF" id', out)
def test_dry_run_reports_without_writing(self):
self.t.write_preset("VendorA", preset("ANEW @P2", inherits="APLA @base",
compatible_printers=["P2"]))
with open(self.t.snapshot, "rb") as f:
before = f.read()
rc, out = self.t.update_snapshot(dry_run=True)
self.assertEqual(rc, 0)
self.assertIn("would be rewritten", out)
self.assertIn("claims added : 1", out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), before)
# the real run writes exactly what the dry run reported
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 0)
self.assertIn("snapshot written", out)
snap = load_json_file(self.t.snapshot)
self.assertEqual(snap["ids"]["AX01"]["filaments"],
["VendorA/ANEW", "VendorA/APLA"])
# ---------------------------------------------------------------------------
# --generate: one rule for inserts and rewrites alike
# ---------------------------------------------------------------------------
@@ -1026,7 +840,7 @@ class TestAssign(OfCleanTreeCase):
def test_parent_of_another_filament_never_receives_the_key(self):
# Members whose id-less parent belongs to another filament (here one
# parent shared by two filaments) carry the key themselves: the
# parent's own triple would mint a different id (check 3).
# parent's own triple would mint a different id (check 2).
self.t.write_preset("VendorA", preset("shared_base", instantiation=False,
filament_vendor="SV",
filament_type="PLA"))
@@ -1044,8 +858,6 @@ class TestAssign(OfCleanTreeCase):
parent = load_json_file(self.t.preset_path("VendorA", "shared_base"))
self.assertNotIn("filament_id", parent)
# ... and the tree they leave behind passes the identity check.
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
@@ -1538,33 +1350,10 @@ class TestCli(unittest.TestCase):
rc = afi.main([])
self.assertEqual(rc, 0)
self.assertIn("usage:", buf.getvalue())
for command in ("check", "generate-id", "normalize", "trim", "update-index",
"update-snapshot"):
for command in ("check", "generate-id", "normalize", "trim", "update-index"):
self.assertIn(command, buf.getvalue())
self.assertEqual(self.t.bytes_map(), before)
def test_another_tree_needs_its_own_snapshot(self):
# --profiles retargets the tree, but the sanctioned state of that tree
# is not the repo snapshot: checking against it is meaningless and
# re-recording into it would overwrite the tracked file.
with open(afi.SNAPSHOT_PATH, "rb") as f:
repo_snapshot = f.read()
for command in ("check", "update-snapshot"):
with self.assertRaises(SystemExit) as caught:
with contextlib.redirect_stderr(io.StringIO()):
afi.main([command, "--profiles", self.t.profiles])
self.assertEqual(caught.exception.code, 2, command)
with open(afi.SNAPSHOT_PATH, "rb") as f:
self.assertEqual(f.read(), repo_snapshot)
# Named explicitly, both commands run against that tree.
rc, out = self.t.cli("update-snapshot")
self.assertEqual(rc, 0, out)
# generate-id never reads the snapshot, so it keeps working without one.
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main(["generate-id", "--dry-run", "--profiles", self.t.profiles])
self.assertEqual(rc, 0, buf.getvalue())
def test_filament_id_and_setting_id_together_are_rejected(self):
# Each flag's help promises it skips the other kind, so the pair cannot
# quietly mean "both".
@@ -1667,8 +1456,8 @@ class TestCli(unittest.TestCase):
"setting_id", load_json_file(self.t.preset_path("VendorA", name)))
def test_check_returns_1_on_errors(self):
# What CI keys off: check exits nonzero when the tree does not match the
# snapshot it is validated against.
# What CI keys off: check exits nonzero when the tree breaks a rule, here
# the baseline's ids that are not minted.
before = self.t.bytes_map()
rc, out = self.t.cli("check")
self.assertEqual(rc, 1)
@@ -1695,10 +1484,12 @@ class TestCli(unittest.TestCase):
["--check"], # the pre-subcommand flag
["--update-snapshot"], # the pre-subcommand flag
["nonsense"], # not a command
["update-snapshot"], # removed command
["generate-id", "--filament-id", "--setting-id"],
["generate-id", "--materials"], # check's option
["check", "--materials"], # removed flag
["check", "--obsolete-keys"], # removed flag
["check", "--snapshot", "x"], # removed flag
["check", "--filament-id"], # generate-id's option
["normalize", "--snapshot", "x"], # not a snapshot command
["normalize", "--profile-type", "nozzle"]): # not a profile type
with self.subTest(argv=argv):
with self.assertRaises(SystemExit) as cm, \
@@ -1714,7 +1505,7 @@ class TestCli(unittest.TestCase):
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
class TestRealTree(unittest.TestCase):
def test_shipped_snapshot_matches_tree(self):
def test_shipped_filament_ids_pass(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = afi.check_filament_ids(REAL_PROFILES)
@@ -1776,10 +1567,10 @@ class TestReviewFixes(OfCleanTreeCase):
self.assertIn(path, str(caught.exception))
self.assertIn("test edit", str(caught.exception))
def test_check3_skips_of_id_inherited_from_other_vendor(self):
def test_check2_accepts_an_of_id_inherited_from_another_vendor(self):
# An OFL filament carries its own minted OF id and a vendor tunes it
# correctly (same base name, non-empty printers). The new claim must
# trip only the snapshot gate, never mint conformance.
# correctly (same base name, non-empty printers): the id it inherits is
# the mint of its own triple.
fid = afi.generate_filament_id("Generic", "PLA", "Generic PLA Matte")
self.t.write_preset(OFL, preset("Generic PLA Matte @base", filament_id=fid,
instantiation=False,
@@ -1788,22 +1579,13 @@ class TestReviewFixes(OfCleanTreeCase):
self.t.write_preset(OFL, preset("Generic PLA Matte @System",
inherits="Generic PLA Matte @base",
compatible_printers=[]))
rc, _ = self.t.update_snapshot()
self.assertEqual(rc, 0)
self.t.write_preset("VendorA", preset("Generic PLA Matte @P1",
inherits="Generic PLA Matte @System",
compatible_printers=["P1 0.4 nozzle"]))
errors, out = self.t.check()
self.assertNotIn("does not match the mint", out)
self.assertIn("not sanctioned", out)
self.assertEqual(errors, 1, out)
# After sanctioning the claim the tree is fully green again.
rc, _ = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
def test_check3c_prints_expected_mint(self):
def test_check2c_prints_expected_mint(self):
self.t.write_preset("VendorA", preset("Orphan PLA @P1",
compatible_printers=["P1 0.4 nozzle"],
filament_vendor="OV",
+110 -22
View File
@@ -12,6 +12,7 @@ import contextlib
import io
import json
import os
import re
import shutil
import sys
import tempfile
@@ -125,6 +126,20 @@ class TreeCase(unittest.TestCase):
# 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"})
@@ -148,16 +163,36 @@ class TestNormalize(TreeCase):
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_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", {
@@ -186,6 +221,15 @@ class TestNormalize(TreeCase):
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)
@@ -200,7 +244,7 @@ class TestNormalize(TreeCase):
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"})
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)
@@ -453,6 +497,8 @@ class TestCheck(TreeCase):
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):
@@ -467,6 +513,25 @@ class TestCheck(TreeCase):
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"}')
@@ -516,15 +581,26 @@ class TestCheck(TreeCase):
self.assertGreater(errors, 0)
self.assertIn("Filament id too long", out)
def test_obsolete_keys_are_opt_in_warnings(self):
def test_obsolete_key_warnings_exclude_active_and_renamed_options(self):
self.bundle().write("V", "filament/B.json", {
"type": "filament", "name": "B", "silent_mode": True})
"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, 1)
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",
@@ -535,6 +611,28 @@ class TestCheck(TreeCase):
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()
@@ -637,9 +735,7 @@ class TestCheck(TreeCase):
self.t.write("V", f"filament/Stray{n}.json",
{"type": "filament", "name": f"Stray{n}"})
self.t.write("V", "filament/NoType.json", {"name": "NoType"})
snapshot = os.path.join(self.t.dir, "snapshot.json")
self.run_command("update-snapshot", "--snapshot", snapshot)
rc, out = self.run_command("check", "--snapshot", snapshot)
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)
@@ -691,11 +787,6 @@ class TestNormalized(TreeCase):
errors, gaps = apt.check_normalized(self.t.profiles, vendor)
return errors, gaps, buf.getvalue()
def snapshot(self):
path = os.path.join(self.t.dir, "snapshot.json")
self.run_command("update-snapshot", "--snapshot", path)
return path
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"})
@@ -752,12 +843,11 @@ class TestNormalized(TreeCase):
self.assertEqual(gaps["stale_index"], 0, out)
def test_the_shared_base_bundle_is_covered_too(self):
# The per-vendor pass leaves OrcaFilamentLibrary out because its filaments are
# generic by design. That says nothing about the shape of its files, and
# normalize and update-index rewrite that bundle like any other.
# 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", "--snapshot", self.snapshot())
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertIn(f"{apt.OFL}/filament/A.json: normalize would remove version", out)
@@ -767,7 +857,7 @@ class TestNormalized(TreeCase):
{"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", "--snapshot", self.snapshot())
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)
@@ -791,11 +881,9 @@ class TestDispatch(TreeCase):
self.assertIn(expected, out)
def test_an_option_belongs_to_one_command_only(self):
for argv in (["normalize", "--materials"],
["trim", "--force"],
for argv in (["trim", "--force"],
["update-index", "--filament-id"],
["check", "--profile-type", "filament"],
["update-snapshot", "--vendor", "V"]):
["check", "--profile-type", "filament"]):
with self.subTest(argv=argv):
with self.assertRaises(SystemExit) as cm, \
contextlib.redirect_stdout(io.StringIO()), \