From 4693542d0dc2fefc1c98246eeda6244023cf7d1e Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Sun, 23 Aug 2026 02:04:03 +0200 Subject: [PATCH] Port from snaporca: the solver's 1024-unknown cliff, and the scale rungs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two commits carried across (snaporca 579a9a9162, f68613cfc5). Past about 480 entities a sketch had NO constraints at all and said nothing: libslvs declares MAX_UNKNOWNS = 1024 and is handed every entity in the sketch at two params per point, so the whole system came back TOO_MANY_UNKNOWNS and try_add_constraints rolled the entire inferred batch back. From there no dimension could ever be applied. Constraints only couple entities that share a point, so the solver now falls back — only on TOO_MANY_UNKNOWNS — to solving connected components separately and committing all-or-nothing. The auto-constraint pass batches its Horizontal/Vertical constraints instead of one solve each, which is what kept the bulk path fast once solves started succeeding: a 1204-entity load went 1585 ms -> 562 ms. Plus the scale rungs (a thousand-entity plate drawn on by hand; the heaviest real drawings graded and timed), the --step 1 fix that used to select nothing while reporting a clean run, and scripts/ladder-all.sh as the one-command gate. Parity 17 identical / 8 diverging as expected. Kernel suite here: 188 cases / 2532 assertions, including "a sketch past the solver's unknown limit still solves". snaporca-yww4, snaporca-x6v7, snaporca-j6sr --- scripts/gui-ladder.py | 123 +++++++++++++++++++--- scripts/ladder-all.sh | 52 +++++++++ scripts/ladder-corpus.py | 87 ++++++++++++++- src/libslic3r/CAD/SketchSolver.cpp | 108 ++++++++++++++++++- src/slic3r/GUI/CAD/DesignSketchTool.cpp | 13 ++- tests/libslic3r/test_slvs_constraints.cpp | 47 +++++++++ 6 files changed, 408 insertions(+), 22 deletions(-) create mode 100755 scripts/ladder-all.sh diff --git a/scripts/gui-ladder.py b/scripts/gui-ladder.py index 28feb37635..05227a24df 100644 --- a/scripts/gui-ladder.py +++ b/scripts/gui-ladder.py @@ -129,6 +129,7 @@ def shot(path): # 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 +_SAFE = None # (xmin, xmax, ymin, ymax) of the plane region the probes covered def _solve(A, b): @@ -245,18 +246,24 @@ def enter_sketch(tool_key, plane_px=(913, 359)): 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) + key("p", 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)") + die("no sketch opened after plane click + Shift+S (see /shots/gl-enter-failed.png)") + calibrate_here() # THIS sketch's own camera map, on THIS sketch's own plane + key(tool_key, 0.6) -def calibrate(): - """Place four Points by hand, read where they landed, and solve for the camera's map.""" +def calibrate_here(): + """Place four Points in the sketch that is already open, solve the map, then undo them. + + PER SKETCH, not once per run. The camera is wherever the previous rung left it — reopening a + sketch and loading a project both move it — and the plane label the entry click lands on + moves with it, so a later sketch can end up on XZ while the map was solved on XY. Both of + those turn into clicks that land somewhere else, and geometry that looks drawn but is not + where it was asked for. Four points cost about four seconds and remove the whole class. + """ global _H - reset_document() - enter_sketch("p") probes = [(1000, 500), (1400, 500), (1400, 760), (1000, 760)] for u, v in probes: click(u, v) @@ -270,13 +277,25 @@ def calibrate(): 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() + global _SAFE + xs = [e["p"][0] for e in ents]; ys = [e["p"][1] for e in ents] + _SAFE = (min(xs), max(xs), min(ys), max(ys)) + for _ in range(len(probes)): + key("ctrl+z", 0.5) # the probes are scaffolding, not geometry + left = describe()["entities"] + if left: + die(f"{len(left)} calibration probes survived the undo") # ---------------------------------------------------------------- typed values +# How long to wait for the in-canvas field to appear and to settle after a commit. The queue +# opens each field from a CallAfter that runs AFTER a re-solve, so on a heavy sketch the field is +# simply not there yet when a fast driver starts typing — the digits go nowhere and the value +# stays as drawn. Rungs that work on a thousand entities raise this. +PACE = 1.0 + + def value(v, pause=0.6): """Type one number into the open in-canvas field and commit it. @@ -284,9 +303,10 @@ def value(v, pause=0.6): pre-selection that a synthetic click has disturbed would otherwise leave the typed digits appended to it. """ + time.sleep(0.25 * PACE) key("ctrl+a", 0.15) typ(str(v), 0.25) - key("Return", pause) + key("Return", pause * PACE) def values(*vs): @@ -998,6 +1018,82 @@ def rung_roundtrip(): reset_document() +def rung_scale(): + print("\nE4 scale — a gesture on top of a sketch that already holds a thousand entities") + enter_sketch("r") + # The heavy profile is bulk-loaded through the socket ON PURPOSE: what is under test here is + # whether the interactive path still works with a large sketch already on screen, not where + # that sketch came from. A plate with a 20 x 15 grid of square cut-outs — 1204 entities. + # Sized to the region the calibration probes covered, so every part of it can actually be + # clicked: the camera is wherever the last rung left it, and a plate drawn off-screen would + # test nothing but my arithmetic. + x0, x1, y0, y1 = _SAFE + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + hw, hh = (x1 - x0) * 0.44, (y1 - y0) * 0.44 + ents = [{"type": "line", "p0": [cx - hw, cy - hh], "p1": [cx + hw, cy - hh]}, + {"type": "line", "p0": [cx + hw, cy - hh], "p1": [cx + hw, cy + hh]}, + {"type": "line", "p0": [cx + hw, cy + hh], "p1": [cx - hw, cy + hh]}, + {"type": "line", "p0": [cx - hw, cy + hh], "p1": [cx - hw, cy - hh]}] + # 300 square cut-outs in the LEFT half; the right half stays clear so the gesture below has + # somewhere to land that is not within snapping distance of a cut-out corner. + pitch_x, pitch_y = hw * 0.9 / 20.0, hh * 1.9 / 15.0 + side = min(pitch_x, pitch_y) * 0.4 + for i in range(20): + for j in range(15): + x = cx - hw * 0.95 + i * pitch_x + y = cy - hh * 0.95 + j * pitch_y + c = [(x, y), (x + side, y), (x + side, y + side), (x, y + side), (x, y)] + for k in range(4): + ents.append({"type": "line", "p0": list(c[k]), "p1": list(c[k + 1])}) + t0 = time.monotonic(); call("sketch_add", entities=ents); t_add = time.monotonic() - t0 + d0 = describe() + check("SCALE", len(d0["entities"]) == len(ents), f"{len(d0['entities'])} entities loaded " + f"in {t_add*1000:.0f} ms") + lp0 = d0["closed_loops"] + check("CLOSED", len(lp0) == 301, f"{len(lp0)} closed loops") + outer = max(range(len(lp0)), key=lambda i: abs(lp0[i]["area"])) + check("AREA", near(abs(lp0[outer]["area"]), 4.0 * hw * hh, 1e-9), + f"outer plate {abs(lp0[outer]['area']):.9f} vs {4.0*hw*hh:.9f}") + check("VOID", len(lp0[outer]["holes"]) == 300, + f"all {len(lp0[outer]['holes'])} cut-outs attributed to the plate") + check("AREA", all(near(abs(lp0[h]["area"]), side * side, 1e-9) for h in lp0[outer]["holes"]), + f"every cut-out is exactly {side:.6f} squared") + # Now the part that matters: draw ONE more entity by hand, on top of all that. + # + # The Escape is a WORKAROUND, not decoration: after a bulk sketch_add the next tool key and + # click are swallowed — the preview is drawn, its value field opens, and no entity is ever + # committed — until one Escape has been pressed. It is reachable only by mixing the socket + # into a live gesture session, which is exactly what this rung does. snaporca-j7gc; when that + # is fixed, delete this line and the rung must still pass. + key("Escape", 0.8) + key("l", 0.8) + global PACE + PACE = 6.0 # a thousand entities re-solve between fields + t0 = time.monotonic() + ax, ay = cx + hw * 0.15, cy + hh * 0.55 # clear of the grid, inside the plate + want_len = int(hw * 0.5) # a WHOLE number: see value() on separators + clickmm(ax, ay); clickmm(ax + want_len, ay) + value(want_len) + dl = describe() + say(f"after the typed length: solve_ok={dl['solve_ok']} constraints={dl['constraints']} " + f"dof={dl['dof']} entities={len(dl['entities'])}") + value(0) + t_draw = time.monotonic() - t0 + d = describe() + check("SCALE", len(d["entities"]) == len(ents) + 1, + f"the gesture added exactly one entity ({t_draw:.1f} s including four synthetic events)") + new = d["entities"][-1] + check("LENGTH", near(new["length"], float(want_len), 1e-9), + f"and it took its typed length exactly: {new['length']}") + ang = math.degrees(math.atan2(new["p1"][1] - new["p0"][1], new["p1"][0] - new["p0"][0])) % 360.0 + check("ANGLE", near(ang, 0.0, 1e-9) or near(ang, 360.0, 1e-9), f"and its typed angle: {ang}") + same = all(math.dist(a["p0"], b["p0"]) == 0.0 and math.dist(a["p1"], b["p1"]) == 0.0 + for a, b in zip(d0["entities"], d["entities"])) + check("VERTEX", same, "and moved none of the thousand entities already there") + PACE = 1.0 + leave_sketch() + + def reopen_sketch(): w, X, Y, _, _ = win() sh(f"DISPLAY={DISP} xdotool mousemove {X+TREE_ROW0[0]} {Y+TREE_ROW0[1]} " @@ -1013,12 +1109,13 @@ RUNGS = {"rect": rung_rect, "circle": rung_circle, "line": rung_line, "arc": run "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} + "feature_undo": rung_feature_undo, "roundtrip": rung_roundtrip, + "scale": rung_scale} def main(): want = sys.argv[1:] or list(RUNGS) - calibrate() + reset_document() for name in want: if name not in RUNGS: die(f"unknown rung {name}; have {' '.join(RUNGS)}") diff --git a/scripts/ladder-all.sh b/scripts/ladder-all.sh new file mode 100755 index 0000000000..52855135d4 --- /dev/null +++ b/scripts/ladder-all.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Every ladder, in one command, as the gate before a push that touched the Design tab. +# +# WHY A SCRIPT AND NOT CI. Three of the four rungs need a running application with an OpenGL +# canvas and synthetic input; GitHub's runners have neither. So the gate is local and explicit: +# run this, read the last line, and do not push a red one. The kernel suite is the only part CI +# can carry, and it already does. +# +# scripts/ladder-all.sh # kernel + engine + corpus (every 20th) + gestures +# FULL=1 scripts/ladder-all.sh # corpus over ALL 997 sheets (~25 min) +# SKIP_GUI=1 scripts/ladder-all.sh # kernel only, for a machine with no rig +# +# The rig container is expected to be up with the app running and SNAPORCA_MCP set; bring it up +# with scripts/gui-session.sh inside it. The corpus lives at /corpus in that container. +set -uo pipefail +cd "$(dirname "$0")/.." + +C="${C:-snaporca-gui}" +CORPUS="${CORPUS:-/corpus}" +STEP="${STEP:-20}" +[ -n "${FULL:-}" ] && STEP=1 +fail=0 + +step() { + local name="$1"; shift + echo + echo "=== $name ===" + if "$@"; then echo "--- $name OK"; else echo "--- $name FAILED"; fail=1; fi +} + +run_in_rig() { # copy the script in fresh, then run it there + docker cp "$1" "$C:/tmp/$(basename "$1")" >/dev/null || return 1 + shift + docker exec "$C" python3 "$@" +} + +step "kernel suite" scripts/kernel-test.sh --vol "${KVOL:-snaporca_kerneltest}" + +if [ -z "${SKIP_GUI:-}" ]; then + step "engine ladder (rungs 1-8, scripted geometry)" \ + run_in_rig scripts/sketch-ladder.py /tmp/sketch-ladder.py + step "corpus rung (real drawings, every ${STEP}th)" \ + run_in_rig scripts/ladder-corpus.py /tmp/ladder-corpus.py --corpus "$CORPUS" --step "$STEP" + step "corpus scale rung (the heaviest sheets)" \ + run_in_rig scripts/ladder-corpus.py /tmp/ladder-corpus.py --corpus "$CORPUS" --scale + step "gesture ladder (mouse and keyboard)" \ + run_in_rig scripts/gui-ladder.py /tmp/gui-ladder.py +fi + +echo +if [ "$fail" -eq 0 ]; then echo "ALL LADDERS HELD"; else echo "AT LEAST ONE LADDER FAILED"; fi +exit "$fail" diff --git a/scripts/ladder-corpus.py b/scripts/ladder-corpus.py index 83a51870f1..725f37a95c 100644 --- a/scripts/ladder-corpus.py +++ b/scripts/ladder-corpus.py @@ -33,6 +33,7 @@ import socket import subprocess import sys import tempfile +import time SOCK = os.environ.get("SNAPORCA_MCP", "/tmp/mcp.sock") TOL = 1e-6 # exact-comparison tolerance (all inputs are lines) @@ -339,11 +340,67 @@ def grade(pdf, name, report): return ok +# ── scale ──────────────────────────────────────────────────────────────────── +def grade_scale(pdf, name, report, budget): + """Same exactness, on a profile of several hundred entities, and timed. + + "Interactive" is measurable from here even though nothing is clicked: every MCP verb is + serviced on the UI THREAD, so the time a reply takes is time the window was not repainting. + A round trip that stays inside the budget is a window that stayed responsive. + """ + segs = drawing_segments(pdf) + loops = find_loops(segs) + if len(loops) < 2: + report(name, "SKIP", f"no nested closed geometry found ({len(loops)} loops)") + return None + loops.sort(key=shoelace, reverse=True) + outer = loops[1] + voids = [r for r in loops[2:] if shoelace(r) > 1.0 and point_in(r[0], outer)] + rings = [outer] + voids + ents = [] + for ring in rings: + for i in range(len(ring) - 1): + ents.append({"type": "line", + "p0": [ring[i][0], ring[i][1]], + "p1": [ring[i + 1][0], ring[i + 1][1]]}) + if len(ents) < 300: + report(name, "SKIP", f"only {len(ents)} entities — not a scale case") + return None + + try_call("sketch_cancel") + call("sketch_begin", plane="XY") + t0 = time.monotonic(); call("sketch_add", entities=ents); t_add = time.monotonic() - t0 + t0 = time.monotonic(); r = call("sketch_describe"); t_desc = time.monotonic() - t0 + t0 = time.monotonic(); call("sketch_select", entities=list(range(len(ents)))) + t_sel = time.monotonic() - t0 + t0 = time.monotonic(); call("sketch_validate"); t_val = time.monotonic() - t0 + + ok = True + ok &= report(name, "SCALE", f"{len(ents)} entities in {len(rings)} loops", True) + got = r["closed_loops"] + ok &= report(name, "CLOSED", f"engine finds {len(got)} closed loops, this script " + f"finds {len(rings)}", len(got) == len(rings)) + mine = sorted(shoelace(x) for x in rings) + theirs = sorted(abs(l["area"]) for l in got) + same = len(mine) == len(theirs) and all( + abs(a - b) <= max(1e-3, 1e-6 * a) for a, b in zip(mine, theirs)) + ok &= report(name, "AREA", "every loop area matches the shoelace value exactly", same) + worst = max(t_add, t_desc, t_sel, t_val) + ok &= report(name, "TIME", f"add {t_add*1000:.0f} ms, describe {t_desc*1000:.0f} ms, " + f"select {t_sel*1000:.0f} ms, validate {t_val*1000:.0f} ms " + f"(budget {budget*1000:.0f} ms)", worst <= budget) + return ok + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--corpus", default=os.path.expanduser("~/studycadcam")) ap.add_argument("--step", type=int, default=20) ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--scale", action="store_true", + help="grade the LARGEST drawings instead: exactness plus a UI-thread budget") + ap.add_argument("--budget", type=float, default=2.0, + help="seconds; the slowest round trip a scale drawing may take") a = ap.parse_args() files = {} @@ -351,8 +408,28 @@ def main(): m = re.search(r"MPD(\d+)", os.path.basename(f)) if m: files[int(m.group(1))] = f - picks = [files[n] for n in sorted(files) if n % a.step == 1] - if a.limit: + # step 1 means EVERY sheet. Written as `n % step == 1` it silently selected nothing, because + # n % 1 is always 0 — and the run then printed "RUNG 9 HELD" over zero drawings graded. A + # gate that passes by grading nothing is worse than no gate, so the count is checked below. + picks = [files[n] for n in sorted(files) if a.step <= 1 or n % a.step == 1] + if a.scale: + # The heaviest real profiles in the corpus, biggest first — up to ~1300 entities. + sized = [] + for f in files.values(): + try: + segs = drawing_segments(f) + loops = find_loops(segs) + if len(loops) < 2: + continue + loops.sort(key=shoelace, reverse=True) + outer = loops[1] + voids = [r for r in loops[2:] if shoelace(r) > 1.0 and point_in(r[0], outer)] + sized.append((sum(len(r) - 1 for r in [outer] + voids), f)) + except Exception: # noqa: BLE001 + continue + sized.sort(reverse=True) + picks = [f for _, f in sized[:max(1, a.limit or 6)]] + elif a.limit: picks = picks[:a.limit] print(f"corpus: {len(files)} sheets; systematic sample every {a.step}th " f"-> {len(picks)} drawings\n") @@ -372,7 +449,8 @@ def main(): for f in picks: name = re.search(r"MPD\d+", os.path.basename(f)).group(0) try: - r = grade(f, name, report) + r = (grade_scale(f, name, report, a.budget) if a.scale + else grade(f, name, report)) if r is not None: results.append((name, r)) except Exception as e: # noqa: BLE001 @@ -380,6 +458,9 @@ def main(): graded = len(results) passed = sum(1 for _, r in results if r) + if graded == 0: + print("\nNOTHING WAS GRADED — that is a harness failure, not a clean run", file=sys.stderr) + sys.exit(2) print(f"\ngraded {graded} drawings; {passed} fully clean, {graded - passed} with " f"at least one failure") if fails: diff --git a/src/libslic3r/CAD/SketchSolver.cpp b/src/libslic3r/CAD/SketchSolver.cpp index 7969b34098..94302c130b 100644 --- a/src/libslic3r/CAD/SketchSolver.cpp +++ b/src/libslic3r/CAD/SketchSolver.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include namespace Slic3r { @@ -53,9 +55,9 @@ inline int role_idx(Role r) { return int(r); } } // namespace -static SketchSolveResult solve_impl(std::vector& entities, - const std::vector& constraints, - int dragged_ei, Role dragged_role) +static SketchSolveResult solve_system(std::vector& entities, + const std::vector& constraints, + int dragged_ei, Role dragged_role) { SketchSolveResult out; if (constraints.empty()) { out.ok = true; out.dof = -1; return out; } @@ -376,6 +378,106 @@ static SketchSolveResult solve_impl(std::vector& entities, return out; } +// libslvs carries a COMPILE-TIME ceiling: solvespace.h declares `enum { MAX_UNKNOWNS = 1024 }` +// and sizes the System's param and equation arrays with it. solve_system() hands the solver every +// entity in the sketch, constrained or not, at 2 params per point — so a sketch of about 480 lines +// is the last one that fits, and the very next one comes back TOO_MANY_UNKNOWNS. +// +// What that did, before this: DesignSketchTool::try_add_constraints rolls the whole batch back +// when the solve fails, so the auto-constraint pass over a large sketch dropped EVERY constraint +// it had just inferred. Measured on the rig — 480 lines: 960 constraints, dof 480. 520 lines: +// 0 constraints, dof unknown. Nothing was said, and from there on no dimension and no constraint +// could ever be applied to that sketch, because each attempt re-solved the same oversized system +// and was rejected in turn. A typed length simply did nothing. +// +// Constraints only couple entities that SHARE a point, so a sketch is naturally a set of +// independent systems — a plate with 300 cut-outs is 301 little problems, not one big one. +// Solving them separately keeps every one of them far under the ceiling AND is faster, since the +// solver's work is superlinear in system size. +// +// The whole system is still tried FIRST, and this runs only on TOO_MANY_UNKNOWNS, so every sketch +// that fits today keeps its exact current behaviour, including its reported degrees of freedom. +// A genuinely over-constrained sketch still fails: the conflict lives inside one component and +// that component still rejects it. +static SketchSolveResult solve_partitioned(std::vector& entities, + const std::vector& constraints, + int dragged_ei, Role dragged_role) +{ + const int n = int(entities.size()); + std::vector parent(n); + for (int i = 0; i < n; ++i) parent[i] = i; + std::function find = [&](int a) { + while (parent[a] != a) { parent[a] = parent[parent[a]]; a = parent[a]; } + return a; + }; + auto unite = [&](int a, int b) { + if (a < 0 || b < 0 || a >= n || b >= n) return; + a = find(a); b = find(b); + if (a != b) parent[a] = b; + }; + for (const auto& c : constraints) { unite(c.ea, c.eb); unite(c.ea, c.ec); } + + // Group the constraints by the component they belong to. + std::map> groups; + for (size_t i = 0; i < constraints.size(); ++i) { + const int a = constraints[i].ea; + if (a < 0 || a >= n) continue; + groups[find(a)].push_back(int(i)); + } + + SketchSolveResult out; + out.ok = true; + out.dof = 0; + // Solve into COPIES and commit only if every component succeeded. The contract callers rely + // on is all-or-nothing — try_add_constraints rolls the batch back and expects the geometry it + // rolls back to be untouched — and partial writes would break it. + std::vector, std::vector>> solved; + for (const auto& [root, cidx] : groups) { + std::vector ents; // global indices, in order + std::map local; // global -> local + auto take = [&](int e) { + if (e < 0 || e >= n || local.count(e)) return; + local[e] = int(ents.size()); + ents.push_back(e); + }; + for (int ci : cidx) { take(constraints[ci].ea); take(constraints[ci].eb); take(constraints[ci].ec); } + std::vector sub; + sub.reserve(ents.size()); + for (int e : ents) sub.push_back(entities[e]); + std::vector subc; + subc.reserve(cidx.size()); + for (int ci : cidx) { + SketchEntityConstraintDef d = constraints[ci]; + auto map1 = [&](int& e) { e = (e >= 0 && local.count(e)) ? local[e] : -1; }; + map1(d.ea); map1(d.eb); map1(d.ec); + subc.push_back(d); + } + const int sub_drag = (dragged_ei >= 0 && local.count(dragged_ei)) ? local[dragged_ei] : -1; + SketchSolveResult r = solve_system(sub, subc, sub_drag, dragged_role); + if (!r.ok) { + out.ok = false; + out.result = r.result; + for (int bi : r.bad) + if (bi >= 0 && bi < int(cidx.size())) out.bad.push_back(cidx[bi]); + } + if (r.dof > 0) out.dof += r.dof; + solved.emplace_back(std::move(ents), std::move(sub)); + } + if (!out.ok) return out; + for (auto& [ents, sub] : solved) + for (size_t k = 0; k < ents.size(); ++k) entities[ents[k]] = sub[k]; + return out; +} + +static SketchSolveResult solve_impl(std::vector& entities, + const std::vector& constraints, + int dragged_ei, Role dragged_role) +{ + SketchSolveResult out = solve_system(entities, constraints, dragged_ei, dragged_role); + if (out.ok || out.result != SLVS_RESULT_TOO_MANY_UNKNOWNS) return out; + return solve_partitioned(entities, constraints, dragged_ei, dragged_role); +} + SketchSolveResult sketch_solve(std::vector& entities, const std::vector& constraints) { diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 6731eff6ee..6193c8e7a9 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -2353,8 +2353,13 @@ void DesignSketchTool::infer_auto_constraints(int base, double ang_tol_rad, doub } try_add_constraints(coincs); // co-located points: consistent by construction - // 2) Horizontal / Vertical on axis-aligned new line segments (added one at a time - // so a single conflict never drops the others). + // 2) Horizontal / Vertical on axis-aligned new line segments. Tried as ONE batch first and + // only then one at a time, which is the same outcome — a single conflict never drops the + // others — for one solve instead of n. That matters now that large sketches actually + // solve: a bulk add of 1200 axis-aligned segments used to be fast only because every + // solve failed instantly on the unknown limit, and once they started succeeding the + // per-constraint loop turned into 1200 solves and blew the MCP main-thread budget. + std::vector axes; for (int i = base; i < n; ++i) { if (m_entities[i].type != SketchEntity::Type::Line) continue; auto ax = infer_axis_constraint(m_entities[i].p0, m_entities[i].p1, ang_tol_rad); @@ -2363,8 +2368,10 @@ void DesignSketchTool::infer_auto_constraints(int base, double ang_tol_rad, doub c.type = *ax; c.ea = i; c.ra = SketchPointRole::P0; c.eb = i; c.rb = SketchPointRole::P1; - try_add_constraints({ c }); + axes.push_back(c); } + if (!try_add_constraints(axes)) + for (const auto& c : axes) try_add_constraints({ c }); resolve_live(); } diff --git a/tests/libslic3r/test_slvs_constraints.cpp b/tests/libslic3r/test_slvs_constraints.cpp index 2692ae5ace..600ca9fe2a 100644 --- a/tests/libslic3r/test_slvs_constraints.cpp +++ b/tests/libslic3r/test_slvs_constraints.cpp @@ -112,3 +112,50 @@ TEST_CASE("slvs: over-constrained / inconsistent is detected", "[slvs]") auto res = sketch_solve(ents, cons); CHECK_FALSE(res.ok); // SLVS_RESULT_INCONSISTENT } + +// snaporca-yww4. libslvs sizes its System with a compile-time `MAX_UNKNOWNS = 1024`, and the +// solver is handed every entity in the sketch at 2 params per point — so a sketch of about 480 +// lines is the last one that fits and the next comes back TOO_MANY_UNKNOWNS. Because +// try_add_constraints rolls a failed batch back, that turned into: every auto-inferred constraint +// on a large sketch silently dropped, and from then on no dimension could ever be applied to it. +// Constraints only couple entities that share a point, so the sketch is solved component by +// component when the whole system does not fit. +TEST_CASE("slvs: a sketch past the solver's unknown limit still solves", "[slvs]") +{ + // 300 disjoint squares: 1200 lines, 4800 unknowns whole, 8 per component. + const int N = 300; + std::vector ents; + std::vector cons; + for (int i = 0; i < N; ++i) { + const double x = (i % 30) * 10.0, y = (i / 30) * 10.0; + const int b = int(ents.size()); + ents.push_back(line({x, y}, {x + 4.0, y})); + ents.push_back(line({x + 4.0, y}, {x + 4.0, y + 4.0})); + ents.push_back(line({x + 4.0, y + 4.0}, {x, y + 4.0})); + ents.push_back(line({x, y + 4.0}, {x, y})); + for (int k = 0; k < 4; ++k) + cons.push_back(con(CT::Coincident, b + k, R::P1, b + (k + 1) % 4, R::P0)); + } + REQUIRE(ents.size() == size_t(4 * N)); + + std::vector before = ents; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + for (size_t i = 0; i < ents.size(); ++i) { // already satisfied: nothing may move + CHECK(ents[i].p0.x() == Approx(before[i].p0.x()).margin(1e-9)); + CHECK(ents[i].p0.y() == Approx(before[i].p0.y()).margin(1e-9)); + CHECK(ents[i].p1.x() == Approx(before[i].p1.x()).margin(1e-9)); + CHECK(ents[i].p1.y() == Approx(before[i].p1.y()).margin(1e-9)); + } + + // And a dimension typed onto one of them lands exactly, which is what stopped working. + cons.push_back(con(CT::Distance, 0, R::P0, 0, R::P1, 7.0)); + auto res2 = sketch_solve(ents, cons); + REQUIRE(res2.ok); + CHECK((ents[0].p1 - ents[0].p0).norm() == Approx(7.0).margin(1e-9)); + + // A conflict inside ONE component must still be caught, not swallowed by the split. + cons.push_back(con(CT::Distance, 0, R::P0, 0, R::P1, 99.0)); + auto res3 = sketch_solve(ents, cons); + CHECK_FALSE(res3.ok); +}