mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 22:42:37 +00:00
Sync cad-mainline with upstream main and carry the value-field + rename work on top
This commit is contained in:
@@ -23,7 +23,7 @@ The verb in the name is the role:
|
||||
| `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` |
|
||||
| `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
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
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
|
||||
# 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}"
|
||||
@@ -32,11 +32,11 @@ echo "REPO=$REPO IMAGE=$IMAGE BUILD_VOL=$BUILD_VOL"
|
||||
# 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
|
||||
# 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 snaporca tree does not have, so a fully successful link still exited
|
||||
# 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) -------------------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# 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
|
||||
# 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.
|
||||
@@ -21,7 +21,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
# 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 ;;
|
||||
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
|
||||
|
||||
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())
|
||||
@@ -11,16 +11,16 @@ action and no shortcut, so a key-driven ladder cannot reach them at all.
|
||||
This ladder drives the menu. Nothing here is asserted from pixels:
|
||||
|
||||
WHAT WAS CLICKED -> the offer's own [OFFER] trace, emitted by show_offer_menu from the same
|
||||
loop that builds the rows (SNAPORCA_KEYTRACE). It cannot drift from what
|
||||
loop that builds the rows (ORCA_CAD_KEYTRACE). It cannot drift from what
|
||||
the user is shown, which a hand-written expectation list would.
|
||||
WHAT IS OFFERED -> the same trace, compared against DesignOffer.hpp parsed independently.
|
||||
"The menu shows exactly the verbs the table says apply here" is a
|
||||
property; a copied list of row names is a transcription.
|
||||
WHAT IT PRODUCED -> the MCP socket, read-only, exactly as in the gesture ladder.
|
||||
|
||||
Run inside the rig container, with the app launched under SNAPORCA_KEYTRACE=1:
|
||||
Run inside the rig container, with the app launched under ORCA_CAD_KEYTRACE=1:
|
||||
|
||||
docker exec snaporca-gui python3 /OrcaSlicer/scripts/CAD/check-gui-context-menu.py [rung ...]
|
||||
docker exec orcacad-gui python3 /OrcaSlicer/scripts/CAD/check-gui-context-menu.py [rung ...]
|
||||
"""
|
||||
import importlib.util
|
||||
import math
|
||||
@@ -38,13 +38,13 @@ _spec = importlib.util.spec_from_file_location("gui_ladder", os.path.join(HERE,
|
||||
G = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(G)
|
||||
|
||||
LOG = os.environ.get("SNAPORCA_GUI_LOG", "/tmp/gui-session.log")
|
||||
LOG = os.environ.get("ORCA_CAD_GUI_LOG", "/tmp/gui-session.log")
|
||||
|
||||
# The rig container's /OrcaSlicer is the image's own baked source tree, not this checkout, so the
|
||||
# generated header is not where a repo-relative path expects it. Look in both places and say which
|
||||
# one was read — a ladder that silently graded against the WRONG table would be worse than one
|
||||
# that refuses to start.
|
||||
HEADER_CANDIDATES = [os.environ.get("SNAPORCA_OFFER_HPP", ""),
|
||||
HEADER_CANDIDATES = [os.environ.get("ORCA_CAD_OFFER_HPP", ""),
|
||||
os.path.join(HERE, "..", "src", "slic3r", "GUI", "CAD", "DesignOffer.hpp"),
|
||||
os.path.join(HERE, "DesignOffer.hpp")]
|
||||
|
||||
@@ -61,12 +61,12 @@ def load_table():
|
||||
"""Every verb in DesignOffer.hpp, as dicts. The independent half of the comparison.
|
||||
|
||||
Parsed from the generated header rather than from tool_atlas.json on purpose: the header is
|
||||
what the binary was compiled from, and the two have been out of step before (snaporca-ziam,
|
||||
what the binary was compiled from, and the two have been out of step before (ziam,
|
||||
where regenerating the header silently dropped the Constrain row).
|
||||
"""
|
||||
path = next((p for p in HEADER_CANDIDATES if p and os.path.exists(p)), None)
|
||||
if path is None:
|
||||
raise SystemExit("no DesignOffer.hpp found; set SNAPORCA_OFFER_HPP or copy it beside "
|
||||
raise SystemExit("no DesignOffer.hpp found; set ORCA_CAD_OFFER_HPP or copy it beside "
|
||||
"this script (tried: " + ", ".join(filter(None, HEADER_CANDIDATES)) + ")")
|
||||
print(f"offer table: {os.path.realpath(path)}")
|
||||
src = open(path).read()
|
||||
@@ -301,7 +301,7 @@ def rung_kinds():
|
||||
# Empty space first: nothing is selected, so the sketch vocabulary's no-selection row set.
|
||||
# Right-click has two jobs on a draw tool, and which one it does depends on whether an
|
||||
# anchor is down. Both are asserted here: the version that consumed EVERY right-click made
|
||||
# the offer unreachable from any armed tool (snaporca-ghcz), which is the goal's own
|
||||
# the offer unreachable from any armed tool (ghcz), which is the goal's own
|
||||
# mechanism failing silently.
|
||||
hi = y1 - (y1 - y0) * 0.12
|
||||
o = open_offer(cx, hi)
|
||||
@@ -766,7 +766,7 @@ def rung_curves():
|
||||
f"and it takes a typed radius exactly: {got:.9f} (asked 30.0)")
|
||||
# The DoF of ONE CIRCLE is three. Asserted here because it is where the lie showed:
|
||||
# after a delete the solver was never re-run, so this reported the DoF of the
|
||||
# geometry that had just been erased. snaporca-ua9g.
|
||||
# geometry that had just been erased. ua9g.
|
||||
G.check("VERTEX", G.describe()["dof"] == 2,
|
||||
f"and the sketch reports the DoF of what is actually in it: {G.describe()['dof']}")
|
||||
|
||||
@@ -1198,7 +1198,7 @@ def main():
|
||||
if not os.path.exists(LOG):
|
||||
G.die(f"no {LOG} — launch the app through scripts/CAD/start-headless-gui.sh")
|
||||
if "[OFFER]" not in open(LOG, errors="replace").read()[-400000:]:
|
||||
print(f"note: no [OFFER] lines in {LOG} yet — the app must run with SNAPORCA_KEYTRACE=1")
|
||||
print(f"note: no [OFFER] lines in {LOG} yet — the app must run with ORCA_CAD_KEYTRACE=1")
|
||||
want = sys.argv[1:] or list(RUNGS)
|
||||
# TWICE. From a cold launch the app shows the Home page over the Design tab, and the first
|
||||
# click only selects the tab — the second is what brings the viewport forward. A ladder that
|
||||
|
||||
@@ -9,16 +9,16 @@ cannot say the Design tab meets its goal. This one draws with synthetic clicks a
|
||||
values into the in-canvas field, then reads the result back through the socket, which is used
|
||||
here ONLY as an instrument, never as an author.
|
||||
|
||||
Runs INSIDE the headless rig container (Xvfb :10 + openbox + the app with SNAPORCA_MCP set):
|
||||
Runs INSIDE the headless rig container (Xvfb :10 + openbox + the app with ORCA_CAD_MCP set):
|
||||
|
||||
docker cp scripts/CAD/check-gui-sketching.py snaporca-gui:/tmp/ && \
|
||||
docker exec snaporca-gui python3 /tmp/check-gui-sketching.py [rung ...]
|
||||
docker cp scripts/CAD/check-gui-sketching.py orcacad-gui:/tmp/ && \
|
||||
docker exec orcacad-gui python3 /tmp/check-gui-sketching.py [rung ...]
|
||||
|
||||
With no arguments every rung runs. Exit 0 = every property held.
|
||||
"""
|
||||
import json, math, os, re, socket, subprocess, sys, time
|
||||
|
||||
SOCK = os.environ.get("SNAPORCA_MCP", "/tmp/mcp.sock")
|
||||
SOCK = os.environ.get("ORCA_CAD_MCP", "/tmp/mcp.sock")
|
||||
DISP = os.environ.get("DISPLAY", ":10")
|
||||
_n = 0
|
||||
_fail = 0
|
||||
@@ -225,7 +225,7 @@ def leave_sketch():
|
||||
# "no sketch opened after plane click + Shift+S"; the unshifted Construction checkbox reported
|
||||
# "0 construction axis". Both name the wrong subsystem. Canvas coordinates are immune because
|
||||
# clickmm() derives them from the live canvas geometry — only the chrome constants need this.
|
||||
CHROME_DY = int(os.environ.get("SNAPORCA_CHROME_DY", "26"))
|
||||
CHROME_DY = int(os.environ.get("ORCA_CAD_CHROME_DY", "26"))
|
||||
|
||||
DESIGN_TAB = (128, 29 + CHROME_DY)
|
||||
|
||||
@@ -324,45 +324,34 @@ def calibrate_here():
|
||||
PACE = 1.0
|
||||
|
||||
|
||||
def field_win():
|
||||
"""The open in-canvas value field as (x, y, w, h) in SCREEN pixels, or None.
|
||||
def field_open():
|
||||
"""Is a sketch value field open? Asked of the APP, not of the window list.
|
||||
|
||||
It is a top-level window of its own, not a child of the canvas (a native child cannot be
|
||||
composited over the double-buffered wxGLCanvas), so it is found by enumerating windows rather
|
||||
than by looking inside the app's frame. Two other small top-levels exist: the status chip,
|
||||
which lives on the bottom edge, and 1x1/10x10 helpers.
|
||||
It used to be answered by hunting for a small top-level window, because the field WAS one.
|
||||
It is not any more — it is drawn by ImGui inside the GL canvas precisely so that no window
|
||||
manager gets a vote on whether it may hold the keyboard. Enumerating windows now always
|
||||
answers "no field", which turns every check built on it into one that cannot fail.
|
||||
|
||||
sketch_describe's `editing` is DesignSketchTool::value_field_open(), i.e. the app's own
|
||||
answer to the same question.
|
||||
"""
|
||||
_, X, Y, W, H = win()
|
||||
for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --class '.'").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
|
||||
x, y, ww, hh = int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"])
|
||||
if ww >= W or hh < 24 or hh > 120 or ww < 40:
|
||||
continue
|
||||
if y > Y + H - 80: # the status chip, pinned to the bottom edge
|
||||
continue
|
||||
return (x, y, ww, hh)
|
||||
return None
|
||||
d = try_call("sketch_describe")
|
||||
return bool(d and d.get("editing"))
|
||||
|
||||
|
||||
def focus_field():
|
||||
"""Put the keyboard in the value field, by clicking it.
|
||||
"""Deliberately nothing.
|
||||
|
||||
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. The failure is
|
||||
invisible from the outside: a constraint IS created, the solve succeeds, and the sketch simply
|
||||
holds the dimension you did not ask for (typed 40, got 54.94). One click fixes it.
|
||||
This used to click into the value field before typing, and its old docstring explained why:
|
||||
"WITHOUT THIS THE TYPED VALUE IS SILENTLY DISCARDED ... the window manager does not give it
|
||||
the keyboard, so xdotool's digits go to the canvas". That was a workaround for the field
|
||||
being a separate top-level window, and it is also what made this ladder blind to the very
|
||||
defect the user reported — a suite that clicks the field first can never see that typing
|
||||
without clicking is broken.
|
||||
|
||||
The field is now inside the canvas and the canvas has the keyboard, so typing just works and
|
||||
there is nothing to click. Kept as a no-op so the call sites still read in order.
|
||||
"""
|
||||
r = field_win()
|
||||
if r is None:
|
||||
return False
|
||||
x, y, w, h = r
|
||||
xdo(f"mousemove {x + w // 2} {y + h // 2} click --delay 120 1")
|
||||
time.sleep(0.3)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1732,7 +1721,7 @@ def rung_type_guards():
|
||||
clickmm(*rim(cs[0])); clickmm(*rim(cs[1]))
|
||||
click(*CON_BTN["angle"])
|
||||
time.sleep(1.0)
|
||||
check("ANGLE", field_win() is None,
|
||||
check("ANGLE", not field_open(),
|
||||
"Angle on two circles opened no value field")
|
||||
key("Escape", 0.6)
|
||||
d2 = confirm_and_reopen()
|
||||
@@ -1884,7 +1873,7 @@ def rung_scale():
|
||||
f"every cut-out is exactly {side:.6f} squared")
|
||||
# Now the part that matters: draw ONE more entity by hand, on top of all that.
|
||||
#
|
||||
# No Escape here, deliberately: this rung is the regression test for snaporca-j7gc, where a
|
||||
# No Escape here, deliberately: this rung is the regression test for j7gc, where a
|
||||
# bulk sketch_add made while a creation tool is armed was read as a drawn gesture, opened that
|
||||
# tool's value field and swallowed the next key and click until one Escape dismissed it. The
|
||||
# gesture below has to land on the FIRST try. Fixed by resyncing m_autoedit_seen in
|
||||
|
||||
@@ -8,7 +8,7 @@ cost a GUI session and a human. The socket verbs make each one a call, and this
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -35,7 +35,7 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
SOCK = os.environ.get("SNAPORCA_MCP", "/tmp/mcp.sock")
|
||||
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
|
||||
|
||||
@@ -295,7 +295,7 @@ def grade(pdf, name, report):
|
||||
# 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.
|
||||
# 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):
|
||||
|
||||
@@ -15,7 +15,7 @@ Every rung asserts those. Area appears only as a cross-check, never as the verdi
|
||||
|
||||
Entirely 2D: sketch entities only, no extrude, revolve or any solid feature.
|
||||
|
||||
SNAPORCA_MCP=/tmp/mcp.sock <binary>
|
||||
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.
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/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.
|
||||
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"
|
||||
@@ -10,7 +10,7 @@
|
||||
# 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
|
||||
# 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
|
||||
@@ -19,7 +19,7 @@ set -uo pipefail
|
||||
# scripts/scripts/ and reporting instant failures that were all the same typo.
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1
|
||||
|
||||
# orcacad-gui, NOT snaporca-gui: that is the other fork's rig, and defaulting to it makes
|
||||
# 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}"
|
||||
@@ -53,13 +53,13 @@ run_in_rig() { # copy the script in fresh, then run it ther
|
||||
# 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).
|
||||
# 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:-snaporca_kerneltest}"
|
||||
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)" \
|
||||
@@ -71,7 +71,7 @@ if [ -z "${SKIP_GUI:-}" ]; then
|
||||
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
|
||||
# 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.
|
||||
|
||||
@@ -23,18 +23,18 @@
|
||||
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.
|
||||
# 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 snaporca_buildcache: that is the other fork's volume, and pointing
|
||||
# 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 (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
|
||||
# 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
|
||||
|
||||
@@ -36,8 +36,8 @@ export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
|
||||
# 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 SNAPORCA_MCP="${SNAPORCA_MCP:-/tmp/mcp.sock}"
|
||||
export SNAPORCA_KEYTRACE="${SNAPORCA_KEYTRACE:-1}"
|
||||
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
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Deps-only base image for fast iteration on SnapOrca.
|
||||
# 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 snaporca-deps -f scripts/Dockerfile.deps .
|
||||
# docker build -t snapmaker-deps -f scripts/Dockerfile.deps .
|
||||
FROM docker.io/ubuntu:24.04
|
||||
LABEL maintainer="SnapOrca CAD iteration base"
|
||||
LABEL maintainer="Orca CAD iteration base"
|
||||
|
||||
# Disable interactive package configuration
|
||||
RUN apt-get update && \
|
||||
@@ -80,11 +80,11 @@ RUN ./build_linux.sh -dr -j 12
|
||||
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
|
||||
# snaporca-deps; that lineage is Trap 1 in docs/rig_build_traps.md (a baked project(Snapmaker_Orca)
|
||||
# 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 snaporca-deps carries.
|
||||
# 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 \
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
#
|
||||
# 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 snaporca and never independently
|
||||
# tested (snaporca-w80c). The Snapmaker fork does not hit this: its base requires neither
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user