the same phantom-endpoint bug in Coincident, and the two unguarded branches next to it

Port of snaporca da8d011b87; parity holds (DesignPanel.cpp still exactly 32 divergent
lines). Verified independently on this fork's own rig: full ladder 126/126 against
BuildID 96c697a3, built from this tree.

Reviewing the DistanceX/Y fix for OTHER members of its class found three more live defects
on the constrain toolbar. All four share one root: a branch assumes every picked entity has
two endpoints, and the solver's refusal to resolve a role it cannot find is silent.

COINCIDENT had the identical closest-pair walk over {P0,p0},{P1,p1}. For two Points the
phantom (0,0) pair sits at distance 0, which is the smallest distance there is, so it
ALWAYS won: ptOf(Point,P1) -> 0, ref_ok fails (SketchSolver.cpp:185), constraint dropped.
Not sometimes -- every press.

HORIZONTAL/VERTICAL hardcoded ra=P0, rb=P1 with no type check. With a Point picked the
constraint is dropped by the same mechanism but still STORED: constraints goes 0 -> 1 after
the commit and nothing moves, so the Constraints list shows a dimension that can never do
anything. Worse than refusing -- the panel claims the sketch is constrained when it is not.

ANGLE computed p1-p0 on whatever was picked. On a circle that is (0,0)-centre, so two
circles pre-filled the field with the angle between their centre POSITION VECTORS (178.83
deg for two on the x axis), and accepting it emits SLVS_C_ANGLE on two circle prims.

Both branches now refuse with a message. entity_ends()/closest_ends() are file-scope and
shared by Coincident and DistanceX/Y, so there is one implementation instead of two that
drift.

Two smaller findings from the same review: infer_auto_constraints' roles_of omitted
EllipseArc while heal_coincidences' identical copy has it; and set_point(Circle, Center)
wrote e.center and not e.p0, breaking the "p0 mirrors centre" invariant for the duration of
a live drag.

New rungs D8 and D9, both RED against the shipped binary and green here.

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 13:52:12 +02:00
co-authored by Claude Opus 5
parent 507b45431c
commit cd30fb891e
3 changed files with 187 additions and 49 deletions
+89
View File
@@ -1155,6 +1155,43 @@ def rung_distance_xy():
leave_sketch()
def rung_coincident_points():
reset_document()
print("\nD8 constrain — Coincident on two POINTS actually joins them")
# The regression this exists for: the closest-pair search used to enumerate {P0,p0},{P1,p1}
# on both entities, and a Point's p1 reads (0,0). For two points the phantom pair is at
# distance 0, which ALWAYS wins, so the constraint was added against a role the solver
# cannot resolve and dropped in silence -- constraints=0, the points never moved, no error.
# Two points is the simplest possible use of the button and it did nothing on every press.
enter_sketch("p")
clickmm(-30, -20)
key("p", 0.6)
clickmm(25, 18)
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])
gap0 = math.dist(ps[0]["p"], ps[1]["p"])
check("VERTEX", gap0 > 1.0, f"they start apart: {gap0:.6f}")
clickmm(*ps[0]["p"]); clickmm(*ps[1]["p"])
click(*CON_BTN["coincident"])
time.sleep(1.2)
d = describe()
gap = math.dist(d["entities"][ia]["p"], d["entities"][ib]["p"])
check("VERTEX", gap < 1e-6, f"and are now joined: gap {gap:.9f}")
# The count has to be read AFTER the round trip, never during the session: while
# constraining, sketch_describe reports the LIVE tool's constraints, which the constrain
# session never touches -- it writes the committed feature's entity_constraints. Reading it
# here says 0 for a constraint that really did land. Same trap the D2 rung documents.
d2 = confirm_and_reopen()
check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0,
f"{d2['constraints']} constraints survived the commit — a dropped one leaves this at 0")
ps2 = [e for e in d2["entities"] if e["type"] == "point"]
check("VERTEX", math.dist(ps2[0]["p"], ps2[1]["p"]) < 1e-6,
"and they are still joined after the round trip")
leave_sketch()
def rung_symmetric_axis():
reset_document()
print("\nD7 constrain — symmetric about the vertical axis, with no construction line")
@@ -1187,6 +1224,56 @@ def rung_symmetric_axis():
leave_sketch()
def rung_type_guards():
reset_document()
print("\nD9 constrain — a button that cannot apply must SAY so, not store a dead constraint")
# Both halves were live defects found by reviewing the DistanceX/Y fix for its class.
#
# Horizontal on a lone Point: P1 is a role the solver cannot resolve, so the constraint was
# dropped at ref_ok -- but still STORED. Measured before the fix: constraints 0 -> 1 after the
# commit, point unmoved. A constraint listed in the panel that can never do anything is worse
# than a refusal, because the panel then claims the sketch is constrained when it is not.
enter_sketch("p")
clickmm(-30, -20)
key("k", 1.5)
d = describe()
ps = [e for e in d["entities"] if e["type"] == "point"]
ia = d["entities"].index(ps[0])
clickmm(*ps[0]["p"])
click(*CON_BTN["horizontal"])
time.sleep(1.0)
d = describe()
check("VERTEX", near(d["entities"][ia]["p"][0], ps[0]["p"][0], 1e-9) and
near(d["entities"][ia]["p"][1], ps[0]["p"][1], 1e-9),
"Horizontal on a point moved nothing, as it must")
d2 = confirm_and_reopen()
check("CLOSED", d2["constraints"] == 0,
f"and stored NO constraint: {d2['constraints']} (a dead stored one reads 1)")
leave_sketch()
# Angle on two circles: p1-p0 on a circle is (0,0)-centre, so the field used to pre-fill with
# the angle between the two centre POSITION VECTORS -- 178.83 deg for two circles on the x
# axis -- and committing it fed SLVS_C_ANGLE two circle prims, which are not directions.
reset_document()
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)
key("k", 1.5)
d = describe()
cs = [e for e in d["entities"] if e["type"] == "circle"]
clickmm(*rim(cs[0])); clickmm(*rim(cs[1]))
click(*CON_BTN["angle"])
time.sleep(1.0)
check("ANGLE", field_win() is None,
"Angle on two circles opened no value field")
key("Escape", 0.6)
d2 = confirm_and_reopen()
check("CLOSED", d2["constraints"] == 0,
f"and stored no angle: {d2['constraints']}")
leave_sketch()
def rung_undo():
print("\nE1 undo — the last entity goes, the rest do not move")
enter_sketch("l")
@@ -1452,6 +1539,8 @@ RUNGS = {"rect": rung_rect, "circle": rung_circle, "line": rung_line, "arc": run
"perpendicular": rung_perpendicular,
"equal_radius": rung_equal_radius, "collinear": rung_collinear,
"distance_xy": rung_distance_xy, "symmetric_axis": rung_symmetric_axis,
"coincident_points": rung_coincident_points,
"type_guards": rung_type_guards,
"undo": rung_undo,
"feature_undo": rung_feature_undo, "roundtrip": rung_roundtrip,
"scale": rung_scale}