Port from snaporca: the solver's 1024-unknown cliff, and the scale rungs

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
This commit is contained in:
Tommaso Bianchi
2026-08-23 02:04:03 +02:00
parent 82db99f337
commit 4693542d0d
6 changed files with 408 additions and 22 deletions
+110 -13
View File
@@ -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)}")
+52
View File
@@ -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"
+84 -3
View File
@@ -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:
+105 -3
View File
@@ -4,6 +4,8 @@
#include <cmath>
#include <cstring>
#include <functional>
#include <map>
#include <unordered_map>
namespace Slic3r {
@@ -53,9 +55,9 @@ inline int role_idx(Role r) { return int(r); }
} // namespace
static SketchSolveResult solve_impl(std::vector<SketchEntity>& entities,
const std::vector<SketchEntityConstraintDef>& constraints,
int dragged_ei, Role dragged_role)
static SketchSolveResult solve_system(std::vector<SketchEntity>& entities,
const std::vector<SketchEntityConstraintDef>& 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<SketchEntity>& 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<SketchEntity>& entities,
const std::vector<SketchEntityConstraintDef>& constraints,
int dragged_ei, Role dragged_role)
{
const int n = int(entities.size());
std::vector<int> parent(n);
for (int i = 0; i < n; ++i) parent[i] = i;
std::function<int(int)> 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<int, std::vector<int>> 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::pair<std::vector<int>, std::vector<SketchEntity>>> solved;
for (const auto& [root, cidx] : groups) {
std::vector<int> ents; // global indices, in order
std::map<int, int> 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<SketchEntity> sub;
sub.reserve(ents.size());
for (int e : ents) sub.push_back(entities[e]);
std::vector<SketchEntityConstraintDef> 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<SketchEntity>& entities,
const std::vector<SketchEntityConstraintDef>& 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<SketchEntity>& entities,
const std::vector<SketchEntityConstraintDef>& constraints)
{
+10 -3
View File
@@ -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<SketchEntityConstraintDef> 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();
}
+47
View File
@@ -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<SketchEntity> ents;
std::vector<SketchEntityConstraintDef> 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<SketchEntity> 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);
}