Every 2D verb without a shortcut, driven from the offer — and three more defects

The 2D vocabulary is 46 verbs: 22 have a shortcut and the gesture ladder drives them, 24 have
none and nothing had ever exercised those. They are reachable only from the right-click offer, so
a key-driven ladder could not have touched them whatever it did. Four new rungs drive all 24, and
the coverage claim itself is now arithmetic against DesignOffer.hpp (rung O8) rather than a
sentence in a comment that rots when a verb is added.

The assertions are CONSTRUCTION invariants wherever a click cannot be exact — a regular polygon's
sides are equal to 1e-9 and its vertices lie on one circle; a tangent arc's radius at the shared
endpoint is perpendicular to the line to 1e-9 (measured cos 5.97e-17); the three clicks of a
3-point circle all lie on it; a circumscribed pentagon's circumradius is the inscribed one's over
cos(pi/5), 1.236067977 against 1.236067977. Where a value field opens, the typed value is graded
exactly: a moved line travels +25.000000000 in X and 0 in Y, a rotation turns 30.000000000 deg
and leaves the length alone, a scale multiplies it by exactly 3, a linear array's pitch is
[20.0, 20.0, 20.0] and a polar one's spokes are 60 deg apart all the way round.

Three defects found doing it, all fixed here:

snaporca-ua9g (P1) — delete_selected left three things behind. The AUTO-EDIT QUEUE, so a queued
field opened on a deleted entity and its commit went nowhere: draw a rounded rectangle, delete
everything, draw a 2-point circle, type 30 — the field opens, the digits are accepted, and the
radius stays 32.992020763. reset_autoedit() exists for exactly this and its own comment says so;
it was simply never called from here. The FEATURE GROUPS, whose [begin,end) ranges all shift on a
delete, so feature_of() answered with a group the user never drew — survivors are now remapped
and any group that lost a member is dropped, the rule the placed quotes already followed. And the
SOLVER STATE: no re-solve, so sketch_describe reported dof=16 for a document holding one circle.

snaporca-ekt9 (P2) — the read-back could not see three of its seven entity types. Ellipse,
EllipseArc and BSpline serialised as a bare type name: no centre, no semi-axes, no rotation, no
sweep, no poles. gui-ladder's ellipse rung had to grade the faceted area of the loop at 2e-2 —
that tolerance IS the faceting error — and its spline rung could only count entities. Now they
carry their parameters, and the ellipse arc's ends are asserted to satisfy (x/a)^2+(y/b)^2 = 1 to
1e-9.

Also read-only, and the reason the other two were found at all: sketch_describe now reports the
armed TOOL, the count of PENDING anchors, and whether a value field is EDITING. A menu walk that
lands one row off arms a neighbouring tool and then draws something plausible — the first run of
the authoring rung drew a circle of area 45238.93 and graded it as a rectangle. Every menu pick
now asserts which tool it armed, and the polyline rung (a per-segment Length field freezes the
canvas after every click) could only be written once the driver could ask whether a field was open.

Offer ladder 102/102 -> 105/105 with coverage. Gesture ladder 93/93 and the kernel suite
188 cases / 2532 assertions, both unchanged.

snaporca-ua9g snaporca-ekt9

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 05:11:15 +02:00
co-authored by Claude Opus 5
parent 8c4b05ae9d
commit 1bde448f51
4 changed files with 724 additions and 4 deletions
+36
View File
@@ -381,6 +381,42 @@ void DesignSketchTool::delete_selected()
// a stale m_dim_e0 would dereference out of range on the next click. Drop it too.
m_dim_e0 = -1;
m_dim_r0 = SketchPointRole::P0;
// FEATURE GROUPS hold [begin,end) ranges into m_entities, and every index past a deletion has
// just moved. Left alone they point at other people's geometry: feature_of() then answers with
// a group the user never drew, and the rect/slot/polygon handles and live quotes follow it.
// Survivors are remapped (a contiguous range stays contiguous, since the remap preserves
// order); a group that lost any member is dropped, the same rule the placed quotes above
// already follow — dangling is worse than absent.
{
std::vector<Feature> kept_f;
for (const Feature& f : m_features) {
if (f.begin < 0 || f.end > n || f.end <= f.begin) continue;
bool whole = true;
for (int k = f.begin; k < f.end; ++k)
if (del[k]) { whole = false; break; }
if (!whole) continue;
Feature g = f;
g.begin = remap[f.begin];
g.end = remap[f.end - 1] + 1;
kept_f.push_back(g);
}
m_features.swap(kept_f);
m_open_feature = -1;
}
// The draw-then-edit QUEUE outlives the entities it was queued for. Its own helper says so:
// "Removing an entity that still has a deferred auto-edit would otherwise open a field on a
// now-deleted entity and freeze the flow" — it was simply never called from here. Measured:
// delete a rectangle whose Width/Height were still queued, draw a circle, type its radius —
// the field opens, the digits go in, and the radius does not move, because the field belongs
// to a rectangle that no longer exists. snaporca-ua9g.
reset_autoedit();
// And re-solve, so the sketch's reported degrees of freedom describe the sketch that is
// actually there. Without this, sketch_describe answered dof=16 for a document holding one
// circle — the DoF of the geometry that had just been deleted.
resolve_live();
if (on_selection_changed) on_selection_changed(0);
}
+8
View File
@@ -62,6 +62,14 @@ public:
// In-canvas bounding-box transform for imported Text/SVG art:
TransformArt,
Constrain };
// Which tool is armed, and how many anchors it has down. Read-only, for the offer ladder:
// "the menu armed the verb I chose" is otherwise unassertable, and a menu walk that lands one
// row off arms a NEIGHBOURING tool and then grades whatever that drew. snaporca-ekt9.
Mode mode() const { return m_mode; }
int pending_points() const { return int(m_points.size()); }
// Is an in-canvas value field open? While one is, the canvas is frozen and every letter is
// swallowed — the single most common reason a driven gesture "does nothing".
bool value_field_open() const { return m_awaiting_length; }
bool is_edit_op_mode() const { return m_mode == Mode::Fillet || m_mode == Mode::Chamfer ||
m_mode == Mode::Offset || m_mode == Mode::Mirror; }
bool is_transform_mode() const { return m_mode == Mode::Move || m_mode == Mode::Rotate ||
+50 -3
View File
@@ -1271,9 +1271,38 @@ json sketch_entity_to(const SketchEntity& e, int index)
j["type"] = "point";
j["p"] = json::array({e.p0.x(), e.p0.y()});
break;
case SketchEntity::Type::Ellipse: j["type"] = "ellipse"; break;
case SketchEntity::Type::EllipseArc: j["type"] = "ellipse_arc"; break;
case SketchEntity::Type::BSpline: j["type"] = "spline"; break;
// Ellipses and splines used to serialise as a TYPE NAME and nothing else, so every
// parameter they have was invisible to the only read-back this project has. A ladder could
// count them and grade the faceted area of the loop they close (2e-2, the faceting error) —
// it could not check a single axis, angle or pole. "Precise definition of every aspect"
// cannot be asserted about an entity whose aspects the instrument cannot see.
case SketchEntity::Type::Ellipse:
j["type"] = "ellipse";
j["center"] = json::array({e.center.x(), e.center.y()});
j["radius"] = e.radius; // semi-major (a)
j["rminor"] = e.rminor; // semi-minor (b)
j["rotation"] = e.rotation; // major-axis angle, radians
break;
case SketchEntity::Type::EllipseArc:
j["type"] = "ellipse_arc";
j["center"] = json::array({e.center.x(), e.center.y()});
j["radius"] = e.radius;
j["rminor"] = e.rminor;
j["rotation"] = e.rotation;
j["start_angle"] = e.start_angle;
j["end_angle"] = e.end_angle;
j["p0"] = json::array({e.p0.x(), e.p0.y()});
j["p1"] = json::array({e.p1.x(), e.p1.y()});
break;
case SketchEntity::Type::BSpline: {
j["type"] = "spline";
json poles = json::array();
for (const Vec2d& c : e.ctrl) poles.push_back(json::array({c.x(), c.y()}));
j["ctrl"] = poles;
j["p0"] = json::array({e.p0.x(), e.p0.y()});
j["p1"] = json::array({e.p1.x(), e.p1.y()});
break;
}
}
return j;
}
@@ -1462,11 +1491,29 @@ json action_sketch_describe(DesignPanel* panel, const json& params)
json ents = json::array();
for (int i = 0; i < int(t.entities().size()); ++i)
ents.push_back(sketch_entity_to(t.entities()[i], i));
// The armed TOOL and its pending anchors. Without these the only way to tell which tool a
// menu row actually armed is to draw with it and infer from what came out — which is how a
// menu walk that lands one row off gets diagnosed as "the tool is broken".
static const char* const kModeNames[] = {
"select", "dimension", "polyline", "line", "rect_corner", "rect_center", "rect_oblique",
"rect_rounded", "circle_center", "circle_2pt", "point",
"circle_3pt", "arc_3pt", "arc_tangent", "arc_center", "slot", "slot_arc", "polygon",
"ellipse", "ellipse_arc", "spline",
"fillet", "chamfer", "offset", "mirror",
"trim", "extend",
"move", "rotate", "scale", "array", "array_polar",
"transform_art",
"constrain" };
const int mi = int(t.mode());
json out{{"ok", true},
{"entities", ents},
{"constraints", int(t.constraints().size())},
{"dof", t.dof()},
{"solve_ok", t.solve_ok()},
{"tool", (mi >= 0 && mi < int(sizeof(kModeNames) / sizeof(kModeNames[0])))
? kModeNames[mi] : "unknown"},
{"pending", t.pending_points()},
{"editing", t.value_field_open()},
{"selection", t.selection()}};
out.update(sketch_report(t));
return out;