From 7114e316ea5992ba8b85cf270e04983c02d3576f Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Fri, 31 Jul 2026 12:20:07 +0200 Subject: [PATCH] =?UTF-8?q?Design:=20the=20offer=20ships=20=E2=80=94=20rig?= =?UTF-8?q?ht-click=20the=20geometry,=20get=20what=20applies=20to=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row order is RATIFIED (charter 4.1, 2026-07-31) and this is the first working implementation of it: right-click in the Design viewport and a vertical list opens at the pointer with the eight families in their fixed order, the verbs that apply live, and the ones that do not disabled IN PLACE carrying their reason. THE MAP EXISTS ONCE. DesignOffer.hpp is GENERATED from docs/ux/tool_atlas.json by docs/ux/mockups/gen_offer_table.py — the same file the 113 mockups are drawn from. A drawing and the product therefore cannot drift apart, which is the only way row constancy survives contact with a codebase. Never hand-edit the header. NOTHING IS RE-IMPLEMENTED. Each row routes to the code that already runs that verb: "key:S+E" through m_keys_feature, "key:L" through m_keys_sketch, "fly:material#4" through the feature flyout's own action, "btn:colour" through the standalone button. The offer is a second door onto the same room, so the toolbar, the shortcuts and the menu cannot drift into three behaviours. The 8 verbs with kernel support but no GUI path show disabled, which is honest and matches section 10 of the charter. Right-click only fires the offer when the canvas is IDLE. Right-click already ends a polyline chain and finishes the move gizmo; taking those over would break two working interactions to add a third. Two things the running build corrected, both found by looking at screenshots: - THE REASON MUST BE TRUE FOR WHAT IS IN FRONT OF THE USER. Taking the first refusal in a family printed "Transform needs a body — add or import one first" on a document that HAS a body, because the real obstacle was that nothing was selected. Now the reason comes from a verb that accepts the current selection and fails only on document state; if no verb in the family accepts this selection at all, it says "select something first" or says nothing. A menu whose whole value is telling the truth cannot ship a lie. - Classification follows the level the pick cycle has REACHED, not the face the ray happened to hit, so the header cannot name a face while the whole body is lit. Sketching on the face you merely clicked is untouched — that path is sketch_plane_from_selection (snaporca-3a2). Verified on :11 end to end: nothing selected shows Sketch live with Shift+S and seven greyed rows each explaining itself; a selected solid shows Move directly with Shift+Y (one applicable verb, so no submenu and no extra click) and five families as submenus. Both forks compile and link. Fork parity re-checked after the port: DesignPanel.cpp 30 divergent lines, DesignCanvas.cpp 16, every other CAD file byte-identical — the invariant exactly. snaporca-96r. --- docs/cad_ux_guidelines.md | 9 +- docs/ux/mockups/gen_offer_table.py | 132 +++ docs/ux/tool_atlas.json | 1512 ++++++++++++++++++++++++---- src/slic3r/GUI/DesignCanvas.cpp | 18 + src/slic3r/GUI/DesignCanvas.hpp | 8 + src/slic3r/GUI/DesignOffer.hpp | 142 +++ src/slic3r/GUI/DesignPanel.cpp | 162 +++ src/slic3r/GUI/DesignPanel.hpp | 9 + 8 files changed, 1792 insertions(+), 200 deletions(-) create mode 100644 docs/ux/mockups/gen_offer_table.py create mode 100644 src/slic3r/GUI/DesignOffer.hpp diff --git a/docs/cad_ux_guidelines.md b/docs/cad_ux_guidelines.md index 43477c76b1..d0290a1d3d 100644 --- a/docs/cad_ux_guidelines.md +++ b/docs/cad_ux_guidelines.md @@ -314,11 +314,12 @@ Two consequences the group must accept together with the invariant: and unlearnable. A constant one is a few rows longer, teaches while it waits, and is memorised in a week. -#### The map — for the group to ratify +#### The map — RATIFIED 2026-07-31 -The invariant is not negotiable. The specific assignment below is a first -proposal, and the group should argue about it *once*, then never again — every -later change re-addresses somebody's muscle memory. +The invariant is not negotiable, and as of 2026-07-31 neither is the assignment: +the row order below is **ratified**. It was argued once; it is not argued again. +Changing an index from here on is a breaking change to every user's muscle +memory and needs the group, not a pull request (§9 q12). Eight families, ordered so the sequence itself has a logic: material is created, grows, is taken away, is refined, is repeated, is moved, is referred to, is diff --git a/docs/ux/mockups/gen_offer_table.py b/docs/ux/mockups/gen_offer_table.py new file mode 100644 index 0000000000..2ef0938c93 --- /dev/null +++ b/docs/ux/mockups/gen_offer_table.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Emit the C++ offer table from docs/ux/tool_atlas.json. + + python3 docs/ux/mockups/gen_offer_table.py + +The map exists ONCE. The mockups and the shipping menu read the same rows in the same +order from the same file, so a drawing and the product cannot drift apart — which is the +only way row constancy (charter 4.1) survives contact with a codebase. + +Output: src/slic3r/GUI/DesignOffer.hpp, checked in and never hand-edited. +""" + +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +UX = os.path.dirname(HERE) +REPO = os.path.dirname(os.path.dirname(UX)) +ATLAS = os.path.join(UX, "tool_atlas.json") +OUT = os.path.join(REPO, "src", "slic3r", "GUI", "DesignOffer.hpp") + +# selection id -> C++ enumerator +ENUM = { + "none": "None", "face_planar": "FacePlanar", "face_cyl": "FaceCyl", + "face_other": "FaceOther", "edge_str": "EdgeStr", "edge_circ": "EdgeCirc", + "vertex": "Vertex", "body_solid": "BodySolid", "body_sheet": "BodySheet", + "bodies_2": "Bodies2", "datum_plane": "DatumPlane", "datum_axis": "DatumAxis", + "coordsys": "CoordSys", "art": "Art", "sk_loop": "SkLoop", "sk_none": "SkNone", + "sk_line": "SkLine", "sk_arc": "SkArc", "sk_point": "SkPoint", "sk_2ent": "Sk2Ent", +} + + +def cstr(s): + if s is None: + return "nullptr" + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def main(): + A = json.load(open(ATLAS, encoding="utf-8")) + sels = [s["id"] for s in A["selections"]] + assert all(s in ENUM for s in sels), [s for s in sels if s not in ENUM] + slots = [s["id"] for s in A["slots"]] + + lines = [ + "// GENERATED FILE — DO NOT EDIT.", + "// Source: docs/ux/tool_atlas.json Generator: docs/ux/mockups/gen_offer_table.py", + "//", + "// The object-driven tool offer (charter 4.1): every verb has ONE row index, that index", + "// is the same in every selection it appears in, and verbs that do not apply are shown", + "// disabled in place with their reason rather than removed. Row order was ratified", + "// 2026-07-31; changing an index is a breaking change to every user's muscle memory.", + "#ifndef slic3r_GUI_DesignOffer_hpp_", + "#define slic3r_GUI_DesignOffer_hpp_", + "", + "#include ", + "", + "namespace Slic3r { namespace GUI {", + "", + "// What the viewport has selected. Ordered as in tool_atlas.json; the bitmask in", + "// OfferVerb::accepts indexes these.", + "enum class OfferSel : int {", + ] + for i, s in enumerate(sels): + lines.append(f" {ENUM[s]} = {i},") + lines += [ + f" Count = {len(sels)}", + "};", + "", + "inline uint32_t offer_bit(OfferSel s) { return 1u << int(s); }", + "", + "// One row of the offer. `action` routes to the code that already implements the verb:", + '// "key:S+E" -> m_keys_feature[SHIFT(\'E\')]', + '// "key:L" -> m_keys_sketch[\'L\']', + '// "fly:material#4" -> row 4 of the "material" feature flyout', + '// "btn:delete" -> a standalone toolbar button', + "// nullptr -> kernel support exists, no GUI path yet (row shows disabled)", + "struct OfferVerb {", + " const char* id;", + " const char* name; // drawing-office word (L10); translated at use with wxGetTranslation", + " int row; // 0..7, the ratified index — NEVER reorder", + " const char* key; // shortcut shown in the row, or nullptr", + " const char* action;", + " const char* refusal; // why this row is greyed, in the product's own words", + " uint32_t accepts; // bitmask over OfferSel", + " int need_bodies;", + " int need_sketches;", + " bool need_sheet;", + " bool sketch_mode; // belongs to the sketch-mode vocabulary, not the model one", + "};", + "", + "// Row labels, in ratified order.", + "static const char* const kOfferRowNames[] = {", + ] + for s in A["slots"]: + lines.append(f' "{s["label"]}",') + lines += [ + "};", + f"static const int kOfferRowCount = {len(slots)};", + "", + "static const OfferVerb kOfferVerbs[] = {", + ] + for v in A["verbs"]: + mask = 0 + for a in v["accepts"]: + mask |= 1 << sels.index(a) + n = v.get("needs") or {} + lines.append( + " {%s, %s, %d, %s, %s, %s, 0x%08xu, %d, %d, %s, %s}," % ( + cstr(v["id"]), cstr(v["name"]), slots.index(v["slot"]), + cstr(v.get("key")), cstr(v.get("action")), cstr(v.get("refusal")), + mask, n.get("bodies", 0), n.get("sketches", 0), + "true" if n.get("sheet") else "false", + "true" if v.get("mode") == "sketch" else "false")) + lines += [ + "};", + f"static const int kOfferVerbCount = {len(A['verbs'])};", + "", + "}} // namespace Slic3r::GUI", + "", + "#endif // slic3r_GUI_DesignOffer_hpp_", + "", + ] + with open(OUT, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + wired = sum(1 for v in A["verbs"] if v.get("action")) + print(f"wrote {os.path.relpath(OUT, REPO)}: {len(A['verbs'])} verbs, " + f"{len(slots)} rows, {wired} wired to existing actions") + + +if __name__ == "__main__": + main() diff --git a/docs/ux/tool_atlas.json b/docs/ux/tool_atlas.json index 3ede7c175e..02f0960c8a 100644 --- a/docs/ux/tool_atlas.json +++ b/docs/ux/tool_atlas.json @@ -11,210 +11,1330 @@ "gen_offer_mockups.py reads this file; the eventual C++ offer table is generated from it", "too, so the map exists once." ], - "slots": [ - {"id": "create", "pos": "N", "angle": 270, "label": "Create", "why": "makes new geometry from nothing you have yet"}, - {"id": "add", "pos": "NE", "angle": 315, "label": "Add material", "why": "grows solid or sheet material"}, - {"id": "remove", "pos": "E", "angle": 0, "label": "Remove", "why": "takes material away"}, - {"id": "dressup", "pos": "SE", "angle": 45, "label": "Dress-up", "why": "finishes faces and edges without changing the shape's intent"}, - {"id": "repeat", "pos": "S", "angle": 90, "label": "Repeat", "why": "copies what exists"}, - {"id": "transform", "pos": "SW", "angle": 135, "label": "Transform", "why": "moves without changing shape"}, - {"id": "reference", "pos": "W", "angle": 180, "label": "Reference", "why": "makes datums, curves and measurements"}, - {"id": "modify", "pos": "NW", "angle": 225, "label": "Modify", "why": "edits or removes what is already there"} + { + "id": "create", + "pos": "N", + "angle": 270, + "label": "Create", + "why": "makes new geometry from nothing you have yet" + }, + { + "id": "add", + "pos": "NE", + "angle": 315, + "label": "Add material", + "why": "grows solid or sheet material" + }, + { + "id": "remove", + "pos": "E", + "angle": 0, + "label": "Remove", + "why": "takes material away" + }, + { + "id": "dressup", + "pos": "SE", + "angle": 45, + "label": "Dress-up", + "why": "finishes faces and edges without changing the shape's intent" + }, + { + "id": "repeat", + "pos": "S", + "angle": 90, + "label": "Repeat", + "why": "copies what exists" + }, + { + "id": "transform", + "pos": "SW", + "angle": 135, + "label": "Transform", + "why": "moves without changing shape" + }, + { + "id": "reference", + "pos": "W", + "angle": 180, + "label": "Reference", + "why": "makes datums, curves and measurements" + }, + { + "id": "modify", + "pos": "NW", + "angle": 225, + "label": "Modify", + "why": "edits or removes what is already there" + } ], - "selections": [ - {"id": "none", "name": "Nothing selected", "mode": "model", "shape": "empty"}, - {"id": "face_planar", "name": "Planar face", "mode": "model", "shape": "box_top"}, - {"id": "face_cyl", "name": "Cylindrical face", "mode": "model", "shape": "box_bore"}, - {"id": "face_other", "name": "Curved face", "mode": "model", "shape": "box_fillet_face"}, - {"id": "edge_str", "name": "Straight edge", "mode": "model", "shape": "box_edge"}, - {"id": "edge_circ", "name": "Circular edge", "mode": "model", "shape": "box_bore_rim"}, - {"id": "vertex", "name": "Vertex", "mode": "model", "shape": "box_vertex"}, - {"id": "body_solid", "name": "Solid body", "mode": "model", "shape": "box_whole"}, - {"id": "body_sheet", "name": "Sheet body", "mode": "model", "shape": "sheet"}, - {"id": "bodies_2", "name": "Two bodies", "mode": "model", "shape": "two_boxes"}, - {"id": "datum_plane", "name": "Datum plane", "mode": "model", "shape": "datum"}, - {"id": "datum_axis", "name": "Datum axis", "mode": "model", "shape": "axis"}, - {"id": "coordsys", "name": "Coordinate system", "mode": "model", "shape": "csys"}, - {"id": "art", "name": "Text / imported art","mode": "model", "shape": "art"}, - {"id": "sk_loop", "name": "Closed sketch loop", "mode": "model", "shape": "loop"}, - {"id": "sk_none", "name": "Sketch, nothing picked", "mode": "sketch", "shape": "sk_empty"}, - {"id": "sk_line", "name": "Sketch line", "mode": "sketch", "shape": "sk_line"}, - {"id": "sk_arc", "name": "Sketch arc or circle","mode": "sketch","shape": "sk_arc"}, - {"id": "sk_point", "name": "Sketch point", "mode": "sketch", "shape": "sk_point"}, - {"id": "sk_2ent", "name": "Two sketch entities","mode": "sketch", "shape": "sk_two"} + { + "id": "none", + "name": "Nothing selected", + "mode": "model", + "shape": "empty" + }, + { + "id": "face_planar", + "name": "Planar face", + "mode": "model", + "shape": "box_top" + }, + { + "id": "face_cyl", + "name": "Cylindrical face", + "mode": "model", + "shape": "box_bore" + }, + { + "id": "face_other", + "name": "Curved face", + "mode": "model", + "shape": "box_fillet_face" + }, + { + "id": "edge_str", + "name": "Straight edge", + "mode": "model", + "shape": "box_edge" + }, + { + "id": "edge_circ", + "name": "Circular edge", + "mode": "model", + "shape": "box_bore_rim" + }, + { + "id": "vertex", + "name": "Vertex", + "mode": "model", + "shape": "box_vertex" + }, + { + "id": "body_solid", + "name": "Solid body", + "mode": "model", + "shape": "box_whole" + }, + { + "id": "body_sheet", + "name": "Sheet body", + "mode": "model", + "shape": "sheet" + }, + { + "id": "bodies_2", + "name": "Two bodies", + "mode": "model", + "shape": "two_boxes" + }, + { + "id": "datum_plane", + "name": "Datum plane", + "mode": "model", + "shape": "datum" + }, + { + "id": "datum_axis", + "name": "Datum axis", + "mode": "model", + "shape": "axis" + }, + { + "id": "coordsys", + "name": "Coordinate system", + "mode": "model", + "shape": "csys" + }, + { + "id": "art", + "name": "Text / imported art", + "mode": "model", + "shape": "art" + }, + { + "id": "sk_loop", + "name": "Closed sketch loop", + "mode": "model", + "shape": "loop" + }, + { + "id": "sk_none", + "name": "Sketch, nothing picked", + "mode": "sketch", + "shape": "sk_empty" + }, + { + "id": "sk_line", + "name": "Sketch line", + "mode": "sketch", + "shape": "sk_line" + }, + { + "id": "sk_arc", + "name": "Sketch arc or circle", + "mode": "sketch", + "shape": "sk_arc" + }, + { + "id": "sk_point", + "name": "Sketch point", + "mode": "sketch", + "shape": "sk_point" + }, + { + "id": "sk_2ent", + "name": "Two sketch entities", + "mode": "sketch", + "shape": "sk_two" + } ], - "doc_states": [ - {"id": "rich", "name": "Working document", "bodies": 2, "sketches": 2, "sheet": true, - "note": "two solids, two sketches, one sheet body — everything doc-gated is legal"}, - {"id": "fresh", "name": "Fresh document", "bodies": 0, "sketches": 0, "sheet": false, - "note": "nothing built yet — shows how much of the ring is empty on first open"} + { + "id": "rich", + "name": "Working document", + "bodies": 2, + "sketches": 2, + "sheet": true, + "note": "two solids, two sketches, one sheet body — everything doc-gated is legal" + }, + { + "id": "fresh", + "name": "Fresh document", + "bodies": 0, + "sketches": 0, + "sheet": false, + "note": "nothing built yet — shows how much of the ring is empty on first open" + } ], - "verbs": [ - {"id": "sketch", "name": "Sketch", "slot": "create", "key": "Shift+S", "feature": "Sketch", "mcp": null, - "accepts": ["face_planar", "datum_plane", "none"], "needs": {}, - "refusal": "Click a face or a reference plane in the viewport, then a sketch tool", "gui": true}, - - {"id": "extrude", "name": "Extrude", "slot": "add", "key": "Shift+E", "feature": "Extrude", "mcp": "extrude", - "accepts": ["sk_loop", "face_planar"], "needs": {}, - "refusal": "Create a sketch, or pick a solid face, first", "gui": true}, - {"id": "revolve", "name": "Revolve", "slot": "add", "key": "Shift+R", "feature": "Revolve", "mcp": "revolve", - "accepts": ["sk_loop"], "needs": {}, - "refusal": "Create a sketch profile to revolve first", "gui": true}, - {"id": "sweep", "name": "Sweep", "slot": "add", "key": "Shift+W", "feature": "Sweep", "mcp": null, - "accepts": ["sk_loop"], "needs": {"sketches": 2}, - "refusal": "Create a profile sketch to sweep first", "gui": true}, - {"id": "loft", "name": "Loft", "slot": "add", "key": "Shift+L", "feature": "Loft", "mcp": null, - "accepts": ["sk_loop"], "needs": {"sketches": 2}, - "refusal": "Create at least two profile sketches to loft", "gui": true}, - {"id": "thicken", "name": "Thicken", "slot": "add", "key": null, "feature": "Thicken", "mcp": "thicken", - "accepts": ["face_planar", "face_other"], "needs": {"bodies": 1}, - "refusal": "Thicken needs a solid body — add or import one first", "gui": true}, - {"id": "rib", "name": "Rib", "slot": "add", "key": null, "feature": "Rib", "mcp": "rib", - "accepts": ["sk_line"], "needs": {"bodies": 1}, - "refusal": "Rib needs a solid body — add or import one first", "gui": true}, - {"id": "boolean", "name": "Combine", "slot": "add", "key": "Shift+B", "feature": "Boolean", "mcp": "boolean", - "accepts": ["bodies_2"], "needs": {"bodies": 2}, - "refusal": "Boolean needs two bodies — create or import a second solid", "gui": true}, - {"id": "surf_extrude", "name": "Surface Extrude", "slot": "add", "key": "Shift+G", "feature": "SurfaceExtrude","mcp": "surface_extrude", - "accepts": ["sk_loop"], "needs": {}, "refusal": "Create a sketch first", "gui": true}, - {"id": "surf_revolve", "name": "Surface Revolve", "slot": "add", "key": null, "feature": "SurfaceRevolve","mcp": "surface_revolve", - "accepts": ["sk_loop"], "needs": {}, "refusal": "Create a sketch profile to revolve first", "gui": true}, - {"id": "surf_loft", "name": "Surface Loft", "slot": "add", "key": null, "feature": "SurfaceLoft", "mcp": "surface_loft", - "accepts": ["sk_loop"], "needs": {"sketches": 2}, "refusal": "Create at least two profile sketches to loft", "gui": true}, - {"id": "surf_fill", "name": "Surface Fill", "slot": "add", "key": null, "feature": "SurfaceFill", "mcp": "surface_fill", - "accepts": ["sk_loop"], "needs": {}, "refusal": "Create a closed sketch first", "gui": true}, - {"id": "thicken_surf", "name": "Thicken Surface", "slot": "add", "key": null, "feature": "ThickenSurface","mcp": "thicken_surface", - "accepts": ["body_sheet"], "needs": {"sheet": true}, "refusal": "target is not a sheet body", "gui": true}, - - {"id": "hole", "name": "Hole", "slot": "remove", "key": "Shift+H", "feature": "Hole", "mcp": "hole", - "accepts": ["face_planar", "datum_plane"], "needs": {"bodies": 1}, - "refusal": "Pick a face or a plane to drill into", "gui": true}, - {"id": "thread", "name": "Thread", "slot": "remove", "key": "Shift+T", "feature": "Thread", "mcp": null, - "accepts": ["face_cyl", "edge_circ"], "needs": {"bodies": 1}, - "refusal": "Pick a cylindrical surface (bore / outer) or a circular edge for a thread", "gui": true}, - {"id": "shell", "name": "Shell", "slot": "remove", "key": "Shift+K", "feature": "Shell", "mcp": "shell", - "accepts": ["face_planar", "body_solid"], "needs": {"bodies": 1}, - "refusal": "Shell needs a solid body", "gui": true}, - {"id": "cut", "name": "Cut", "slot": "remove", "key": "Shift+X", "feature": "Cut", "mcp": null, - "accepts": ["body_solid", "datum_plane"], "needs": {"bodies": 1}, - "refusal": "Create a solid body to cut first", "gui": true}, - {"id": "split", "name": "Split", "slot": "remove", "key": null, "feature": "Cut", "mcp": "split", - "accepts": ["body_solid"], "needs": {"bodies": 1}, "refusal": "Split needs a solid body", "gui": false}, - - {"id": "fillet", "name": "Fillet", "slot": "dressup", "key": "Shift+F", "feature": "Fillet", "mcp": "fillet", - "accepts": ["edge_str", "edge_circ", "face_planar", "body_solid"], "needs": {"bodies": 1}, - "refusal": "Pick an edge to round", "gui": true}, - {"id": "chamfer", "name": "Chamfer", "slot": "dressup", "key": null, "feature": "Chamfer", "mcp": "chamfer", - "accepts": ["edge_str", "edge_circ", "face_planar", "body_solid"], "needs": {"bodies": 1}, - "refusal": "Pick an edge to bevel", "gui": true}, - {"id": "draft", "name": "Draft", "slot": "dressup", "key": "Shift+D", "feature": "Draft", "mcp": "draft", - "accepts": ["face_planar", "face_other"], "needs": {"bodies": 1}, - "refusal": "Pick a face to taper", "gui": true}, - {"id": "surf_offset", "name": "Surface Offset", "slot": "dressup", "key": null, "feature": "SurfaceOffset", "mcp": "surface_offset", - "accepts": ["body_sheet"], "needs": {"sheet": true}, "refusal": "target is not a sheet body", "gui": true}, - - {"id": "pattern", "name": "Pattern", "slot": "repeat", "key": "Shift+N", "feature": "Pattern", "mcp": "pattern", - "accepts": ["body_solid", "face_planar", "sk_loop", "art"], "needs": {"bodies": 1}, - "refusal": "Create a solid body to pattern first", "gui": true}, - {"id": "mirror", "name": "Mirror", "slot": "repeat", "key": "Shift+Z", "feature": "Mirror", "mcp": "mirror", - "accepts": ["body_solid", "datum_plane"], "needs": {"bodies": 1}, - "refusal": "Mirror needs a body — add or import one first", "gui": true}, - {"id": "pat_curve", "name": "Pattern on Curve","slot": "repeat", "key": null, "feature": "Pattern", "mcp": "pattern_on_curve", - "accepts": ["body_solid", "edge_str"], "needs": {"bodies": 1}, - "refusal": "Pattern on curve needs a body and a curve", "gui": false}, - - {"id": "transform", "name": "Move", "slot": "transform", "key": "Shift+Y", "feature": "Transform", "mcp": "transform", - "accepts": ["body_solid", "body_sheet", "art"], "needs": {"bodies": 1}, - "refusal": "Transform needs a body — add or import one first", "gui": true}, - {"id": "mate", "name": "Mate", "slot": "transform", "key": null, "feature": "Mate", "mcp": "mate", - "accepts": ["coordsys", "bodies_2", "face_planar"], "needs": {"bodies": 2}, - "refusal": "A mate needs two coordinate systems", "gui": true}, - {"id": "align", "name": "Align to", "slot": "transform", "key": null, "feature": "Transform", "mcp": "transform", - "accepts": ["face_planar"], "needs": {"bodies": 1}, "refusal": "Align needs a body", "gui": false}, - - {"id": "plane", "name": "Plane", "slot": "reference", "key": "Shift+P", "feature": "Plane", "mcp": null, - "accepts": ["none", "face_planar", "edge_str", "datum_plane", "vertex"], "needs": {}, - "refusal": null, "gui": true}, - {"id": "axis", "name": "Axis", "slot": "reference", "key": "Shift+A", "feature": "Axis", "mcp": "axis", - "accepts": ["none", "face_planar", "face_cyl", "edge_str", "vertex"], "needs": {}, - "refusal": null, "gui": true}, - {"id": "coordsys_v", "name": "Coord Sys", "slot": "reference", "key": "Shift+C", "feature": "CoordSys", "mcp": "coordsys", - "accepts": ["none", "face_planar", "vertex"], "needs": {}, "refusal": null, "gui": true}, - {"id": "helix", "name": "Helix", "slot": "reference", "key": null, "feature": "Helix", "mcp": "helix", - "accepts": ["none", "datum_plane", "face_cyl"], "needs": {}, "refusal": null, "gui": true}, - {"id": "project", "name": "Project", "slot": "reference", "key": null, "feature": "Project", "mcp": "project", - "accepts": ["body_solid", "face_planar", "datum_plane"], "needs": {"bodies": 1}, - "refusal": "Project needs a body — add or import one first", "gui": true}, - {"id": "measure", "name": "Measure", "slot": "reference", "key": null, "feature": null, "mcp": "measure", - "accepts": ["face_planar", "face_cyl", "face_other", "edge_str", "edge_circ", "vertex", "body_solid", "body_sheet", "bodies_2", "sk_line", "sk_arc", "sk_2ent"], - "needs": {}, "refusal": null, "gui": false}, - {"id": "mass_props", "name": "Mass", "slot": "reference", "key": null, "feature": null, "mcp": "mass_properties", - "accepts": ["body_solid"], "needs": {"bodies": 1}, "refusal": null, "gui": false}, - {"id": "interference", "name": "Interference", "slot": "reference", "key": null, "feature": null, "mcp": "check_interference", - "accepts": ["bodies_2"], "needs": {"bodies": 2}, "refusal": null, "gui": false}, - - {"id": "edit_feature", "name": "Edit", "slot": "modify", "key": null, "feature": null, "mcp": "set_feature_expr", - "accepts": ["body_solid", "face_planar", "face_cyl", "face_other", "sk_loop", "art", "datum_plane", "datum_axis", "coordsys", "body_sheet"], - "needs": {}, "refusal": null, "gui": true}, - {"id": "delete_face", "name": "Delete Face", "slot": "modify", "key": null, "feature": "DeleteFace", "mcp": "delete_face", - "accepts": ["face_planar", "face_cyl", "face_other"], "needs": {"bodies": 1}, - "refusal": "Delete Face needs a body — add or import one first", "gui": true}, - {"id": "colour", "name": "Colour", "slot": "modify", "key": null, "feature": null, "mcp": null, - "accepts": ["body_solid", "body_sheet"], "needs": {"bodies": 1}, "refusal": null, "gui": true}, - {"id": "delete", "name": "Delete", "slot": "modify", "key": "Del", "feature": null, "mcp": null, - "accepts": ["body_solid", "body_sheet", "sk_loop", "art", "datum_plane", "datum_axis", "coordsys", "bodies_2", "sk_line", "sk_arc", "sk_point", "sk_2ent"], - "needs": {}, "refusal": null, "gui": true}, - - {"id": "sk_line_t", "name": "Line", "slot": "create", "key": "L", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_point", "sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_rect", "name": "Rectangle", "slot": "create", "key": "R", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_point", "sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_circle", "name": "Circle", "slot": "create", "key": "C", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_point", "sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_arc_t", "name": "Arc", "slot": "create", "key": "A", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_point", "sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_slot", "name": "Slot", "slot": "create", "key": "S", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_point", "sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_ellipse", "name": "Ellipse", "slot": "create", "key": "E", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_point", "sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_spline", "name": "Spline", "slot": "create", "key": "B", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_point", "sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_polygon", "name": "Polygon", "slot": "create", "key": "G", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_point", "sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_point_t", "name": "Point", "slot": "create", "key": "P", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_point", "sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - - {"id": "sk_offset", "name": "Offset", "slot": "add", "key": "O", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_trim", "name": "Trim", "slot": "remove", "key": "T", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_fillet", "name": "Fillet", "slot": "dressup", "key": "F", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_line", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_chamfer", "name": "Chamfer", "slot": "dressup", "key": "H", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_line", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_mirror", "name": "Mirror", "slot": "repeat", "key": "M", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_move", "name": "Move", "slot": "transform", "key": null, "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_line", "sk_arc", "sk_point", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_dimension", "name": "Dimension", "slot": "reference", "key": "D", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_line", "sk_arc", "sk_point", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_constrain", "name": "Constrain", "slot": "reference", "key": "K", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_line", "sk_arc", "sk_point", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_construct", "name": "Construction", "slot": "reference", "key": "Q", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_none", "sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_extend", "name": "Extend", "slot": "modify", "key": "X", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_line", "sk_arc", "sk_2ent"], "needs": {}, "refusal": null, "gui": true}, - {"id": "sk_delete", "name": "Delete", "slot": "modify", "key": "Del", "feature": "Sketch", "mcp": null, "mode": "sketch", - "accepts": ["sk_line", "sk_arc", "sk_point", "sk_2ent"], "needs": {}, "refusal": null, "gui": true} + { + "id": "sketch", + "name": "Sketch", + "slot": "create", + "key": "Shift+S", + "feature": "Sketch", + "mcp": null, + "accepts": [ + "face_planar", + "datum_plane", + "none" + ], + "needs": {}, + "refusal": "Click a face or a reference plane in the viewport, then a sketch tool", + "gui": true, + "action": "key:S+S" + }, + { + "id": "extrude", + "name": "Extrude", + "slot": "add", + "key": "Shift+E", + "feature": "Extrude", + "mcp": "extrude", + "accepts": [ + "sk_loop", + "face_planar" + ], + "needs": {}, + "refusal": "Create a sketch, or pick a solid face, first", + "gui": true, + "action": "key:S+E" + }, + { + "id": "revolve", + "name": "Revolve", + "slot": "add", + "key": "Shift+R", + "feature": "Revolve", + "mcp": "revolve", + "accepts": [ + "sk_loop" + ], + "needs": {}, + "refusal": "Create a sketch profile to revolve first", + "gui": true, + "action": "key:S+R" + }, + { + "id": "sweep", + "name": "Sweep", + "slot": "add", + "key": "Shift+W", + "feature": "Sweep", + "mcp": null, + "accepts": [ + "sk_loop" + ], + "needs": { + "sketches": 2 + }, + "refusal": "Create a profile sketch to sweep first", + "gui": true, + "action": "key:S+W" + }, + { + "id": "loft", + "name": "Loft", + "slot": "add", + "key": "Shift+L", + "feature": "Loft", + "mcp": null, + "accepts": [ + "sk_loop" + ], + "needs": { + "sketches": 2 + }, + "refusal": "Create at least two profile sketches to loft", + "gui": true, + "action": "key:S+L" + }, + { + "id": "thicken", + "name": "Thicken", + "slot": "add", + "key": null, + "feature": "Thicken", + "mcp": "thicken", + "accepts": [ + "face_planar", + "face_other" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Thicken needs a solid body — add or import one first", + "gui": true, + "action": "fly:material#4" + }, + { + "id": "rib", + "name": "Rib", + "slot": "add", + "key": null, + "feature": "Rib", + "mcp": "rib", + "accepts": [ + "sk_line" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Rib needs a solid body — add or import one first", + "gui": true, + "action": "fly:material#5" + }, + { + "id": "boolean", + "name": "Combine", + "slot": "add", + "key": "Shift+B", + "feature": "Boolean", + "mcp": "boolean", + "accepts": [ + "bodies_2" + ], + "needs": { + "bodies": 2 + }, + "refusal": "Boolean needs two bodies — create or import a second solid", + "gui": true, + "action": "key:S+B" + }, + { + "id": "surf_extrude", + "name": "Surface Extrude", + "slot": "add", + "key": "Shift+G", + "feature": "SurfaceExtrude", + "mcp": "surface_extrude", + "accepts": [ + "sk_loop" + ], + "needs": {}, + "refusal": "Create a sketch first", + "gui": true, + "action": "key:S+G" + }, + { + "id": "surf_revolve", + "name": "Surface Revolve", + "slot": "add", + "key": null, + "feature": "SurfaceRevolve", + "mcp": "surface_revolve", + "accepts": [ + "sk_loop" + ], + "needs": {}, + "refusal": "Create a sketch profile to revolve first", + "gui": true, + "action": "fly:surface#1" + }, + { + "id": "surf_loft", + "name": "Surface Loft", + "slot": "add", + "key": null, + "feature": "SurfaceLoft", + "mcp": "surface_loft", + "accepts": [ + "sk_loop" + ], + "needs": { + "sketches": 2 + }, + "refusal": "Create at least two profile sketches to loft", + "gui": true, + "action": "fly:surface#2" + }, + { + "id": "surf_fill", + "name": "Surface Fill", + "slot": "add", + "key": null, + "feature": "SurfaceFill", + "mcp": "surface_fill", + "accepts": [ + "sk_loop" + ], + "needs": {}, + "refusal": "Create a closed sketch first", + "gui": true, + "action": "fly:surface#3" + }, + { + "id": "thicken_surf", + "name": "Thicken Surface", + "slot": "add", + "key": null, + "feature": "ThickenSurface", + "mcp": "thicken_surface", + "accepts": [ + "body_sheet" + ], + "needs": { + "sheet": true + }, + "refusal": "target is not a sheet body", + "gui": true, + "action": "fly:surface#5" + }, + { + "id": "hole", + "name": "Hole", + "slot": "remove", + "key": "Shift+H", + "feature": "Hole", + "mcp": "hole", + "accepts": [ + "face_planar", + "datum_plane" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pick a face or a plane to drill into", + "gui": true, + "action": "key:S+H" + }, + { + "id": "thread", + "name": "Thread", + "slot": "remove", + "key": "Shift+T", + "feature": "Thread", + "mcp": null, + "accepts": [ + "face_cyl", + "edge_circ" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pick a cylindrical surface (bore / outer) or a circular edge for a thread", + "gui": true, + "action": "key:S+T" + }, + { + "id": "shell", + "name": "Shell", + "slot": "remove", + "key": "Shift+K", + "feature": "Shell", + "mcp": "shell", + "accepts": [ + "face_planar", + "body_solid" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Shell needs a solid body", + "gui": true, + "action": "key:S+K" + }, + { + "id": "cut", + "name": "Cut", + "slot": "remove", + "key": "Shift+X", + "feature": "Cut", + "mcp": null, + "accepts": [ + "body_solid", + "datum_plane" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Create a solid body to cut first", + "gui": true, + "action": "key:S+X" + }, + { + "id": "split", + "name": "Split", + "slot": "remove", + "key": null, + "feature": "Cut", + "mcp": "split", + "accepts": [ + "body_solid" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Split needs a solid body", + "gui": false, + "action": null + }, + { + "id": "fillet", + "name": "Fillet", + "slot": "dressup", + "key": "Shift+F", + "feature": "Fillet", + "mcp": "fillet", + "accepts": [ + "edge_str", + "edge_circ", + "face_planar", + "body_solid" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pick an edge to round", + "gui": true, + "action": "key:S+F" + }, + { + "id": "chamfer", + "name": "Chamfer", + "slot": "dressup", + "key": null, + "feature": "Chamfer", + "mcp": "chamfer", + "accepts": [ + "edge_str", + "edge_circ", + "face_planar", + "body_solid" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pick an edge to bevel", + "gui": true, + "action": "key:S+F" + }, + { + "id": "draft", + "name": "Draft", + "slot": "dressup", + "key": "Shift+D", + "feature": "Draft", + "mcp": "draft", + "accepts": [ + "face_planar", + "face_other" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pick a face to taper", + "gui": true, + "action": "key:S+D" + }, + { + "id": "surf_offset", + "name": "Surface Offset", + "slot": "dressup", + "key": null, + "feature": "SurfaceOffset", + "mcp": "surface_offset", + "accepts": [ + "body_sheet" + ], + "needs": { + "sheet": true + }, + "refusal": "target is not a sheet body", + "gui": true, + "action": "fly:surface#4" + }, + { + "id": "pattern", + "name": "Pattern", + "slot": "repeat", + "key": "Shift+N", + "feature": "Pattern", + "mcp": "pattern", + "accepts": [ + "body_solid", + "face_planar", + "sk_loop", + "art" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Create a solid body to pattern first", + "gui": true, + "action": "key:S+N" + }, + { + "id": "mirror", + "name": "Mirror", + "slot": "repeat", + "key": "Shift+Z", + "feature": "Mirror", + "mcp": "mirror", + "accepts": [ + "body_solid", + "datum_plane" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Mirror needs a body — add or import one first", + "gui": true, + "action": "key:S+Z" + }, + { + "id": "pat_curve", + "name": "Pattern on Curve", + "slot": "repeat", + "key": null, + "feature": "Pattern", + "mcp": "pattern_on_curve", + "accepts": [ + "body_solid", + "edge_str" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pattern on curve needs a body and a curve", + "gui": false, + "action": null + }, + { + "id": "transform", + "name": "Move", + "slot": "transform", + "key": "Shift+Y", + "feature": "Transform", + "mcp": "transform", + "accepts": [ + "body_solid", + "body_sheet", + "art" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Transform needs a body — add or import one first", + "gui": true, + "action": "key:S+Y" + }, + { + "id": "mate", + "name": "Mate", + "slot": "transform", + "key": null, + "feature": "Mate", + "mcp": "mate", + "accepts": [ + "coordsys", + "bodies_2", + "face_planar" + ], + "needs": { + "bodies": 2 + }, + "refusal": "A mate needs two coordinate systems", + "gui": true, + "action": "fly:placement#2" + }, + { + "id": "align", + "name": "Align to", + "slot": "transform", + "key": null, + "feature": "Transform", + "mcp": "transform", + "accepts": [ + "face_planar" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Align needs a body", + "gui": false, + "action": null + }, + { + "id": "plane", + "name": "Plane", + "slot": "reference", + "key": "Shift+P", + "feature": "Plane", + "mcp": null, + "accepts": [ + "none", + "face_planar", + "edge_str", + "datum_plane", + "vertex" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:S+P" + }, + { + "id": "axis", + "name": "Axis", + "slot": "reference", + "key": "Shift+A", + "feature": "Axis", + "mcp": "axis", + "accepts": [ + "none", + "face_planar", + "face_cyl", + "edge_str", + "vertex" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:S+A" + }, + { + "id": "coordsys_v", + "name": "Coord Sys", + "slot": "reference", + "key": "Shift+C", + "feature": "CoordSys", + "mcp": "coordsys", + "accepts": [ + "none", + "face_planar", + "vertex" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:S+C" + }, + { + "id": "helix", + "name": "Helix", + "slot": "reference", + "key": null, + "feature": "Helix", + "mcp": "helix", + "accepts": [ + "none", + "datum_plane", + "face_cyl" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:plane#3" + }, + { + "id": "project", + "name": "Project", + "slot": "reference", + "key": null, + "feature": "Project", + "mcp": "project", + "accepts": [ + "body_solid", + "face_planar", + "datum_plane" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Project needs a body — add or import one first", + "gui": true, + "action": "fly:plane#4" + }, + { + "id": "measure", + "name": "Measure", + "slot": "reference", + "key": null, + "feature": null, + "mcp": "measure", + "accepts": [ + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex", + "body_solid", + "body_sheet", + "bodies_2", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": false, + "action": null + }, + { + "id": "mass_props", + "name": "Mass", + "slot": "reference", + "key": null, + "feature": null, + "mcp": "mass_properties", + "accepts": [ + "body_solid" + ], + "needs": { + "bodies": 1 + }, + "refusal": null, + "gui": false, + "action": null + }, + { + "id": "interference", + "name": "Interference", + "slot": "reference", + "key": null, + "feature": null, + "mcp": "check_interference", + "accepts": [ + "bodies_2" + ], + "needs": { + "bodies": 2 + }, + "refusal": null, + "gui": false, + "action": null + }, + { + "id": "edit_feature", + "name": "Edit", + "slot": "modify", + "key": null, + "feature": null, + "mcp": "set_feature_expr", + "accepts": [ + "body_solid", + "face_planar", + "face_cyl", + "face_other", + "sk_loop", + "art", + "datum_plane", + "datum_axis", + "coordsys", + "body_sheet" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": null + }, + { + "id": "delete_face", + "name": "Delete Face", + "slot": "modify", + "key": null, + "feature": "DeleteFace", + "mcp": "delete_face", + "accepts": [ + "face_planar", + "face_cyl", + "face_other" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Delete Face needs a body — add or import one first", + "gui": true, + "action": "fly:dressup#3" + }, + { + "id": "colour", + "name": "Colour", + "slot": "modify", + "key": null, + "feature": null, + "mcp": null, + "accepts": [ + "body_solid", + "body_sheet" + ], + "needs": { + "bodies": 1 + }, + "refusal": null, + "gui": true, + "action": "btn:colour" + }, + { + "id": "delete", + "name": "Delete", + "slot": "modify", + "key": "Del", + "feature": null, + "mcp": null, + "accepts": [ + "body_solid", + "body_sheet", + "sk_loop", + "art", + "datum_plane", + "datum_axis", + "coordsys", + "bodies_2", + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:delete" + }, + { + "id": "sk_line_t", + "name": "Line", + "slot": "create", + "key": "L", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:L" + }, + { + "id": "sk_rect", + "name": "Rectangle", + "slot": "create", + "key": "R", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:R" + }, + { + "id": "sk_circle", + "name": "Circle", + "slot": "create", + "key": "C", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:C" + }, + { + "id": "sk_arc_t", + "name": "Arc", + "slot": "create", + "key": "A", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:A" + }, + { + "id": "sk_slot", + "name": "Slot", + "slot": "create", + "key": "S", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:S" + }, + { + "id": "sk_ellipse", + "name": "Ellipse", + "slot": "create", + "key": "E", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:E" + }, + { + "id": "sk_spline", + "name": "Spline", + "slot": "create", + "key": "B", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:B" + }, + { + "id": "sk_polygon", + "name": "Polygon", + "slot": "create", + "key": "G", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:G" + }, + { + "id": "sk_point_t", + "name": "Point", + "slot": "create", + "key": "P", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:P" + }, + { + "id": "sk_offset", + "name": "Offset", + "slot": "add", + "key": "O", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:O" + }, + { + "id": "sk_trim", + "name": "Trim", + "slot": "remove", + "key": "T", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:T" + }, + { + "id": "sk_fillet", + "name": "Fillet", + "slot": "dressup", + "key": "F", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:F" + }, + { + "id": "sk_chamfer", + "name": "Chamfer", + "slot": "dressup", + "key": "H", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:H" + }, + { + "id": "sk_mirror", + "name": "Mirror", + "slot": "repeat", + "key": "M", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:M" + }, + { + "id": "sk_move", + "name": "Move", + "slot": "transform", + "key": null, + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": null + }, + { + "id": "sk_dimension", + "name": "Dimension", + "slot": "reference", + "key": "D", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:D" + }, + { + "id": "sk_constrain", + "name": "Constrain", + "slot": "reference", + "key": "K", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:K" + }, + { + "id": "sk_construct", + "name": "Construction", + "slot": "reference", + "key": "Q", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:Q" + }, + { + "id": "sk_extend", + "name": "Extend", + "slot": "modify", + "key": "X", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:X" + }, + { + "id": "sk_delete", + "name": "Delete", + "slot": "modify", + "key": "Del", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:delete" + } ], - "chrome_only": { "_why": "document-level actions act on the DOCUMENT, not on a selection, so they stay in chrome and never enter the offer (see 4.1)", - "items": ["Import STEP", "Import mesh", "Text", "SVG", "Export STEP", "Commit to Plate", - "Undo", "Redo", "Variables", "Section view", "Origin planes", "World axes"] - } + "items": [ + "Import STEP", + "Import mesh", + "Text", + "SVG", + "Export STEP", + "Commit to Plate", + "Undo", + "Redo", + "Variables", + "Section view", + "Origin planes", + "World axes" + ] + }, + "_ratified": "Row order ratified 2026-07-31 by Tommaso. Changing an index is a breaking change (charter 4.1)." } diff --git a/src/slic3r/GUI/DesignCanvas.cpp b/src/slic3r/GUI/DesignCanvas.cpp index f736564b63..f0e58d12cf 100644 --- a/src/slic3r/GUI/DesignCanvas.cpp +++ b/src/slic3r/GUI/DesignCanvas.cpp @@ -789,6 +789,24 @@ void DesignCanvas::set_on_move_exit(std::function cb) m_sketch_tool.on_move_exit = std::move(cb); } +void DesignCanvas::set_on_context_menu(std::function cb) +{ + m_on_context_menu = std::move(cb); + if (!m_canvas_widget || m_ctx_bound) + return; + m_ctx_bound = true; + // Bound AFTER GLCanvas3D's own handlers, so this runs first and can consume the event. + // It only consumes when it actually opens the offer; every other right-click still falls + // through to the polyline-chain end and the move gizmo, which were there first. + m_canvas_widget->Bind(wxEVT_RIGHT_UP, [this](wxMouseEvent& e) { + if (m_on_context_menu && !is_sketching() && !inline_busy()) { + m_on_context_menu(m_canvas_widget->ClientToScreen(e.GetPosition())); + return; // consumed + } + e.Skip(); + }); +} + void DesignCanvas::set_on_undo_redo(std::function cb) { m_sketch_tool.on_undo_redo = std::move(cb); diff --git a/src/slic3r/GUI/DesignCanvas.hpp b/src/slic3r/GUI/DesignCanvas.hpp index 71af55056c..c3250bc577 100644 --- a/src/slic3r/GUI/DesignCanvas.hpp +++ b/src/slic3r/GUI/DesignCanvas.hpp @@ -182,6 +182,11 @@ public: void set_body_translucent(bool on); // render the solid see-through (fillet/chamfer preview) void set_body_hidden(bool on); // preview-only: hide base bodies, show only the result ghost void set_on_move_exit(std::function cb); // right-click finished the move-body gizmo + // Right-click (or its platform equivalent) on the viewport with no tool running: open the + // object-driven offer there. Fires with SCREEN coordinates. Deliberately NOT fired while a + // tool is live — right-click already ends a polyline chain and finishes the move gizmo, and + // taking those over would break two working interactions in order to add a third. + void set_on_context_menu(std::function cb); void delete_selected_sketch_entities(); bool inline_busy() const; // a sketch value field is open (guard keys) bool undo_last_sketch_entity(); // Ctrl+Z in a sketch: drop the last entity @@ -262,6 +267,9 @@ private: GLCanvas3D* m_canvas{nullptr}; int m_sw_gl{-1}; // -1 unknown, 0 hardware GL, 1 software GL + std::function m_on_context_menu; + bool m_ctx_bound{false}; // bind the RIGHT_UP handler once, however often the cb is set + Bed3D m_bed; Model m_model; bool m_first_frame{true}; diff --git a/src/slic3r/GUI/DesignOffer.hpp b/src/slic3r/GUI/DesignOffer.hpp new file mode 100644 index 0000000000..af65b7710e --- /dev/null +++ b/src/slic3r/GUI/DesignOffer.hpp @@ -0,0 +1,142 @@ +// GENERATED FILE — DO NOT EDIT. +// Source: docs/ux/tool_atlas.json Generator: docs/ux/mockups/gen_offer_table.py +// +// The object-driven tool offer (charter 4.1): every verb has ONE row index, that index +// is the same in every selection it appears in, and verbs that do not apply are shown +// disabled in place with their reason rather than removed. Row order was ratified +// 2026-07-31; changing an index is a breaking change to every user's muscle memory. +#ifndef slic3r_GUI_DesignOffer_hpp_ +#define slic3r_GUI_DesignOffer_hpp_ + +#include + +namespace Slic3r { namespace GUI { + +// What the viewport has selected. Ordered as in tool_atlas.json; the bitmask in +// OfferVerb::accepts indexes these. +enum class OfferSel : int { + None = 0, + FacePlanar = 1, + FaceCyl = 2, + FaceOther = 3, + EdgeStr = 4, + EdgeCirc = 5, + Vertex = 6, + BodySolid = 7, + BodySheet = 8, + Bodies2 = 9, + DatumPlane = 10, + DatumAxis = 11, + CoordSys = 12, + Art = 13, + SkLoop = 14, + SkNone = 15, + SkLine = 16, + SkArc = 17, + SkPoint = 18, + Sk2Ent = 19, + Count = 20 +}; + +inline uint32_t offer_bit(OfferSel s) { return 1u << int(s); } + +// One row of the offer. `action` routes to the code that already implements the verb: +// "key:S+E" -> m_keys_feature[SHIFT('E')] +// "key:L" -> m_keys_sketch['L'] +// "fly:material#4" -> row 4 of the "material" feature flyout +// "btn:delete" -> a standalone toolbar button +// nullptr -> kernel support exists, no GUI path yet (row shows disabled) +struct OfferVerb { + const char* id; + const char* name; // drawing-office word (L10); translated at use with wxGetTranslation + int row; // 0..7, the ratified index — NEVER reorder + const char* key; // shortcut shown in the row, or nullptr + const char* action; + const char* refusal; // why this row is greyed, in the product's own words + uint32_t accepts; // bitmask over OfferSel + int need_bodies; + int need_sketches; + bool need_sheet; + bool sketch_mode; // belongs to the sketch-mode vocabulary, not the model one +}; + +// Row labels, in ratified order. +static const char* const kOfferRowNames[] = { + "Create", + "Add material", + "Remove", + "Dress-up", + "Repeat", + "Transform", + "Reference", + "Modify", +}; +static const int kOfferRowCount = 8; + +static const OfferVerb kOfferVerbs[] = { + {"sketch", "Sketch", 0, "Shift+S", "key:S+S", "Click a face or a reference plane in the viewport, then a sketch tool", 0x00000403u, 0, 0, false, false}, + {"extrude", "Extrude", 1, "Shift+E", "key:S+E", "Create a sketch, or pick a solid face, first", 0x00004002u, 0, 0, false, false}, + {"revolve", "Revolve", 1, "Shift+R", "key:S+R", "Create a sketch profile to revolve first", 0x00004000u, 0, 0, false, false}, + {"sweep", "Sweep", 1, "Shift+W", "key:S+W", "Create a profile sketch to sweep first", 0x00004000u, 0, 2, false, false}, + {"loft", "Loft", 1, "Shift+L", "key:S+L", "Create at least two profile sketches to loft", 0x00004000u, 0, 2, false, false}, + {"thicken", "Thicken", 1, nullptr, "fly:material#4", "Thicken needs a solid body — add or import one first", 0x0000000au, 1, 0, false, false}, + {"rib", "Rib", 1, nullptr, "fly:material#5", "Rib needs a solid body — add or import one first", 0x00010000u, 1, 0, false, false}, + {"boolean", "Combine", 1, "Shift+B", "key:S+B", "Boolean needs two bodies — create or import a second solid", 0x00000200u, 2, 0, false, false}, + {"surf_extrude", "Surface Extrude", 1, "Shift+G", "key:S+G", "Create a sketch first", 0x00004000u, 0, 0, false, false}, + {"surf_revolve", "Surface Revolve", 1, nullptr, "fly:surface#1", "Create a sketch profile to revolve first", 0x00004000u, 0, 0, false, false}, + {"surf_loft", "Surface Loft", 1, nullptr, "fly:surface#2", "Create at least two profile sketches to loft", 0x00004000u, 0, 2, false, false}, + {"surf_fill", "Surface Fill", 1, nullptr, "fly:surface#3", "Create a closed sketch first", 0x00004000u, 0, 0, false, false}, + {"thicken_surf", "Thicken Surface", 1, nullptr, "fly:surface#5", "target is not a sheet body", 0x00000100u, 0, 0, true, false}, + {"hole", "Hole", 2, "Shift+H", "key:S+H", "Pick a face or a plane to drill into", 0x00000402u, 1, 0, false, false}, + {"thread", "Thread", 2, "Shift+T", "key:S+T", "Pick a cylindrical surface (bore / outer) or a circular edge for a thread", 0x00000024u, 1, 0, false, false}, + {"shell", "Shell", 2, "Shift+K", "key:S+K", "Shell needs a solid body", 0x00000082u, 1, 0, false, false}, + {"cut", "Cut", 2, "Shift+X", "key:S+X", "Create a solid body to cut first", 0x00000480u, 1, 0, false, false}, + {"split", "Split", 2, nullptr, nullptr, "Split needs a solid body", 0x00000080u, 1, 0, false, false}, + {"fillet", "Fillet", 3, "Shift+F", "key:S+F", "Pick an edge to round", 0x000000b2u, 1, 0, false, false}, + {"chamfer", "Chamfer", 3, nullptr, "key:S+F", "Pick an edge to bevel", 0x000000b2u, 1, 0, false, false}, + {"draft", "Draft", 3, "Shift+D", "key:S+D", "Pick a face to taper", 0x0000000au, 1, 0, false, false}, + {"surf_offset", "Surface Offset", 3, nullptr, "fly:surface#4", "target is not a sheet body", 0x00000100u, 0, 0, true, false}, + {"pattern", "Pattern", 4, "Shift+N", "key:S+N", "Create a solid body to pattern first", 0x00006082u, 1, 0, false, false}, + {"mirror", "Mirror", 4, "Shift+Z", "key:S+Z", "Mirror needs a body — add or import one first", 0x00000480u, 1, 0, false, false}, + {"pat_curve", "Pattern on Curve", 4, nullptr, nullptr, "Pattern on curve needs a body and a curve", 0x00000090u, 1, 0, false, false}, + {"transform", "Move", 5, "Shift+Y", "key:S+Y", "Transform needs a body — add or import one first", 0x00002180u, 1, 0, false, false}, + {"mate", "Mate", 5, nullptr, "fly:placement#2", "A mate needs two coordinate systems", 0x00001202u, 2, 0, false, false}, + {"align", "Align to", 5, nullptr, nullptr, "Align needs a body", 0x00000002u, 1, 0, false, false}, + {"plane", "Plane", 6, "Shift+P", "key:S+P", nullptr, 0x00000453u, 0, 0, false, false}, + {"axis", "Axis", 6, "Shift+A", "key:S+A", nullptr, 0x00000057u, 0, 0, false, false}, + {"coordsys_v", "Coord Sys", 6, "Shift+C", "key:S+C", nullptr, 0x00000043u, 0, 0, false, false}, + {"helix", "Helix", 6, nullptr, "fly:plane#3", nullptr, 0x00000405u, 0, 0, false, false}, + {"project", "Project", 6, nullptr, "fly:plane#4", "Project needs a body — add or import one first", 0x00000482u, 1, 0, false, false}, + {"measure", "Measure", 6, nullptr, nullptr, nullptr, 0x000b03feu, 0, 0, false, false}, + {"mass_props", "Mass", 6, nullptr, nullptr, nullptr, 0x00000080u, 1, 0, false, false}, + {"interference", "Interference", 6, nullptr, nullptr, nullptr, 0x00000200u, 2, 0, false, false}, + {"edit_feature", "Edit", 7, nullptr, nullptr, nullptr, 0x00007d8eu, 0, 0, false, false}, + {"delete_face", "Delete Face", 7, nullptr, "fly:dressup#3", "Delete Face needs a body — add or import one first", 0x0000000eu, 1, 0, false, false}, + {"colour", "Colour", 7, nullptr, "btn:colour", nullptr, 0x00000180u, 1, 0, false, false}, + {"delete", "Delete", 7, "Del", "btn:delete", nullptr, 0x000f7f80u, 0, 0, false, false}, + {"sk_line_t", "Line", 0, "L", "key:L", nullptr, 0x000f8000u, 0, 0, false, true}, + {"sk_rect", "Rectangle", 0, "R", "key:R", nullptr, 0x000f8000u, 0, 0, false, true}, + {"sk_circle", "Circle", 0, "C", "key:C", nullptr, 0x000f8000u, 0, 0, false, true}, + {"sk_arc_t", "Arc", 0, "A", "key:A", nullptr, 0x000f8000u, 0, 0, false, true}, + {"sk_slot", "Slot", 0, "S", "key:S", nullptr, 0x000f8000u, 0, 0, false, true}, + {"sk_ellipse", "Ellipse", 0, "E", "key:E", nullptr, 0x000f8000u, 0, 0, false, true}, + {"sk_spline", "Spline", 0, "B", "key:B", nullptr, 0x000f8000u, 0, 0, false, true}, + {"sk_polygon", "Polygon", 0, "G", "key:G", nullptr, 0x000f8000u, 0, 0, false, true}, + {"sk_point_t", "Point", 0, "P", "key:P", nullptr, 0x000f8000u, 0, 0, false, true}, + {"sk_offset", "Offset", 1, "O", "key:O", nullptr, 0x000b0000u, 0, 0, false, true}, + {"sk_trim", "Trim", 2, "T", "key:T", nullptr, 0x000b0000u, 0, 0, false, true}, + {"sk_fillet", "Fillet", 3, "F", "key:F", nullptr, 0x00090000u, 0, 0, false, true}, + {"sk_chamfer", "Chamfer", 3, "H", "key:H", nullptr, 0x00090000u, 0, 0, false, true}, + {"sk_mirror", "Mirror", 4, "M", "key:M", nullptr, 0x000b0000u, 0, 0, false, true}, + {"sk_move", "Move", 5, nullptr, nullptr, nullptr, 0x000f0000u, 0, 0, false, true}, + {"sk_dimension", "Dimension", 6, "D", "key:D", nullptr, 0x000f8000u, 0, 0, false, true}, + {"sk_constrain", "Constrain", 6, "K", "key:K", nullptr, 0x000f0000u, 0, 0, false, true}, + {"sk_construct", "Construction", 6, "Q", "key:Q", nullptr, 0x000b8000u, 0, 0, false, true}, + {"sk_extend", "Extend", 7, "X", "key:X", nullptr, 0x000b0000u, 0, 0, false, true}, + {"sk_delete", "Delete", 7, "Del", "btn:delete", nullptr, 0x000f0000u, 0, 0, false, true}, +}; +static const int kOfferVerbCount = 60; + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_DesignOffer_hpp_ diff --git a/src/slic3r/GUI/DesignPanel.cpp b/src/slic3r/GUI/DesignPanel.cpp index b4dcf1b61a..7ee244c47b 100644 --- a/src/slic3r/GUI/DesignPanel.cpp +++ b/src/slic3r/GUI/DesignPanel.cpp @@ -1,6 +1,7 @@ #include "DesignPanel.hpp" #include "DesignCanvas.hpp" #include "DesignSketchTool.hpp" +#include "DesignOffer.hpp" // generated offer table — see docs/ux/tool_atlas.json #include "libslic3r/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor #include "libslic3r/TriangleMesh.hpp" // mesh import: STL/OBJ -> indexed_triangle_set #include "libslic3r/Format/OBJ.hpp" @@ -476,6 +477,11 @@ DesignPanel::DesignPanel(wxWindow* parent) fo->actions.push_back(std::move(v.action)); fo->icon_names.emplace_back(v.icon); if (v.key) m_keys_feature[v.key] = fo->actions.back(); // key runs the same action + // …and the offer reaches the same action by its ratified address. Keyed on + // "fly:#" so the generated table can name it without the item + // struct growing a field at 26 call sites. + m_verb_actions["fly:" + std::string(id) + "#" + + std::to_string(fo->actions.size() - 1)] = fo->actions.back(); } fo->btn = b; fo->drop.Create(b); @@ -868,6 +874,8 @@ DesignPanel::DesignPanel(wxWindow* parent) auto* b_color = icon_btn("color_palette", _L("Color — set the selected body's display colour")); b_color->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_body_color(); }); fadd("color", b_color); + m_verb_actions["btn:colour"] = [this] { on_set_body_color(); }; + m_verb_actions["btn:delete"] = [this] { on_delete_feature(); }; // Dress-up: finishing operations on the faces and edges of an existing solid — nothing // that moves a body (see the Placement drawer) and nothing that creates geometry. @@ -3482,6 +3490,10 @@ DesignPanel::DesignPanel(wxWindow* parent) // action bar (shown while moving) hides and the move state clears. m_viewport->set_on_move_exit([this]() { m_move_body = -1; show_move_card(false); update_action_bar(); }); + // The offer (§4.1): right-click the geometry, get the verbs that apply to it. Left-click + // still only selects, so pointing at things stays quiet. + m_viewport->set_on_context_menu([this](const wxPoint& p) { show_offer_menu(p); }); + // The Line tool's length and the Dimension tool's value are both entered in-canvas now // (live quote labels + the floating SketchInlineEditor), so the old docked-card // callbacks (on_segment_drawn / on_dimension_pick_complete) are no longer wired. @@ -4885,6 +4897,156 @@ SketchPlane DesignPanel::sketch_plane_from_selection(wxString& what) const return plane_from_choice(m_ref_plane); } +// --------------------------------------------------------------------------------------------- +// The object-driven offer (charter §4.1). Right-click the geometry and get a vertical list in the +// ratified row order, with the verbs that do not apply DISABLED IN PLACE carrying their reason. +// The rows come from DesignOffer.hpp, generated from docs/ux/tool_atlas.json — the same file the +// mockups are drawn from, so a drawing and the product cannot drift apart. +// --------------------------------------------------------------------------------------------- + +// Which kind of thing is selected, as an OfferSel. Classified by the level the pick cycle has +// actually REACHED, so the menu describes what is highlighted — a header that names a face while +// the whole body is lit would be lying, and this menu's whole value is that it tells the truth +// about the selection. (Sketching on the face you merely clicked is unaffected: that path is +// sketch_plane_from_selection, which deliberately uses m_pick_face. snaporca-3a2.) +int DesignPanel::offer_selection_kind() const +{ + if (m_viewport && m_viewport->is_sketching()) + return int(OfferSel::SkNone); + + const int nb = int(m_doc.bodies.size()); + if (m_sel_solid_edge >= 0 && m_sel_solid_body >= 0 && m_sel_solid_body < nb) { + const TopoDS_Edge e = GeometryEngine::edge_by_index(m_doc.bodies[m_sel_solid_body].shape, + m_sel_solid_edge); + return int(GeometryEngine::circle_of_edge(e).ok ? OfferSel::EdgeCirc : OfferSel::EdgeStr); + } + if (m_sel_solid_face >= 0 && m_sel_solid_body >= 0 && m_sel_solid_body < nb) { + SketchPlane p; + if (m_doc.plane_of_face(m_sel_solid_body, m_sel_solid_face, p)) + return int(OfferSel::FacePlanar); + const TopoDS_Face f = GeometryEngine::face_by_index(m_doc.bodies[m_sel_solid_body].shape, + m_sel_solid_face); + return int(GeometryEngine::cylinder_of_face(f).ok ? OfferSel::FaceCyl : OfferSel::FaceOther); + } + if (m_sel_sketch_region >= 0) + return int(OfferSel::SkLoop); + if (m_sel_solid_body >= 0 && m_sel_solid_body < nb) + return int(CadDocument::is_sheet_shape(m_doc.bodies[m_sel_solid_body].shape) + ? OfferSel::BodySheet : OfferSel::BodySolid); + return int(OfferSel::None); +} + +// Route a row to the code that already implements the verb. Nothing here re-implements a tool: +// the offer is a second door onto the same room, which is what keeps the toolbar, the shortcuts +// and the menu from drifting into three behaviours. +void DesignPanel::run_offer_action(const char* action) +{ + if (!action || !*action) + return; + const std::string a(action); + if (a.rfind("key:", 0) == 0) { + const std::string k = a.substr(4); + if (k.size() >= 3 && k[0] == 'S' && k[1] == '+') { // "S+E" -> Shift+E, model mode + auto it = m_keys_feature.find(int(k[2]) | SC_SHIFT); + if (it != m_keys_feature.end() && it->second) it->second(); + } else if (!k.empty()) { // "L" -> sketch-mode letter + auto it = m_keys_sketch.find(int(k[0])); + if (it != m_keys_sketch.end() && it->second) it->second(); + } + return; + } + auto it = m_verb_actions.find(a); + if (it != m_verb_actions.end() && it->second) + it->second(); +} + +void DesignPanel::show_offer_menu(const wxPoint& screen_pos) +{ + const int kind = offer_selection_kind(); + const uint32_t bit = offer_bit(OfferSel(kind)); + const bool sketching = m_viewport && m_viewport->is_sketching(); + + const int bodies = int(m_doc.bodies.size()); + int sketches = 0; + for (const auto& f : m_doc.features) + if (f.type == CadFeatureType::Sketch) ++sketches; + bool sheet = false; + for (const auto& b : m_doc.bodies) + if (CadDocument::is_sheet_shape(b.shape)) { sheet = true; break; } + + auto applies = [&](const OfferVerb& v) { + return (v.accepts & bit) && v.need_bodies <= bodies && v.need_sketches <= sketches + && (!v.need_sheet || sheet); + }; + // Names and reasons live in the generated table as plain literals; they are the same strings + // the toolbar already ships, so the catalogue already carries their translations. + auto tr = [](const char* s) { return wxGetTranslation(wxString::FromUTF8(s)); }; + auto label = [&](const OfferVerb& v) { + wxString s = tr(v.name); + if (v.key && *v.key) s += "\t" + wxString::FromUTF8(v.key); + return s; + }; + + wxMenu menu; + std::vector bound; // menu id offset -> verb + const int base = wxID_HIGHEST + 4200; + + for (int row = 0; row < kOfferRowCount; ++row) { + std::vector live, family; + for (int i = 0; i < kOfferVerbCount; ++i) { + const OfferVerb& v = kOfferVerbs[i]; + if (v.row != row || v.sketch_mode != sketching) continue; + family.push_back(&v); + if (applies(v)) live.push_back(&v); + } + if (family.empty()) + continue; // no verb of this family in this mode + const wxString fam = tr(kOfferRowNames[row]); + + if (live.empty()) { + // DISABLED IN PLACE, with the reason. This is the row that makes the list worth + // having: a control that cannot be used still says what it is and what you would + // have to do first, in the product's own words (L7). + // + // The reason must be TRUE for the situation in front of the user. Taking the first + // refusal in the family printed "Transform needs a body — add or import one first" + // on a document that has a body, because the real obstacle was that nothing was + // selected. So: prefer the refusal of a verb that accepts THIS selection and fails + // only on document state — that message is about the actual blocker. If no verb in + // the family accepts this selection at all, the honest thing is to say so, or say + // nothing. + const char* why = nullptr; + for (const OfferVerb* v : family) + if ((v->accepts & bit) && v->refusal) { why = v->refusal; break; } + wxString s = fam; + if (why) + s += wxString::FromUTF8(" — ") + tr(why); + else if (OfferSel(kind) == OfferSel::None) + s += wxString::FromUTF8(" — ") + _L("select something first"); + menu.Append(base + int(bound.size()), s)->Enable(false); + bound.push_back(nullptr); + } else if (live.size() == 1) { + menu.Append(base + int(bound.size()), label(*live[0])) + ->Enable(live[0]->action != nullptr); + bound.push_back(live[0]); + } else { + auto* sub = new wxMenu(); + for (const OfferVerb* v : live) { + sub->Append(base + int(bound.size()), label(*v))->Enable(v->action != nullptr); + bound.push_back(v); + } + menu.AppendSubMenu(sub, fam); + } + } + + menu.Bind(wxEVT_MENU, [this, &bound, base](wxCommandEvent& e) { + const int i = e.GetId() - base; + if (i >= 0 && i < int(bound.size()) && bound[i]) + run_offer_action(bound[i]->action); + }); + PopupMenu(&menu, ScreenToClient(screen_pos)); +} + void DesignPanel::apply_plane_refs(CadFeature& f) const { f.plane_type = (PlaneType)m_plane_type->GetSelection(); diff --git a/src/slic3r/GUI/DesignPanel.hpp b/src/slic3r/GUI/DesignPanel.hpp index ad0f7f8078..19ff2ca0dd 100644 --- a/src/slic3r/GUI/DesignPanel.hpp +++ b/src/slic3r/GUI/DesignPanel.hpp @@ -602,6 +602,15 @@ private: // What the live sketch was actually opened on ("the picked face", "XY", a datum's name), so the // hint can say it. Resolved from the selection at begin_sketch, not read back from a combo. wxString m_sketch_on; + // --- the object-driven offer (charter 4.1) --------------------------------------------- + // Right-click the geometry -> a vertical list in ratified row order, verbs that do not + // apply disabled IN PLACE with their reason. The rows come from the generated table in + // DesignOffer.hpp; this map is how a row reaches the code that already implements it, for + // the verbs that have no keyboard shortcut to route through. + std::map> m_verb_actions; + void show_offer_menu(const wxPoint& screen_pos); + int offer_selection_kind() const; // an OfferSel, as int to keep the header light + void run_offer_action(const char* action); // Face-as-profile extrude (Onshape): when Extrude is opened on a picked solid face with // no sketch source, this carries that global face id so the kernel extrudes the face. // -1 = ordinary sketch/loop extrude. Set when opening the Extrude card, consumed on add.