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.