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
+40 -3
View File
@@ -214,6 +214,7 @@ void DesignSketchTool::rebuild_features_from_entities()
void DesignSketchTool::set_tool(Mode mode)
{
m_exit_refused = false; // any new action re-arms the one-shot exit refusal
// A READY edit-op carries the user's typed or dragged value, so switching tools commits it
// rather than dropping it — the same rule Tab follows in the dimension editor. Discarding it
// here is most of why Fillet looked like it simply did not work: every documented route (type
@@ -331,7 +332,20 @@ void DesignSketchTool::request_exit()
// Drop any pending edit-op BEFORE the downgrade: set_tool commits a ready one, and Esc must
// cancel it, never apply it. Right-click already discards it through its own branch.
if (m_mode != Mode::Select) { reset_op(); set_tool(Mode::Select); return; }
if (on_exit) on_exit(); else cancel();
if (on_exit) {
// This is the layer that would destroy a drawn-but-uncommitted sketch. That is the one
// thing Esc must not do silently: refuse the FIRST time work exists, and only let a
// second consecutive Esc through. The panel reports the refusal; the tool only decides.
if (live_sketch_has_work() && !m_exit_refused) {
m_exit_refused = true;
if (on_exit_refused) on_exit_refused();
return;
}
m_exit_refused = false;
on_exit();
} else {
cancel();
}
}
void DesignSketchTool::request_undo_redo(bool redo)
@@ -350,6 +364,7 @@ void DesignSketchTool::clear_selection()
void DesignSketchTool::delete_selected()
{
if (m_selection.empty()) return;
m_exit_refused = false; // deleting is an action; re-arm the exit refusal
const int n = int(m_entities.size());
std::vector<bool> del(n, false);
for (int i : m_selection)
@@ -7442,12 +7457,17 @@ void DesignSketchTool::build_constraint_glyphs(double unit_per_px,
void DesignSketchTool::draw_entities_preview(const std::vector<SketchEntity>& ents, const ColorRGBA& color)
{
std::vector<Vec2d> point_markers;
for (const SketchEntity& e : ents) {
if (e.type == SketchEntity::Type::Point) continue;
// A Point has no polyline (entity_polyline returns nothing), so the strip path below
// cannot draw it; render it as a vertex marker, the same way the live session does.
if (e.type == SketchEntity::Type::Point) { point_markers.push_back(e.p0); continue; }
bool closed = false;
std::vector<Vec2d> poly = entity_polyline(e, closed);
draw_quad_strip(m_highlight_model, poly, closed, color);
}
if (!point_markers.empty())
draw_vertices(m_highlight_model, point_markers, color);
}
// ---- In-canvas edit-op gizmo (Fillet/Chamfer/Offset/Mirror toolbar tools) ----------
@@ -8448,15 +8468,28 @@ void DesignSketchTool::render(GLCanvas3D& canvas)
for (int h : loops[m_display_pick_region].holes) mark(h);
}
}
std::vector<Vec2d> point_markers, sel_point_markers;
for (int i = 0; i < int(ds.entities.size()); ++i) {
const SketchEntity& e = ds.entities[i];
if (e.type == SketchEntity::Type::Point) continue;
// A Point has no polyline to strip; draw it as a vertex marker so a committed
// point stays visible (it vanished on commit). Selection colouring keeps working:
// sel_ent[] stays index-aligned with ds.entities, untouched for the other types.
if (e.type == SketchEntity::Type::Point) {
(sel_ent[i] ? sel_point_markers : point_markers).push_back(e.p0);
continue;
}
bool closed = false;
std::vector<Vec2d> poly = entity_polyline(e, closed);
const ColorRGBA* hlc = sketch_hl_color(ds.feature);
ColorRGBA wc = sel_ent[i] ? swire : (hlc ? *hlc : dwire);
draw_quad_strip(m_line_model, poly, closed, wc);
}
if (!point_markers.empty()) {
const ColorRGBA* hlc = sketch_hl_color(ds.feature);
draw_vertices(m_vertex_model, point_markers, hlc ? *hlc : dwire);
}
if (!sel_point_markers.empty())
draw_vertices(m_highlight_model, sel_point_markers, swire);
}
m_plane = saved_plane;
}
@@ -9525,6 +9558,10 @@ bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas)
bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas)
{
// Re-arm the one-shot exit refusal on a BUTTON press only, never on motion: moving the
// mouse between the two Esc presses is what anyone would do, and re-arming there would
// make the second Esc refuse again — an Esc that can never exit while the hand moves.
if (evt.LeftDown() || evt.RightDown() || evt.MiddleDown()) m_exit_refused = false;
// Track the cursor in canvas client px so the in-canvas value editor can open right
// where the user clicked (Onshape places the field at the click, not via a camera
// projection — the design canvas's viewport isn't valid outside its own paint).