Files
OrcaSlicer/docs/ux/mockups/gen_offer_table.py
T
Tommaso BianchiandClaude Opus 5 96f9261425 The offer table is generated again, and its last verb was unreachable
snaporca-ziam said gen_offer_table.py would silently delete the model-mode "Constrain sketch"
row, because that row lived in the generated header and not in tool_atlas.json. Running it found
more than that: FOUR rows existed only in the header — constrain, rename, and the three typed-
value rows sk_length / sk_radius / sk_angdist — and sk_delete's action had drifted, pointing the
sketch row at btn:delete, the FEATURE delete.

All five are now in the atlas, so the header regenerates byte-identically from it. Verbs may carry
a `note`, emitted as a C++ comment above the row: a rationale written into a generated file is
deleted by the next regeneration, which is how this started.

snaporca-z8rs (P1), found by making that true: after the atlas held all 92 verbs, the regenerated
header differed from the checked-in one by EXACTLY ONE LINE — kOfferVerbCount, 91 against 92.
Every consumer loops i < kOfferVerbCount, so the last row of the table was invisible: never listed
by show_offer_menu, never findable by mcp_run_verb. The verb that fell off the end is sk_angdist,
"Angle / distance…" — the typed-value row for a two-entity selection. On the one selection where
you would ask for the angle between two lines, the row that types it was not in the menu.

It survived because nothing compared the Sk2Ent menu against the table: sk_angdist accepts Sk2Ent
and nothing else, so an off-by-one that dropped the LAST verb was invisible from every other
selection. The vocabulary rung now covers Sk2Ent too, and picking the pair taught it one more
rig fact — shift-clicking a circle at its +X point grabs the RADIUS GRIP, which replaces the
selection with that one entity, so the pair silently collapsed to one and the offer answered
SkLine. Correctly, for the selection that actually existed.

gen_offer_table.py --check proves header == atlas and changes nothing; it is now the first step of
scripts/ladder-all.sh, and the only one that needs no rig.

Offer ladder 107/107, gesture ladder 93/93, both on the rig.

snaporca-ziam snaporca-z8rs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-23 06:14:48 +02:00

169 lines
7.8 KiB
Python

#!/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/CAD/DesignOffer.hpp, checked in and never hand-edited.
"""
import json
import os
import sys
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", "CAD", "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 <cstdint>",
"",
"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",
" // Second level INSIDE a row, for tools that come in variants: \"Rectangle\" holds corner,",
" // centre, oblique and rounded. nullptr = sits directly in the row. Keeps the row's own",
" // address fixed (L4.1) while the variants hang one level below it, mirroring the toolbar's",
" // grouping instead of flattening 19 create tools into one wall.",
" const char* family;",
" const char* icon; // resources/images name, or nullptr — the offer draws it beside the row",
" const char* hint; // what the verb does / what to click; shown on hover",
"};",
"",
"// 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[] = {",
]
# A verb the user can PICK must say what it does. Fail loudly rather than ship a
# bare name — the offer is now the only door to these tools.
blind = [v["id"] for v in A["verbs"] if v.get("action") and not v.get("hint")]
assert not blind, f"wired verbs with no hint: {blind}"
for v in A["verbs"]:
# A verb may carry a NOTE: the reason it exists, emitted as a C++ comment above its row.
# Without somewhere to put it, a rationale written into the generated header is deleted by
# the next regeneration — which is how the model-mode "Constrain sketch" row came to exist
# in the header and not in the atlas at all (snaporca-ziam). The map exists once; so does
# the explanation.
for ln in ([v["note"]] if isinstance(v.get("note"), str) else v.get("note") or []):
lines.append(f" // {ln}")
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, %s, %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",
cstr(v.get("family")), cstr(v.get("icon")), cstr(v.get("hint"))))
lines += [
"};",
f"static const int kOfferVerbCount = {len(A['verbs'])};",
"",
"}} // namespace Slic3r::GUI",
"",
"#endif // slic3r_GUI_DesignOffer_hpp_",
"",
]
text = "\n".join(lines)
# --check: prove the checked-in header IS what this generator produces, and change nothing.
# The header calls itself GENERATED and was hand-edited anyway; a claim like that is only
# worth having if something enforces it, so ladder-all.sh runs this on every gate.
if "--check" in sys.argv:
have = open(OUT, encoding="utf-8").read() if os.path.exists(OUT) else ""
if have == text:
print(f"{os.path.relpath(OUT, REPO)} matches tool_atlas.json")
return 0
import difflib
d = list(difflib.unified_diff(have.splitlines(), text.splitlines(),
"checked-in", "generated", lineterm="", n=1))
print(f"{os.path.relpath(OUT, REPO)} DIFFERS from tool_atlas.json:")
print("\n".join(d[:60]))
return 1
with open(OUT, "w", encoding="utf-8") as f:
f.write(text)
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")
return 0
if __name__ == "__main__":
sys.exit(main())