The offer ladder: drive right-click, and fix the two things it found

The gesture ladder proved the TARGET — a complex closed profile, exact in vertices, lengths, arcs
and symmetry, voids correctly attributed. It proved it by arming every tool with a letter key,
which leaves the goal's own MECHANISM untested: the design logic pivots on right-click, and the
verbs offered are supposed to adapt to the element under the cursor. 47 of 86 Design-tab verbs
have a GUI action and no shortcut, so a key-driven ladder cannot reach more than half of them.

scripts/offer-ladder.py drives the menu. It asserts nothing from pixels: show_offer_menu emits an
[OFFER] trace from the same loop that builds the rows (behind the existing SNAPORCA_KEYTRACE), so
what the ladder reads cannot drift from what the user is shown, and the expected row set is
predicted by parsing DesignOffer.hpp rather than transcribed by hand. 25 properties, four rungs:
what each element type offers, that the menu equals the table for four selections AND that the
four differ, a 120 x 80 profile authored entirely through the menu, and a tool with no keyboard
route at all driven from the only door it has.

Two real defects, both found by it, both fixed here:

snaporca-ghcz (P1) — right-click was a black hole while any draw tool was armed. Every draw case
ended with `if (evt.RightDown()) { m_points.clear(); return true; }` and returned true even with
nothing to abandon; on_mouse records that in m_right_consumed and DesignCanvas suppresses the
offer whenever it is set. Measured: with Line armed, two right-clicks in a row produced no menu
and no tool change; only Escape freed it. Same rule snaporca-xmh6 wrote for the selection —
clearing nothing is not a gesture terminator. One shared right_abandon() now consumes the click
only when an anchor was really down; 16 sites, plus Polyline/BSpline (which end a chain, correct
only when there IS one) and Point (which has no anchor at all).

snaporca-lnri (P2) — right-clicking a sketch point offered the empty vocabulary. select_at_screen
tests hit_test_point first and records the hit in m_point_sel, but the offer counts m_selection
only, so a Point entity could never reach the entity branch and SkPoint was unreachable by
construction. A Point IS its own handle, so it is selected as an entity; other entities keep the
handle pick, since a line's endpoint is a drag target, not a vocabulary.

Offer ladder 25/25, gesture ladder 93/93 (no regression), both on the rig. The offer ladder joins
scripts/ladder-all.sh as the fifth rung.

snaporca-ghcz snaporca-lnri

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-23 03:45:33 +02:00
co-authored by Claude Opus 5
parent 12047e4085
commit 8c4b05ae9d
5 changed files with 617 additions and 22 deletions
+30
View File
@@ -12,6 +12,7 @@
#include <Standard_Failure.hxx>
#include <cassert>
#include <cstdarg> // offer_trace: diagnostic row dump for the offer ladder
#include <map>
#include <set>
#include <wx/sizer.h>
@@ -5968,6 +5969,22 @@ wxMenuItem* DesignPanel::append_offer_item(wxMenu* menu, int id, const wxString&
return item;
}
// Diagnostic only: what the offer is about to show, line per row, on stderr. Costs one getenv
// per menu when off. The ladder that drives right-click needs to assert the ROW SET, and the only
// honest source for that is the loop that builds the rows.
static void offer_trace(const char* fmt, ...)
{
static const bool on = std::getenv("SNAPORCA_KEYTRACE") != nullptr;
if (!on) return;
va_list ap;
va_start(ap, fmt);
fprintf(stderr, "[OFFER] ");
vfprintf(stderr, fmt, ap);
fprintf(stderr, "\n");
va_end(ap);
fflush(stderr);
}
void DesignPanel::show_offer_menu(const wxPoint& screen_pos)
{
const int kind = offer_selection_kind();
@@ -5977,6 +5994,11 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos)
// is_sketching() the offer opened on entering a sketch showing the FEATURE rows, every one
// of them refusing the sketch selection, so it read as a menu of nine dead entries.
const bool sketching = sketch_map_applies();
// The offer ladder reads THIS, not the pixels: the trace is emitted from the same loop that
// builds the menu, so it cannot drift from what the user is shown. Gated on the existing
// SNAPORCA_KEYTRACE so a rig run needs one env var, not two. snaporca-<offer ladder>.
offer_trace("open kind=%d sketching=%d bodies=%d", kind, sketching ? 1 : 0,
int(m_doc.bodies.size()));
const int bodies = int(m_doc.bodies.size());
int sketches = 0;
@@ -6046,9 +6068,13 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos)
s += wxString::FromUTF8("") + tr(why);
else if (OfferSel(kind) == OfferSel::None)
s += wxString::FromUTF8("") + _L("select something first");
offer_trace("row=%d %s DISABLED (%s)", row, kOfferRowNames[row],
why ? why : "no verb accepts this selection");
menu.Append(base + int(bound.size()), s)->Enable(false);
bound.push_back(nullptr);
} else if (live.size() == 1) {
offer_trace("row=%d %s -> %s%s", row, kOfferRowNames[row], live[0]->id,
live[0]->action ? "" : " (no GUI route)");
append_offer_item(&menu, base + int(bound.size()), label(*live[0]), *live[0])
->Enable(live[0]->action != nullptr);
bound.push_back(live[0]);
@@ -6073,6 +6099,10 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos)
target = it->second;
}
}
offer_trace("row=%d %s > %s%s%s%s", row, kOfferRowNames[row],
(v->family && *v->family) ? v->family : "",
(v->family && *v->family) ? " > " : "", v->id,
v->action ? "" : " (no GUI route)");
append_offer_item(target, base + int(bound.size()), label(*v), *v)
->Enable(v->action != nullptr);
bound.push_back(v);
+53 -21
View File
@@ -9170,6 +9170,21 @@ bool DesignSketchTool::select_at_screen(GLCanvas3D& canvas, int sx, int sy)
// A point handle beats the curve it belongs to, same precedence the left-click pick uses.
int ei = -1; SketchPointRole role = SketchPointRole::P0;
if (hit_test_point(p, tol, ei, role)) {
// A Point ENTITY is its own handle: there is nothing else to select there. Taking the
// handle branch for it filled m_point_sel and left m_selection empty — and the offer
// counts only m_selection, so right-clicking a sketch point produced the EMPTY
// vocabulary and every SkPoint row in the atlas was unreachable from the menu. Other
// entities keep the handle pick: a line's endpoint is a drag target, not a thing with a
// vocabulary of its own. snaporca-lnri.
if (ei >= 0 && ei < int(m_entities.size())
&& m_entities[ei].type == SketchEntity::Type::Point) {
if (std::find(m_selection.begin(), m_selection.end(), ei) != m_selection.end())
return false; // already selected: leave it alone
m_selection.assign(1, ei);
m_point_sel.clear();
if (on_selection_changed) on_selection_changed(1);
return true;
}
const auto pr = std::make_pair(ei, role);
if (std::find(m_point_sel.begin(), m_point_sel.end(), pr) != m_point_sel.end())
return false; // already selected: leave it alone
@@ -9236,6 +9251,23 @@ std::vector<int> DesignSketchTool::connected_loop(int seed) const
// The honest test is not "which mode are we in" but "did the tool actually USE this right-click",
// and only the tool knows. Wrapping on_mouse records that once, for every terminator, instead of
// threading a flag through the twenty-odd sites that consume a RightDown.
// Right-click abandons the anchor a draw tool has down. With NOTHING down there is nothing to
// abandon — and consuming the click anyway made the offer unreachable from every armed draw tool:
// on_mouse records the consumption in m_right_consumed and DesignCanvas's RIGHT_UP handler
// suppresses the menu whenever it is set, so right-click became a no-op that also hid the one door
// to half the vocabulary (47 of 86 verbs have no shortcut). Measured on the rig: with Line armed,
// two right-clicks in a row produced no menu and no tool change; only Escape freed it.
// Same rule as snaporca-xmh6, which said it for the selection: clearing nothing is not a gesture
// terminator. snaporca-ghcz.
bool DesignSketchTool::right_abandon()
{
if (m_points.empty())
return false; // hand it back, so the canvas opens the offer
m_points.clear();
m_has_cursor = false;
return true;
}
bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas)
{
const bool consumed = on_mouse_impl(evt, canvas);
@@ -10285,6 +10317,8 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown() && m_points.empty())
return false; // no chain to end — snaporca-ghcz, let the offer open
if (evt.RightDown()) {
// END the chain — do NOT close it. This used to call push_closed_lines() for three
// or more points, i.e. it drew a final segment from the last point back to the
@@ -10328,11 +10362,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
keep_segment_as_drawn();
return true;
}
if (evt.RightDown()) { // abandon the in-progress anchor
m_points.clear();
m_has_cursor = false;
return true;
}
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10354,7 +10384,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10379,7 +10409,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10407,7 +10437,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10437,7 +10467,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10454,7 +10484,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10471,7 +10501,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10485,7 +10515,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10505,7 +10535,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10523,7 +10553,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10543,7 +10573,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10569,7 +10599,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10595,7 +10625,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10614,7 +10644,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10632,7 +10662,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10651,7 +10681,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
}
return true;
}
if (evt.RightDown()) { m_points.clear(); return true; }
if (evt.RightDown()) return right_abandon();
break;
}
@@ -10666,6 +10696,8 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
m_points.push_back(p);
return true;
}
if (evt.RightDown() && m_points.empty())
return false; // no poles down — snaporca-ghcz, let the offer open
if (evt.LeftDClick() || evt.RightDown()) {
if (m_points.size() >= 2) {
const int base = int(m_entities.size());
@@ -10685,7 +10717,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
push_point(p);
return true;
}
if (evt.RightDown()) { return true; }
if (evt.RightDown()) return false; // no anchor to abandon: the offer belongs here
break;
}
+3
View File
@@ -123,6 +123,9 @@ public:
bool is_active() const { return m_active; }
bool has_entities() const { return !m_entities.empty(); }
bool on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas);
// Right-click on a draw tool: true when an in-progress anchor was abandoned, false when
// there was nothing to abandon — and false is what lets the offer menu open. snaporca-ghcz.
bool right_abandon();
// True if the LAST right-press was consumed as a gesture terminator (end a polyline chain,
// abandon an anchor, exit a tool). Read-and-clear: the canvas asks on the matching release to
// decide whether that right-click was the user's, in which case it opens the offer.