gesture rungs for the six new constraint buttons, and the DistanceX/Y bug they found

Port of snaporca 2951e3c60b; parity holds (17 identical, DesignPanel.cpp still exactly 32
divergent lines, so the hunk landed on the right side of the DropDown divergence).

The sketch-constraint epic added six toolbar buttons and covered all six with kernel
tests. Not one of them was ever clicked. The gesture ladder only pressed 'perpendicular'
and 'equal' -- and CON_BTN, which locates buttons by index, was silently wrong for every
entry past index 5 for the whole epic. The untested half of the toolbar was exactly the
broken half (snaporca-rqsy).

Four rungs now drive them: D4 Equal on two circles (must mean equal RADIUS, not the
equal-length no-op the epic fixed), D5 Collinear on two oblique lines, D6 a horizontal
distance, D7 symmetric about the implicit vertical axis. Full ladder 118/118 on snaporca;
this fork's rig has not been rebuilt against the change yet, so here it is reviewed,
parity-checked and NOT exercised.

D6 found a shipped defect. apply_entity_constraint enumerated {P0,p0},{P1,p1} for BOTH
entities regardless of type, but a Point's p1 is unused and reads (0,0), as does a
Circle's. The closest-pair search then picked those two phantom origins, distance 0: the
field opened pre-filled 0.00 and the solver dropped the constraint, because ptOf(Point,P1)
resolves to no handle. Nothing errored -- the dimension simply did nothing. ends_of() now
enumerates only the roles an entity actually exposes, and the pair with no point at all is
refused with a message instead of a silent no-op.

Five kernel tests, a 7/7 ladder, a review and a fork port all passed over this, because
every one of them exercises the kernel, where the geometry was always right.

Two rig faults fixed in the same pass, both of which produce a green-looking session that
tests nothing: start-headless-gui.sh never exported SNAPORCA_MCP or SNAPORCA_KEYTRACE, so
a freshly launched rig comes up healthy and every ladder dies on "Connection refused"; and
it never dismissed the "Restore" dialog a killed session leaves behind, which grabs every
synthetic click afterwards.

The value field also takes no keyboard focus from the WM -- typed digits go to the canvas
and Return commits the pre-filled number (typed 40, got 54.94). focus_field() finds it as
its own top-level window and clicks it first. The no-op tolerance is now 5e-3, the field's
own two-decimal display resolution, not 1e-6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
This commit is contained in:
Tommaso Bianchi
2026-08-31 10:40:38 +02:00
co-authored by Claude Opus 5
parent d0791c3b8a
commit 648b930e75
3 changed files with 281 additions and 10 deletions
+235 -1
View File
@@ -308,6 +308,48 @@ def calibrate_here():
PACE = 1.0
def field_win():
"""The open in-canvas value field as (x, y, w, h) in SCREEN pixels, or None.
It is a top-level window of its own, not a child of the canvas (a native child cannot be
composited over the double-buffered wxGLCanvas), so it is found by enumerating windows rather
than by looking inside the app's frame. Two other small top-levels exist: the status chip,
which lives on the bottom edge, and 1x1/10x10 helpers.
"""
_, X, Y, W, H = win()
for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --class '.'").split():
g = dict(l.split("=", 1) for l in
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
if "=" in l)
if "WIDTH" not in g:
continue
x, y, ww, hh = int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"])
if ww >= W or hh < 24 or hh > 120 or ww < 40:
continue
if y > Y + H - 80: # the status chip, pinned to the bottom edge
continue
return (x, y, ww, hh)
return None
def focus_field():
"""Put the keyboard in the value field, by clicking it.
WITHOUT THIS THE TYPED VALUE IS SILENTLY DISCARDED. The field is shown and raised but the
window manager does not give it the keyboard, so xdotool's digits go to the canvas and Return
commits the value the field opened with — the pre-filled as-drawn number. The failure is
invisible from the outside: a constraint IS created, the solve succeeds, and the sketch simply
holds the dimension you did not ask for (typed 40, got 54.94). One click fixes it.
"""
r = field_win()
if r is None:
return False
x, y, w, h = r
xdo(f"mousemove {x + w // 2} {y + h // 2} click --delay 120 1")
time.sleep(0.3)
return True
def value(v, pause=0.6):
"""Type one number into the open in-canvas field and commit it.
@@ -316,6 +358,7 @@ def value(v, pause=0.6):
appended to it.
"""
time.sleep(0.25 * PACE)
focus_field()
key("ctrl+a", 0.15)
typ(str(v), 0.25)
key("Return", pause * PACE)
@@ -941,6 +984,194 @@ def angle_between(a, b):
# =================================================================== DURABILITY
# Exactness that does not survive an undo or a save is not exactness.
def rim(e, deg=135.0):
"""A point on a circle's rim, away from +X.
NOT the +X point: a click there grabs the RADIUS GRIP instead of selecting the circle,
and a grip click REPLACES the selection with that one entity — so the second pick of a
two-entity constraint silently discards the first.
"""
a = math.radians(deg)
return (e["center"][0] + e["radius"] * math.cos(a),
e["center"][1] + e["radius"] * math.sin(a))
def rung_equal_radius():
reset_document()
print("\nD4 constrain — Equal on two CIRCLES means equal radius, not equal length")
# The dead end this fixed: two circles + Equal used to emit EQUAL_LENGTH_LINES, which
# constrains nothing on a curve. The user got a silent no-op with no error and no way to
# tell why. One Equal button, two meanings — lines get length, curves get radius.
# NOT dimensioned: a typed radius is a DRIVING Radius constraint, and EqualRadius on two
# circles pinned to 15 and 25 is genuinely inconsistent -- the kernel refuses the addition
# and is right to. Draw them at different sizes and leave the radius free.
enter_sketch("c")
clickmm(-35, 0); clickmm(-20, 0); key("Escape", 0.7)
key("c", 0.6)
clickmm(35, 0); clickmm(60, 0); key("Escape", 0.7)
d0 = describe()
cs = [e for e in d0["entities"] if e["type"] == "circle"]
check("ARC", len(cs) == 2 and not near(cs[0]["radius"], cs[1]["radius"], 1e-6),
f"they start unequal: {[round(c['radius'], 6) for c in cs]}")
key("k", 1.5)
d1 = describe()
cs = [e for e in d1["entities"] if e["type"] == "circle"]
ia, ib = d1["entities"].index(cs[0]), d1["entities"].index(cs[1])
clickmm(*rim(cs[0])); clickmm(*rim(cs[1]))
click(*CON_BTN["equal"]) # the SHARED Equal button, not design_c_equal_radius
time.sleep(1.0)
d = describe()
ra, rb = d["entities"][ia]["radius"], d["entities"][ib]["radius"]
check("ARC", near(ra, rb, 1e-9), f"the two radii are now equal: {ra:.9f} and {rb:.9f}")
check("ARC", ra > 1e-6, f"and not equal at zero: {ra:.9f}")
d2 = confirm_and_reopen()
check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0,
f"{d2['constraints']} constraints survived the commit")
rs = sorted(round(e["radius"], 9) for e in d2["entities"] if e["type"] == "circle")
check("ARC", len(rs) == 2 and near(rs[0], rs[-1], 1e-9),
f"still equal after the round trip: {rs}")
leave_sketch()
def rung_collinear():
reset_document()
print("\nD5 constrain — two offset lines brought onto one infinite line")
# Oblique on purpose: axis-aligned segments pick up an auto Horizontal at draw time, and
# this rung is about Collinear, not about interacting with inference.
enter_sketch("l")
clickmm(-60, -14); clickmm(-12, -6)
key("Escape", 0.7); key("Escape", 0.7)
key("l", 0.6)
clickmm(12, 14); clickmm(60, 22)
key("Escape", 0.7); key("Escape", 0.7)
d0 = describe()
ls = [e for e in d0["entities"] if e["type"] == "line"]
check("VERTEX", len(ls) == 2 and abs(ls[0]["p0"][1] - ls[1]["p0"][1]) > 1.0,
f"they start on different lines, dy = {abs(ls[0]['p0'][1] - ls[1]['p0'][1]):.6f}")
key("k", 1.5)
d1 = describe()
ls = [e for e in d1["entities"] if e["type"] == "line"]
ia, ib = d1["entities"].index(ls[0]), d1["entities"].index(ls[1])
clickmm(*mid(ls[0])); clickmm(*mid(ls[1]))
click(*CON_BTN["collinear"])
time.sleep(1.0)
d = describe()
A, B = d["entities"][ia], d["entities"][ib]
ax, ay = A["p0"]
dx, dy = A["p1"][0] - ax, A["p1"][1] - ay
cross = [dx * (q[1] - ay) - dy * (q[0] - ax) for q in (B["p0"], B["p1"])]
check("VERTEX", all(abs(c) < 1e-6 for c in cross),
f"both ends of the second line lie on the first: cross {[round(c, 9) for c in cross]}")
# A line collapsed to a point is trivially collinear with anything, so the cross products
# above pass on a degenerate solve. Both lines have to survive.
check("LENGTH", A["length"] > 1.0 and B["length"] > 1.0,
f"neither line collapsed: {A['length']:.6f}, {B['length']:.6f}")
d2 = confirm_and_reopen()
check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0,
f"{d2['constraints']} constraints survived the commit")
leave_sketch()
def rung_distance_xy():
reset_document()
print("\nD6 constrain — horizontal distance, and accepting its own value moves nothing")
enter_sketch("p")
clickmm(-30, -20)
key("p", 0.6)
clickmm(25, 18)
# A THIRD point, placed now while the point tool is still armed. The typed half of this rung
# needs a pair that carries no dimension yet, and pressing "p" again once the constrain tool
# has taken over does not re-arm the point tool -- the click is consumed as a pick and no
# point appears, which read as an IndexError three lines later rather than as what it was.
key("p", 0.6)
clickmm(5, 5)
d0 = describe()
ps = [e for e in d0["entities"] if e["type"] == "point"]
check("VERTEX", len(ps) == 3, f"three points placed: {len(ps)}")
key("k", 1.5)
d1 = describe()
ps = [e for e in d1["entities"] if e["type"] == "point"]
ia, ib = d1["entities"].index(ps[0]), d1["entities"].index(ps[1])
ic = d1["entities"].index(ps[2])
# THE NO-OP PROPERTY, which is the one a unit test cannot reach: the field opens pre-filled
# with the current projected gap, and pressing Return must change nothing. The constraint is
# SIGNED — (pB - pA).dot(axis) — so if the refs were ordered to show |delta| while the real
# delta is negative, accepting the number on screen teleports the point across its anchor.
before = [list(d1["entities"][ia]["p"]), list(d1["entities"][ib]["p"])]
clickmm(*ps[0]["p"]); clickmm(*ps[1]["p"])
click(*CON_BTN["dist_x"])
time.sleep(0.8)
key("Return", 1.2) # accept the pre-filled value, type nothing
d = describe()
after = [list(d["entities"][ia]["p"]), list(d["entities"][ib]["p"])]
moved = max(abs(a - b) for pa, pb in zip(before, after) for a, b in zip(pa, pb))
# 5 microns, not zero: the field shows two decimals, so accepting what it shows commits a
# ROUNDED value and the geometry legitimately shifts by up to half a displayed unit. The
# defect this guards is a sign flip, which moves the point by twice the gap — tens of
# millimetres. A 1e-6 tolerance here failed on a 0.96 micron rounding step, which is the
# instrument disagreeing with the display, not the app misbehaving.
check("VERTEX", moved < 5e-3,
f"accepting the shown value moved nothing (max {moved:.9f})")
# "nothing moved" passes just as happily when the constraint was never applied at all --
# which is exactly how the phantom-endpoint bug hid. The gap has to be REAL and driveable,
# so prove the mechanism works before trusting the no-op above.
check("LENGTH", abs(d["entities"][ib]["p"][0] - d["entities"][ia]["p"][0]) > 1.0,
"and the two points still have a real horizontal gap to dimension")
# Now drive it with a TYPED value, on a FRESH pair. Not on the pair just dimensioned: that
# one already carries its DistanceX, the button rightly refuses to dimension it twice, and no
# field opens — so the digits went nowhere and the gap kept the value the no-op step had
# accepted. A green "gap is 54.94" for a rung that typed 40 is the rung's fault, not the app's.
dy_before = d["entities"][ic]["p"][1] - d["entities"][ia]["p"][1]
clickmm(*d["entities"][ia]["p"]); clickmm(*d["entities"][ic]["p"])
click(*CON_BTN["dist_x"])
time.sleep(0.8)
values(40)
d = describe()
gx = abs(d["entities"][ic]["p"][0] - d["entities"][ia]["p"][0])
gy = d["entities"][ic]["p"][1] - d["entities"][ia]["p"][1]
check("LENGTH", near(gx, 40.0, 1e-6), f"horizontal gap driven to {gx:.9f}")
check("VERTEX", near(gy, dy_before, 1e-6),
f"the vertical gap is untouched at {gy:.9f} — this is not a straight-line distance")
d2 = confirm_and_reopen()
check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0,
f"{d2['constraints']} constraints survived the commit")
leave_sketch()
def rung_symmetric_axis():
reset_document()
print("\nD7 constrain — symmetric about the vertical axis, with no construction line")
# Plain Symmetric needs a third pick for the mirror axis, so being symmetric about the
# sketch's own vertical axis used to mean drawing a construction line first. This button
# uses the implicit axis: two picks, no axis entity.
enter_sketch("p")
clickmm(-40, 15)
key("p", 0.6)
clickmm(12, 15)
key("k", 1.5)
d1 = describe()
ps = [e for e in d1["entities"] if e["type"] == "point"]
ia, ib = d1["entities"].index(ps[0]), d1["entities"].index(ps[1])
check("VERTEX", not near(abs(ps[0]["p"][0]), abs(ps[1]["p"][0]), 1e-3),
f"they start unmirrored: x = {ps[0]['p'][0]:.6f}, {ps[1]['p'][0]:.6f}")
clickmm(*ps[0]["p"]); clickmm(*ps[1]["p"])
click(*CON_BTN["sym_v"])
time.sleep(1.0)
d = describe()
xa, xb = d["entities"][ia]["p"][0], d["entities"][ib]["p"][0]
ya, yb = d["entities"][ia]["p"][1], d["entities"][ib]["p"][1]
check("VERTEX", near(xa, -xb, 1e-9), f"mirrored across x = 0: {xa:.9f} and {xb:.9f}")
# Both collapsing onto the axis satisfies the mirror trivially.
check("VERTEX", abs(xa) > 1e-6, f"and not both collapsed onto the axis: |x| = {abs(xa):.9f}")
check("VERTEX", near(ya, yb, 1e-6), f"y untouched on both: {ya:.6f}, {yb:.6f}")
d2 = confirm_and_reopen()
check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0,
f"{d2['constraints']} constraints survived the commit")
leave_sketch()
def rung_undo():
print("\nE1 undo — the last entity goes, the rest do not move")
enter_sketch("l")
@@ -1203,7 +1434,10 @@ RUNGS = {"rect": rung_rect, "circle": rung_circle, "line": rung_line, "arc": run
"fillet": rung_fillet, "chamfer": rung_chamfer, "offset": rung_offset,
"mirror": rung_mirror, "mirror_arcs": rung_mirror_arcs, "trim": rung_trim, "extend": rung_extend,
"dimension": rung_dimension, "constrain": rung_constrain,
"perpendicular": rung_perpendicular, "undo": rung_undo,
"perpendicular": rung_perpendicular,
"equal_radius": rung_equal_radius, "collinear": rung_collinear,
"distance_xy": rung_distance_xy, "symmetric_axis": rung_symmetric_axis,
"undo": rung_undo,
"feature_undo": rung_feature_undo, "roundtrip": rung_roundtrip,
"scale": rung_scale}
+12 -1
View File
@@ -31,6 +31,13 @@ LOG="${LOG:-/tmp/gui-session.log}"
export DISPLAY="$DISP" HOME=/root
export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
# The ladders read the document back through the MCP socket, and the offer ladder's only
# instrument is the [OFFER] keytrace. Neither is on by default, and a session launched without
# them comes up looking perfectly healthy: the window is there, status says app up, and every
# ladder then dies on "Connection refused" — which reads as a dead app rather than a rig that was
# started without its instrument. The script that launches the rig is where they belong.
export SNAPORCA_MCP="${SNAPORCA_MCP:-/tmp/mcp.sock}"
export SNAPORCA_KEYTRACE="${SNAPORCA_KEYTRACE:-1}"
export LD_LIBRARY_PATH="$LIBPY:$LIBPY2:${LD_LIBRARY_PATH:-}"
mkdir -p /root/.config # startup dies in boost::filesystem::create_directory without this
@@ -121,7 +128,11 @@ close_dialog() {
sleep 2
return 0
}
for name in "Setup Wizard" "New version"; do
# "Restore" is not a first-RUN dialog, it is a second-run one: killing the app mid-session leaves
# unsaved items behind, and the next launch asks whether to restore them. It sits over the tab bar
# with a modal grab, so every synthetic click afterwards lands on it and the ladder reports
# geometry that never got drawn — that is the "success with no log" shape twice already.
for name in "Setup Wizard" "New version" "Restore"; do
for _ in 1 2 3; do close_dialog "$name" || break; done
done
+34 -8
View File
@@ -7785,15 +7785,41 @@ void DesignPanel::apply_entity_constraint(SketchConstraintType type)
// the typed value (same deferred pattern as Angle).
const SketchEntity& A = feat.entities[e0];
const SketchEntity& B = feat.entities[e1];
const std::pair<R, Vec2d> aps[2] = {{R::P0, A.p0}, {R::P1, A.p1}};
const std::pair<R, Vec2d> bps[2] = {{R::P0, B.p0}, {R::P1, B.p1}};
R ra = R::P1, rb = R::P0;
Vec2d pa = A.p1, pb = B.p0;
// ONLY the roles an entity actually exposes. A Point's p1 is unused and reads (0,0),
// and a Circle's likewise -- enumerate {P0,p0},{P1,p1} blindly and the closest-pair
// search below picks those two phantom origins, distance 0. The field then opens
// pre-filled 0.00 and the solver drops the whole constraint, because ptOf(Point, P1)
// resolves to no handle and ref_ok fails. Nothing errors; the dimension just does
// nothing. Same role set as roles_of() in DesignSketchTool::heal_coincidences.
auto ends_of = [](const SketchEntity& e, std::pair<R, Vec2d> out[2]) -> int {
using ET = SketchEntity::Type;
switch (e.type) {
case ET::Line: case ET::Arc: case ET::BSpline: case ET::EllipseArc:
out[0] = {R::P0, e.p0}; out[1] = {R::P1, e.p1}; return 2;
case ET::Point:
out[0] = {R::P0, e.p0}; return 1;
case ET::Circle: case ET::Ellipse:
out[0] = {R::Center, e.center}; return 1;
}
return 0;
};
std::pair<R, Vec2d> aps[2], bps[2];
const int na = ends_of(A, aps), nb = ends_of(B, bps);
if (na == 0 || nb == 0) {
fail(_L("This dimension needs two entities with a point to measure between"));
return;
}
R ra = aps[0].first, rb = bps[0].first;
Vec2d pa = aps[0].second, pb = bps[0].second;
double best = 1e30;
for (const auto& ap : aps)
for (const auto& bp : bps) {
const double d = (ap.second - bp.second).squaredNorm();
if (d < best) { best = d; ra = ap.first; rb = bp.first; pa = ap.second; pb = bp.second; }
for (int i = 0; i < na; ++i)
for (int j = 0; j < nb; ++j) {
const double d = (aps[i].second - bps[j].second).squaredNorm();
if (d < best) {
best = d;
ra = aps[i].first; rb = bps[j].first;
pa = aps[i].second; pb = bps[j].second;
}
}
// The constraint is SIGNED: PROJ_PT_DISTANCE fixes (pB - pA).dot(axis), not its
// magnitude. Showing |delta| while the current signed delta is negative would mean