Every 2D verb without a shortcut, driven from the offer — and three more defects

The 2D vocabulary is 46 verbs: 22 have a shortcut and the gesture ladder drives them, 24 have
none and nothing had ever exercised those. They are reachable only from the right-click offer, so
a key-driven ladder could not have touched them whatever it did. Four new rungs drive all 24, and
the coverage claim itself is now arithmetic against DesignOffer.hpp (rung O8) rather than a
sentence in a comment that rots when a verb is added.

The assertions are CONSTRUCTION invariants wherever a click cannot be exact — a regular polygon's
sides are equal to 1e-9 and its vertices lie on one circle; a tangent arc's radius at the shared
endpoint is perpendicular to the line to 1e-9 (measured cos 5.97e-17); the three clicks of a
3-point circle all lie on it; a circumscribed pentagon's circumradius is the inscribed one's over
cos(pi/5), 1.236067977 against 1.236067977. Where a value field opens, the typed value is graded
exactly: a moved line travels +25.000000000 in X and 0 in Y, a rotation turns 30.000000000 deg
and leaves the length alone, a scale multiplies it by exactly 3, a linear array's pitch is
[20.0, 20.0, 20.0] and a polar one's spokes are 60 deg apart all the way round.

Three defects found doing it, all fixed here:

snaporca-ua9g (P1) — delete_selected left three things behind. The AUTO-EDIT QUEUE, so a queued
field opened on a deleted entity and its commit went nowhere: draw a rounded rectangle, delete
everything, draw a 2-point circle, type 30 — the field opens, the digits are accepted, and the
radius stays 32.992020763. reset_autoedit() exists for exactly this and its own comment says so;
it was simply never called from here. The FEATURE GROUPS, whose [begin,end) ranges all shift on a
delete, so feature_of() answered with a group the user never drew — survivors are now remapped
and any group that lost a member is dropped, the rule the placed quotes already followed. And the
SOLVER STATE: no re-solve, so sketch_describe reported dof=16 for a document holding one circle.

snaporca-ekt9 (P2) — the read-back could not see three of its seven entity types. Ellipse,
EllipseArc and BSpline serialised as a bare type name: no centre, no semi-axes, no rotation, no
sweep, no poles. gui-ladder's ellipse rung had to grade the faceted area of the loop at 2e-2 —
that tolerance IS the faceting error — and its spline rung could only count entities. Now they
carry their parameters, and the ellipse arc's ends are asserted to satisfy (x/a)^2+(y/b)^2 = 1 to
1e-9.

Also read-only, and the reason the other two were found at all: sketch_describe now reports the
armed TOOL, the count of PENDING anchors, and whether a value field is EDITING. A menu walk that
lands one row off arms a neighbouring tool and then draws something plausible — the first run of
the authoring rung drew a circle of area 45238.93 and graded it as a rectangle. Every menu pick
now asserts which tool it armed, and the polyline rung (a per-segment Length field freezes the
canvas after every click) could only be written once the driver could ask whether a field was open.

Offer ladder 102/102 -> 105/105 with coverage. Gesture ladder 93/93 and the kernel suite
188 cases / 2532 assertions, both unchanged.

snaporca-ua9g snaporca-ekt9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
This commit is contained in:
Tommaso Bianchi
2026-08-23 05:11:15 +02:00
co-authored by Claude Opus 5
parent 8c4b05ae9d
commit 1bde448f51
4 changed files with 724 additions and 4 deletions
+630 -1
View File
@@ -23,6 +23,7 @@ Run inside the rig container, with the app launched under SNAPORCA_KEYTRACE=1:
docker exec snaporca-gui python3 /OrcaSlicer/scripts/offer-ladder.py [rung ...]
"""
import importlib.util
import math
import os
import re
import sys
@@ -494,8 +495,636 @@ def rung_no_shortcut():
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.
while G.describe().get("editing") and n < 6:
G.key("Escape", 0.5)
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 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")
G.enter_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")
G.enter_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")
G.enter_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}
"author": rung_author, "no_shortcut": rung_no_shortcut,
"curves": rung_curves, "transforms": rung_transforms,
"art": rung_art, "coverage": rung_coverage}
def main():
+36
View File
@@ -381,6 +381,42 @@ void DesignSketchTool::delete_selected()
// a stale m_dim_e0 would dereference out of range on the next click. Drop it too.
m_dim_e0 = -1;
m_dim_r0 = SketchPointRole::P0;
// FEATURE GROUPS hold [begin,end) ranges into m_entities, and every index past a deletion has
// just moved. Left alone they point at other people's geometry: feature_of() then answers with
// a group the user never drew, and the rect/slot/polygon handles and live quotes follow it.
// Survivors are remapped (a contiguous range stays contiguous, since the remap preserves
// order); a group that lost any member is dropped, the same rule the placed quotes above
// already follow — dangling is worse than absent.
{
std::vector<Feature> kept_f;
for (const Feature& f : m_features) {
if (f.begin < 0 || f.end > n || f.end <= f.begin) continue;
bool whole = true;
for (int k = f.begin; k < f.end; ++k)
if (del[k]) { whole = false; break; }
if (!whole) continue;
Feature g = f;
g.begin = remap[f.begin];
g.end = remap[f.end - 1] + 1;
kept_f.push_back(g);
}
m_features.swap(kept_f);
m_open_feature = -1;
}
// The draw-then-edit QUEUE outlives the entities it was queued for. Its own helper says so:
// "Removing an entity that still has a deferred auto-edit would otherwise open a field on a
// now-deleted entity and freeze the flow" — it was simply never called from here. Measured:
// delete a rectangle whose Width/Height were still queued, draw a circle, type its radius —
// the field opens, the digits go in, and the radius does not move, because the field belongs
// to a rectangle that no longer exists. snaporca-ua9g.
reset_autoedit();
// And re-solve, so the sketch's reported degrees of freedom describe the sketch that is
// actually there. Without this, sketch_describe answered dof=16 for a document holding one
// circle — the DoF of the geometry that had just been deleted.
resolve_live();
if (on_selection_changed) on_selection_changed(0);
}
+8
View File
@@ -62,6 +62,14 @@ public:
// In-canvas bounding-box transform for imported Text/SVG art:
TransformArt,
Constrain };
// Which tool is armed, and how many anchors it has down. Read-only, for the offer ladder:
// "the menu armed the verb I chose" is otherwise unassertable, and a menu walk that lands one
// row off arms a NEIGHBOURING tool and then grades whatever that drew. snaporca-ekt9.
Mode mode() const { return m_mode; }
int pending_points() const { return int(m_points.size()); }
// Is an in-canvas value field open? While one is, the canvas is frozen and every letter is
// swallowed — the single most common reason a driven gesture "does nothing".
bool value_field_open() const { return m_awaiting_length; }
bool is_edit_op_mode() const { return m_mode == Mode::Fillet || m_mode == Mode::Chamfer ||
m_mode == Mode::Offset || m_mode == Mode::Mirror; }
bool is_transform_mode() const { return m_mode == Mode::Move || m_mode == Mode::Rotate ||
+50 -3
View File
@@ -1271,9 +1271,38 @@ json sketch_entity_to(const SketchEntity& e, int index)
j["type"] = "point";
j["p"] = json::array({e.p0.x(), e.p0.y()});
break;
case SketchEntity::Type::Ellipse: j["type"] = "ellipse"; break;
case SketchEntity::Type::EllipseArc: j["type"] = "ellipse_arc"; break;
case SketchEntity::Type::BSpline: j["type"] = "spline"; break;
// Ellipses and splines used to serialise as a TYPE NAME and nothing else, so every
// parameter they have was invisible to the only read-back this project has. A ladder could
// count them and grade the faceted area of the loop they close (2e-2, the faceting error) —
// it could not check a single axis, angle or pole. "Precise definition of every aspect"
// cannot be asserted about an entity whose aspects the instrument cannot see.
case SketchEntity::Type::Ellipse:
j["type"] = "ellipse";
j["center"] = json::array({e.center.x(), e.center.y()});
j["radius"] = e.radius; // semi-major (a)
j["rminor"] = e.rminor; // semi-minor (b)
j["rotation"] = e.rotation; // major-axis angle, radians
break;
case SketchEntity::Type::EllipseArc:
j["type"] = "ellipse_arc";
j["center"] = json::array({e.center.x(), e.center.y()});
j["radius"] = e.radius;
j["rminor"] = e.rminor;
j["rotation"] = e.rotation;
j["start_angle"] = e.start_angle;
j["end_angle"] = e.end_angle;
j["p0"] = json::array({e.p0.x(), e.p0.y()});
j["p1"] = json::array({e.p1.x(), e.p1.y()});
break;
case SketchEntity::Type::BSpline: {
j["type"] = "spline";
json poles = json::array();
for (const Vec2d& c : e.ctrl) poles.push_back(json::array({c.x(), c.y()}));
j["ctrl"] = poles;
j["p0"] = json::array({e.p0.x(), e.p0.y()});
j["p1"] = json::array({e.p1.x(), e.p1.y()});
break;
}
}
return j;
}
@@ -1462,11 +1491,29 @@ json action_sketch_describe(DesignPanel* panel, const json& params)
json ents = json::array();
for (int i = 0; i < int(t.entities().size()); ++i)
ents.push_back(sketch_entity_to(t.entities()[i], i));
// The armed TOOL and its pending anchors. Without these the only way to tell which tool a
// menu row actually armed is to draw with it and infer from what came out — which is how a
// menu walk that lands one row off gets diagnosed as "the tool is broken".
static const char* const kModeNames[] = {
"select", "dimension", "polyline", "line", "rect_corner", "rect_center", "rect_oblique",
"rect_rounded", "circle_center", "circle_2pt", "point",
"circle_3pt", "arc_3pt", "arc_tangent", "arc_center", "slot", "slot_arc", "polygon",
"ellipse", "ellipse_arc", "spline",
"fillet", "chamfer", "offset", "mirror",
"trim", "extend",
"move", "rotate", "scale", "array", "array_polar",
"transform_art",
"constrain" };
const int mi = int(t.mode());
json out{{"ok", true},
{"entities", ents},
{"constraints", int(t.constraints().size())},
{"dof", t.dof()},
{"solve_ok", t.solve_ok()},
{"tool", (mi >= 0 && mi < int(sizeof(kModeNames) / sizeof(kModeNames[0])))
? kModeNames[mi] : "unknown"},
{"pending", t.pending_points()},
{"editing", t.value_field_open()},
{"selection", t.selection()}};
out.update(sketch_report(t));
return out;