Files
OrcaSlicer/scripts/CAD/check-gui-context-menu.py
T
Tommaso Bianchi 13d5eac891 Move the Design-tab scripts into scripts/CAD/ and name them by role
Requested by SoftFever on PR #15238: ten of these had accumulated loose in
scripts/ next to ~20 unrelated upstream ones, with names that only meant
something to whoever wrote them. They now sit in scripts/CAD/, mirroring the
src/libslic3r/CAD/ and src/slic3r/GUI/CAD/ split, and the verb in the name is
the role: build- produces a binary, start- brings something up, run- runs a
suite, check- asserts one thing against a live app.

  kernel-test.sh        -> CAD/run-kernel-tests.sh
  ladder-all.sh         -> CAD/run-all-checks.sh
  sketch-ladder.py      -> CAD/check-sketch-engine.py
  ladder-corpus.py      -> CAD/check-sketch-engine-corpus.py
  gui-ladder.py         -> CAD/check-gui-sketching.py
  offer-ladder.py       -> CAD/check-gui-context-menu.py
  mcp-sketch-smoke.py   -> CAD/check-mcp-sketch.py
  rig-build.sh          -> CAD/build-gui.sh
  docker-iter-build.sh  -> CAD/build-gui-incremental.sh
  gui-session.sh        -> CAD/start-headless-gui.sh

"Ladder" was the worst of them: it named the shape of the test (rungs of
increasing difficulty) rather than what the test proves, so nothing in the
directory listing told you which one needed a GPU and which was pure kernel.

Every reference rewritten -- the docs, the cross-calls between the scripts,
Dockerfile.deps, and the container-side /OrcaSlicer/scripts paths. The three
shell scripts resolve REPO relative to themselves and now sit one level
deeper, so that walk went from /.. to /../.. . The copies these push into a
container's /tmp were renamed to match, or the container would have kept the
old names alive.

Two runtime paths deliberately NOT renamed. /tmp/orca-rig-build.lock is a
cross-fork contract -- both forks take the same lock so two concurrent builds
serialise instead of OOMing the box, and renaming it on one side silently
removes that guard. /tmp/gui-session.log is a runtime artefact, not a script.

Added scripts/CAD/README.md: what each script proves, what it needs, and the
two constraints that have each cost a session (never build inside the GUI
container; a window manager is required or synthetic keys are ignored).

On CI, which was the other half of the request: the kernel suite is already
there and always has been. The cases are registered in
tests/libslic3r/CMakeLists.txt under if (SLIC3R_CAD), which defaults ON and no
workflow turns off, so they build into libslic3r_tests and run under ctest on
every platform via unit_tests.yml -- like any other unit test, needing no new
job. They have simply never been seen to run, because the workflows on this PR
are still awaiting maintainer approval. run-kernel-tests.sh is the local loop
over the same cases, and it is the only script here CI could run: the other
six need an OpenGL canvas and synthetic input.

Verified: scripts/CAD/run-kernel-tests.sh from its new location, all tests
passed, 2562 assertions in 190 test cases.
2026-08-28 19:34:03 +02:00

1219 lines
58 KiB
Python

#!/usr/bin/env python3
"""The OFFER ladder: prove that right-click is the pivot, and that it adapts to what was clicked.
The gesture ladder (scripts/CAD/check-gui-sketching.py) proved the TARGET — a complex closed profile, precise
in vertices, lengths, arcs and symmetry, with its voids correctly attributed. It proved it by
arming every tool with a letter key. That leaves the goal's own MECHANISM untested: the design
logic pivots on right-click, and the verbs offered are supposed to adapt to the element under the
cursor. Half the vocabulary is only reachable that way — 47 of 86 Design-tab verbs have a GUI
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
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:
docker exec snaporca-gui python3 /OrcaSlicer/scripts/CAD/check-gui-context-menu.py [rung ...]
"""
import importlib.util
import math
import os
import re
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
# The gesture ladder owns the hand and the eye: the homography per sketch, the window lookup by
# class, the synthetic click, the typed value, the socket. Importing it is the only way those
# stay one implementation — a second copy would drift the first time a rig detail moved.
_spec = importlib.util.spec_from_file_location("gui_ladder", os.path.join(HERE, "check-gui-sketching.py"))
G = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(G)
LOG = os.environ.get("SNAPORCA_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", ""),
os.path.join(HERE, "..", "src", "slic3r", "GUI", "CAD", "DesignOffer.hpp"),
os.path.join(HERE, "DesignOffer.hpp")]
# OfferSel, in the order the generated enum declares it. The trace reports the integer; a test
# that printed "kind=16" and expected the reader to know what that is would be half a test.
SEL = ["None", "FacePlanar", "FaceCyl", "FaceOther", "EdgeStr", "EdgeCirc", "Vertex",
"BodySolid", "BodySheet", "Bodies2", "DatumPlane", "DatumAxis", "CoordSys", "Art",
"SkLoop", "SkNone", "SkLine", "SkArc", "SkPoint", "Sk2Ent"]
# ---------------------------------------------------------------- the table, parsed
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,
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 "
"this script (tried: " + ", ".join(filter(None, HEADER_CANDIDATES)) + ")")
print(f"offer table: {os.path.realpath(path)}")
src = open(path).read()
body = src[src.index("kOfferVerbs[]"):]
body = body[:body.index("\n};")]
out = []
for line in body.splitlines():
line = line.strip()
if not line.startswith('{"'):
continue
# id, name, row, key, action, refusal, accepts, need_bodies, need_sketches, need_sheet,
# sketch_mode, family, ... — split on top-level commas, respecting quotes.
f, cur, q, esc = [], "", False, False
for ch in line[1:]:
if esc:
cur += ch; esc = False; continue
if ch == "\\":
cur += ch; esc = True; continue
if ch == '"':
q = not q
if ch == "," and not q:
f.append(cur.strip()); cur = ""; continue
if ch == "}" and not q:
break
cur += ch
f.append(cur.strip())
if len(f) < 12:
continue
lit = lambda s: None if s == "nullptr" else s.strip('"')
out.append({"id": lit(f[0]), "name": lit(f[1]), "row": int(f[2]), "key": lit(f[3]),
"action": lit(f[4]), "accepts": int(f[6].rstrip("u"), 0),
"need_bodies": int(f[7]), "need_sketches": int(f[8]),
"need_sheet": f[9] == "true", "sketch_mode": f[10] == "true",
"family": lit(f[11])})
return out
TABLE = load_table()
def predicted(kind, sketching, bodies=0, sketches=0, sheet=False):
"""The verbs show_offer_menu should list for this selection — the table's own answer.
Mirrors the `applies` lambda and the sketch_mode gate in DesignPanel::show_offer_menu. If the
two ever disagree, one of them is the bug; this ladder says which selection exposed it.
"""
bit = 1 << kind
return [v["id"] for v in TABLE
if v["sketch_mode"] == sketching and (v["accepts"] & bit)
and v["need_bodies"] <= bodies and v["need_sketches"] <= sketches
and (not v["need_sheet"] or sheet)]
# ---------------------------------------------------------------- the trace, read back
def log_mark():
"""Where the log ends now, so the next read sees only this gesture's lines."""
try:
return os.path.getsize(LOG)
except OSError:
return 0
def log_since(mark):
with open(LOG, "rb") as fh:
fh.seek(mark)
return fh.read().decode("utf-8", "replace")
class Offer:
"""One opening of the offer menu, as the app described it while building the rows."""
def __init__(self, text):
self.kind = None
self.sketching = None
self.entries = [] # top level, in order: dicts with label/enabled/verbs
self.verbs = [] # every live verb id, in menu order
for m in re.finditer(r"^\[OFFER\] (.*)$", text, re.M):
self._line(m.group(1))
def _line(self, s):
head = re.match(r"open kind=(\d+) sketching=(\d+) bodies=(\d+)", s)
if head:
self.kind = int(head.group(1))
self.sketching = head.group(2) == "1"
self.entries = []
self.verbs = []
return
dis = re.match(r"row=(\d+) (.*?) DISABLED \((.*)\)$", s)
if dis:
self.entries.append({"row": int(dis.group(1)), "label": dis.group(2),
"enabled": False, "kids": [], "why": dis.group(3)})
return
one = re.match(r"row=(\d+) (.*?) -> (\S+)(?: \(no GUI route\))?$", s)
if one:
routed = "(no GUI route)" not in s
self.entries.append({"row": int(one.group(1)), "label": one.group(2),
"enabled": routed, "kids": [], "verb": one.group(3)})
self.verbs.append(one.group(3))
return
sub = re.match(r"row=(\d+) (.*?) > (.*)$", s)
if sub:
row, label, rest = int(sub.group(1)), sub.group(2), sub.group(3)
routed = "(no GUI route)" not in rest
rest = rest.replace(" (no GUI route)", "")
fam, vid = (rest.split(" > ", 1) + [None])[:2] if " > " in rest else (None, rest)
if not self.entries or self.entries[-1]["row"] != row or "verb" in self.entries[-1]:
self.entries.append({"row": row, "label": label, "enabled": True, "kids": []})
self.entries[-1]["kids"].append({"family": fam, "verb": vid, "enabled": routed})
self.verbs.append(vid)
return
def kind_name(self):
return SEL[self.kind] if self.kind is not None and self.kind < len(SEL) else str(self.kind)
def path_to(self, verb):
"""Keyboard path to a verb: how many Downs at each level, top level first.
GTK skips insensitive items on arrow navigation, so the count is over ENABLED entries
only — which is exactly why the trace records the enabled state per row instead of the
ladder assuming every row is live.
"""
n = 0
for e in self.entries:
if not e["enabled"]:
continue
n += 1
if e.get("verb") == verb:
return [n]
if e["kids"]:
# Families become a nested submenu at the position of their first member.
pos, seen = 0, []
for k in e["kids"]:
if k["family"]:
if k["family"] not in seen:
seen.append(k["family"])
pos += 1
fam_pos = pos
if k["verb"] == verb:
inner = [x for x in e["kids"] if x["family"] == k["family"]]
j = sum(1 for x in inner[:inner.index(k) + 1] if x["enabled"])
return [n, fam_pos, j]
continue
if not k["enabled"]:
continue
pos += 1
if k["verb"] == verb:
return [n, pos]
return None
def open_offer(X=None, Y=None, pause=1.2):
"""Right-click (on the plane point given, or wherever the cursor is) and read the offer back.
The menu is modal — PopupMenu blocks the main thread — so no socket call may be made between
this and choose()/dismiss(). Every assertion about the menu comes from the trace.
"""
mark = log_mark()
if X is None:
G.xdo("click --delay 120 3")
else:
G.clickmm(X, Y, pause=0.5, btn=3)
time.sleep(pause)
return Offer(log_since(mark))
def choose(offer, verb):
"""Walk the open menu to a verb with the keyboard and activate it."""
path = offer.path_to(verb)
if path is None:
G.die(f"{verb} is not in the offer (kind={offer.kind_name()}, has {offer.verbs})")
for level, downs in enumerate(path):
# At the top level nothing is highlighted when the menu pops, so the first Down lands on
# entry 1. Inside a submenu GTK has ALREADY highlighted its first item as part of opening
# it, so reaching entry k takes k-1 more. Getting this wrong is silent: the walk activates
# a neighbouring verb and the rung grades a shape nobody asked for — the first run of this
# rung drew a circle of area 45238.93 and called it a rectangle.
for _ in range(downs if level == 0 else downs - 1):
G.key("Down", 0.12)
if level < len(path) - 1:
G.key("Right", 0.35) # open the submenu; its first item is now highlighted
G.key("Return", 0.9)
def dismiss(offer=None):
"""Escape ONLY when a menu is really open.
A stray Escape on the canvas is not harmless: with no tool armed and no points down, the
sketch's layered exit reads it as "leave the sketch", and the next socket call answers
"no sketch is open" three rungs from where the mistake was made.
"""
if offer is not None and offer.kind is None:
return
G.key("Escape", 0.5)
def menu_windows():
"""X windows that are override-redirect popups — evidence the menu really opened."""
out = []
for w in G.sh(f"DISPLAY={G.DISP} xdotool search --class '.'").split():
g = G.sh(f"DISPLAY={G.DISP} xdotool getwindowgeometry --shell {w}")
d = dict(l.split("=", 1) for l in g.strip().splitlines() if "=" in l)
if "WIDTH" in d and 60 < int(d["WIDTH"]) < 700 and 40 < int(d["HEIGHT"]) < 900:
out.append(w)
return out
# ---------------------------------------------------------------- rungs
def draw_line_at(x0, y0, x1, y1, length):
"""Draw one horizontal line and COMMIT it by typing its length and angle.
Deliberately no Escape. Escape is overloaded in a sketch — field, then tool, then the sketch
itself — so a driver that presses it one time too many leaves the session and every later
assertion answers "no sketch is open" from three rungs away. Typing the value closes the
field, which is what the gesture ladder proved commits exactly.
"""
G.key("l", 0.5)
G.clickmm(x0, y0)
G.clickmm(x1, y1)
G.values(int(length), 0)
keep_as_drawn() # drain any straggler field before the caller clicks anything
def rung_kinds():
"""O1 — the offer adapts to the element under the cursor, one element type at a time."""
print("\nO1 the offer reads what was right-clicked")
fresh_sketch("l")
x0, x1, y0, y1 = G._SAFE
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
# 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
# mechanism failing silently.
hi = y1 - (y1 - y0) * 0.12
o = open_offer(cx, hi)
G.check("OFFER", o.kind is not None,
"Line armed, nothing anchored: right-click opens the offer")
G.check("OFFER", o.kind == 15, f"right-click on empty space -> {o.kind_name()}")
G.check("OFFER", o.sketching, "the sketch vocabulary is the one being offered")
dismiss(o)
# ...and with an anchor down it abandons the anchor instead, offering nothing.
G.key("l", 0.5)
G.clickmm(cx, hi) # anchor the first point
o = open_offer(cx + (x1 - x0) * 0.1, hi)
G.check("OFFER", o.kind is None,
"with an anchor down, the same gesture abandons it and does not offer")
o = open_offer(cx, hi)
G.check("OFFER", o.kind is not None, "and the offer is back on the next right-click")
dismiss(o)
G.key("Escape", 0.4)
# A line.
ax, ay = x0 + (x1 - x0) * 0.15, cy
bx, by = x0 + (x1 - x0) * 0.55, cy
draw_line_at(ax, ay, bx, by, 40)
o = open_offer((ax + bx) / 2.0, ay)
G.check("OFFER", o.kind == 16, f"right-click on a line -> {o.kind_name()}")
dismiss(o)
# A circle: every curve takes the same vocabulary, which is what SkArc means.
G.key("c", 0.5)
ccx, ccy = x0 + (x1 - x0) * 0.30, cy + (y1 - y0) * 0.25
G.clickmm(ccx, ccy)
G.clickmm(ccx + (x1 - x0) * 0.10, ccy)
G.values(20)
ents = G.describe()["entities"]
circ = [e for e in ents if e["type"] == "circle"]
G.check("OFFER", len(circ) == 1, "one circle drawn to right-click on")
r = circ[0]["radius"]
o = open_offer(circ[0]["center"][0] + r, circ[0]["center"][1])
G.check("OFFER", o.kind == 17, f"right-click on a circle -> {o.kind_name()}")
dismiss(o)
# A point.
G.key("p", 0.5)
pxx, pyy = x0 + (x1 - x0) * 0.80, cy + (y1 - y0) * 0.25
G.clickmm(pxx, pyy)
o = open_offer(pxx, pyy)
G.check("OFFER", o.kind == 18, f"right-click on a point -> {o.kind_name()}")
dismiss(o)
# Two entities: a second line, then both picked. Two LINES rather than line-plus-point on
# purpose — the Sk2Ent vocabulary (angle, equal, parallel, the two-entity constraints) is
# about pairs of curves, so the pair the ladder builds should be the pair the verbs mean.
draw_line_at(ax, ay - (y1 - y0) * 0.18, bx, ay - (y1 - y0) * 0.18, 40)
# ONE Escape, to drop the armed tool to Select, and only once the value fields are quiet.
# Left-click means "draw" while a tool is armed, so a picking gesture has to say so first —
# and from a draw tool with no anchor down Escape does exactly that and nothing more; it is
# only a second Escape, from Select, that would leave the sketch.
keep_as_drawn()
G.key("Escape", 0.5)
G.clickmm(*on_line((ax, ay), (bx, ay)))
G.xdo("keydown shift")
G.clickmm(*on_line((ax, ay - (y1 - y0) * 0.18), (bx, ay - (y1 - y0) * 0.18)))
G.xdo("keyup shift")
d = G.describe()
G.check("OFFER", len(d["selection"]) == 2,
f"two entities picked: {d['selection']} (tool={d['tool']} pending={d['pending']} "
f"editing={d['editing']})")
o = open_offer((ax + bx) / 2.0, ay)
G.check("OFFER", o.kind == 19,
f"right-click with two picked -> {o.kind_name()}"
+ ("" if o.kind is not None else
f" (no menu: tool={G.describe()['tool']} pending={G.describe()['pending']} "
f"editing={G.describe()['editing']})"))
dismiss(o)
G.leave_sketch()
G.reset_document()
def rung_vocabulary():
"""O2 — the rows offered are exactly the ones the table says apply to that selection.
Five selections, not one. The interesting failure is not "the menu is empty", it is "the
menu is the same whatever you clicked" — and only comparing several kinds against their own
predictions can tell those apart.
"""
print("\nO2 the menu and the offer table agree, selection by selection")
fresh_sketch("l")
x0, x1, y0, y1 = G._SAFE
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
ax, ay = x0 + (x1 - x0) * 0.15, cy
bx = x0 + (x1 - x0) * 0.55
draw_line_at(ax, ay, bx, ay, 40)
G.key("c", 0.5)
ccx, ccy = x0 + (x1 - x0) * 0.30, cy + (y1 - y0) * 0.25
G.clickmm(ccx, ccy); G.clickmm(ccx + (x1 - x0) * 0.10, ccy)
G.values(20)
circ = [e for e in G.describe()["entities"] if e["type"] == "circle"][0]
G.key("p", 0.5)
pxx, pyy = x0 + (x1 - x0) * 0.80, cy + (y1 - y0) * 0.25
G.clickmm(pxx, pyy)
where = [("SkNone", cx, y1 - (y1 - y0) * 0.12),
("SkLine", (ax + bx) / 2.0, ay),
("SkArc", circ["center"][0] + circ["radius"], circ["center"][1]),
("SkPoint", pxx, pyy),
("Sk2Ent", None, None)]
seen = {}
for name, X, Y in where:
if name == "Sk2Ent":
# The two-entity vocabulary was the one selection nothing compared against the table,
# and it is where the missing row hid: sk_angdist accepts Sk2Ent and nothing else, so
# an off-by-one that dropped the LAST verb was invisible from every other selection.
keep_as_drawn()
G.key("Escape", 0.5)
G.clickmm(*on_line((ax, ay), (bx, ay)))
G.xdo("keydown shift")
# The TOP of the circle, not its +X point: the radius grip lives there, and a click on
# a grip arms a handle drag which REPLACES the selection with that one entity. The
# pick then silently collapses to one and the offer answers SkLine — right, for the
# selection that actually existed.
G.clickmm(circ["center"][0], circ["center"][1] + circ["radius"])
G.xdo("keyup shift")
picked = len(G.describe()["selection"])
G.check("OFFER", picked == 2, f"two entities picked for the pair vocabulary: {picked}")
X, Y = (ax + bx) / 2.0, ay
o = open_offer(X, Y)
want = sorted(predicted(o.kind, o.sketching))
got = sorted(o.verbs)
G.check("OFFER", o.kind is not None and o.kind_name() == name and got == want,
f"{o.kind_name()}: {len(got)} verbs, exactly the table's set"
+ ("" if got == want else f"\n menu {got}\n table {want}"))
seen[name] = set(got)
dismiss(o)
# And the sets are genuinely DIFFERENT — an offer that adapts is not one that always shows
# the same rows. Without this, four identical menus would have passed four checks.
G.check("OFFER", len(set(map(frozenset, seen.values()))) == len(seen),
"every selection offers a different set: "
+ ", ".join(f"{k}={len(v)}" for k, v in seen.items()))
G.check("OFFER", seen["SkLine"] - seen["SkNone"],
f"a picked line adds {len(seen['SkLine'] - seen['SkNone'])} verbs an empty pick has not: "
+ ", ".join(sorted(seen["SkLine"] - seen["SkNone"])[:8]))
G.leave_sketch()
G.reset_document()
def rung_author():
"""O3 — the target itself, authored through the menu: no tool key is pressed anywhere here."""
print("\nO3 a precise closed profile, drawn entirely from the right-click offer")
fresh_sketch("p") # 'p' only to open the session; calibration needs the Point tool
G.key("Escape", 0.4)
x0, x1, y0, y1 = G._SAFE
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
w, h = 120.0, 80.0
o = open_offer(cx, cy)
G.check("OFFER", o.kind == 15, f"empty sketch -> {o.kind_name()}")
choose(o, "sk_rect")
G.clickmm(cx - w / 2.0, cy - h / 2.0)
G.clickmm(cx + w / 2.0, cy + h / 2.0)
G.values(int(w), int(h))
ents = G.describe()["entities"]
G.check("LENGTH", G.lengths(ents) == [80.0, 80.0, 120.0, 120.0],
f"sides {G.lengths(ents)} — typed through the offer, exact")
lp = G.loops()
G.check("CLOSED", len(lp) == 1, f"{len(lp)} closed loop(s)")
G.check("AREA", G.near(abs(lp[0]["area"]), w * h, 1e-9), f"area {abs(lp[0]['area']):.6f}")
G.leave_sketch()
G.reset_document()
def rung_no_shortcut():
"""O4 — a tool with NO keyboard route at all, armed from the menu and graded on its geometry.
This is the half of the vocabulary a key-driven ladder cannot reach: 47 of the 86 Design-tab
verbs have a GUI action and no shortcut, and for those the offer is not one door, it is the
only door. Arming the tool is not the assertion — the exact rectangle it then draws is.
"""
print("\nO4 a tool that has no shortcut, reached the only way it can be")
fresh_sketch("p")
G.key("Escape", 0.5)
x0, x1, y0, y1 = G._SAFE
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
o = open_offer(cx, cy)
keyless = [v for v in TABLE if v["id"] in o.verbs and not v["key"] and v["action"]]
G.check("OFFER", len(keyless) >= 10,
f"{len(keyless)} of the {len(o.verbs)} verbs offered here have no shortcut at all")
G.check("OFFER", any(v["id"] == "sk_rect_center" for v in keyless),
"centre rectangle among them — unreachable from the keyboard")
w, h = 120.0, 80.0
choose(o, "sk_rect_center")
G.clickmm(cx, cy) # centre
G.clickmm(cx + w / 2.0, cy + h / 2.0) # corner
G.values(int(w), int(h))
ents = G.describe()["entities"]
G.check("LENGTH", G.lengths(ents) == [80.0, 80.0, 120.0, 120.0],
f"sides {G.lengths(ents)} from a tool with no key")
lp = G.loops()
G.check("CLOSED", len(lp) == 1 and G.near(abs(lp[0]["area"]), w * h, 1e-9),
f"{len(lp)} closed loop, area {abs(lp[0]['area']):.6f}")
xs = sorted({round(p, 6) for e in ents for p in (e["p0"][0], e["p1"][0])})
ys = sorted({round(p, 6) for e in ents for p in (e["p0"][1], e["p1"][1])})
# Centred on the CLICK, to within the click itself. A synthetic click lands on a whole
# pixel, so the plane point it names is only ever as exact as one pixel is — grading this to
# 1e-6 would be grading the homography, not the tool. What is exact is the SHAPE, and that
# is asserted above; what is asserted here is that this was a centre rectangle and not a
# corner one, which a whole pixel is plenty to tell apart at 120 x 80.
tol = 1.5 * G.mm_per_px(cx, cy)
mx, my = (xs[0] + xs[-1]) / 2.0, (ys[0] + ys[-1]) / 2.0
G.check("SYMMETRY", abs(mx - cx) <= tol and abs(my - cy) <= tol,
f"centred on the click within {tol:.3f} mm (one pixel): "
f"off by {abs(mx - cx):.4f}, {abs(my - cy):.4f} — a CENTRE rectangle, not a corner one")
G.leave_sketch()
G.reset_document()
# ---------------------------------------------------------------- fixtures
def keep_as_drawn():
"""Close an in-canvas value field if one is open, keeping the geometry as drawn.
Checked, never assumed. Escape is overloaded: with a field open it means keep-as-drawn, with
none open it drops the armed tool, and one Escape too many leaves the sketch. The socket now
reports whether a field IS open ("editing"), so this presses the key only when it means what
the caller wants it to mean.
"""
n = 0
# A LOOP, not one press: the auto-edit queue opens the next field from a CallAfter as the
# previous one commits (a rectangle queues Width then Height), so one Escape leaves a second
# field on screen and the canvas still frozen. The loop stops the moment nothing is open,
# which is what keeps the last press from being the one that drops the tool.
#
# And it waits for QUIET, not for a single false reading. A field that has not opened YET
# reads exactly like one that will never open, so a driver that looks once, sees nothing and
# moves on gets frozen by the field that arrives a moment later — with no symptom except
# that clicks stop working. Measured: this rung passed alone and failed inside the gate,
# where the app is warmer and the CallAfter lands later; the diagnostic that found it was
# editing=True with an empty selection after two clicks that should have picked two entities.
for _ in range(8):
time.sleep(0.35 * G.PACE)
if not G.describe().get("editing"):
time.sleep(0.35 * G.PACE)
if not G.describe().get("editing"):
return n
G.key("Escape", 0.45)
n += 1
return n
def clear_sketch():
"""Empty the live sketch through the socket.
Fixture TEARDOWN, not the thing under test: what is being graded is always the geometry a
verb just produced, never how the canvas got emptied. Doing it through the socket keeps each
verb's rung independent without paying for a fresh sketch (four calibration probes) each time.
"""
keep_as_drawn() # a shape left mid-edit freezes the canvas for whatever comes next
n = len(G.describe()["entities"])
if n:
G.call("sketch_delete", entities=list(range(n)))
# Which tool each creation verb is supposed to arm. The menu walk counts rows, and a walk that
# lands ONE ROW OFF arms a neighbouring tool and then draws something plausible with it — the
# first run of this rung drew a circle and graded it as a rectangle. Asserting the armed tool
# turns that whole class of silent misnavigation into a loud failure at the point it happens.
ARMS = {"sk_polyline": "polyline", "sk_rect": "rect_corner", "sk_rect_center": "rect_center",
"sk_rect_oblique": "rect_oblique", "sk_rect_rounded": "rect_rounded",
"sk_circle_2pt": "circle_2pt", "sk_circle_3pt": "circle_3pt",
"sk_arc_tangent": "arc_tangent", "sk_arc_center": "arc_center",
"sk_slot_arc": "slot_arc", "sk_ellipse_arc": "ellipse_arc",
"sk_poly_3": "polygon", "sk_poly_4": "polygon", "sk_poly_5": "polygon",
"sk_poly_8": "polygon", "sk_poly_12": "polygon",
"sk_move": "move", "sk_rotate": "rotate", "sk_scale": "scale",
"sk_array": "array", "sk_array_polar": "array_polar"}
def arm(verb, X, Y, check_tool=True):
"""Open the offer on empty plane at (X, Y) and pick a verb out of it. No key is ever pressed."""
o = open_offer(X, Y)
if o.kind is None:
G.die(f"the offer did not open for {verb} (tool={G.describe().get('tool')}, "
f"pending={G.describe().get('pending')})")
choose(o, verb)
if check_tool and verb in ARMS:
got = G.describe().get("tool")
G.check("OFFER", got == ARMS[verb], f"{verb} armed the {got} tool")
return o
def ents(kind=None):
e = G.describe()["entities"]
return [x for x in e if kind is None or x["type"] == kind]
def clicked(X, Y):
"""The plane point the app REALLY saw for clickmm(X, Y).
A synthetic click lands on a whole pixel, so the plane point it names is not the one asked
for. Rounding the pixel and mapping it back is what the app got, and grading a construction
against it is grading the tool rather than the driver's arithmetic.
"""
u, v = G.px(X, Y)
return G.unpx(int(u), int(v))
def fresh_sketch(tool):
"""Enter a sketch from a KNOWN empty state, whatever the previous rung or run left behind.
check-gui-sketching's enter_sketch dismisses the old session with keys, and a key is exactly what an
open value field swallows — so a session that should have been cancelled survives, the four
calibration probes land in it on top of whatever was already there, and the run dies with
"calibration expected 4 points, got 7". Cancelling through the socket cannot be swallowed:
it reaches the tool directly. This is fixture teardown, not the thing under test.
"""
G.try_call("sketch_cancel")
G.key("Escape", 0.3)
G.enter_sketch(tool)
def on_line(a, b, t=0.3):
"""A point a fraction t ALONG a line — never its midpoint.
A left-click within ~24 px of a live dimension label opens that dimension's value editor
instead of selecting anything (the Select branch tests m_live_quotes before it picks), and a
line's Length quote sits at its middle. Clicking there froze the canvas on an open field, so
the following clicks and the right-click all landed on nothing and the pair vocabulary was
never reached. It bit only when the previous step had left the line selected — live quotes are
drawn for the SELECTION — which is why it passed alone and failed inside the gate.
"""
return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
def poly_click(pt):
"""One click of a multi-segment tool, then close whatever value field that click opened.
The polyline arms a Length field after EVERY segment, and a field freezes the canvas — so a
driver that just clicks four times places two points and loses the rest. Nothing had ever
exercised the polyline (it has no shortcut), so nothing had ever met this.
"""
G.clickmm(*pt)
keep_as_drawn()
def dist(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
def spread(vals):
return max(vals) - min(vals)
# ---------------------------------------------------------------- the keyless 2D vocabulary
def rung_curves():
"""O5 — every 2D creation verb that has no shortcut, drawn from the offer and graded exactly.
These are reachable ONLY from the right-click menu, so nothing has ever exercised them. The
assertions are CONSTRUCTION invariants — a regular polygon's vertices are equidistant, a
tangent arc meets its line at a right angle to the radius, a circumscribed polygon's
circumradius is the inscribed one's over cos(pi/n) — because those hold exactly whatever
pixel the click landed on. Where a value field opens, the typed value is graded exactly too.
"""
print("\nO5 the 2D creation verbs that have no keyboard route")
fresh_sketch("p")
G.key("Escape", 0.5)
x0, x1, y0, y1 = G._SAFE
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
W, H = (x1 - x0), (y1 - y0)
free = (cx, y1 - H * 0.10) # a corner of the safe box that stays empty to right-click
# --- Polyline: an explicitly CLOSED chain, which is the goal's own shape ------------------
clear_sketch()
arm("sk_polyline", *free)
ring = [(cx - W * 0.20, cy - H * 0.15), (cx + W * 0.20, cy - H * 0.15),
(cx + W * 0.20, cy + H * 0.15), (cx - W * 0.20, cy + H * 0.15)]
for pt in ring:
poly_click(pt)
poly_click(ring[0]) # click the start again: the explicit close
lines = ents("line")
lp = G.loops()
G.check("CLOSED", len(lines) == 4 and len(lp) == 1,
f"sk_polyline: {len(lines)} lines, {len(lp)} closed loop — closed by clicking the start")
# --- Oblique rectangle: three clicks, and the point of it is that it is NOT axis-aligned --
clear_sketch()
arm("sk_rect_oblique", *free)
a = (cx - W * 0.20, cy - H * 0.10)
b = (cx + W * 0.15, cy + H * 0.05) # first edge, deliberately skew
G.clickmm(*a); G.clickmm(*b); G.clickmm(cx - W * 0.10, cy + H * 0.20)
q = ents("line")
G.check("LENGTH", len(q) == 4, f"sk_rect_oblique: {len(q)} lines")
if len(q) == 4:
L = sorted(round(e["length"], 9) for e in q)
G.check("LENGTH", L[0] == L[1] and L[2] == L[3],
f"opposite sides equal to 1e-9: {L}")
angs = []
for i in range(4):
for j in range(i + 1, 4):
u = (q[i]["p1"][0] - q[i]["p0"][0], q[i]["p1"][1] - q[i]["p0"][1])
v = (q[j]["p1"][0] - q[j]["p0"][0], q[j]["p1"][1] - q[j]["p0"][1])
c = abs(u[0] * v[0] + u[1] * v[1]) / (math.hypot(*u) * math.hypot(*v))
angs.append(c)
G.check("ANGLE", sum(1 for c in angs if c < 1e-9) == 4,
f"four right angles to 1e-9 ({sum(1 for c in angs if c < 1e-9)} perpendicular pairs)")
d0 = (q[0]["p1"][0] - q[0]["p0"][0], q[0]["p1"][1] - q[0]["p0"][1])
G.check("ANGLE", abs(d0[0]) > 1e-6 and abs(d0[1]) > 1e-6,
f"and it really is oblique: first edge {math.degrees(math.atan2(*d0[::-1])):.3f} deg")
# --- Rounded rectangle: four lines, four arcs, one radius --------------------------------
clear_sketch()
arm("sk_rect_rounded", *free)
G.clickmm(cx - W * 0.20, cy - H * 0.15)
G.clickmm(cx + W * 0.20, cy + H * 0.15)
G.clickmm(cx + W * 0.20 - W * 0.04, cy + H * 0.15) # third click sets the radius
ls, ar = ents("line"), ents("arc")
G.check("ARC", len(ls) == 4 and len(ar) == 4, f"sk_rect_rounded: {len(ls)} lines + {len(ar)} arcs")
if len(ar) == 4:
rr = sorted(round(e["radius"], 9) for e in ar)
G.check("ARC", spread(rr) == 0.0, f"all four fillets share one radius to 1e-9: {rr[0]}")
lp = G.loops()
G.check("CLOSED", len(lp) == 1, f"{len(lp)} closed loop")
if len(lp) == 1:
xs = [p for e in ls for p in (e["p0"][0], e["p1"][0])]
ys = [p for e in ls for p in (e["p0"][1], e["p1"][1])]
# The four straight sides already span the FULL outer box — the top edge runs from
# x_min+r to x_max-r at y_max — so their bbox is the rectangle itself, and the
# rounding costs the four corner squares less their quarter-discs: r^2(4 - pi).
w, h, r = max(xs) - min(xs), max(ys) - min(ys), rr[0]
want = w * h - r * r * (4 - math.pi)
G.check("AREA", G.near(abs(lp[0]["area"]), want, 1e-6),
f"area {abs(lp[0]['area']):.9f} vs W*H - r^2(4-pi) {want:.9f}")
# --- Two-point circle: the two clicks are the ends of a diameter --------------------------
clear_sketch()
arm("sk_circle_2pt", *free)
p_a = (cx - W * 0.18, cy - H * 0.10)
p_b = (cx + W * 0.18, cy + H * 0.10)
G.clickmm(*p_a); G.clickmm(*p_b)
c2 = ents("circle")
G.check("ARC", len(c2) == 1, f"sk_circle_2pt: {len(c2)} circle")
if c2:
A, B = clicked(*p_a), clicked(*p_b)
mid = ((A[0] + B[0]) / 2.0, (A[1] + B[1]) / 2.0)
tol = 1.5 * G.mm_per_px(cx, cy)
G.check("VERTEX", dist(c2[0]["center"], mid) <= tol,
f"centred on the midpoint of the two clicks (off by {dist(c2[0]['center'], mid):.4f} mm)")
G.check("ARC", abs(c2[0]["radius"] - dist(A, B) / 2.0) <= tol,
f"radius {c2[0]['radius']:.6f} vs half the click separation {dist(A, B) / 2.0:.6f}")
opened = bool(G.describe().get("editing"))
G.check("OFFER", opened, "a radius field opens for it, as it does for the keyed circle")
if opened:
G.value(30)
got = ents("circle")[0]["radius"]
G.check("ARC", G.near(got, 30.0, 1e-9),
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.
G.check("VERTEX", G.describe()["dof"] == 2,
f"and the sketch reports the DoF of what is actually in it: {G.describe()['dof']}")
# --- Three-point circle: all three clicks lie on it ---------------------------------------
clear_sketch()
arm("sk_circle_3pt", *free)
three = [(cx - W * 0.18, cy), (cx, cy + H * 0.18), (cx + W * 0.16, cy - H * 0.06)]
for pt in three:
G.clickmm(*pt)
c3 = ents("circle")
G.check("ARC", len(c3) == 1, f"sk_circle_3pt: {len(c3)} circle")
if c3:
ds = [dist(clicked(*pt), c3[0]["center"]) for pt in three]
tol = 1.5 * G.mm_per_px(cx, cy)
G.check("ARC", spread(ds) <= tol and abs(ds[0] - c3[0]["radius"]) <= tol,
f"all three clicks lie on it: distances {[round(d, 4) for d in ds]} "
f"vs radius {c3[0]['radius']:.4f}")
# --- Centre arc: centre, start, end -------------------------------------------------------
clear_sketch()
arm("sk_arc_center", *free)
C = (cx, cy)
G.clickmm(*C); G.clickmm(cx + W * 0.15, cy); G.clickmm(cx, cy + H * 0.15)
aa = ents("arc")
G.check("ARC", len(aa) == 1, f"sk_arc_center: {len(aa)} arc")
if aa:
tol = 1.5 * G.mm_per_px(cx, cy)
G.check("VERTEX", dist(aa[0]["center"], clicked(*C)) <= tol,
f"centred on the first click (off by {dist(aa[0]['center'], clicked(*C)):.4f} mm)")
for nm, pt in (("start", aa[0]["p0"]), ("end", aa[0]["p1"])):
G.check("ARC", abs(dist(pt, aa[0]["center"]) - aa[0]["radius"]) < 1e-9,
f"its {nm} sits exactly on the radius, to 1e-9")
# --- Tangent arc: the construction property, exact whatever the click ---------------------
clear_sketch()
G.key("l", 0.5) # fixture: one line for the arc to leave tangentially
la, lb = (cx - W * 0.20, cy - H * 0.05), (cx + W * 0.05, cy - H * 0.05)
G.clickmm(*la); G.clickmm(*lb)
G.values(40, 0)
line = ents("line")[0]
G.key("Escape", 0.5)
arm("sk_arc_tangent", *free)
G.clickmm(*lb) # start snaps onto the line's endpoint
G.clickmm(cx + W * 0.12, cy + H * 0.12)
ta = ents("arc")
G.check("ARC", len(ta) == 1, f"sk_arc_tangent: {len(ta)} arc off the line's endpoint")
if ta:
end = min((ta[0]["p0"], ta[0]["p1"]), key=lambda q: dist(q, line["p1"]))
rad = (end[0] - ta[0]["center"][0], end[1] - ta[0]["center"][1])
d = (line["p1"][0] - line["p0"][0], line["p1"][1] - line["p0"][1])
cosang = abs(rad[0] * d[0] + rad[1] * d[1]) / (math.hypot(*rad) * math.hypot(*d))
G.check("TANGENT", cosang < 1e-9,
f"its radius at the shared end is perpendicular to the line to 1e-9 (cos={cosang:.2e})")
# --- Arc slot: two concentric arcs, one width --------------------------------------------
clear_sketch()
arm("sk_slot_arc", *free)
G.clickmm(cx - W * 0.15, cy) # start
G.clickmm(cx, cy - H * 0.10) # centre
G.clickmm(cx + W * 0.15, cy) # end direction
G.clickmm(cx + W * 0.15, cy + H * 0.04) # width
sa = ents("arc")
G.check("ARC", len(sa) >= 2, f"sk_slot_arc: {len(sa)} arcs")
if len(sa) >= 2:
# Group by centre rather than by size: an arc slot is two RAILS about a common centre
# plus two end caps about their own, and "the two biggest arcs" is not the same set —
# it picked a rail and a cap and called them non-concentric.
groups = {}
for e in sa:
k = (round(e["center"][0], 9), round(e["center"][1], 9))
groups.setdefault(k, []).append(e["radius"])
rails = max(groups.values(), key=len)
G.check("ARC", len(rails) == 2,
f"two rails share one centre to 1e-9 (radii {[round(r, 6) for r in sorted(rails)]}), "
f"{len(groups) - 1} cap centre(s) besides")
# --- Ellipse arc: five clicks, and now the socket can actually see its parameters ---------
clear_sketch()
arm("sk_ellipse_arc", *free)
G.clickmm(cx, cy)
G.clickmm(cx + W * 0.18, cy)
G.clickmm(cx, cy + H * 0.10)
G.clickmm(cx + W * 0.18, cy)
G.clickmm(cx, cy + H * 0.10)
ea = ents("ellipse_arc")
G.check("ARC", len(ea) == 1, f"sk_ellipse_arc: {len(ea)} ellipse arc")
if ea and "radius" in ea[0]:
G.check("ARC", ea[0]["radius"] > ea[0]["rminor"] > 0,
f"semi-axes a={ea[0]['radius']:.6f} b={ea[0]['rminor']:.6f}, a > b > 0")
for nm, pt in (("start", ea[0]["p0"]), ("end", ea[0]["p1"])):
X = (pt[0] - ea[0]["center"][0], pt[1] - ea[0]["center"][1])
ph = ea[0]["rotation"]
u = (X[0] * math.cos(ph) + X[1] * math.sin(ph)) / ea[0]["radius"]
v = (-X[0] * math.sin(ph) + X[1] * math.cos(ph)) / ea[0]["rminor"]
G.check("ARC", abs(u * u + v * v - 1.0) < 1e-9,
f"its {nm} satisfies (x/a)^2+(y/b)^2 = 1 to 1e-9")
# --- The five fixed-count polygons: regular, to 1e-9 --------------------------------------
for verb, n in (("sk_poly_3", 3), ("sk_poly_4", 4), ("sk_poly_5", 5),
("sk_poly_8", 8), ("sk_poly_12", 12)):
clear_sketch()
arm(verb, *free)
G.clickmm(cx, cy)
G.clickmm(cx + W * 0.15, cy)
q = ents("line")
if len(q) != n:
G.check("LENGTH", False, f"{verb}: {len(q)} sides, expected {n}")
continue
L = [round(e["length"], 9) for e in q]
ctr = clicked(cx, cy)
R = [dist(e["p0"], ctr) for e in q]
G.check("LENGTH", spread(L) == 0.0 and spread(R) < 1.5 * G.mm_per_px(cx, cy),
f"{verb}: {n} equal sides to 1e-9 ({L[0]:.9f}), all vertices on one circle")
# --- Inscribed vs circumscribed: the exact ratio between them -----------------------------
radii = {}
for verb, fit in (("sk_poly_inscribed", "inscribed"), ("sk_poly_circumscribed", "circumscribed")):
clear_sketch()
arm(verb, *free) # a tool PARAMETER, chosen from the menu
arm("sk_poly_5", *free)
G.clickmm(cx, cy)
G.clickmm(cx + W * 0.15, cy)
q = ents("line")
ctr = clicked(cx, cy)
radii[fit] = dist(q[0]["p0"], ctr) if q else 0.0
want = 1.0 / math.cos(math.pi / 5.0)
got = (radii["circumscribed"] / radii["inscribed"]) if radii["inscribed"] else 0.0
G.check("ARC", G.near(got, want, 1e-6),
f"circumscribed/inscribed circumradius = {got:.9f} vs 1/cos(pi/5) = {want:.9f} "
"— the two fits are genuinely different constructions")
G.leave_sketch()
G.reset_document()
def tf_fixture(cx, cy, W, L=40):
"""One horizontal line of exactly L mm, drawn by key. Fixture, not the thing under test.
Horizontal and exactly L because every transform assertion below is derived from it: the
gizmo seeds its parameters from the target's own size (pivot = the line's midpoint, handle
radius = half its length), so knowing the line exactly is what makes the handle and its value
label land on a computable pixel instead of a guessed one.
"""
G.key("l", 0.5)
G.clickmm(cx - W * 0.10, cy)
G.clickmm(cx + W * 0.10, cy)
G.values(L, 0)
e = ents("line")[0]
G.key("Escape", 0.5)
return e
def tf_label(pivot, handle, at):
"""Where the gizmo prints its value — the same formula render_tf_gizmo uses.
label = handle + outward * 1.2 * max(15 px, 1e-4), outward = the pivot -> handle direction.
Recomputing it here rather than hunting for it in pixels is what keeps this a click on a
control and not a search: if the formula ever moves, this rung fails loudly instead of
clicking somewhere harmless.
"""
th = max(15.0 * G.mm_per_px(*at), 1e-4)
d = (handle[0] - pivot[0], handle[1] - pivot[1])
n = math.hypot(*d) or 1.0
return (handle[0] + d[0] / n * th * 1.2, handle[1] + d[1] / n * th * 1.2)
def rung_transforms():
"""O6 — Move, Rotate, Scale, Array and Polar array: five verbs, none with a shortcut.
Each is a gizmo, so the whole gesture is menu -> pick -> click the value label -> type ->
click empty to apply, with no keyboard route anywhere in it. The assertions are the exact
ones the operation promises: a translation moves every point by the typed amount and nothing
else, a rotation turns the direction by the typed angle and leaves the length alone, a scale
multiplies the length and leaves the direction alone.
"""
print("\nO6 the 2D transforms — gizmo verbs, none of them on the keyboard")
fresh_sketch("p")
G.key("Escape", 0.5)
x0, x1, y0, y1 = G._SAFE
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
W, H = (x1 - x0), (y1 - y0)
free = (cx, y1 - H * 0.10)
away = (x0 + W * 0.03, y0 + H * 0.03) # empty plane: the click that applies a gizmo
L = 40.0
half = L / 2.0
step = max(half * 1.5, 1.0)
def pivot_of(e):
return ((e["p0"][0] + e["p1"][0]) / 2.0, (e["p0"][1] + e["p1"][1]) / 2.0)
def direction(e):
return math.degrees(math.atan2(e["p1"][1] - e["p0"][1], e["p1"][0] - e["p0"][0]))
# --- Move: 25 mm along +X, and nothing else changes --------------------------------------
clear_sketch()
before = tf_fixture(cx, cy, W, L)
# The transforms are offered for a SELECTION, not for empty space — so the right-click that
# opens the menu happens ON the line, which is also what selects it. Then one more click
# picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick.
arm("sk_move", *pivot_of(before))
G.clickmm(*pivot_of(before)) # pick the line
piv = pivot_of(before)
G.clickmm(*tf_label(piv, (piv[0] + step, piv[1]), (cx, cy)))
G.value(25)
G.clickmm(*away) # empty click applies
after = ents("line")
G.check("VERTEX", len(after) == 1, f"sk_move: {len(after)} line after the transform")
if len(after) == 1:
dx = [after[0]["p0"][0] - before["p0"][0], after[0]["p1"][0] - before["p1"][0]]
dy = [after[0]["p0"][1] - before["p0"][1], after[0]["p1"][1] - before["p1"][1]]
G.check("LENGTH", all(abs(v - 25.0) < 1e-9 for v in dx) and all(abs(v) < 1e-9 for v in dy),
f"every point moved by exactly +25.000000000 in X and 0 in Y: dx={dx} dy={dy}")
# --- Rotate: 30 degrees about the centroid, length untouched ------------------------------
clear_sketch()
before = tf_fixture(cx, cy, W, L)
# The transforms are offered for a SELECTION, not for empty space — so the right-click that
# opens the menu happens ON the line, which is also what selects it. Then one more click
# picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick.
arm("sk_rotate", *pivot_of(before))
G.clickmm(*pivot_of(before))
piv = pivot_of(before)
h = (piv[0] + half * math.cos(math.pi / 4), piv[1] + half * math.sin(math.pi / 4))
G.clickmm(*tf_label(piv, h, (cx, cy)))
G.value(30)
G.clickmm(*away)
after = ents("line")
G.check("VERTEX", len(after) == 1, f"sk_rotate: {len(after)} line")
if len(after) == 1:
turned = (direction(after[0]) - direction(before)) % 360.0
G.check("ANGLE", min(abs(turned - 30.0), abs(turned - 210.0)) < 1e-9,
f"turned by exactly {turned:.9f} deg")
G.check("LENGTH", abs(after[0]["length"] - before["length"]) < 1e-9,
f"and its length is untouched: {after[0]['length']:.9f}")
# --- Scale: x3 about the centroid, direction untouched ------------------------------------
clear_sketch()
before = tf_fixture(cx, cy, W, L)
# The transforms are offered for a SELECTION, not for empty space — so the right-click that
# opens the menu happens ON the line, which is also what selects it. Then one more click
# picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick.
arm("sk_scale", *pivot_of(before))
G.clickmm(*pivot_of(before))
piv = pivot_of(before)
G.clickmm(*tf_label(piv, (piv[0] + 2.0 * half, piv[1]), (cx, cy)))
G.value(3)
G.clickmm(*away)
after = ents("line")
G.check("VERTEX", len(after) == 1, f"sk_scale: {len(after)} line")
if len(after) == 1:
G.check("LENGTH", abs(after[0]["length"] - 3.0 * before["length"]) < 1e-9,
f"length {before['length']:.9f} -> {after[0]['length']:.9f}, exactly x3")
G.check("ANGLE", abs(direction(after[0]) - direction(before)) < 1e-9,
"and its direction is untouched to 1e-9")
# --- Linear array: 4 copies at an exact pitch ---------------------------------------------
clear_sketch()
before = tf_fixture(cx, cy, W, L)
# The transforms are offered for a SELECTION, not for empty space — so the right-click that
# opens the menu happens ON the line, which is also what selects it. Then one more click
# picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick.
arm("sk_array", *pivot_of(before))
G.clickmm(*pivot_of(before))
piv = pivot_of(before)
# A single LINE target seeds the spacing PERPENDICULAR to it, which for a horizontal line
# is +Y. That is the tool's own rule, not an assumption: see tf_pick's Array branch.
G.clickmm(*tf_label(piv, (piv[0], piv[1] + step), (cx, cy)))
G.value(20)
th = max(15.0 * G.mm_per_px(cx, cy), 1e-4)
G.clickmm(piv[0] + th * 1.5, piv[1] + th * 1.5) # the "xN" count label
G.value(4)
G.clickmm(*away)
rows = sorted(ents("line"), key=lambda e: e["p0"][1])
G.check("VERTEX", len(rows) == 4, f"sk_array: {len(rows)} lines (1 original + 3 copies)")
if len(rows) == 4:
pitch = [round(rows[i + 1]["p0"][1] - rows[i]["p0"][1], 9) for i in range(3)]
G.check("LENGTH", pitch == [20.0, 20.0, 20.0], f"pitch exactly {pitch} mm")
G.check("LENGTH", spread([round(e["length"], 9) for e in rows]) == 0.0,
"and every copy is the same length to 1e-9")
# --- Polar array: 6 copies, 60 degrees apart, sharing one centre --------------------------
clear_sketch()
before = tf_fixture(cx, cy, W, L)
# The transforms are offered for a SELECTION, not for empty space — so the right-click that
# opens the menu happens ON the line, which is also what selects it. Then one more click
# picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick.
arm("sk_array_polar", *pivot_of(before))
G.clickmm(*pivot_of(before))
piv = pivot_of(before)
G.clickmm(*tf_label(piv, (piv[0] + half, piv[1]), (cx, cy)))
G.value(360)
th = max(15.0 * G.mm_per_px(cx, cy), 1e-4)
G.clickmm(piv[0] + th * 1.5, piv[1] + th * 1.5)
G.value(6)
G.clickmm(*away)
spokes = ents("line")
G.check("VERTEX", len(spokes) == 6, f"sk_array_polar: {len(spokes)} lines")
if len(spokes) == 6:
mids = [((e["p0"][0] + e["p1"][0]) / 2.0, (e["p0"][1] + e["p1"][1]) / 2.0) for e in spokes]
G.check("VERTEX", max(dist(m, mids[0]) for m in mids) < 1e-9,
"all six share one centre to 1e-9 — rotated about the pivot, not scattered")
# mod 360, not 180. A line carries an orientation, and folding the six directions into a
# half-turn collapses opposite spokes onto each other: a perfectly even star then reads
# as gaps of [0, 60, 0, 60, 0] and the rung fails on its own arithmetic.
angs = sorted(direction(e) % 360.0 for e in spokes)
gaps = [round(angs[(i + 1) % 6] - angs[i], 9) % 360.0 for i in range(6)]
G.check("ANGLE", all(abs(g - 60.0) < 1e-9 for g in gaps),
f"and they are 60 deg apart all the way round: {gaps}")
G.leave_sketch()
G.reset_document()
def rung_art():
"""O7 — Text and SVG: the last two 2D verbs, and the only two that open a dialog.
Both are keyless, so the offer is their only door; both also leave the canvas for a modal
window, which is why nothing that drives the canvas had ever reached them. The properties
graded are the ones that survive a change of font or of importer scale: how many CLOSED loops
came back, and the exact aspect ratio of a shape whose proportions are known.
"""
print("\nO7 Text and SVG — the two verbs that go through a dialog")
fresh_sketch("p")
G.key("Escape", 0.5)
x0, x1, y0, y1 = G._SAFE
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
H = y1 - y0
free = (cx, y1 - H * 0.10)
# --- Text ---------------------------------------------------------------------------------
clear_sketch()
o = open_offer(*free)
G.check("OFFER", "sk_text" in o.verbs, "sk_text is offered on an empty sketch")
choose(o, "sk_text")
time.sleep(1.5)
names = G.sh(f"DISPLAY={G.DISP} xdotool search --name '.' getwindowname %@").split("\n")
G.check("OFFER", any(n.strip() == "Text" for n in names),
"choosing it opens the Text dialog")
G.typ("LT", 0.4)
G.key("Return", 2.5)
lp = G.loops()
G.check("CLOSED", len(lp) == 2 and all(l["closed"] for l in lp),
f"two letters came back as {len(lp)} closed loops")
G.check("VERTEX", all(abs(l["area"]) > 1.0 for l in lp),
f"both enclose real area: {[round(abs(l['area']), 3) for l in lp]}")
# --- SVG ----------------------------------------------------------------------------------
# A file whose proportions are known EXACTLY, so the assertion does not depend on what the
# importer decides a user unit is: a 40 x 20 path is 2:1 at any scale.
# FILLED, not stroked. A stroked path imports as its stroke OUTLINE — two loops, an outer and
# an inner, each inflated by half the stroke width — so the shape that comes back is 8 lines
# at 1.952 : 1 and the assertion would be grading the pen, not the importer.
svg = "/tmp/offer-ladder-2to1.svg"
G.sh("cat > %s <<'EOF'\n<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"20\" "
"viewBox=\"0 0 40 20\"><path d=\"M0,0 L40,0 L40,20 L0,20 Z\" fill=\"black\"/>"
"</svg>\nEOF" % svg)
clear_sketch()
o = open_offer(*free)
G.check("OFFER", "sk_svg" in o.verbs, "sk_svg is offered too")
choose(o, "sk_svg")
time.sleep(2.0)
G.key("ctrl+l", 0.6) # GTK's own "type a path" entry: never guess at the file list
G.typ(svg, 0.5)
G.key("Return", 3.0)
ls = ents("line")
lp = G.loops()
G.check("CLOSED", len(lp) == 1 and len(ls) == 4,
f"the imported path is {len(ls)} lines and {len(lp)} closed loop")
if ls:
xs = [p for e in ls for p in (e["p0"][0], e["p1"][0])]
ys = [p for e in ls for p in (e["p0"][1], e["p1"][1])]
w, h = max(xs) - min(xs), max(ys) - min(ys)
# 1e-6, not 1e-9, and the reason is measured rather than tuned away: the imported box is
# 10.583333000 x 5.291667000 where 40 and 20 user units at 25.4/96 are 10.58333333... and
# 5.29166666..., so the SVG path coordinates arrive ROUNDED TO SIX DECIMAL PLACES (both
# numbers are exactly 6 dp, one rounded down and one up — which is also why the ratio is
# 1.999999811 rather than 2). Everything the sketcher itself draws is exact to 1e-9; this
# 1e-6 belongs to the import path alone, and it is the band the assertion allows.
G.check("LENGTH", abs(w / h - 2.0) < 1e-6,
f"and its proportions survived the import: {w:.9f} x {h:.9f} = {w / h:.9f} : 1 "
f"(the import rounds coordinates to 1e-6 mm)")
G.leave_sketch()
G.reset_document()
# Every 2D verb this ladder drives from the menu, by id. Kept as data so the coverage claim can
# be CHECKED rather than asserted in prose: rung_coverage compares it against the offer table and
# fails the moment a keyless sketch verb exists that nothing here exercises.
DRIVEN = {
"sk_rect_center", # O4
"sk_polyline", "sk_rect_oblique", "sk_rect_rounded", # O5
"sk_circle_2pt", "sk_circle_3pt", "sk_arc_center", "sk_arc_tangent",
"sk_slot_arc", "sk_ellipse_arc",
"sk_poly_3", "sk_poly_4", "sk_poly_5", "sk_poly_8", "sk_poly_12",
"sk_poly_inscribed", "sk_poly_circumscribed",
"sk_move", "sk_rotate", "sk_scale", "sk_array", "sk_array_polar", # O6
"sk_text", "sk_svg", # O7
}
def rung_coverage():
"""O8 — the coverage claim, checked against the table instead of written in a comment.
"Every 2D verb with no keyboard route is exercised" is the whole point of the rungs above, and
a claim like that rots the day someone adds a verb. Here it is arithmetic: the set of keyless
sketch verbs in DesignOffer.hpp, minus the set this file drives, must be empty.
"""
print("\nO8 coverage — every keyless 2D verb, checked against the table")
sk = [v for v in TABLE if v["sketch_mode"]]
keyless = {v["id"] for v in sk if v["action"] and not v["key"]}
keyed = {v["id"] for v in sk if v["key"]}
dead = {v["id"] for v in sk if not v["action"]}
missing = keyless - DRIVEN
G.check("OFFER", not missing,
f"all {len(keyless)} keyless 2D verbs are driven from the menu"
+ ("" if not missing else f" — MISSING: {sorted(missing)}"))
G.check("OFFER", not (DRIVEN - keyless - keyed),
f"and nothing is driven that is not in the table: {sorted(DRIVEN - keyless - keyed)}")
G.check("OFFER", not dead,
f"no 2D verb is a dead row: {len(sk)} sketch verbs, {len(keyed)} with a shortcut, "
f"{len(keyless)} without, {len(dead)} with no GUI route at all")
RUNGS = {"kinds": rung_kinds, "vocabulary": rung_vocabulary,
"author": rung_author, "no_shortcut": rung_no_shortcut,
"curves": rung_curves, "transforms": rung_transforms,
"art": rung_art, "coverage": rung_coverage}
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")
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
# clicked once drew its whole first rung into a webview.
G.go_design(); G.go_design()
G.key("Escape", 0.4)
G.reset_document()
for name in want:
if name not in RUNGS:
G.die(f"unknown rung {name}; have {' '.join(RUNGS)}")
RUNGS[name]()
print(f"\n{G._checks - G._fail}/{G._checks} properties held")
sys.exit(1 if G._fail else 0)
if __name__ == "__main__":
main()