Three cues, because one is missed. A teal banner across the top of the viewport names the session
("Editing: Sketch N") and where its exits are; the printer bed is muted for the duration, since a
plate grid and a sketch grid are the same visual language and reading one as the other is how a
sketch gets drawn against the wrong reference; and N looks straight down the plane normal at the
current zoom, with the plane's own y axis as up, because no hand-orbit lands exactly square and a
sketch read at an angle is one whose right angles do not look like right angles.
The banner is an INDICATOR. Finish and Cancel stay on the single ribbon action bar — the tab had
three competing confirm surfaces once and that is not being reopened for a strip of colour. It
sits above the canvas rather than floating inside it: a child window over a wxGLCanvas is a native
window on GTK with no reliable stacking over GL, and being unmissable beats being clever.
The bed checkbox stays the stored preference and is restored on leaving the sketch; ticking it
mid-sketch still shows the bed, because that is a deliberate act and this is only a default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
7.9 KiB
Design tab — interaction model
The contract for Esc, the right mouse button, and the states between them. Code that changes any of the three changes this file in the same commit.
1. The state machine
src/slic3r/GUI/CAD/DesignInteraction.hpp — a four-level LIFO stack. The enum value is the
depth, so "which level does this press belong to" is a comparison rather than a chain of
special cases spread over three files.
enum class CadLevel : int {
Idle = 0, // nothing transient is up: Esc clears the selection
Tool = 1, // a feature card / armed sketch tool / constrain session: Esc exits it
Gesture = 2, // an uncommitted delta (entity being drawn, body being dragged): Esc reverts it
Transient = 3, // a value field or a popup menu: Esc closes just that
};
struct CadInteractionState { // the four bits routing actually needs
bool value_field_open{false};
bool gesture_active{false};
bool tool_armed{false};
bool has_selection{false};
};
constexpr CadLevel cad_escape_level(const CadInteractionState& s)
{
if (s.value_field_open) return CadLevel::Transient;
if (s.gesture_active) return CadLevel::Gesture;
if (s.tool_armed) return CadLevel::Tool;
return CadLevel::Idle;
}
The rule is a constexpr free function over a POD, not a method on the panel, so the ordering
that is the entire contract is checkable without a window, a GL context or an event loop. Five
static_asserts in the header do exactly that, at compile time.
Strict invariant. No level of Esc deletes a feature, discards a sketch that holds geometry, or rolls history back. Destroying work needs a gesture that says so:
| To destroy | Gesture |
|---|---|
| a feature | Delete / Backspace on an explicit selection |
| a drawn sketch | the ribbon's ✗ Cancel, which asks first |
| the last committed change | Ctrl+Z |
2. Event routing
OnKeyDown(WXK_ESCAPE) — DesignPanel's wxEVT_CHAR_HOOK, one line:
if (key == WXK_ESCAPE) { escape(); return; }
Every Esc in the tab goes through it, whatever holds focus. DesignPanel::escape_level() answers
the four questions of CadInteractionState about this panel; DesignPanel::escape() acts on the
one level that answer names, and on no other:
| Level | What one press does | What it must not touch |
|---|---|---|
Transient |
close the value field (cancel_value / inline_cancel) |
the tool, which stays armed |
Gesture |
drop the clicks of the entity being drawn, or put a moved body back at the pose it had when the gizmo appeared | everything already committed |
Tool |
discard a feature card's candidate; drop an armed sketch tool to Select; end Constrain | committed features; entities already drawn |
Idle |
clear the selection (model and sketch); leave a sketch session only if it is empty | a sketch holding geometry — it is left through Finish or Cancel |
A sketch session is deliberately not a Tool. It is the environment the Idle level lives in,
which is what makes the destructive path unrepresentable rather than merely unlikely.
OnRightDown / OnRightUp — DesignCanvas::set_on_context_menu, bound after GLCanvas3D's
own handlers so it can consume the event before them:
RIGHT_DOWN: remember the press position and the clock, then Skip() // the canvas still seeds the orbit
RIGHT_UP: terminated = sketch_tool.take_right_consumed(); // read-and-clear, always
is_click = drift <= 3 px && dt <= 200 ms; // both budgets, or it was navigation
if (callback && !terminated && !inline_busy && is_click) {
select_at_screen(press.x, press.y); // raycast at the PRESS, not the release
on_context_menu(ClientToScreen(press));
return; // consumed
}
Skip(); // orbit / pan / the handlers underneath
Two independent budgets because the two failure modes are independent: drift alone still popped a
menu at the end of a slow, careful orbit. take_right_consumed() is how a right-click that
already meant something to the armed sketch tool (terminate a chain, drop an edit-op) declines to
also mean "open a menu".
3. Transition table
sel = something is picked. Blank = the input does nothing at that state.
| State | Left-click | Right-click | Esc | Enter |
|---|---|---|---|---|
| Idle — model view | pick / escalate the pick | offer menu for what is under the cursor | clear the selection | — |
| Idle — sketch, empty | pick | sketch offer menu | leave the session (nothing to lose) | Finish sketch |
| Idle — sketch, drawn | pick | sketch offer menu | clear the selection; status says the sketch is kept | Finish sketch |
| Tool — feature card | pick the card's next reference | offer menu | discard the candidate, close the card | commit the feature |
| Tool — sketch tool armed | place the first point | drop the tool to Select | drop the tool to Select | — |
| Tool — constrain | pick an entity | offer menu | end the session | apply |
| Gesture — drawing | place the next point | terminate the chain (keep what is drawn) | drop the in-progress entity, tool stays armed | commit the entity as drawn |
| Gesture — moving a body | drop the body here | end the move | revert to the pose at move-start | keep the placement |
| Transient — value field | — | — | close the field, tool stays armed | commit the value, advance the chain |
| Transient — popup menu | run the entry | — | close the menu | run the highlighted entry |
| any | — | — | never deletes, discards or rolls back | — |
Right-hold-and-drag is not in the table on purpose: past 3 px or 200 ms it is navigation, and navigation does not transition the state machine.
4. Visual scaffolding
Entering a sketch changes three things at once, so the state is legible from across the room:
- Banner. A teal strip across the top of the viewport:
Editing: Sketch N · N = look normal to the plane · Finish or Cancel in the toolbar. Indicator only — Confirm and Cancel stay on the one ribbon action bar, per the Design UX contract. It is a sibling above the canvas, not a floating child over it: a child window over awxGLCanvasis a native window on GTK and does not reliably stack over GL, and this banner's job is to be unmissable rather than clever. - The printer bed is muted. A plate grid and a sketch grid are the same visual language, and reading one as the other is how a sketch gets drawn against the wrong reference. The view checkbox remains the stored preference and is restored on the way out; ticking it mid-sketch still shows the bed, because that is a deliberate act and this is only a default.
Nlooks normal to the plane, keeping the current zoom, with the plane's own y axis as up. Sketch key map only — in Feature mode the navigator orb owns orientation.
5. Context menu content
The offer is generated from docs/CAD/ux/tool_atlas.json; its 8-row shape and permanent row
indices are ratified and are not changed here. Checked against the per-context vocabularies asked
for in the 2026-09-05 interaction brief, the atlas already carries all of them except two, both on
a planar face:
| Asked for | Status |
|---|---|
| Revolve on a planar face | not offered, and should not be: revolve accepts sk_loop only, because the kernel takes a sketch profile — a face is not one |
| Offset Face | offered as Thicken (thicken, accepts face_planar); surf_offset is the sheet-body verb and accepts body_sheet |
View and document actions — Zoom to Fit, View Isometric, Clear Selection, Finish Sketch, Normal to
Sketch — stay in chrome by the atlas's own rule: the offer describes verbs that consume a
selection, and these act on the document or the camera. Esc covers Clear Selection, N covers
Normal to Sketch, and the ribbon covers Finish.