Files
OrcaSlicer/scripts/gui-ladder.py
T
Tommaso Bianchi 82db99f337 Port from snaporca: sketch-only projects save, scripted geometry arrives exact,
and a ladder that draws with the mouse

Three commits carried across (snaporca 4ffd60eacb, 421055c2ec, b71216ce0b):

1. A design made only of sketches must survive being saved. CadDocument::recompute
   returned false with "no solid-producing features" for a document that has no
   solid, and two callers read that as "unusable": the GUI syncs the 3MF recipe
   only after a successful recompute, so a sketch-only design was saved with no
   recipe at all, and deserialize_recipe ends with `return recompute()`, so even a
   project that carried one was refused on load. Having nothing to build is now a
   success; a feature that MEANT to build a solid and produced none still fails.
   DesignPanel::refresh_tree syncs the recipe too, for the paths that call
   m_doc.recompute() directly.

2. Scripted geometry arrives exact. The Horizontal/Vertical inference window and
   the endpoint weld window both close to zero for add_entities_scripted; void
   attribution probes from a point strictly inside each loop instead of from its
   first vertex. Corpus rung 39 graded / 39 fully clean, was 35 with 6 failures.

3. scripts/gui-ladder.py — 17 rungs, 84 properties, all driven by synthetic clicks
   and typed values rather than through the socket.

Parity 17 identical / 8 diverging as expected. Kernel suite here: 188 cases /
2532 assertions.

snaporca-mtav, snaporca-8xg1, snaporca-5hvl, snaporca-730j
2026-08-23 01:22:32 +02:00

1033 lines
42 KiB
Python

#!/usr/bin/env python3
"""A ladder of sketches drawn the way a person draws them: mouse gestures and typed values.
WHY THIS EXISTS, next to scripts/sketch-ladder.py. That ladder proves the ENGINE — it feeds
geometry through the MCP socket's add_entities_scripted and grades what comes back. The socket
path skips everything the goal actually rests on: gesture state, the auto-edit queue, snapping,
inference at gesture tolerance, and the right-click offer. A ladder that only drives the socket
cannot say the Design tab meets its goal. This one draws with synthetic clicks and types the
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):
docker cp scripts/gui-ladder.py snaporca-gui:/tmp/ && \
docker exec snaporca-gui python3 /tmp/gui-ladder.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")
DISP = os.environ.get("DISPLAY", ":10")
_n = 0
_fail = 0
_checks = 0
# ---------------------------------------------------------------- the instrument (read-only)
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 try_call(method, **params):
try:
return call(method, **params)
except Exception:
return None
def describe():
return call("sketch_describe")
# ---------------------------------------------------------------- the hand (synthetic input)
_win = None
def win():
"""The app window's id and origin. Asked fresh once per run: a relaunch changes the id."""
global _win
# BY SIZE, never by title. Saving a project renames the window to the file, and a driver
# that hunts for "Untitled" then reports "no app window" for an app that is running fine —
# which is a false negative in the one place a false negative is most expensive.
if _win is None:
best = None
# --class, not --name: after a project is opened the main window can come back with no
# WM_NAME at all, and a name search then does not list it — the driver picks a 200x200
# helper and every click lands on nothing.
for w in sh(f"DISPLAY={DISP} xdotool search --class '.'").split():
g = sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}")
d = dict(l.split("=", 1) for l in g.strip().splitlines() if "=" in l)
if "WIDTH" not in d:
continue
a = int(d["WIDTH"]) * int(d["HEIGHT"])
if best is None or a > best[0]:
best = (a, w, int(d["X"]), int(d["Y"]), int(d["WIDTH"]), int(d["HEIGHT"]))
if best is None:
die("no app window on " + DISP)
sh(f"DISPLAY={DISP} xdotool windowactivate --sync {best[1]}")
_win = best[1:]
return _win
def sh(cmd):
return subprocess.run(["bash", "-lc", cmd], capture_output=True, text=True).stdout
def xdo(args):
sh(f"DISPLAY={DISP} xdotool {args}")
def key(k, pause=0.35):
xdo(f"key {k}")
time.sleep(pause)
def typ(s, pause=0.35):
xdo(f"type --delay 40 -- '{s}'")
time.sleep(pause)
def click(px, py, pause=0.45, btn=1):
_, X, Y, _, _ = win()
xdo(f"mousemove {X+int(px)} {Y+int(py)} click --delay 120 {btn}")
time.sleep(pause)
def move(px, py, pause=0.2):
_, X, Y, _, _ = win()
xdo(f"mousemove {X+int(px)} {Y+int(py)}")
time.sleep(pause)
def shot(path):
w, X, Y, W, H = win()
sh(f"DISPLAY={DISP} import -window root -crop {W}x{H}+{X}+{Y} +repage {path}")
# ---------------------------------------------------------------- pixels <-> plane
# The viewport is a perspective camera looking at the sketch plane, so pixel -> plane is a
# HOMOGRAPHY, not a scale: the same pixel span covers more millimetres at the far edge than at
# the near one. Four measured correspondences determine it exactly. Measuring beats assuming —
# the camera can be anywhere, and a wrong constant silently puts every click somewhere else.
_H = None # plane -> pixel, row-major 3x3
def _solve(A, b):
"""Tiny dense solve; no numpy in the rig container."""
n = len(A)
M = [row[:] + [b[i]] for i, row in enumerate(A)]
for c in range(n):
p = max(range(c, n), key=lambda r: abs(M[r][c]))
if abs(M[p][c]) < 1e-12:
die("calibration is degenerate — the four probe points are not in general position")
M[c], M[p] = M[p], M[c]
for r in range(n):
if r == c:
continue
f = M[r][c] / M[c][c]
for k in range(c, n + 1):
M[r][k] -= f * M[c][k]
return [M[i][n] / M[i][i] for i in range(n)]
def fit_homography(pairs):
"""pairs: [((X_mm, Y_mm), (u_px, v_px)), ...] -> 3x3 plane->pixel with h22 = 1."""
A, b = [], []
for (X, Y), (u, v) in pairs:
A.append([X, Y, 1, 0, 0, 0, -u * X, -u * Y]); b.append(u)
A.append([0, 0, 0, X, Y, 1, -v * X, -v * Y]); b.append(v)
h = _solve(A, b)
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0]
def px(X, Y):
"""Plane millimetres -> window pixels."""
h = _H
w = h[6] * X + h[7] * Y + h[8]
return ((h[0] * X + h[1] * Y + h[2]) / w, (h[3] * X + h[4] * Y + h[5]) / w)
def unpx(u, v):
"""Window pixels -> plane millimetres (the homography inverted, by hand)."""
h = _H
A = [[h[0] - u * h[6], h[1] - u * h[7]], [h[3] - v * h[6], h[4] - v * h[7]]]
b = [u * h[8] - h[2], v * h[8] - h[5]]
return tuple(_solve(A, b))
def mm_per_px(X, Y):
"""The viewport's local scale at a plane point — what the tool calls unit_per_px."""
u, v = px(X, Y)
a = unpx(u, v)
b = unpx(u + 1.0, v)
return math.dist(a, b)
def clickmm(X, Y, pause=0.45, btn=1):
u, v = px(X, Y)
click(u, v, pause, btn)
def movemm(X, Y, pause=0.2):
u, v = px(X, Y)
move(u, v, pause)
# ---------------------------------------------------------------- session control
def leave_sketch():
"""Back to a clean Feature-mode document, whatever state the last rung left behind."""
try_call("sketch_cancel")
for _ in range(4):
key("Escape", 0.25)
time.sleep(0.5)
# Feature-tree rows, measured on the rig at 1920x1080: first row centre, then 23 px apart.
# x=300, not the label: a second click ON the label opens the inline rename, and Delete then
# edits the text instead of removing the feature.
TREE_ROW0 = (300, 215)
DESIGN_TAB = (128, 29)
def go_design():
"""Make sure the Design tab is in front — loading a project lands on Prepare."""
click(*DESIGN_TAB, pause=1.0)
def reset_document():
"""Delete every committed feature, by picking its tree row and pressing Delete.
A rung that ends in Constrain COMMITS its sketch, and the next rung's Constrain resolves
'the last sketch' — which is then the PREVIOUS rung's. That is how D2 first read a rectangle
of exactly 120 x 80 back from a sketch it had drawn at 120.020087: it was grading a sketch
left behind by an earlier run. The document is part of the fixture; reset it like one.
"""
go_design()
leave_sketch()
for _ in range(40):
if not call("describe_scene")["features"]:
return
click(*TREE_ROW0, pause=0.35)
key("Delete", 0.5)
die("could not empty the feature tree")
def enter_sketch(tool_key, plane_px=(913, 359)):
"""Enter a sketch the way the design law says: pick the plane in the viewport, then the tool.
Shift+S enters sketch MODE and pops the offer; Escape dismisses it; the tool letter then
starts the session on the plane the click selected. All four steps are real input — nothing
here goes through the socket.
"""
leave_sketch()
click(*plane_px)
key("shift+s", 0.8)
key("Escape", 0.4) # entering sketch mode pops the offer; dismiss it
key(tool_key, 0.6)
if try_call("sketch_describe") is None:
shot("/shots/gl-enter-failed.png")
die("no sketch opened after plane click + Shift+S + " + tool_key
+ " (see /shots/gl-enter-failed.png)")
def calibrate():
"""Place four Points by hand, read where they landed, and solve for the camera's map."""
global _H
reset_document()
enter_sketch("p")
probes = [(1000, 500), (1400, 500), (1400, 760), (1000, 760)]
for u, v in probes:
click(u, v)
ents = describe()["entities"]
if len(ents) != 4 or any(e["type"] != "point" for e in ents):
die(f"calibration expected 4 points, got {[e['type'] for e in ents]}")
_H = fit_homography([((e["p"][0], e["p"][1]), probes[i]) for i, e in enumerate(ents)])
# Prove the fit by round-tripping the probes: a homography through its own four points is
# exact, so anything but a sub-pixel residual means the points came back mismatched.
for i, e in enumerate(ents):
u, v = px(e["p"][0], e["p"][1])
if abs(u - probes[i][0]) > 0.5 or abs(v - probes[i][1]) > 0.5:
die(f"calibration residual too large at probe {i}: {(u, v)} vs {probes[i]}")
say(f"calibrated: 4 probes, plane span "
f"{ents[1]['p'][0] - ents[0]['p'][0]:.1f} x {ents[0]['p'][1] - ents[3]['p'][1]:.1f} mm")
leave_sketch()
# ---------------------------------------------------------------- typed values
def value(v, pause=0.6):
"""Type one number into the open in-canvas field and commit it.
Select-all first: the field opens pre-filled with the as-drawn value and pre-selected, but a
pre-selection that a synthetic click has disturbed would otherwise leave the typed digits
appended to it.
"""
key("ctrl+a", 0.15)
typ(str(v), 0.25)
key("Return", pause)
def values(*vs):
for v in vs:
value(v)
# ---------------------------------------------------------------- grading
def say(msg):
print(f" {msg}")
def check(kind, cond, what):
global _fail, _checks
_checks += 1
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 die(msg):
print(f" FATAL {msg}", file=sys.stderr)
sys.exit(2)
def lengths(ents):
return sorted(round(e["length"], 6) for e in ents if e["type"] == "line")
def loops():
return describe()["closed_loops"]
# =================================================================== LADDER A — one tool each
# Every 2D tool draws its primitive by gesture, then takes its exact value from the keyboard.
# The click only has to be roughly right; the typed number is what must come back exactly.
def rung_rect():
print("\nA1 rectangle — two corners, typed 120 x 80")
enter_sketch("r")
clickmm(-60, -40); clickmm(60, 40)
values(120, 80)
d = describe()
ls = lengths(d["entities"])
check("LENGTH", ls == [80.0, 80.0, 120.0, 120.0], f"sides {ls}")
lp = d["closed_loops"]
check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)")
check("AREA", near(abs(lp[0]["area"]), 9600.0, 1e-6), f"area {abs(lp[0]['area']):.6f}")
check("VERTEX", all(near(abs(e["p1"][0] - e["p0"][0]), 0, 1e-9)
or near(abs(e["p1"][1] - e["p0"][1]), 0, 1e-9)
for e in d["entities"]), "every side axis-aligned")
leave_sketch()
def rung_circle():
print("\nA2 circle — centre then rim, typed radius 25")
enter_sketch("c")
clickmm(0, 0); clickmm(30, 0)
values(25)
d = describe()
e = [x for x in d["entities"] if x["type"] == "circle"]
check("ARC", len(e) == 1 and near(e[0]["radius"], 25.0), f"radius {e[0]['radius'] if e else None}")
check("VERTEX", len(e) == 1 and near(e[0]["center"][0], 0.0, 0.6) and near(e[0]["center"][1], 0.0, 0.6),
f"centre {e[0]['center'] if e else None} at the clicked origin")
lp = d["closed_loops"]
check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)")
check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), math.pi * 625.0, 1e-6),
f"area {abs(lp[0]['area']):.6f} vs pi r^2 {math.pi*625:.6f}")
leave_sketch()
def rung_line():
print("\nA3 line — two clicks, typed length 50 and angle 30")
enter_sketch("l")
clickmm(-40, -20); clickmm(10, 5)
values(50, 30)
d = describe()
e = [x for x in d["entities"] if x["type"] == "line"]
check("LENGTH", len(e) == 1 and near(e[0]["length"], 50.0), f"length {e[0]['length'] if e else None}")
if e:
a = math.degrees(math.atan2(e[0]["p1"][1] - e[0]["p0"][1], e[0]["p1"][0] - e[0]["p0"][0])) % 360.0
check("ANGLE", near(a, 30.0, 1e-9), f"angle {a:.9f} deg")
leave_sketch()
def rung_arc():
print("\nA4 three-point arc — typed radius 40 and sweep 90")
enter_sketch("a")
clickmm(-40, 0); clickmm(40, 0); clickmm(0, 40)
values(40, 90)
d = describe()
e = [x for x in d["entities"] if x["type"] == "arc"]
check("ARC", len(e) == 1 and near(e[0]["radius"], 40.0), f"radius {e[0]['radius'] if e else None}")
if e:
sw = abs(e[0]["end_angle"] - e[0]["start_angle"]) * 180.0 / math.pi
# 1e-7 deg, not exact: the sweep is READ BACK as end_angle - start_angle, two atan2
# results, where the line's angle is STORED as the direction it was given. A 1e-9 deg
# residual here is 7e-10 mm at r=40 — float round-trip, not a defect.
check("ANGLE", near(sw, 90.0, 1e-7), f"sweep {sw:.9f} deg")
ch = math.dist(e[0]["p0"], e[0]["p1"])
check("VERTEX", near(ch, 40.0 * math.sqrt(2.0), 1e-6),
f"chord {ch:.6f} vs r*sqrt2 {40*math.sqrt(2):.6f}")
leave_sketch()
def rung_slot():
print("\nA5 slot — typed centre distance 60, radius 10, angle 0")
enter_sketch("s")
clickmm(-30, 0); clickmm(30, 0); clickmm(30, 12)
values(60, 10, 0)
d = describe()
arcs = [x for x in d["entities"] if x["type"] == "arc"]
lns = [x for x in d["entities"] if x["type"] == "line"]
check("ARC", len(arcs) == 2 and all(near(a["radius"], 10.0) for a in arcs),
f"two end radii {[round(a['radius'], 9) for a in arcs]}")
check("LENGTH", len(lns) == 2 and all(near(l["length"], 60.0) for l in lns),
f"two flanks {[round(l['length'], 9) for l in lns]}")
lp = d["closed_loops"]
check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)")
check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), 60 * 20 + math.pi * 100, 1e-6),
f"area {abs(lp[0]['area']):.6f} vs 60*20+pi*100 {60*20+math.pi*100:.6f}")
leave_sketch()
def rung_polygon():
print("\nA6 polygon — typed side 30, angle 0")
enter_sketch("g")
clickmm(0, 0); clickmm(35, 0)
values(30, 0)
d = describe()
e = [x for x in d["entities"] if x["type"] == "line"]
ls = lengths(d["entities"])
check("LENGTH", len(e) >= 3 and all(near(l, 30.0, 1e-9) for l in ls),
f"{len(e)} equal sides {set(ls)}")
lp = d["closed_loops"]
check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)")
if e:
n = len(e)
want = n * 30.0 ** 2 / (4.0 * math.tan(math.pi / n))
check("AREA", near(abs(lp[0]["area"]), want, 1e-6),
f"area {abs(lp[0]['area']):.6f} vs regular {n}-gon {want:.6f}")
leave_sketch()
def rung_ellipse():
print("\nA7 ellipse — typed major 50, minor 20")
enter_sketch("e")
clickmm(0, 0); clickmm(40, 0); clickmm(0, 15)
values(50, 20)
d = describe()
e = [x for x in d["entities"] if x["type"] == "ellipse"]
check("ARC", len(e) == 1, f"{len(e)} ellipse")
lp = d["closed_loops"]
check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)")
check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), math.pi * 50 * 20, 2e-2),
f"area {abs(lp[0]['area']):.4f} vs pi*a*b {math.pi*1000:.4f}")
leave_sketch()
def rung_point():
print("\nA8 point — one click, no value to type")
enter_sketch("p")
clickmm(20, 10)
d = describe()
e = [x for x in d["entities"] if x["type"] == "point"]
check("VERTEX", len(e) == 1 and near(e[0]["p"][0], 20.0, 0.6) and near(e[0]["p"][1], 10.0, 0.6),
f"placed at {[round(v, 3) for v in e[0]['p']] if e else None}")
leave_sketch()
def rung_spline():
print("\nA9 spline — click control points, right-click to end")
enter_sketch("b")
for p in [(-40, 0), (-15, 30), (15, -30), (40, 0)]:
clickmm(*p)
clickmm(40, 0, btn=3)
d = describe()
e = [x for x in d["entities"] if x["type"] == "spline"]
check("VERTEX", len(e) == 1, f"{len(e)} spline from 4 control points")
leave_sketch()
# =================================================================== LADDER B — voids by hand
# The strategic target itself: one closed outer loop with internal voids, every one of them
# drawn by gesture in a single sketch and given its size from the keyboard.
def rung_voids():
print("\nB1 closed profile with two internal voids, all by gesture")
enter_sketch("r")
clickmm(-60, -40); clickmm(60, 40) # outer 120 x 80
values(120, 80)
key("r", 0.6) # same tool again, from the keyboard
clickmm(-45, -15); clickmm(-5, 15) # void 1: 40 x 30
values(40, 30)
key("c", 0.6)
clickmm(30, 0); clickmm(42, 0) # void 2: circle r 10
values(10)
d = describe()
ls = lengths(d["entities"])
check("LENGTH", ls == [30.0, 30.0, 40.0, 40.0, 80.0, 80.0, 120.0, 120.0], f"sides {ls}")
lp = d["closed_loops"]
check("CLOSED", len(lp) == 3 and all(l["closed"] for l in lp), f"{len(lp)} closed loops")
check("CLOSED", d["buildable"] and not d["open_ends"], "buildable, nothing dangling")
# The void attribution is the property under test: the outer loop must OWN both inner ones,
# and neither inner loop may claim a hole of its own.
outer = max(range(len(lp)), key=lambda i: abs(lp[i]["area"]))
holes = sorted(lp[outer]["holes"])
check("VOID", holes == sorted(i for i in range(len(lp)) if i != outer),
f"outer loop {outer} owns holes {holes}")
check("VOID", all(not lp[i]["holes"] for i in range(len(lp)) if i != outer),
"neither void claims a hole of its own")
a = {i: abs(lp[i]["area"]) for i in range(len(lp))}
check("AREA", near(a[outer], 9600.0, 1e-6), f"outer {a[outer]:.6f}")
inner = sorted(a[i] for i in a if i != outer)
check("AREA", near(inner[0], math.pi * 100, 1e-6) and near(inner[1], 1200.0, 1e-6),
f"voids {inner[0]:.6f} (pi*100) and {inner[1]:.6f} (40*30)")
net = a[outer] - sum(v for i, v in a.items() if i != outer)
check("AREA", near(net, 9600.0 - 1200.0 - math.pi * 100, 1e-6), f"net material {net:.6f}")
leave_sketch()
# =================================================================== LADDER C — combining
# Mirror, offset, trim, extend, fillet and chamfer, each driven by the same picks and the same
# on-geometry value label a person would use. The label's place is COMPUTED from the geometry
# the tool itself derives (render_op_gizmo: tip = anchor + dir * value, label = tip + dir * 1.2
# * max(15 * unit_per_px, 1e-4)) rather than hunted for in the pixels — the tool's own formula
# is the only thing that can be right by construction.
def op_label_mm(anchor, direction, value, at):
th = max(15.0 * mm_per_px(*at), 1e-4)
d = (direction[0] / math.hypot(*direction), direction[1] / math.hypot(*direction))
tip = (anchor[0] + d[0] * value, anchor[1] + d[1] * value)
return (tip[0] + d[0] * th * 1.2, tip[1] + d[1] * th * 1.2)
def mid(e):
return ((e["p0"][0] + e["p1"][0]) / 2.0, (e["p0"][1] + e["p1"][1]) / 2.0)
def corner_of(a, b):
"""The shared endpoint of two adjacent lines, and the bisector pointing into their wedge."""
C = min(((pa, pb) for pa in (a["p0"], a["p1"]) for pb in (b["p0"], b["p1"])),
key=lambda t: math.dist(t[0], t[1]))[0]
def away(e):
f = e["p1"] if math.dist(e["p0"], C) < math.dist(e["p1"], C) else e["p0"]
n = math.dist(f, C)
return ((f[0] - C[0]) / n, (f[1] - C[1]) / n)
ua, ub = away(a), away(b)
bis = (ua[0] + ub[0], ua[1] + ub[1])
return tuple(C), bis
def draw_rect(w, h, x0, y0):
clickmm(x0, y0); clickmm(x0 + w, y0 + h)
values(w, h)
def rung_fillet():
print("\nC1 fillet — pick two legs, type radius 8 on the label")
enter_sketch("r")
draw_rect(120, 80, -60, -40)
d0 = describe()["entities"]
a, b = corner_pair(d0)
key("f", 0.6)
clickmm(*mid(a)); clickmm(*mid(b))
C, bis = corner_of(a, b)
v0 = 0.2 * min(a["length"], b["length"])
clickmm(*op_label_mm(C, bis, v0, C))
values(8)
d = describe()
arcs = [x for x in d["entities"] if x["type"] == "arc"]
check("ARC", len(arcs) == 1 and near(arcs[0]["radius"], 8.0), f"radius {arcs[0]['radius'] if arcs else None}")
lp = d["closed_loops"]
check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)")
want = 9600.0 - 64.0 * (1.0 - math.pi / 4.0)
check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), want, 1e-6),
f"area {abs(lp[0]['area']):.6f} vs 9600 - r^2(1-pi/4) {want:.6f}")
if arcs:
# TANGENT: the arc centre must sit exactly r from each surviving leg's line.
legs = [x for x in d["entities"] if x["type"] == "line"]
ds = sorted(point_line_dist(arcs[0]["center"], l) for l in legs)[:2]
check("TANGENT", all(near(x, 8.0, 1e-9) for x in ds), f"centre stands off both legs by {ds}")
leave_sketch()
def rung_chamfer():
print("\nC2 chamfer — pick two legs, type distance 10 on the label")
enter_sketch("r")
draw_rect(120, 80, -60, -40)
d0 = describe()["entities"]
a, b = corner_pair(d0)
key("h", 0.6)
clickmm(*mid(a)); clickmm(*mid(b))
C, bis = corner_of(a, b)
clickmm(*op_label_mm(C, bis, 0.2 * min(a["length"], b["length"]), C))
values(10)
d = describe()
lp = d["closed_loops"]
check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)")
check("LENGTH", len(lp) == 1 and len(lp[0]["entities"]) == 5,
f"{len(lp[0]['entities'])} sides after the cut")
ls = sorted(e["length"] for e in d["entities"] if e["type"] == "line")
check("LENGTH", any(near(x, 10.0 * math.sqrt(2.0), 1e-9) for x in ls),
f"the new face is d*sqrt2 = {10*math.sqrt(2):.9f}; sides {[round(x,9) for x in ls]}")
check("LENGTH", near(ls[1], 70.0, 1e-9) and near(ls[3], 110.0, 1e-9),
"both legs shortened by exactly d")
check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), 9600.0 - 50.0, 1e-6),
f"area {abs(lp[0]['area']):.6f} vs 9600 - d^2/2")
leave_sketch()
def rung_offset():
print("\nC3 offset — pick a circle, type 5 on the label")
enter_sketch("c")
clickmm(0, 0); clickmm(30, 0)
values(25)
key("o", 0.6)
clickmm(25, 0) # pick the rim
# Circle offset anchors at centre + (r, 0) and grows along +x; the starting value is 0.1 * 2r.
clickmm(*op_label_mm((25.0, 0.0), (1.0, 0.0), 0.1 * 50.0, (25.0, 0.0)))
values(5)
d = describe()
cs = sorted(x["radius"] for x in d["entities"] if x["type"] == "circle")
# The gizmo's arrow starts on the +x side, so the typed 5 lands OUTWARD; what the goal cares
# about is that the separation is exactly the number typed, on whichever side it was given.
check("ARC", len(cs) == 2 and near(cs[0], 25.0) and near(cs[1] - cs[0], 5.0),
f"radii {cs} — separated by exactly {cs[1]-cs[0] if len(cs)==2 else None}")
lp = d["closed_loops"]
check("CLOSED", len(lp) == 2 and all(l["closed"] for l in lp), f"{len(lp)} closed loops")
check("VOID", any(l["holes"] for l in lp), "the inner circle is read as a void of the outer")
leave_sketch()
def cross(a, b):
"""Where two lines' infinite supports meet."""
(x1, y1), (x2, y2) = a["p0"], a["p1"]
(x3, y3), (x4, y4) = b["p0"], b["p1"]
d = (x2 - x1) * (y4 - y3) - (y2 - y1) * (x4 - x3)
t = ((x3 - x1) * (y4 - y3) - (y3 - y1) * (x4 - x3)) / d
return (x1 + t * (x2 - x1), y1 + t * (y2 - y1))
def mid_of(a, b):
return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
def point_line_dist(p, l):
(x0, y0), (x1, y1) = l["p0"], l["p1"]
dx, dy = x1 - x0, y1 - y0
n = math.hypot(dx, dy)
return abs((p[0] - x0) * dy - (p[1] - y0) * dx) / n
def corner_pair(ents):
"""Two adjacent lines of a rectangle: the first line and the one sharing an endpoint."""
ls = [e for e in ents if e["type"] == "line"]
a = ls[0]
for b in ls[1:]:
if min(math.dist(pa, pb) for pa in (a["p0"], a["p1"]) for pb in (b["p0"], b["p1"])) < 1e-6:
return a, b
die("no adjacent pair in what should be a rectangle")
CONSTRUCTION_CHECKBOX = (419, 75)
def draw_line(x0, y0, x1, y1, length, angle):
clickmm(x0, y0); clickmm(x1, y1)
values(length, angle)
def rung_mirror():
print("\nC4 mirror — a half profile reflected about a construction axis")
enter_sketch("l")
click(*CONSTRUCTION_CHECKBOX) # the axis is reference, not material
key("l", 0.6)
draw_line(0, -40, 0, 40, 80, 90) # the axis, on x = 0
click(*CONSTRUCTION_CHECKBOX) # back to real geometry
key("l", 0.6)
draw_line(0, -40, 50, -40, 50, 0)
key("l", 0.6)
draw_line(50, -40, 50, 40, 80, 90)
key("l", 0.6)
draw_line(50, 40, 0, 40, 50, 180)
d0 = describe()["entities"]
axis = [e for e in d0 if e.get("construction")]
check("VERTEX", len(axis) == 1, f"{len(axis)} construction axis")
half = [e for e in d0 if e["type"] == "line" and not e.get("construction")]
check("LENGTH", len(half) == 3, f"{len(half)} lines in the half profile")
key("m", 0.6)
clickmm(*mid(axis[0]))
for e in half:
clickmm(*mid(e))
clickmm(-90, 60) # empty space confirms
d = describe()
real = [e for e in d["entities"] if e["type"] == "line" and not e.get("construction")]
check("LENGTH", len(real) == 6, f"{len(real)} lines after the reflection")
lp = [l for l in d["closed_loops"]]
check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)")
# Graded against the geometry ACTUALLY DRAWN, not against the coordinates I aimed at. A
# synthetic click lands on a whole pixel, so the half profile sits a few tenths of a
# millimetre off the origin; the typed values fix its lengths and angles, not its anchor.
# Demanding 8000.000000 here would grade my aim, and the mirror is what is under test.
far = max(half, key=lambda e: e["length"]) # the edge parallel to the axis
w = point_line_dist(mid(far), axis[0])
want = 2.0 * w * far["length"]
check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), want, 1e-6),
f"area {abs(lp[0]['area']):.6f} vs 2 x {w:.6f} x {far['length']:.6f} = {want:.6f}")
# SYMMETRY is the property this rung exists for: every vertex must have its exact reflection
# ABOUT THE AXIS THAT WAS DRAWN.
vs = [tuple(p) for e in real for p in (e["p0"], e["p1"])]
def refl(q):
(ax, ay), (bx, by) = axis[0]["p0"], axis[0]["p1"]
dx, dy = bx - ax, by - ay
n = dx * dx + dy * dy
t = ((q[0] - ax) * dx + (q[1] - ay) * dy) / n
fx, fy = ax + t * dx, ay + t * dy
return (2 * fx - q[0], 2 * fy - q[1])
missing = [v for v in vs if not any(math.dist(refl(v), o) < 1e-9 for o in vs)]
check("SYMMETRY", not missing,
f"every one of {len(vs)} vertices has its exact reflection about the drawn axis")
leave_sketch()
def rung_trim():
print("\nC5 trim — cut one arm off a crossing")
enter_sketch("l")
draw_line(-50, 0, 50, 0, 100, 0)
key("l", 0.6)
draw_line(0, -50, 0, 50, 100, 90)
d0 = describe()["entities"]
horiz = min(d0, key=lambda e: abs(e["p1"][1] - e["p0"][1]))
vert = max(d0, key=lambda e: abs(e["p1"][1] - e["p0"][1]))
X = cross(horiz, vert)
left = min(horiz["p0"], horiz["p1"]) # the end that must survive
want = math.dist(left, X)
key("t", 0.6)
clickmm(*mid_of(X, max(horiz["p0"], horiz["p1"]))) # the arm on the far side of the crossing
d = describe()
ls = sorted(round(e["length"], 9) for e in d["entities"] if e["type"] == "line")
check("LENGTH", len(ls) == 2 and near(ls[1], vert["length"], 1e-9) and near(ls[0], want, 1e-9),
f"lengths {ls} — the picked arm is gone at the crossing (expected {want:.9f}), "
f"the other line untouched")
ends = [tuple(p) for e in d["entities"] if e["type"] == "line" for p in (e["p0"], e["p1"])]
check("VERTEX", any(math.dist(X, q) < 1e-9 for q in ends), "the cut lands exactly on the crossing")
leave_sketch()
def rung_extend():
print("\nC6 extend — reach a line to the one it stops short of")
enter_sketch("l")
draw_line(-50, 0, -10, 0, 40, 0)
key("l", 0.6)
draw_line(0, -50, 0, 50, 100, 90)
d0 = describe()["entities"]
short = min(d0, key=lambda e: e["length"])
vert = max(d0, key=lambda e: e["length"])
X = cross(short, vert)
far = min((short["p0"], short["p1"]), key=lambda q: q[0]) # the end that stays put
near_end = max((short["p0"], short["p1"]), key=lambda q: q[0])
want = math.dist(far, X)
key("x", 0.6)
clickmm(*mid_of(near_end, mid_of(far, near_end))) # click the end that must grow
d = describe()
ls = sorted(round(e["length"], 9) for e in d["entities"] if e["type"] == "line")
check("LENGTH", len(ls) == 2 and near(ls[0], want, 1e-9),
f"lengths {ls}{short['length']:.6f} grew to exactly {want:.9f}")
ends = [tuple(p) for e in d["entities"] if e["type"] == "line" for p in (e["p0"], e["p1"])]
check("VERTEX", any(math.dist(X, q) < 1e-9 for q in ends),
"the new end sits exactly on the target line")
leave_sketch()
# =================================================================== LADDER D — dimensions
# A drawn shape with no numbers on it, then numbers put on it by hand: the Dimension tool for a
# value, the Constrain buttons for a relation. Both must hold the value they were given AND take
# the degrees of freedom away — a dimension that moves the geometry but leaves the DoF standing
# has not constrained anything, it has only nudged it.
# Constrain-mode toolbar, measured off the rig at 1920x1080 (icon centres, 42 px apart).
CON_BTN_Y = 76
CON_BTN = {n: (449 + 42 * i, CON_BTN_Y) for i, n in enumerate(
["horizontal", "vertical", "parallel", "perpendicular", "coincident", "equal",
"concentric", "tangent", "midpoint", "symmetric", "angle", "radius", "diameter", "fix"])}
def draw_rect_undimensioned():
"""A rectangle by two clicks, with both queued value fields dismissed (Esc keeps it as drawn)."""
clickmm(-60, -40); clickmm(60, 40)
key("Escape", 0.7) # Width — keep as drawn
key("Escape", 0.7) # Height — keep as drawn
def rung_dimension():
print("\nD1 dimension — put a length on a side that had none")
enter_sketch("r")
draw_rect_undimensioned()
d0 = describe()
dof0 = d0["dof"]
check("VERTEX", dof0 > 0, f"the undimensioned rectangle has {dof0} degrees of freedom")
side = max((e for e in d0["entities"] if e["type"] == "line"), key=lambda e: e["length"])
key("d", 0.6)
clickmm(*mid(side))
values(90)
d = describe()
ls = sorted(round(e["length"], 9) for e in d["entities"] if e["type"] == "line")
check("LENGTH", any(near(x, 90.0, 1e-9) for x in ls), f"the dimensioned side reads {ls}")
check("VERTEX", d["dof"] < dof0, f"degrees of freedom {dof0} -> {d['dof']}")
check("CLOSED", d["solve_ok"] and len(d["closed_loops"]) == 1, "still one closed, solved loop")
leave_sketch()
CONFIRM_BTN = (1751, 75)
def confirm_and_reopen():
"""(see reopen_sketch below — same two steps, kept together for the constrain rungs)"""
"""Leave Constrain with the action bar's tick, then re-open the sketch for editing.
THE DoF HAS TO BE READ HERE, not in Constrain mode. While constraining, sketch_describe
reports the LIVE tool's dof and constraint count, which the constrain session does not
touch — it works on the committed feature's own entity_constraints, and the panel computes
its readout from those. Reading during the session says 4 -> 4 for a constraint that really
did land; reading after the round trip says 4 -> 3, and proves the constraint was persisted
rather than merely previewed.
"""
click(*CONFIRM_BTN, pause=1.5)
w, X, Y, _, _ = win()
sh(f"DISPLAY={DISP} xdotool mousemove {X+TREE_ROW0[0]} {Y+TREE_ROW0[1]} "
f"click --repeat 2 --delay 120 1")
time.sleep(2.0)
return describe()
def rung_constrain():
reset_document()
print("\nD2 constrain — Equal length on two adjacent sides, from the Constrain toolbar")
enter_sketch("r")
draw_rect_undimensioned()
a, b = corner_pair(describe()["entities"])
check("LENGTH", not near(a["length"], b["length"], 1e-6),
f"the two sides start unequal: {a['length']:.6f} vs {b['length']:.6f}")
key("k", 1.5) # finish the sketch and enter Constrain
# Re-read the picks from the COMMITTED sketch: finish_sketch repackages the entities, so an
# index taken before Constrain is not the same index afterwards.
d1 = describe()
a, b = corner_pair(d1["entities"])
dof0 = 4 # an undimensioned rectangle: position + size
ia, ib = d1["entities"].index(a), d1["entities"].index(b)
clickmm(*mid(a)); clickmm(*mid(b))
click(*CON_BTN["equal"])
time.sleep(1.0)
d = describe()
la, lb = d["entities"][ia]["length"], d["entities"][ib]["length"]
check("LENGTH", near(la, lb, 1e-9), f"the two sides are now equal: {la:.9f} and {lb:.9f}")
d2 = confirm_and_reopen()
check("VERTEX", d2["dof"] == dof0 - 1, f"degrees of freedom {dof0} -> {d2['dof']} after the round trip")
check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0,
f"{d2['constraints']} constraints survived the commit")
ls2 = sorted(round(e["length"], 9) for e in d2["entities"] if e["type"] == "line")
check("LENGTH", near(ls2[0], la, 1e-9) and near(ls2[-1], la, 1e-9),
f"the geometry came back unchanged: {ls2}")
leave_sketch()
def rung_perpendicular():
reset_document()
print("\nD3 constrain — two free lines made exactly perpendicular")
enter_sketch("l")
clickmm(-50, -30); clickmm(30, -18)
key("Escape", 0.7); key("Escape", 0.7)
key("l", 0.6)
clickmm(30, -18); clickmm(18, 40)
key("Escape", 0.7); key("Escape", 0.7)
d0 = describe()
check("ANGLE", abs(angle_between(d0["entities"][0], d0["entities"][1]) - 90.0) > 1e-3,
f"they start at {angle_between(d0['entities'][0], d0['entities'][1]):.6f} deg")
key("k", 1.5)
d1 = describe()
clickmm(*mid(d1["entities"][0])); clickmm(*mid(d1["entities"][1]))
click(*CON_BTN["perpendicular"])
time.sleep(1.0)
d = describe()
ang = angle_between(d["entities"][0], d["entities"][1])
check("ANGLE", near(ang, 90.0, 1e-9), f"now {ang:.9f} deg")
d2 = confirm_and_reopen()
ang2 = angle_between(d2["entities"][0], d2["entities"][1])
check("ANGLE", near(ang2, 90.0, 1e-9), f"still {ang2:.9f} deg after the round trip")
check("CLOSED", d2["constraints"] > 0 and d2["solve_ok"],
f"{d2['constraints']} constraints survived, dof {d2['dof']}")
leave_sketch()
def angle_between(a, b):
va = (a["p1"][0] - a["p0"][0], a["p1"][1] - a["p0"][1])
vb = (b["p1"][0] - b["p0"][0], b["p1"][1] - b["p0"][1])
c = (va[0] * vb[0] + va[1] * vb[1]) / (math.hypot(*va) * math.hypot(*vb))
return math.degrees(math.acos(max(-1.0, min(1.0, c))))
# =================================================================== DURABILITY
# Exactness that does not survive an undo or a save is not exactness.
def rung_undo():
print("\nE1 undo — the last entity goes, the rest do not move")
enter_sketch("l")
draw_line(-50, -30, 0, -30, 50, 0)
key("l", 0.6); draw_line(0, -30, 0, 20, 50, 90)
key("l", 0.6); draw_line(0, 20, -40, 20, 40, 180)
before = describe()["entities"]
check("LENGTH", len(before) == 3, f"{len(before)} entities drawn")
key("ctrl+z", 1.0)
after = describe()["entities"]
check("VERTEX", len(after) == 2, f"{len(after)} entities after one undo")
same = all(math.dist(a["p0"], b["p0"]) == 0.0 and math.dist(a["p1"], b["p1"]) == 0.0
for a, b in zip(before, after))
check("VERTEX", same, "the two survivors are bit-identical, not re-solved")
key("ctrl+z", 1.0)
check("VERTEX", len(describe()["entities"]) == 1, "a second undo drops one more")
leave_sketch()
def rung_feature_undo():
print("\nE2 undo/redo across the commit — a deleted sketch comes back exactly")
reset_document()
enter_sketch("r")
draw_rect(120, 80, -60, -40)
click(*CONFIRM_BTN, pause=1.5)
n0 = len(call("describe_scene")["features"])
check("VERTEX", n0 == 1, f"{n0} feature committed")
click(*TREE_ROW0, pause=0.4)
key("Delete", 0.8)
check("VERTEX", not call("describe_scene")["features"], "the tree is empty after Delete")
key("ctrl+z", 1.5)
check("VERTEX", len(call("describe_scene")["features"]) == 1, "undo brings the feature back")
d = reopen_sketch()
ls = lengths(d["entities"])
check("LENGTH", ls == [80.0, 80.0, 120.0, 120.0], f"and it is the same rectangle: {ls}")
lp = d["closed_loops"]
check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), 9600.0, 1e-6),
f"area {abs(lp[0]['area']):.6f}")
leave_sketch()
key("ctrl+y", 1.5)
check("VERTEX", not call("describe_scene")["features"], "redo removes it again")
reset_document()
PROJECT_FILE = "/tmp/gl-roundtrip.3mf"
def dialog_up():
names = sh(f"DISPLAY={DISP} xdotool search --class '.' getwindowname %@")
return any(n and n != "snapmaker-orca" and "file" in n.lower() for n in names.splitlines())
def file_dialog(path, settle=5.0):
"""Type an absolute path into the GTK file chooser that is up, and accept it."""
if not dialog_up():
die("no file chooser came up")
key("ctrl+a", 0.3)
typ(path, 0.5)
key("Return", settle)
global _win
_win = None # saving renames the window; drop the cached geometry
def rung_roundtrip():
print("\nE3 save and reload — the profile comes back to the last decimal")
reset_document()
enter_sketch("r")
draw_rect(120, 80, -60, -40)
key("r", 0.6); clickmm(-45, -15); clickmm(-5, 15); values(40, 30)
key("c", 0.6); clickmm(30, 0); clickmm(42, 0); values(10)
before = describe()
click(*CONFIRM_BTN, pause=1.5)
sh(f"rm -f {PROJECT_FILE}")
# Save AS, not Save: once a project has a path, Ctrl+S writes to it silently and no chooser
# appears — which is correct behaviour and a trap for a driver that assumes the dialog.
key("ctrl+shift+s", 3.0)
file_dialog(PROJECT_FILE)
size = sh(f"stat -c %s {PROJECT_FILE} 2>/dev/null").strip()
check("CLOSED", size.isdigit() and int(size) > 0, f"project written, {size} bytes")
reset_document() # wipe the tree, then read it back off disk
key("ctrl+o", 2.5)
file_dialog(PROJECT_FILE, settle=8.0)
go_design() # opening a project lands on Prepare
feats = call("describe_scene")["features"]
check("VERTEX", len(feats) == 1, f"the reloaded document has {len(feats)} feature(s)")
after = reopen_sketch()
b = sorted((e["type"], tuple(round(c, 12) for c in (e.get("p0") or e.get("center") or e.get("p"))),
round(e.get("length", e.get("radius", 0.0)), 12)) for e in before["entities"])
a = sorted((e["type"], tuple(round(c, 12) for c in (e.get("p0") or e.get("center") or e.get("p"))),
round(e.get("length", e.get("radius", 0.0)), 12)) for e in after["entities"])
check("VERTEX", a == b, f"{len(a)} entities identical to 12 decimals after the round trip")
lp = after["closed_loops"]
check("CLOSED", len(lp) == 3 and after["buildable"], f"{len(lp)} loops, buildable")
outer = max(range(len(lp)), key=lambda i: abs(lp[i]["area"]))
check("VOID", sorted(lp[outer]["holes"]) == sorted(i for i in range(len(lp)) if i != outer),
"the voids are still attributed to the outer loop")
check("AREA", near(abs(lp[outer]["area"]), 9600.0, 1e-9), f"outer area {abs(lp[outer]['area']):.9f}")
leave_sketch()
reset_document()
def reopen_sketch():
w, X, Y, _, _ = win()
sh(f"DISPLAY={DISP} xdotool mousemove {X+TREE_ROW0[0]} {Y+TREE_ROW0[1]} "
f"click --repeat 2 --delay 120 1")
time.sleep(2.0)
return describe()
RUNGS = {"rect": rung_rect, "circle": rung_circle, "line": rung_line, "arc": rung_arc,
"slot": rung_slot, "polygon": rung_polygon, "ellipse": rung_ellipse,
"point": rung_point, "spline": rung_spline, "voids": rung_voids,
"fillet": rung_fillet, "chamfer": rung_chamfer, "offset": rung_offset,
"mirror": rung_mirror, "trim": rung_trim, "extend": rung_extend,
"dimension": rung_dimension, "constrain": rung_constrain,
"perpendicular": rung_perpendicular, "undo": rung_undo,
"feature_undo": rung_feature_undo, "roundtrip": rung_roundtrip}
def main():
want = sys.argv[1:] or list(RUNGS)
calibrate()
for name in want:
if name not in RUNGS:
die(f"unknown rung {name}; have {' '.join(RUNGS)}")
RUNGS[name]()
leave_sketch()
print(f"\n{_checks - _fail}/{_checks} properties held")
sys.exit(1 if _fail else 0)
if __name__ == "__main__":
main()