mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-22 16:32:40 +00:00
Merge
This commit is contained in:
@@ -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.
|
||||
Executable
+81
@@ -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"'
|
||||
Executable
+98
@@ -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"
|
||||
Executable
+658
@@ -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
Executable
+132
@@ -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")
|
||||
@@ -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()
|
||||
Executable
+352
@@ -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)
|
||||
Executable
+89
@@ -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"
|
||||
Executable
+85
@@ -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"
|
||||
Executable
+159
@@ -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"
|
||||
Executable
+148
@@ -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
|
||||
@@ -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/*
|
||||
@@ -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)"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()), \
|
||||
|
||||
Reference in New Issue
Block a user