mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 14:32:36 +00:00
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:
+110
-13
@@ -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)}")
|
||||
|
||||
Executable
+52
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user