diff --git a/scripts/offer-ladder.py b/scripts/offer-ladder.py
index c3bd68c123..0c6a767498 100644
--- a/scripts/offer-ladder.py
+++ b/scripts/offer-ladder.py
@@ -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\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():
diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp
index f413b1d82b..6357e07e4b 100644
--- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp
+++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp
@@ -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 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);
}
diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp
index 97d66ccf6c..8aa9809048 100644
--- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp
+++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp
@@ -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 ||
diff --git a/src/slic3r/GUI/CAD/McpControl.cpp b/src/slic3r/GUI/CAD/McpControl.cpp
index 63fbb9f733..3bf270e49c 100644
--- a/src/slic3r/GUI/CAD/McpControl.cpp
+++ b/src/slic3r/GUI/CAD/McpControl.cpp
@@ -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;