mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-27 02:41:17 +00:00
Move the Design-tab scripts into scripts/CAD/ and name them by role
Requested by SoftFever on PR #15238: ten of these had accumulated loose in scripts/ next to ~20 unrelated upstream ones, with names that only meant something to whoever wrote them. They now sit in scripts/CAD/, mirroring the src/libslic3r/CAD/ and src/slic3r/GUI/CAD/ split, and the verb in the name is the role: build- produces a binary, start- brings something up, run- runs a suite, check- asserts one thing against a live app. kernel-test.sh -> CAD/run-kernel-tests.sh ladder-all.sh -> CAD/run-all-checks.sh sketch-ladder.py -> CAD/check-sketch-engine.py ladder-corpus.py -> CAD/check-sketch-engine-corpus.py gui-ladder.py -> CAD/check-gui-sketching.py offer-ladder.py -> CAD/check-gui-context-menu.py mcp-sketch-smoke.py -> CAD/check-mcp-sketch.py rig-build.sh -> CAD/build-gui.sh docker-iter-build.sh -> CAD/build-gui-incremental.sh gui-session.sh -> CAD/start-headless-gui.sh "Ladder" was the worst of them: it named the shape of the test (rungs of increasing difficulty) rather than what the test proves, so nothing in the directory listing told you which one needed a GPU and which was pure kernel. Every reference rewritten -- the docs, the cross-calls between the scripts, Dockerfile.deps, and the container-side /OrcaSlicer/scripts paths. The three shell scripts resolve REPO relative to themselves and now sit one level deeper, so that walk went from /.. to /../.. . The copies these push into a container's /tmp were renamed to match, or the container would have kept the old names alive. Two runtime paths deliberately NOT renamed. /tmp/orca-rig-build.lock is a cross-fork contract -- both forks take the same lock so two concurrent builds serialise instead of OOMing the box, and renaming it on one side silently removes that guard. /tmp/gui-session.log is a runtime artefact, not a script. Added scripts/CAD/README.md: what each script proves, what it needs, and the two constraints that have each cost a session (never build inside the GUI container; a window manager is required or synthetic keys are ignored). On CI, which was the other half of the request: the kernel suite is already there and always has been. The cases are registered in tests/libslic3r/CMakeLists.txt under if (SLIC3R_CAD), which defaults ON and no workflow turns off, so they build into libslic3r_tests and run under ctest on every platform via unit_tests.yml -- like any other unit test, needing no new job. They have simply never been seen to run, because the workflows on this PR are still awaiting maintainer approval. run-kernel-tests.sh is the local loop over the same cases, and it is the only script here CI could run: the other six need an OpenGL canvas and synthetic input. Verified: scripts/CAD/run-kernel-tests.sh from its new location, all tests passed, 2562 assertions in 190 test cases.
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 + `SNAPORCA_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 snaporca-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 snaporca-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 snaporca 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 (snaporca-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=snaporca; 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"
|
||||
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:
|
||||
SNAPORCA_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("SNAPORCA_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. snaporca-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.
|
||||
|
||||
SNAPORCA_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
+69
@@ -0,0 +1,69 @@
|
||||
#!/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 SNAPORCA_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
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
|
||||
C="${C:-snaporca-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
|
||||
}
|
||||
|
||||
# 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 "$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 (snaporca-z8rs, snaporca-ziam).
|
||||
step "offer table matches the atlas" python3 docs/ux/mockups/gen_offer_table.py --check
|
||||
|
||||
step "kernel suite" scripts/CAD/run-kernel-tests.sh --vol "${KVOL:-snaporca_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 SNAPORCA_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
+149
@@ -0,0 +1,149 @@
|
||||
#!/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 snaporca-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 snaporca-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 snaporca_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 (snaporca-tkz), and the internal-thread case turned out to have
|
||||
# correct geometry and a wrong reference in the test (snaporca-kzy). A green run here now means
|
||||
# the whole CAD suite passed, not "everything except the two we gave up on".
|
||||
TAGS="${TAGS:-[CadDocument]}"
|
||||
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
+137
@@ -0,0 +1,137 @@
|
||||
#!/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
|
||||
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
|
||||
}
|
||||
for name in "Setup Wizard" "New version"; 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
|
||||
Reference in New Issue
Block a user