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;