diff --git a/scripts/gui-ladder.py b/scripts/gui-ladder.py new file mode 100644 index 0000000000..28feb37635 --- /dev/null +++ b/scripts/gui-ladder.py @@ -0,0 +1,1032 @@ +#!/usr/bin/env python3 +"""A ladder of sketches drawn the way a person draws them: mouse gestures and typed values. + +WHY THIS EXISTS, next to scripts/sketch-ladder.py. That ladder proves the ENGINE — it feeds +geometry through the MCP socket's add_entities_scripted and grades what comes back. The socket +path skips everything the goal actually rests on: gesture state, the auto-edit queue, snapping, +inference at gesture tolerance, and the right-click offer. A ladder that only drives the socket +cannot say the Design tab meets its goal. This one draws with synthetic clicks and types the +values into the in-canvas field, then reads the result back through the socket, which is used +here ONLY as an instrument, never as an author. + +Runs INSIDE the headless rig container (Xvfb :10 + openbox + the app with SNAPORCA_MCP set): + + docker cp scripts/gui-ladder.py snaporca-gui:/tmp/ && \ + docker exec snaporca-gui python3 /tmp/gui-ladder.py [rung ...] + +With no arguments every rung runs. Exit 0 = every property held. +""" +import json, math, os, re, socket, subprocess, sys, time + +SOCK = os.environ.get("SNAPORCA_MCP", "/tmp/mcp.sock") +DISP = os.environ.get("DISPLAY", ":10") +_n = 0 +_fail = 0 +_checks = 0 + +# ---------------------------------------------------------------- the instrument (read-only) + +def call(method, **params): + global _n + _n += 1 + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(30) + s.connect(SOCK) + s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method, + "params": params}) + "\n").encode()) + buf = b"" + while b"\n" not in buf: + d = s.recv(65536) + if not d: + break + buf += d + r = json.loads(buf.decode().strip()) + if "error" in r: + raise RuntimeError(f"{method}: {r['error']['message']}") + return r["result"] + + +def try_call(method, **params): + try: + return call(method, **params) + except Exception: + return None + + +def describe(): + return call("sketch_describe") + + +# ---------------------------------------------------------------- the hand (synthetic input) + +_win = None + +def win(): + """The app window's id and origin. Asked fresh once per run: a relaunch changes the id.""" + global _win + # BY SIZE, never by title. Saving a project renames the window to the file, and a driver + # that hunts for "Untitled" then reports "no app window" for an app that is running fine — + # which is a false negative in the one place a false negative is most expensive. + if _win is None: + best = None + # --class, not --name: after a project is opened the main window can come back with no + # WM_NAME at all, and a name search then does not list it — the driver picks a 200x200 + # helper and every click lands on nothing. + for w in sh(f"DISPLAY={DISP} xdotool search --class '.'").split(): + g = sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}") + d = dict(l.split("=", 1) for l in g.strip().splitlines() if "=" in l) + if "WIDTH" not in d: + continue + a = int(d["WIDTH"]) * int(d["HEIGHT"]) + if best is None or a > best[0]: + best = (a, w, int(d["X"]), int(d["Y"]), int(d["WIDTH"]), int(d["HEIGHT"])) + if best is None: + die("no app window on " + DISP) + sh(f"DISPLAY={DISP} xdotool windowactivate --sync {best[1]}") + _win = best[1:] + return _win + + +def sh(cmd): + return subprocess.run(["bash", "-lc", cmd], capture_output=True, text=True).stdout + + +def xdo(args): + sh(f"DISPLAY={DISP} xdotool {args}") + + +def key(k, pause=0.35): + xdo(f"key {k}") + time.sleep(pause) + + +def typ(s, pause=0.35): + xdo(f"type --delay 40 -- '{s}'") + time.sleep(pause) + + +def click(px, py, pause=0.45, btn=1): + _, X, Y, _, _ = win() + xdo(f"mousemove {X+int(px)} {Y+int(py)} click --delay 120 {btn}") + time.sleep(pause) + + +def move(px, py, pause=0.2): + _, X, Y, _, _ = win() + xdo(f"mousemove {X+int(px)} {Y+int(py)}") + time.sleep(pause) + + +def shot(path): + w, X, Y, W, H = win() + sh(f"DISPLAY={DISP} import -window root -crop {W}x{H}+{X}+{Y} +repage {path}") + + +# ---------------------------------------------------------------- pixels <-> plane + +# The viewport is a perspective camera looking at the sketch plane, so pixel -> plane is a +# HOMOGRAPHY, not a scale: the same pixel span covers more millimetres at the far edge than at +# the near one. Four measured correspondences determine it exactly. Measuring beats assuming — +# the camera can be anywhere, and a wrong constant silently puts every click somewhere else. +_H = None # plane -> pixel, row-major 3x3 + + +def _solve(A, b): + """Tiny dense solve; no numpy in the rig container.""" + n = len(A) + M = [row[:] + [b[i]] for i, row in enumerate(A)] + for c in range(n): + p = max(range(c, n), key=lambda r: abs(M[r][c])) + if abs(M[p][c]) < 1e-12: + die("calibration is degenerate — the four probe points are not in general position") + M[c], M[p] = M[p], M[c] + for r in range(n): + if r == c: + continue + f = M[r][c] / M[c][c] + for k in range(c, n + 1): + M[r][k] -= f * M[c][k] + return [M[i][n] / M[i][i] for i in range(n)] + + +def fit_homography(pairs): + """pairs: [((X_mm, Y_mm), (u_px, v_px)), ...] -> 3x3 plane->pixel with h22 = 1.""" + A, b = [], [] + for (X, Y), (u, v) in pairs: + A.append([X, Y, 1, 0, 0, 0, -u * X, -u * Y]); b.append(u) + A.append([0, 0, 0, X, Y, 1, -v * X, -v * Y]); b.append(v) + h = _solve(A, b) + return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0] + + +def px(X, Y): + """Plane millimetres -> window pixels.""" + h = _H + w = h[6] * X + h[7] * Y + h[8] + return ((h[0] * X + h[1] * Y + h[2]) / w, (h[3] * X + h[4] * Y + h[5]) / w) + + +def unpx(u, v): + """Window pixels -> plane millimetres (the homography inverted, by hand).""" + h = _H + A = [[h[0] - u * h[6], h[1] - u * h[7]], [h[3] - v * h[6], h[4] - v * h[7]]] + b = [u * h[8] - h[2], v * h[8] - h[5]] + return tuple(_solve(A, b)) + + +def mm_per_px(X, Y): + """The viewport's local scale at a plane point — what the tool calls unit_per_px.""" + u, v = px(X, Y) + a = unpx(u, v) + b = unpx(u + 1.0, v) + return math.dist(a, b) + + +def clickmm(X, Y, pause=0.45, btn=1): + u, v = px(X, Y) + click(u, v, pause, btn) + + +def movemm(X, Y, pause=0.2): + u, v = px(X, Y) + move(u, v, pause) + + +# ---------------------------------------------------------------- session control + +def leave_sketch(): + """Back to a clean Feature-mode document, whatever state the last rung left behind.""" + try_call("sketch_cancel") + for _ in range(4): + key("Escape", 0.25) + time.sleep(0.5) + + +# Feature-tree rows, measured on the rig at 1920x1080: first row centre, then 23 px apart. +# x=300, not the label: a second click ON the label opens the inline rename, and Delete then +# edits the text instead of removing the feature. +TREE_ROW0 = (300, 215) + + +DESIGN_TAB = (128, 29) + + +def go_design(): + """Make sure the Design tab is in front — loading a project lands on Prepare.""" + click(*DESIGN_TAB, pause=1.0) + + +def reset_document(): + """Delete every committed feature, by picking its tree row and pressing Delete. + + A rung that ends in Constrain COMMITS its sketch, and the next rung's Constrain resolves + 'the last sketch' — which is then the PREVIOUS rung's. That is how D2 first read a rectangle + of exactly 120 x 80 back from a sketch it had drawn at 120.020087: it was grading a sketch + left behind by an earlier run. The document is part of the fixture; reset it like one. + """ + go_design() + leave_sketch() + for _ in range(40): + if not call("describe_scene")["features"]: + return + click(*TREE_ROW0, pause=0.35) + key("Delete", 0.5) + die("could not empty the feature tree") + + +def enter_sketch(tool_key, plane_px=(913, 359)): + """Enter a sketch the way the design law says: pick the plane in the viewport, then the tool. + + Shift+S enters sketch MODE and pops the offer; Escape dismisses it; the tool letter then + starts the session on the plane the click selected. All four steps are real input — nothing + here goes through the socket. + """ + leave_sketch() + click(*plane_px) + key("shift+s", 0.8) + key("Escape", 0.4) # entering sketch mode pops the offer; dismiss it + key(tool_key, 0.6) + if try_call("sketch_describe") is None: + shot("/shots/gl-enter-failed.png") + die("no sketch opened after plane click + Shift+S + " + tool_key + + " (see /shots/gl-enter-failed.png)") + + +def calibrate(): + """Place four Points by hand, read where they landed, and solve for the camera's map.""" + global _H + reset_document() + enter_sketch("p") + probes = [(1000, 500), (1400, 500), (1400, 760), (1000, 760)] + for u, v in probes: + click(u, v) + ents = describe()["entities"] + if len(ents) != 4 or any(e["type"] != "point" for e in ents): + die(f"calibration expected 4 points, got {[e['type'] for e in ents]}") + _H = fit_homography([((e["p"][0], e["p"][1]), probes[i]) for i, e in enumerate(ents)]) + # Prove the fit by round-tripping the probes: a homography through its own four points is + # exact, so anything but a sub-pixel residual means the points came back mismatched. + for i, e in enumerate(ents): + u, v = px(e["p"][0], e["p"][1]) + if abs(u - probes[i][0]) > 0.5 or abs(v - probes[i][1]) > 0.5: + die(f"calibration residual too large at probe {i}: {(u, v)} vs {probes[i]}") + say(f"calibrated: 4 probes, plane span " + f"{ents[1]['p'][0] - ents[0]['p'][0]:.1f} x {ents[0]['p'][1] - ents[3]['p'][1]:.1f} mm") + leave_sketch() + + +# ---------------------------------------------------------------- typed values + +def value(v, pause=0.6): + """Type one number into the open in-canvas field and commit it. + + Select-all first: the field opens pre-filled with the as-drawn value and pre-selected, but a + pre-selection that a synthetic click has disturbed would otherwise leave the typed digits + appended to it. + """ + key("ctrl+a", 0.15) + typ(str(v), 0.25) + key("Return", pause) + + +def values(*vs): + for v in vs: + value(v) + + +# ---------------------------------------------------------------- grading + +def say(msg): + print(f" {msg}") + + +def check(kind, cond, what): + global _fail, _checks + _checks += 1 + if cond: + print(f" {kind:9s} ok {what}") + else: + print(f" {kind:9s} FAIL {what}", file=sys.stderr) + _fail += 1 + + +def near(a, b, tol=1e-6): + return abs(a - b) <= tol + + +def die(msg): + print(f" FATAL {msg}", file=sys.stderr) + sys.exit(2) + + +def lengths(ents): + return sorted(round(e["length"], 6) for e in ents if e["type"] == "line") + + +def loops(): + return describe()["closed_loops"] + + +# =================================================================== LADDER A — one tool each +# Every 2D tool draws its primitive by gesture, then takes its exact value from the keyboard. +# The click only has to be roughly right; the typed number is what must come back exactly. + +def rung_rect(): + print("\nA1 rectangle — two corners, typed 120 x 80") + enter_sketch("r") + clickmm(-60, -40); clickmm(60, 40) + values(120, 80) + d = describe() + ls = lengths(d["entities"]) + check("LENGTH", ls == [80.0, 80.0, 120.0, 120.0], f"sides {ls}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + check("AREA", near(abs(lp[0]["area"]), 9600.0, 1e-6), f"area {abs(lp[0]['area']):.6f}") + check("VERTEX", all(near(abs(e["p1"][0] - e["p0"][0]), 0, 1e-9) + or near(abs(e["p1"][1] - e["p0"][1]), 0, 1e-9) + for e in d["entities"]), "every side axis-aligned") + leave_sketch() + + +def rung_circle(): + print("\nA2 circle — centre then rim, typed radius 25") + enter_sketch("c") + clickmm(0, 0); clickmm(30, 0) + values(25) + d = describe() + e = [x for x in d["entities"] if x["type"] == "circle"] + check("ARC", len(e) == 1 and near(e[0]["radius"], 25.0), f"radius {e[0]['radius'] if e else None}") + check("VERTEX", len(e) == 1 and near(e[0]["center"][0], 0.0, 0.6) and near(e[0]["center"][1], 0.0, 0.6), + f"centre {e[0]['center'] if e else None} at the clicked origin") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), math.pi * 625.0, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs pi r^2 {math.pi*625:.6f}") + leave_sketch() + + +def rung_line(): + print("\nA3 line — two clicks, typed length 50 and angle 30") + enter_sketch("l") + clickmm(-40, -20); clickmm(10, 5) + values(50, 30) + d = describe() + e = [x for x in d["entities"] if x["type"] == "line"] + check("LENGTH", len(e) == 1 and near(e[0]["length"], 50.0), f"length {e[0]['length'] if e else None}") + if e: + a = math.degrees(math.atan2(e[0]["p1"][1] - e[0]["p0"][1], e[0]["p1"][0] - e[0]["p0"][0])) % 360.0 + check("ANGLE", near(a, 30.0, 1e-9), f"angle {a:.9f} deg") + leave_sketch() + + +def rung_arc(): + print("\nA4 three-point arc — typed radius 40 and sweep 90") + enter_sketch("a") + clickmm(-40, 0); clickmm(40, 0); clickmm(0, 40) + values(40, 90) + d = describe() + e = [x for x in d["entities"] if x["type"] == "arc"] + check("ARC", len(e) == 1 and near(e[0]["radius"], 40.0), f"radius {e[0]['radius'] if e else None}") + if e: + sw = abs(e[0]["end_angle"] - e[0]["start_angle"]) * 180.0 / math.pi + # 1e-7 deg, not exact: the sweep is READ BACK as end_angle - start_angle, two atan2 + # results, where the line's angle is STORED as the direction it was given. A 1e-9 deg + # residual here is 7e-10 mm at r=40 — float round-trip, not a defect. + check("ANGLE", near(sw, 90.0, 1e-7), f"sweep {sw:.9f} deg") + ch = math.dist(e[0]["p0"], e[0]["p1"]) + check("VERTEX", near(ch, 40.0 * math.sqrt(2.0), 1e-6), + f"chord {ch:.6f} vs r*sqrt2 {40*math.sqrt(2):.6f}") + leave_sketch() + + +def rung_slot(): + print("\nA5 slot — typed centre distance 60, radius 10, angle 0") + enter_sketch("s") + clickmm(-30, 0); clickmm(30, 0); clickmm(30, 12) + values(60, 10, 0) + d = describe() + arcs = [x for x in d["entities"] if x["type"] == "arc"] + lns = [x for x in d["entities"] if x["type"] == "line"] + check("ARC", len(arcs) == 2 and all(near(a["radius"], 10.0) for a in arcs), + f"two end radii {[round(a['radius'], 9) for a in arcs]}") + check("LENGTH", len(lns) == 2 and all(near(l["length"], 60.0) for l in lns), + f"two flanks {[round(l['length'], 9) for l in lns]}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), 60 * 20 + math.pi * 100, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs 60*20+pi*100 {60*20+math.pi*100:.6f}") + leave_sketch() + + +def rung_polygon(): + print("\nA6 polygon — typed side 30, angle 0") + enter_sketch("g") + clickmm(0, 0); clickmm(35, 0) + values(30, 0) + d = describe() + e = [x for x in d["entities"] if x["type"] == "line"] + ls = lengths(d["entities"]) + check("LENGTH", len(e) >= 3 and all(near(l, 30.0, 1e-9) for l in ls), + f"{len(e)} equal sides {set(ls)}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + if e: + n = len(e) + want = n * 30.0 ** 2 / (4.0 * math.tan(math.pi / n)) + check("AREA", near(abs(lp[0]["area"]), want, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs regular {n}-gon {want:.6f}") + leave_sketch() + + +def rung_ellipse(): + print("\nA7 ellipse — typed major 50, minor 20") + enter_sketch("e") + clickmm(0, 0); clickmm(40, 0); clickmm(0, 15) + values(50, 20) + d = describe() + e = [x for x in d["entities"] if x["type"] == "ellipse"] + check("ARC", len(e) == 1, f"{len(e)} ellipse") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), math.pi * 50 * 20, 2e-2), + f"area {abs(lp[0]['area']):.4f} vs pi*a*b {math.pi*1000:.4f}") + leave_sketch() + + +def rung_point(): + print("\nA8 point — one click, no value to type") + enter_sketch("p") + clickmm(20, 10) + d = describe() + e = [x for x in d["entities"] if x["type"] == "point"] + check("VERTEX", len(e) == 1 and near(e[0]["p"][0], 20.0, 0.6) and near(e[0]["p"][1], 10.0, 0.6), + f"placed at {[round(v, 3) for v in e[0]['p']] if e else None}") + leave_sketch() + + +def rung_spline(): + print("\nA9 spline — click control points, right-click to end") + enter_sketch("b") + for p in [(-40, 0), (-15, 30), (15, -30), (40, 0)]: + clickmm(*p) + clickmm(40, 0, btn=3) + d = describe() + e = [x for x in d["entities"] if x["type"] == "spline"] + check("VERTEX", len(e) == 1, f"{len(e)} spline from 4 control points") + leave_sketch() + + +# =================================================================== LADDER B — voids by hand +# The strategic target itself: one closed outer loop with internal voids, every one of them +# drawn by gesture in a single sketch and given its size from the keyboard. + +def rung_voids(): + print("\nB1 closed profile with two internal voids, all by gesture") + enter_sketch("r") + clickmm(-60, -40); clickmm(60, 40) # outer 120 x 80 + values(120, 80) + key("r", 0.6) # same tool again, from the keyboard + clickmm(-45, -15); clickmm(-5, 15) # void 1: 40 x 30 + values(40, 30) + key("c", 0.6) + clickmm(30, 0); clickmm(42, 0) # void 2: circle r 10 + values(10) + d = describe() + ls = lengths(d["entities"]) + check("LENGTH", ls == [30.0, 30.0, 40.0, 40.0, 80.0, 80.0, 120.0, 120.0], f"sides {ls}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 3 and all(l["closed"] for l in lp), f"{len(lp)} closed loops") + check("CLOSED", d["buildable"] and not d["open_ends"], "buildable, nothing dangling") + # The void attribution is the property under test: the outer loop must OWN both inner ones, + # and neither inner loop may claim a hole of its own. + outer = max(range(len(lp)), key=lambda i: abs(lp[i]["area"])) + holes = sorted(lp[outer]["holes"]) + check("VOID", holes == sorted(i for i in range(len(lp)) if i != outer), + f"outer loop {outer} owns holes {holes}") + check("VOID", all(not lp[i]["holes"] for i in range(len(lp)) if i != outer), + "neither void claims a hole of its own") + a = {i: abs(lp[i]["area"]) for i in range(len(lp))} + check("AREA", near(a[outer], 9600.0, 1e-6), f"outer {a[outer]:.6f}") + inner = sorted(a[i] for i in a if i != outer) + check("AREA", near(inner[0], math.pi * 100, 1e-6) and near(inner[1], 1200.0, 1e-6), + f"voids {inner[0]:.6f} (pi*100) and {inner[1]:.6f} (40*30)") + net = a[outer] - sum(v for i, v in a.items() if i != outer) + check("AREA", near(net, 9600.0 - 1200.0 - math.pi * 100, 1e-6), f"net material {net:.6f}") + leave_sketch() + + +# =================================================================== LADDER C — combining +# Mirror, offset, trim, extend, fillet and chamfer, each driven by the same picks and the same +# on-geometry value label a person would use. The label's place is COMPUTED from the geometry +# the tool itself derives (render_op_gizmo: tip = anchor + dir * value, label = tip + dir * 1.2 +# * max(15 * unit_per_px, 1e-4)) rather than hunted for in the pixels — the tool's own formula +# is the only thing that can be right by construction. + +def op_label_mm(anchor, direction, value, at): + th = max(15.0 * mm_per_px(*at), 1e-4) + d = (direction[0] / math.hypot(*direction), direction[1] / math.hypot(*direction)) + tip = (anchor[0] + d[0] * value, anchor[1] + d[1] * value) + return (tip[0] + d[0] * th * 1.2, tip[1] + d[1] * th * 1.2) + + +def mid(e): + return ((e["p0"][0] + e["p1"][0]) / 2.0, (e["p0"][1] + e["p1"][1]) / 2.0) + + +def corner_of(a, b): + """The shared endpoint of two adjacent lines, and the bisector pointing into their wedge.""" + C = min(((pa, pb) for pa in (a["p0"], a["p1"]) for pb in (b["p0"], b["p1"])), + key=lambda t: math.dist(t[0], t[1]))[0] + def away(e): + f = e["p1"] if math.dist(e["p0"], C) < math.dist(e["p1"], C) else e["p0"] + n = math.dist(f, C) + return ((f[0] - C[0]) / n, (f[1] - C[1]) / n) + ua, ub = away(a), away(b) + bis = (ua[0] + ub[0], ua[1] + ub[1]) + return tuple(C), bis + + +def draw_rect(w, h, x0, y0): + clickmm(x0, y0); clickmm(x0 + w, y0 + h) + values(w, h) + + +def rung_fillet(): + print("\nC1 fillet — pick two legs, type radius 8 on the label") + enter_sketch("r") + draw_rect(120, 80, -60, -40) + d0 = describe()["entities"] + a, b = corner_pair(d0) + key("f", 0.6) + clickmm(*mid(a)); clickmm(*mid(b)) + C, bis = corner_of(a, b) + v0 = 0.2 * min(a["length"], b["length"]) + clickmm(*op_label_mm(C, bis, v0, C)) + values(8) + d = describe() + arcs = [x for x in d["entities"] if x["type"] == "arc"] + check("ARC", len(arcs) == 1 and near(arcs[0]["radius"], 8.0), f"radius {arcs[0]['radius'] if arcs else None}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + want = 9600.0 - 64.0 * (1.0 - math.pi / 4.0) + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), want, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs 9600 - r^2(1-pi/4) {want:.6f}") + if arcs: + # TANGENT: the arc centre must sit exactly r from each surviving leg's line. + legs = [x for x in d["entities"] if x["type"] == "line"] + ds = sorted(point_line_dist(arcs[0]["center"], l) for l in legs)[:2] + check("TANGENT", all(near(x, 8.0, 1e-9) for x in ds), f"centre stands off both legs by {ds}") + leave_sketch() + + +def rung_chamfer(): + print("\nC2 chamfer — pick two legs, type distance 10 on the label") + enter_sketch("r") + draw_rect(120, 80, -60, -40) + d0 = describe()["entities"] + a, b = corner_pair(d0) + key("h", 0.6) + clickmm(*mid(a)); clickmm(*mid(b)) + C, bis = corner_of(a, b) + clickmm(*op_label_mm(C, bis, 0.2 * min(a["length"], b["length"]), C)) + values(10) + d = describe() + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + check("LENGTH", len(lp) == 1 and len(lp[0]["entities"]) == 5, + f"{len(lp[0]['entities'])} sides after the cut") + ls = sorted(e["length"] for e in d["entities"] if e["type"] == "line") + check("LENGTH", any(near(x, 10.0 * math.sqrt(2.0), 1e-9) for x in ls), + f"the new face is d*sqrt2 = {10*math.sqrt(2):.9f}; sides {[round(x,9) for x in ls]}") + check("LENGTH", near(ls[1], 70.0, 1e-9) and near(ls[3], 110.0, 1e-9), + "both legs shortened by exactly d") + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), 9600.0 - 50.0, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs 9600 - d^2/2") + leave_sketch() + + +def rung_offset(): + print("\nC3 offset — pick a circle, type 5 on the label") + enter_sketch("c") + clickmm(0, 0); clickmm(30, 0) + values(25) + key("o", 0.6) + clickmm(25, 0) # pick the rim + # Circle offset anchors at centre + (r, 0) and grows along +x; the starting value is 0.1 * 2r. + clickmm(*op_label_mm((25.0, 0.0), (1.0, 0.0), 0.1 * 50.0, (25.0, 0.0))) + values(5) + d = describe() + cs = sorted(x["radius"] for x in d["entities"] if x["type"] == "circle") + # The gizmo's arrow starts on the +x side, so the typed 5 lands OUTWARD; what the goal cares + # about is that the separation is exactly the number typed, on whichever side it was given. + check("ARC", len(cs) == 2 and near(cs[0], 25.0) and near(cs[1] - cs[0], 5.0), + f"radii {cs} — separated by exactly {cs[1]-cs[0] if len(cs)==2 else None}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 2 and all(l["closed"] for l in lp), f"{len(lp)} closed loops") + check("VOID", any(l["holes"] for l in lp), "the inner circle is read as a void of the outer") + leave_sketch() + + +def cross(a, b): + """Where two lines' infinite supports meet.""" + (x1, y1), (x2, y2) = a["p0"], a["p1"] + (x3, y3), (x4, y4) = b["p0"], b["p1"] + d = (x2 - x1) * (y4 - y3) - (y2 - y1) * (x4 - x3) + t = ((x3 - x1) * (y4 - y3) - (y3 - y1) * (x4 - x3)) / d + return (x1 + t * (x2 - x1), y1 + t * (y2 - y1)) + + +def mid_of(a, b): + return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0) + + +def point_line_dist(p, l): + (x0, y0), (x1, y1) = l["p0"], l["p1"] + dx, dy = x1 - x0, y1 - y0 + n = math.hypot(dx, dy) + return abs((p[0] - x0) * dy - (p[1] - y0) * dx) / n + + +def corner_pair(ents): + """Two adjacent lines of a rectangle: the first line and the one sharing an endpoint.""" + ls = [e for e in ents if e["type"] == "line"] + a = ls[0] + for b in ls[1:]: + if min(math.dist(pa, pb) for pa in (a["p0"], a["p1"]) for pb in (b["p0"], b["p1"])) < 1e-6: + return a, b + die("no adjacent pair in what should be a rectangle") + + +CONSTRUCTION_CHECKBOX = (419, 75) + + +def draw_line(x0, y0, x1, y1, length, angle): + clickmm(x0, y0); clickmm(x1, y1) + values(length, angle) + + +def rung_mirror(): + print("\nC4 mirror — a half profile reflected about a construction axis") + enter_sketch("l") + click(*CONSTRUCTION_CHECKBOX) # the axis is reference, not material + key("l", 0.6) + draw_line(0, -40, 0, 40, 80, 90) # the axis, on x = 0 + click(*CONSTRUCTION_CHECKBOX) # back to real geometry + key("l", 0.6) + draw_line(0, -40, 50, -40, 50, 0) + key("l", 0.6) + draw_line(50, -40, 50, 40, 80, 90) + key("l", 0.6) + draw_line(50, 40, 0, 40, 50, 180) + d0 = describe()["entities"] + axis = [e for e in d0 if e.get("construction")] + check("VERTEX", len(axis) == 1, f"{len(axis)} construction axis") + half = [e for e in d0 if e["type"] == "line" and not e.get("construction")] + check("LENGTH", len(half) == 3, f"{len(half)} lines in the half profile") + key("m", 0.6) + clickmm(*mid(axis[0])) + for e in half: + clickmm(*mid(e)) + clickmm(-90, 60) # empty space confirms + d = describe() + real = [e for e in d["entities"] if e["type"] == "line" and not e.get("construction")] + check("LENGTH", len(real) == 6, f"{len(real)} lines after the reflection") + lp = [l for l in d["closed_loops"]] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + # Graded against the geometry ACTUALLY DRAWN, not against the coordinates I aimed at. A + # synthetic click lands on a whole pixel, so the half profile sits a few tenths of a + # millimetre off the origin; the typed values fix its lengths and angles, not its anchor. + # Demanding 8000.000000 here would grade my aim, and the mirror is what is under test. + far = max(half, key=lambda e: e["length"]) # the edge parallel to the axis + w = point_line_dist(mid(far), axis[0]) + want = 2.0 * w * far["length"] + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), want, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs 2 x {w:.6f} x {far['length']:.6f} = {want:.6f}") + # SYMMETRY is the property this rung exists for: every vertex must have its exact reflection + # ABOUT THE AXIS THAT WAS DRAWN. + vs = [tuple(p) for e in real for p in (e["p0"], e["p1"])] + def refl(q): + (ax, ay), (bx, by) = axis[0]["p0"], axis[0]["p1"] + dx, dy = bx - ax, by - ay + n = dx * dx + dy * dy + t = ((q[0] - ax) * dx + (q[1] - ay) * dy) / n + fx, fy = ax + t * dx, ay + t * dy + return (2 * fx - q[0], 2 * fy - q[1]) + missing = [v for v in vs if not any(math.dist(refl(v), o) < 1e-9 for o in vs)] + check("SYMMETRY", not missing, + f"every one of {len(vs)} vertices has its exact reflection about the drawn axis") + leave_sketch() + + +def rung_trim(): + print("\nC5 trim — cut one arm off a crossing") + enter_sketch("l") + draw_line(-50, 0, 50, 0, 100, 0) + key("l", 0.6) + draw_line(0, -50, 0, 50, 100, 90) + d0 = describe()["entities"] + horiz = min(d0, key=lambda e: abs(e["p1"][1] - e["p0"][1])) + vert = max(d0, key=lambda e: abs(e["p1"][1] - e["p0"][1])) + X = cross(horiz, vert) + left = min(horiz["p0"], horiz["p1"]) # the end that must survive + want = math.dist(left, X) + key("t", 0.6) + clickmm(*mid_of(X, max(horiz["p0"], horiz["p1"]))) # the arm on the far side of the crossing + d = describe() + ls = sorted(round(e["length"], 9) for e in d["entities"] if e["type"] == "line") + check("LENGTH", len(ls) == 2 and near(ls[1], vert["length"], 1e-9) and near(ls[0], want, 1e-9), + f"lengths {ls} — the picked arm is gone at the crossing (expected {want:.9f}), " + f"the other line untouched") + ends = [tuple(p) for e in d["entities"] if e["type"] == "line" for p in (e["p0"], e["p1"])] + check("VERTEX", any(math.dist(X, q) < 1e-9 for q in ends), "the cut lands exactly on the crossing") + leave_sketch() + + +def rung_extend(): + print("\nC6 extend — reach a line to the one it stops short of") + enter_sketch("l") + draw_line(-50, 0, -10, 0, 40, 0) + key("l", 0.6) + draw_line(0, -50, 0, 50, 100, 90) + d0 = describe()["entities"] + short = min(d0, key=lambda e: e["length"]) + vert = max(d0, key=lambda e: e["length"]) + X = cross(short, vert) + far = min((short["p0"], short["p1"]), key=lambda q: q[0]) # the end that stays put + near_end = max((short["p0"], short["p1"]), key=lambda q: q[0]) + want = math.dist(far, X) + key("x", 0.6) + clickmm(*mid_of(near_end, mid_of(far, near_end))) # click the end that must grow + d = describe() + ls = sorted(round(e["length"], 9) for e in d["entities"] if e["type"] == "line") + check("LENGTH", len(ls) == 2 and near(ls[0], want, 1e-9), + f"lengths {ls} — {short['length']:.6f} grew to exactly {want:.9f}") + ends = [tuple(p) for e in d["entities"] if e["type"] == "line" for p in (e["p0"], e["p1"])] + check("VERTEX", any(math.dist(X, q) < 1e-9 for q in ends), + "the new end sits exactly on the target line") + leave_sketch() + + +# =================================================================== LADDER D — dimensions +# A drawn shape with no numbers on it, then numbers put on it by hand: the Dimension tool for a +# value, the Constrain buttons for a relation. Both must hold the value they were given AND take +# the degrees of freedom away — a dimension that moves the geometry but leaves the DoF standing +# has not constrained anything, it has only nudged it. + +# Constrain-mode toolbar, measured off the rig at 1920x1080 (icon centres, 42 px apart). +CON_BTN_Y = 76 +CON_BTN = {n: (449 + 42 * i, CON_BTN_Y) for i, n in enumerate( + ["horizontal", "vertical", "parallel", "perpendicular", "coincident", "equal", + "concentric", "tangent", "midpoint", "symmetric", "angle", "radius", "diameter", "fix"])} + + +def draw_rect_undimensioned(): + """A rectangle by two clicks, with both queued value fields dismissed (Esc keeps it as drawn).""" + clickmm(-60, -40); clickmm(60, 40) + key("Escape", 0.7) # Width — keep as drawn + key("Escape", 0.7) # Height — keep as drawn + + +def rung_dimension(): + print("\nD1 dimension — put a length on a side that had none") + enter_sketch("r") + draw_rect_undimensioned() + d0 = describe() + dof0 = d0["dof"] + check("VERTEX", dof0 > 0, f"the undimensioned rectangle has {dof0} degrees of freedom") + side = max((e for e in d0["entities"] if e["type"] == "line"), key=lambda e: e["length"]) + key("d", 0.6) + clickmm(*mid(side)) + values(90) + d = describe() + ls = sorted(round(e["length"], 9) for e in d["entities"] if e["type"] == "line") + check("LENGTH", any(near(x, 90.0, 1e-9) for x in ls), f"the dimensioned side reads {ls}") + check("VERTEX", d["dof"] < dof0, f"degrees of freedom {dof0} -> {d['dof']}") + check("CLOSED", d["solve_ok"] and len(d["closed_loops"]) == 1, "still one closed, solved loop") + leave_sketch() + + +CONFIRM_BTN = (1751, 75) + + +def confirm_and_reopen(): + """(see reopen_sketch below — same two steps, kept together for the constrain rungs)""" + """Leave Constrain with the action bar's tick, then re-open the sketch for editing. + + THE DoF HAS TO BE READ HERE, not in Constrain mode. While constraining, sketch_describe + reports the LIVE tool's dof and constraint count, which the constrain session does not + touch — it works on the committed feature's own entity_constraints, and the panel computes + its readout from those. Reading during the session says 4 -> 4 for a constraint that really + did land; reading after the round trip says 4 -> 3, and proves the constraint was persisted + rather than merely previewed. + """ + click(*CONFIRM_BTN, pause=1.5) + w, X, Y, _, _ = win() + sh(f"DISPLAY={DISP} xdotool mousemove {X+TREE_ROW0[0]} {Y+TREE_ROW0[1]} " + f"click --repeat 2 --delay 120 1") + time.sleep(2.0) + return describe() + + +def rung_constrain(): + reset_document() + print("\nD2 constrain — Equal length on two adjacent sides, from the Constrain toolbar") + enter_sketch("r") + draw_rect_undimensioned() + a, b = corner_pair(describe()["entities"]) + check("LENGTH", not near(a["length"], b["length"], 1e-6), + f"the two sides start unequal: {a['length']:.6f} vs {b['length']:.6f}") + key("k", 1.5) # finish the sketch and enter Constrain + # Re-read the picks from the COMMITTED sketch: finish_sketch repackages the entities, so an + # index taken before Constrain is not the same index afterwards. + d1 = describe() + a, b = corner_pair(d1["entities"]) + dof0 = 4 # an undimensioned rectangle: position + size + ia, ib = d1["entities"].index(a), d1["entities"].index(b) + clickmm(*mid(a)); clickmm(*mid(b)) + click(*CON_BTN["equal"]) + time.sleep(1.0) + d = describe() + la, lb = d["entities"][ia]["length"], d["entities"][ib]["length"] + check("LENGTH", near(la, lb, 1e-9), f"the two sides are now equal: {la:.9f} and {lb:.9f}") + d2 = confirm_and_reopen() + check("VERTEX", d2["dof"] == dof0 - 1, f"degrees of freedom {dof0} -> {d2['dof']} after the round trip") + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + ls2 = sorted(round(e["length"], 9) for e in d2["entities"] if e["type"] == "line") + check("LENGTH", near(ls2[0], la, 1e-9) and near(ls2[-1], la, 1e-9), + f"the geometry came back unchanged: {ls2}") + leave_sketch() + + +def rung_perpendicular(): + reset_document() + print("\nD3 constrain — two free lines made exactly perpendicular") + enter_sketch("l") + clickmm(-50, -30); clickmm(30, -18) + key("Escape", 0.7); key("Escape", 0.7) + key("l", 0.6) + clickmm(30, -18); clickmm(18, 40) + key("Escape", 0.7); key("Escape", 0.7) + d0 = describe() + check("ANGLE", abs(angle_between(d0["entities"][0], d0["entities"][1]) - 90.0) > 1e-3, + f"they start at {angle_between(d0['entities'][0], d0['entities'][1]):.6f} deg") + key("k", 1.5) + d1 = describe() + clickmm(*mid(d1["entities"][0])); clickmm(*mid(d1["entities"][1])) + click(*CON_BTN["perpendicular"]) + time.sleep(1.0) + d = describe() + ang = angle_between(d["entities"][0], d["entities"][1]) + check("ANGLE", near(ang, 90.0, 1e-9), f"now {ang:.9f} deg") + d2 = confirm_and_reopen() + ang2 = angle_between(d2["entities"][0], d2["entities"][1]) + check("ANGLE", near(ang2, 90.0, 1e-9), f"still {ang2:.9f} deg after the round trip") + check("CLOSED", d2["constraints"] > 0 and d2["solve_ok"], + f"{d2['constraints']} constraints survived, dof {d2['dof']}") + leave_sketch() + + +def angle_between(a, b): + va = (a["p1"][0] - a["p0"][0], a["p1"][1] - a["p0"][1]) + vb = (b["p1"][0] - b["p0"][0], b["p1"][1] - b["p0"][1]) + c = (va[0] * vb[0] + va[1] * vb[1]) / (math.hypot(*va) * math.hypot(*vb)) + return math.degrees(math.acos(max(-1.0, min(1.0, c)))) + + +# =================================================================== DURABILITY +# Exactness that does not survive an undo or a save is not exactness. + +def rung_undo(): + print("\nE1 undo — the last entity goes, the rest do not move") + enter_sketch("l") + draw_line(-50, -30, 0, -30, 50, 0) + key("l", 0.6); draw_line(0, -30, 0, 20, 50, 90) + key("l", 0.6); draw_line(0, 20, -40, 20, 40, 180) + before = describe()["entities"] + check("LENGTH", len(before) == 3, f"{len(before)} entities drawn") + key("ctrl+z", 1.0) + after = describe()["entities"] + check("VERTEX", len(after) == 2, f"{len(after)} entities after one undo") + same = all(math.dist(a["p0"], b["p0"]) == 0.0 and math.dist(a["p1"], b["p1"]) == 0.0 + for a, b in zip(before, after)) + check("VERTEX", same, "the two survivors are bit-identical, not re-solved") + key("ctrl+z", 1.0) + check("VERTEX", len(describe()["entities"]) == 1, "a second undo drops one more") + leave_sketch() + + +def rung_feature_undo(): + print("\nE2 undo/redo across the commit — a deleted sketch comes back exactly") + reset_document() + enter_sketch("r") + draw_rect(120, 80, -60, -40) + click(*CONFIRM_BTN, pause=1.5) + n0 = len(call("describe_scene")["features"]) + check("VERTEX", n0 == 1, f"{n0} feature committed") + click(*TREE_ROW0, pause=0.4) + key("Delete", 0.8) + check("VERTEX", not call("describe_scene")["features"], "the tree is empty after Delete") + key("ctrl+z", 1.5) + check("VERTEX", len(call("describe_scene")["features"]) == 1, "undo brings the feature back") + d = reopen_sketch() + ls = lengths(d["entities"]) + check("LENGTH", ls == [80.0, 80.0, 120.0, 120.0], f"and it is the same rectangle: {ls}") + lp = d["closed_loops"] + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), 9600.0, 1e-6), + f"area {abs(lp[0]['area']):.6f}") + leave_sketch() + key("ctrl+y", 1.5) + check("VERTEX", not call("describe_scene")["features"], "redo removes it again") + reset_document() + + +PROJECT_FILE = "/tmp/gl-roundtrip.3mf" + + +def dialog_up(): + names = sh(f"DISPLAY={DISP} xdotool search --class '.' getwindowname %@") + return any(n and n != "snapmaker-orca" and "file" in n.lower() for n in names.splitlines()) + + +def file_dialog(path, settle=5.0): + """Type an absolute path into the GTK file chooser that is up, and accept it.""" + if not dialog_up(): + die("no file chooser came up") + key("ctrl+a", 0.3) + typ(path, 0.5) + key("Return", settle) + global _win + _win = None # saving renames the window; drop the cached geometry + + +def rung_roundtrip(): + print("\nE3 save and reload — the profile comes back to the last decimal") + reset_document() + enter_sketch("r") + draw_rect(120, 80, -60, -40) + key("r", 0.6); clickmm(-45, -15); clickmm(-5, 15); values(40, 30) + key("c", 0.6); clickmm(30, 0); clickmm(42, 0); values(10) + before = describe() + click(*CONFIRM_BTN, pause=1.5) + sh(f"rm -f {PROJECT_FILE}") + # Save AS, not Save: once a project has a path, Ctrl+S writes to it silently and no chooser + # appears — which is correct behaviour and a trap for a driver that assumes the dialog. + key("ctrl+shift+s", 3.0) + file_dialog(PROJECT_FILE) + size = sh(f"stat -c %s {PROJECT_FILE} 2>/dev/null").strip() + check("CLOSED", size.isdigit() and int(size) > 0, f"project written, {size} bytes") + reset_document() # wipe the tree, then read it back off disk + key("ctrl+o", 2.5) + file_dialog(PROJECT_FILE, settle=8.0) + go_design() # opening a project lands on Prepare + feats = call("describe_scene")["features"] + check("VERTEX", len(feats) == 1, f"the reloaded document has {len(feats)} feature(s)") + after = reopen_sketch() + b = sorted((e["type"], tuple(round(c, 12) for c in (e.get("p0") or e.get("center") or e.get("p"))), + round(e.get("length", e.get("radius", 0.0)), 12)) for e in before["entities"]) + a = sorted((e["type"], tuple(round(c, 12) for c in (e.get("p0") or e.get("center") or e.get("p"))), + round(e.get("length", e.get("radius", 0.0)), 12)) for e in after["entities"]) + check("VERTEX", a == b, f"{len(a)} entities identical to 12 decimals after the round trip") + lp = after["closed_loops"] + check("CLOSED", len(lp) == 3 and after["buildable"], f"{len(lp)} loops, buildable") + outer = max(range(len(lp)), key=lambda i: abs(lp[i]["area"])) + check("VOID", sorted(lp[outer]["holes"]) == sorted(i for i in range(len(lp)) if i != outer), + "the voids are still attributed to the outer loop") + check("AREA", near(abs(lp[outer]["area"]), 9600.0, 1e-9), f"outer area {abs(lp[outer]['area']):.9f}") + leave_sketch() + reset_document() + + +def reopen_sketch(): + w, X, Y, _, _ = win() + sh(f"DISPLAY={DISP} xdotool mousemove {X+TREE_ROW0[0]} {Y+TREE_ROW0[1]} " + f"click --repeat 2 --delay 120 1") + time.sleep(2.0) + return describe() + + +RUNGS = {"rect": rung_rect, "circle": rung_circle, "line": rung_line, "arc": rung_arc, + "slot": rung_slot, "polygon": rung_polygon, "ellipse": rung_ellipse, + "point": rung_point, "spline": rung_spline, "voids": rung_voids, + "fillet": rung_fillet, "chamfer": rung_chamfer, "offset": rung_offset, + "mirror": rung_mirror, "trim": rung_trim, "extend": rung_extend, + "dimension": rung_dimension, "constrain": rung_constrain, + "perpendicular": rung_perpendicular, "undo": rung_undo, + "feature_undo": rung_feature_undo, "roundtrip": rung_roundtrip} + + +def main(): + want = sys.argv[1:] or list(RUNGS) + calibrate() + for name in want: + if name not in RUNGS: + die(f"unknown rung {name}; have {' '.join(RUNGS)}") + RUNGS[name]() + leave_sketch() + print(f"\n{_checks - _fail}/{_checks} properties held") + sys.exit(1 if _fail else 0) + + +if __name__ == "__main__": + main() diff --git a/scripts/ladder-corpus.py b/scripts/ladder-corpus.py index a3ab880d56..83a51870f1 100644 --- a/scripts/ladder-corpus.py +++ b/scripts/ladder-corpus.py @@ -202,6 +202,33 @@ def point_in(pt, ring): return inside +def interior_point(ring): + """A point strictly inside a simple closed ring (first == last). + + The lowest vertex of a simple polygon is always convex, so stepping from it along the + bisector of its two edges goes inward; the step is a small fraction of the shorter edge so + it stays inside however sharp the corner is. + """ + q = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring + if len(q) < 3: + return ring[0] + k = min(range(len(q)), key=lambda i: (q[i][1], q[i][0])) + v = q[k] + a = (q[(k - 1) % len(q)][0] - v[0], q[(k - 1) % len(q)][1] - v[1]) + b = (q[(k + 1) % len(q)][0] - v[0], q[(k + 1) % len(q)][1] - v[1]) + la, lb = math.hypot(*a), math.hypot(*b) + if la < 1e-12 or lb < 1e-12: + return v + a = (a[0] / la, a[1] / la) + b = (b[0] / lb, b[1] / lb) + bx, by = a[0] + b[0], a[1] + b[1] + n = math.hypot(bx, by) + if n < 1e-12: + return v + step = 1e-3 * min(la, lb) + return (v[0] + bx / n * step, v[1] + by / n * step) + + # ── one drawing ────────────────────────────────────────────────────────────── def grade(pdf, name, report): segs = drawing_segments(pdf) @@ -258,11 +285,17 @@ def grade(pdf, name, report): # void. So compute the same rule here, independently, and compare the whole attribution. if got: rings = [outer] + voids + # Probe from a point STRICTLY INSIDE each ring, never from one of its vertices — the + # same rule the engine now uses (DesignSketchTool::region_loops). A vertex is exactly + # where two loops touch in a real drawing, and a ray cast from a point lying ON the + # polygon under test answers by rounding: that alone accounted for every one of the 6 + # sheets where the two attributions used to disagree. snaporca-5hvl. + probes = [interior_point(r) for r in rings] mine_parent = {} for i, r in enumerate(rings): best, best_a = -1, 0.0 for j, q in enumerate(rings): - if i == j or not point_in(r[0], q): + if i == j or not point_in(probes[i], q): continue a = shoelace(q) if best < 0 or a < best_a: diff --git a/src/libslic3r/CAD/CadDocument.cpp b/src/libslic3r/CAD/CadDocument.cpp index e95e36d954..300fbd9ee3 100644 --- a/src/libslic3r/CAD/CadDocument.cpp +++ b/src/libslic3r/CAD/CadDocument.cpp @@ -3510,6 +3510,14 @@ bool CadDocument::recompute() error.clear(); detect_mate_conflicts(); std::vector built; + // Did any feature in this document even ASK for a solid? A document made only of sketches + // and datums has nothing to build, and that is a legitimate state — it is every document + // between drawing the first profile and extruding it. Reporting it as a failure is what + // made a sketch-only design unsaveable AND unopenable: DesignPanel::recompute_guarded syncs + // the 3MF recipe only "on success", so nothing was written, and deserialize_recipe ends with + // `return recompute()`, so a project that did carry a recipe was refused on load with + // "Could not restore the CAD model" while its features sat correctly in the list. snaporca-mtav. + bool any_solid_feature = false; try { // Parametric pass: evaluate document variables, then each feature's expression bindings, // writing the results into the feature's numeric fields before geometry runs. @@ -3525,6 +3533,8 @@ bool CadDocument::recompute() if (f.type == CadFeatureType::Plane) continue; // datum: no solid, derived on demand if (f.type == CadFeatureType::Axis) continue; // datum axis if (f.type == CadFeatureType::CoordSys) continue; // datum coordinate system + // Past the skips: this feature is one that means to leave a body behind. + any_solid_feature = true; if (f.type == CadFeatureType::Project) { apply_project(built, f); } else { route_feature(built, f); } // Record which feature made each body. "Still unset?" is the whole rule, and it is @@ -3551,7 +3561,7 @@ bool CadDocument::recompute() error = "unknown geometry error"; return false; } - if (built.empty()) { error = "no solid-producing features"; return false; } + if (built.empty() && any_solid_feature) { error = "no solid-producing features"; return false; } // A feature that leaves a body with a null shape must fail loudly. Until this existed, // recompute() returned true and the document kept advertising the body: describe_scene @@ -3624,7 +3634,9 @@ bool CadDocument::recompute() display_mesh = tessellate_bodies(bodies, display_tri_face, display_tri_body, display_body_meshes, linear_deflection, angular_deflection); - if (display_mesh.its.indices.empty()) { + if (display_mesh.its.indices.empty() && any_solid_feature) { + // Empty only because there are no bodies to tessellate is the same legitimate state as + // above: a sketch-only document has nothing to draw as a solid, and that is not a fault. error = "tessellation produced an empty mesh"; return false; } diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 5d416c7a8d..4c9deeff1a 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -6630,6 +6630,24 @@ void DesignPanel::load_recipe(const std::string& blob) void DesignPanel::refresh_tree() { + // The recipe mirrors the FEATURE LIST, and this is the moment the feature list changed — + // every add, delete, reorder, rename and suppression ends here to redraw the tree. Putting + // the sync in recompute_guarded instead tied it to "a solid was built", and CadDocument:: + // recompute() returns FALSE for a document that has no solid ("no solid-producing features", + // CadDocument.cpp) — which is precisely a document the user has only drawn sketches in. So + // drawing a profile, pressing Confirm and saving wrote a 3MF with no SnapOrca_cad.bin in it + // at all, and the app reported success: the whole design was gone on reopen (snaporca-mtav). + // The three sites that say "a lone sketch yields an empty body; that is expected" call + // m_doc.recompute() directly and so never reached the sync either. One hook here covers all + // of them, including the live sketch tool's own commit path. + // + // ONLY when the document has something in it. sync_recipe_to_model() CLEARS the blob for an + // empty document, and the tree is also refreshed while the Design tab is still empty — before + // the deferred load at on_show() has had the chance to read the blob the project arrived + // with. Clearing there would destroy the recipe of every project being opened. Deleting the + // last feature still clears it, through the tree-edit call site that always did. + if (!m_doc.features.empty()) sync_recipe_to_model(); + // Preserve the selected row across the rebuild — wxTreeCtrl::DeleteAllItems // drops the selection, which made every edit/add feel like it "lost" the // selection (and broke Edit/Move/Delete on the just-touched feature). diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index a6a043fa2c..6731eff6ee 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -2311,7 +2311,7 @@ bool DesignSketchTool::try_add_constraints(const std::vector= n) return; @@ -2341,7 +2341,7 @@ void DesignSketchTool::infer_auto_constraints(int base, double ang_tol_rad) for (int b = 0; b < nj; ++b) { if (j >= base && j < i) continue; // avoid duplicate (i,j)/(j,i) Vec2d pb; if (!point_at(j, jr[b], pb)) continue; - if ((pa - pb).squaredNorm() > 1e-6) continue; + if ((pa - pb).squaredNorm() > weld_tol * weld_tol) continue; if (has_coincident(i, ir[a], j, jr[b])) continue; SketchEntityConstraintDef c; c.type = SketchConstraintType::Coincident; @@ -6261,9 +6261,17 @@ DesignSketchTool::region_loops(const std::vector& ents) const // what Tommaso hit: a rectangle with a circle inside extruded to a plain box, because only // the rectangle loop could be picked and only its entities were passed on. // - // Loops in a well-formed sketch do not cross, so testing ONE vertex decides containment. + // Loops in a well-formed sketch do not cross, so testing ONE point decides containment. // Each loop is assigned to the SMALLEST loop that contains it, which is what makes a hole // belong to the region that actually bounds it rather than to every enclosing loop. + // + // The point must be STRICTLY INSIDE the loop, not one of its vertices. A vertex is exactly + // where two loops are most likely to touch in a real drawing — a bore breaking out through + // a boss wall, a slot that ends on an outline — and a ray cast from a point that lies ON the + // polygon being tested answers by rounding, so the same drawing can be read either way. + // Measured on the StudyCadCam corpus: the engine and an independent containment check + // disagreed on 6 of 39 sheets, and every disagreement was a probe point sitting on the other + // loop's boundary. snaporca-5hvl. auto poly_area = [](const std::vector& q) { double a2 = 0.0; for (size_t i = 0, j = q.size() - 1; i < q.size(); j = i++) @@ -6280,13 +6288,36 @@ DesignSketchTool::region_loops(const std::vector& ents) const } return in; }; + // A point strictly inside a simple polygon: the lowest vertex of a simple polygon is always + // CONVEX, so stepping from it along the bisector of its two edges goes into the interior. + // The step is a small fraction of the shorter adjacent edge, so it stays inside however + // sharp the corner is. + auto interior_point = [](const std::vector& q) { + size_t k = 0; + for (size_t i = 1; i < q.size(); ++i) + if (q[i].y() < q[k].y() || (q[i].y() == q[k].y() && q[i].x() < q[k].x())) k = i; + const Vec2d& v = q[k]; + Vec2d a = q[(k + q.size() - 1) % q.size()] - v; + Vec2d b = q[(k + 1) % q.size()] - v; + const double la = a.norm(), lb = b.norm(); + if (la < 1e-12 || lb < 1e-12) return v; // degenerate: nothing better to say + a /= la; b /= lb; + Vec2d bis = a + b; + if (bis.norm() < 1e-12) return v; // 180 deg spike: same + bis.normalize(); + return Vec2d(v + bis * (1e-3 * std::min(la, lb))); + }; + std::vector probe(regions.size()); + for (size_t i = 0; i < regions.size(); ++i) + if (regions[i].poly.size() >= 3) probe[i] = interior_point(regions[i].poly); + else if (!regions[i].poly.empty()) probe[i] = regions[i].poly.front(); for (size_t i = 0; i < regions.size(); ++i) { if (regions[i].poly.empty()) continue; int best = -1; double best_area = 0.0; for (size_t j = 0; j < regions.size(); ++j) { if (i == j || regions[j].poly.size() < 3) continue; - if (!point_in(regions[i].poly.front(), regions[j].poly)) continue; + if (!point_in(probe[i], regions[j].poly)) continue; const double a2 = poly_area(regions[j].poly); if (best < 0 || a2 < best_area) { best = int(j); best_area = a2; } } @@ -8904,7 +8935,19 @@ int DesignSketchTool::add_entities_scripted(const std::vector& ent // 0.067%, because several segments of the polygon fell inside that 3 degree window. Exact // coincidence inference is unaffected — it already tests to 1e-6 — so chains still weld // and genuinely axis-aligned scripted geometry still gets its Horizontal/Vertical. - infer_auto_constraints(base, 1e-4); + // + // ZERO, not 1e-4. Any window at all is a window that moves the caller's points, and 1e-4 rad + // was still wide enough to catch the short chords of a small flattened circle: on four of + // the 39 corpus drawings the loops that came back wrong were all TINY (1.4 to 13 mm^2), out + // by up to 7e-4 relative, because a 0.005 degree tilt on a 0.3 mm chord is inside 1e-4. + // With zero, only a segment that is EXACTLY axis-aligned is constrained, and constraining + // something already true cannot move it. snaporca-8xg1. + // The weld window closes too. Two endpoints a micron apart are not the same point when a + // caller typed both of them: on MPD681, 20 of 363 scripted segments were dragged onto a + // common point up to 0.0021 mm away, because welding is TRANSITIVE and three vertices near + // the origin chained into one. Exactly-equal endpoints still weld, which is what keeps a + // scripted profile closed — a ring's last point IS its first point. + infer_auto_constraints(base, 0.0, 0.0); resolve_live(); return base; } diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 8d27076072..959441e4fb 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -632,7 +632,13 @@ private: // A gesture needs the default 3 degrees — nobody clicks a horizontal line exactly — but // that same slack MOVES geometry that was given exactly, so the scripted path passes a // tolerance tight enough to recognise only what is already true. See add_entities_scripted. - void infer_auto_constraints(int base, double ang_tol_rad = 3.0 * M_PI / 180.0); + // ang_tol_rad: how far off axis a segment may be and still be called Horizontal/Vertical. + // weld_tol: how far apart two endpoints may be and still be called Coincident. + // Both default to GESTURE slack. A scripted add passes zero for both: the caller has + // already said exactly what it means, and every non-zero window is a window in which the + // inference rewrites it. snaporca-8xg1. + void infer_auto_constraints(int base, double ang_tol_rad = 3.0 * M_PI / 180.0, + double weld_tol = 1e-3); // Selection helpers (Mode::Select). int hit_test(const Vec2d& p, double tol) const; // nearest entity within tol, or -1 diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 2200bf4d04..1bb12efa68 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -1328,10 +1328,15 @@ TEST_CASE("datum plane: offset + tilt resolution and sketching on it", "[CadDocu CHECK_THAT(zmax, WithinAbs(34.0, 1e-6)); CHECK_THAT(double(doc.display_mesh.volume()), WithinRel(400.0, 0.02)); - // A datum-plane-only document has no solid -> recompute is a benign failure. + // A datum-plane-only document has no solid, and that is a benign SUCCESS, not a benign + // failure. It used to return false, and "benign failure" is exactly the phrasing that hid + // snaporca-mtav: two callers read the false as "unusable document" and threw the design + // away — the 3MF recipe was never written, and a project that had one was refused on load. CadDocument only_plane; only_plane.add_plane(0, 10.0, 0.0, 0, "P"); - REQUIRE_FALSE(only_plane.recompute()); + REQUIRE(only_plane.recompute()); + REQUIRE(only_plane.error.empty()); + REQUIRE(only_plane.bodies.empty()); } TEST_CASE("loft builds a solid skinning two profiles on parallel planes", "[CadDocument]") @@ -8041,3 +8046,54 @@ TEST_CASE("add_extrude_entities builds a plate with a bore (clockwise circle)", REQUIRE(face_count == 7); } + +// snaporca-mtav. A document that has only sketches in it is not a broken document, it is the +// state every design passes through between drawing a profile and extruding it. recompute() +// used to call that "no solid-producing features" and return false, and two things downstream +// read that false as "the document is unusable": the GUI syncs the 3MF recipe only after a +// successful recompute, so a sketch-only design was saved with NO recipe at all and vanished on +// reopen; and deserialize_recipe ends with `return recompute()`, so even a project that did +// carry one was refused on load. The failure has to stay for a document that ASKED for a solid +// and got none — that is a real geometry failure — so both halves are asserted here. +TEST_CASE("A sketch-only document recomputes and round-trips", "[CadDocument]") +{ + CadDocument doc; + std::vector ents{ + {SketchEntity::Type::Line, Vec2d(-60, -40), Vec2d(60, -40)}, + {SketchEntity::Type::Line, Vec2d(60, -40), Vec2d(60, 40)}, + {SketchEntity::Type::Line, Vec2d(60, 40), Vec2d(-60, 40)}, + {SketchEntity::Type::Line, Vec2d(-60, 40), Vec2d(-60, -40)}, + }; + const int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "Profile"); + REQUIRE(sk == 0); + + const bool built = doc.recompute(); // nothing to build is not a failure + INFO("recompute error: " << doc.error); + REQUIRE(built); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.empty()); + + const std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + REQUIRE(fresh.error.empty()); + REQUIRE(fresh.features.size() == 1); + REQUIRE(fresh.features[0].name == "Profile"); + REQUIRE(fresh.features[0].entities.size() == 4); + for (size_t i = 0; i < ents.size(); ++i) { + REQUIRE(fresh.features[0].entities[i].p0.x() == ents[i].p0.x()); + REQUIRE(fresh.features[0].entities[i].p0.y() == ents[i].p0.y()); + REQUIRE(fresh.features[0].entities[i].p1.x() == ents[i].p1.x()); + REQUIRE(fresh.features[0].entities[i].p1.y() == ents[i].p1.y()); + } + + // The other half of the rule: a feature that MEANT to build a solid and produced none is + // still an error, and must not be swallowed by the change above. + CadDocument bad; + bad.add_sketch_entities(ents, SketchPlane::XY(), "Profile"); + bad.add_extrude(0, 0.0, false, BooleanMode::New, "ZeroDepth"); + REQUIRE_FALSE(bad.recompute()); + REQUIRE_FALSE(bad.error.empty()); +}