Constrain while you sketch, and stop losing work to Esc and to invisible points

Five defects from ten minutes of real use, and the mode split behind the worst
of them. One commit because the changes overlap in the same functions; the
pieces are separable in the diff, not in the file.

CONSTRAINING NO LONGER NEEDS A COMMITTED SKETCH. A constraint could only be
applied by committing the sketch, selecting it in the feature tree, pressing the
padlock, and only then picking. While drawing, the CONSTRAIN toolbar was not even
on screen (set_ui_mode showed it in UiMode::Constrain alone) and apply_constraint
answered "Press Constrain on a sketch first" -- in a status line nobody looks at.
Draw two lines, press Parallel, get nothing: that is what a user reported as "the
UX is a mess", and they were right.

apply_constraint now takes the live session first, reading the picks from the
selection model the sketch tool already had (click, ctrl-click to extend,
double-click for the loop) and applying through try_add_constraints, which
already did append -> solve -> keep-or-rollback. The committed Constrain path
stays for editing an old sketch; it is no longer the only way in. The twenty
constraint buttons now show in Sketch mode as well.

The discriminator is is_sketching() && !is_constraining() &&
!is_constraining_entities(). Both begin_constrain and begin_constrain_entities
set m_active, so is_sketching() alone is true DURING a constrain session and the
new path would hijack the old one -- compiling perfectly and failing in
behaviour.

ONE PLANNER, NOT TWO. A second caller meant duplicating the logic that decides
whether a constraint is legal, which roles it binds and whether it needs a typed
value. That duplication is how today's Coincident bug survived: fixed in one
branch, alive in the next one down. plan_entity_constraint() now lives in the
kernel -- pure, no wx, no translation -- and both UI paths call it. DesignPanel
loses 304 lines and gains 155.

Being in the kernel makes it TESTABLE. The Parallel defect existed because a
constraint type met an entity type nobody had tried, and the only instrument was
a 13-minute GUI ladder. 19 new kernel cases cover the matrix: 264 -> 283 cases,
7648 -> 7867 assertions.

Parallel, Perpendicular and EqualLength gain the two-line guard they never had.
On non-lines they used to emit a def the solver silently dropped -- the sketch
reported itself constrained when it was not, the same class as Horizontal on a
Point. EqualLength on two rounds still promotes to EqualRadius first.

Symmetric is planned completely, including its axis pick: the plan carries a
VECTOR of defs because Symmetric on two lines is two constraints (P0/P0 and
P1/P1). A single def would have half-applied it -- one end pinned, one free,
looking correct until something moves.

A PLACED POINT SURVIVES THE COMMIT. Type::Point was created correctly and never
drawn once committed: both renderers skip it, correctly, since entity_polyline
gives a point nothing. What was missing is the vertex-marker path the live
session already used. rung_point passed throughout because it asserts the
document, and the point was always in the document -- the pixels lied.

ESC STOPS EATING AN UNSAVED SKETCH. The third press reached cancel_sketch(),
clearing m_entities with no warning and nothing to undo. live_sketch_has_work()
existed and was never consulted. The exit layer refuses once when there is work
and lets a second consecutive Esc through; the refusal re-arms on a button press,
never on mouse motion, or Esc could never exit while the hand moves.

TWO NEW RUNGS. D10 drives Parallel through the committed path -- it passes on
the PRE-fix binary, which is how we know the user's failure was the mode and not
the constraint. D11 is the acceptance for the collapse: draw, pick both, press
Parallel, no commit and no padlock. Ladder 126 -> 135 properties, all holding.

CON_BTN_SKETCH is measured, not derived: in Sketch mode the group renders after
the sketch toolbar, so the first button is at 677, not 449. Pitch 42, twenty
buttons, read off a screenshot. Deriving it by offset is how that table drifted
the last time.

Known limit, commented at the call site: a constraint added to a LIVE sketch is
not on the document undo stack, so Ctrl+Z will not take it back until the sketch
is committed.

snaporca-itp4, snaporca-oyhx, snaporca-l2vm
This commit is contained in:
Tommaso Bianchi
2026-08-31 22:12:26 +02:00
parent cd30fb891e
commit 8f3f835636
10 changed files with 870 additions and 306 deletions
+92
View File
@@ -111,6 +111,18 @@ def click(px, py, pause=0.45, btn=1):
time.sleep(pause)
def click_ctrl(px, py, pause=0.45):
"""Ctrl+click: EXTEND the sketch selection instead of replacing it.
A plain second click clears the first pick (DesignSketchTool's Select branch only keeps
one entity unless `extend` is set), so a two-entity constraint driven by two plain clicks
silently arrives with one pick and is rejected for the wrong reason.
"""
_, X, Y, _, _ = win()
xdo(f"keydown ctrl mousemove {X+int(px)} {Y+int(py)} click --delay 120 1 keyup ctrl")
time.sleep(pause)
def move(px, py, pause=0.2):
_, X, Y, _, _ = win()
xdo(f"mousemove {X+int(px)} {Y+int(py)}")
@@ -870,6 +882,16 @@ CON_BTN = {n: (449 + 42 * i, CON_BTN_Y) for i, n in enumerate(
"equal_radius", "collinear", "concentric", "tangent", "midpoint", "symmetric",
"sym_v", "sym_h", "angle", "radius", "diameter", "fix", "dist_x", "dist_y"])}
# The SAME twenty buttons, at their SKETCH-mode x. Constraining during a sketch put the group
# after the (wide) sketch toolbar instead of at the start of an otherwise empty row, so every
# button sits 228 px further right. Measured, not derived: the strip was screenshotted in sketch
# mode and the icon columns detected — first centre 677, pitch 42, twenty of them. Deriving it
# from the Constrain-mode map is exactly how this table drifted the last time.
CON_BTN_SKETCH = {n: (677 + 42 * i, CON_BTN_Y) for i, n in enumerate(
["horizontal", "vertical", "parallel", "perpendicular", "coincident", "equal",
"equal_radius", "collinear", "concentric", "tangent", "midpoint", "symmetric",
"sym_v", "sym_h", "angle", "radius", "diameter", "fix", "dist_x", "dist_y"])}
def draw_rect_undimensioned():
"""A rectangle by two clicks, with both queued value fields dismissed (Esc keeps it as drawn)."""
@@ -996,6 +1018,75 @@ def angle_between(a, b):
return math.degrees(math.acos(max(-1.0, min(1.0, c))))
def parallel_gap(a, b):
"""How far from parallel, in degrees. Parallel reads 0 or 180; both mean parallel."""
ang = angle_between(a, b)
return min(ang, 180.0 - ang)
def rung_parallel():
reset_document()
print("\nD10 constrain — two divergent lines made parallel (the button had no rung at all)")
enter_sketch("l")
clickmm(-50, -30); clickmm(30, -18)
key("Escape", 0.7); key("Escape", 0.7)
key("l", 0.6)
# Well outside inference's 3 deg snap, for the same reason D3 starts at 56: a pair that
# arrives already parallel would let a dead button pass the rung.
clickmm(30, -18); clickmm(55, 35)
key("Escape", 0.7); key("Escape", 0.7)
d0 = describe()
g0 = parallel_gap(d0["entities"][0], d0["entities"][1])
check("ANGLE", g0 > 1e-3, f"they start {g0:.6f} deg from parallel")
key("k", 1.5)
d1 = describe()
clickmm(*mid(d1["entities"][0])); clickmm(*mid(d1["entities"][1]))
click(*CON_BTN["parallel"])
time.sleep(1.0)
d = describe()
g = parallel_gap(d["entities"][0], d["entities"][1])
check("ANGLE", near(g, 0.0, 1e-6), f"now {g:.9f} deg from parallel")
check("LENGTH", d["entities"][0]["length"] > 1.0 and d["entities"][1]["length"] > 1.0,
f"neither line collapsed: {d['entities'][0]['length']:.6f}, {d['entities'][1]['length']:.6f}")
d2 = confirm_and_reopen()
check("CLOSED", d2["constraints"] > 0 and d2["solve_ok"],
f"{d2['constraints']} constraints survived the commit, dof {d2['dof']}")
leave_sketch()
def rung_live_constrain():
reset_document()
print("\nD11 constrain WHILE SKETCHING — no commit, no tree pick, no padlock")
enter_sketch("l")
clickmm(-50, -30); clickmm(30, -18)
key("Escape", 0.7); key("Escape", 0.7)
key("l", 0.6)
clickmm(30, -18); clickmm(55, 35)
# Two Escapes only: the first clears the polyline's pending point, the second drops the
# tool to Select. The session stays LIVE -- that is the whole point of this rung.
key("Escape", 0.7); key("Escape", 0.7)
d0 = describe()
check("VERTEX", len(d0["entities"]) == 2, f"{len(d0['entities'])} entities in the live sketch")
g0 = parallel_gap(d0["entities"][0], d0["entities"][1])
check("ANGLE", g0 > 1e-3, f"they start {g0:.6f} deg from parallel")
# Pick both in the LIVE session, then press the button. No "k": pressing Constrain is
# exactly the step this rung exists to prove is no longer necessary.
clickmm(*mid(d0["entities"][0]))
cx, cy = mid(d0["entities"][1])
click_ctrl(*px(cx, cy))
click(*CON_BTN_SKETCH["parallel"])
time.sleep(1.2)
d = describe()
g = parallel_gap(d["entities"][0], d["entities"][1])
check("ANGLE", near(g, 0.0, 1e-6), f"parallel without ever leaving the sketch: {g:.9f} deg")
check("LENGTH", d["entities"][0]["length"] > 1.0 and d["entities"][1]["length"] > 1.0,
f"neither line collapsed: {d['entities'][0]['length']:.6f}, {d['entities'][1]['length']:.6f}")
d2 = confirm_and_reopen()
check("CLOSED", d2["constraints"] > 0 and d2["solve_ok"],
f"{d2['constraints']} constraints survived the commit, dof {d2['dof']}")
leave_sketch()
# =================================================================== DURABILITY
# Exactness that does not survive an undo or a save is not exactness.
@@ -1541,6 +1632,7 @@ RUNGS = {"rect": rung_rect, "circle": rung_circle, "line": rung_line, "arc": run
"distance_xy": rung_distance_xy, "symmetric_axis": rung_symmetric_axis,
"coincident_points": rung_coincident_points,
"type_guards": rung_type_guards,
"parallel": rung_parallel, "live_constrain": rung_live_constrain,
"undo": rung_undo,
"feature_undo": rung_feature_undo, "roundtrip": rung_roundtrip,
"scale": rung_scale}