diff --git a/docs/ux/mockups/cmp_12__edge_str.svg b/docs/ux/mockups/cmp_12__edge_str.svg
new file mode 100644
index 0000000000..3e641e90ec
--- /dev/null
+++ b/docs/ux/mockups/cmp_12__edge_str.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/cmp_12__face_planar.svg b/docs/ux/mockups/cmp_12__face_planar.svg
new file mode 100644
index 0000000000..85809d5bf6
--- /dev/null
+++ b/docs/ux/mockups/cmp_12__face_planar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/cmp_12__sk_line.svg b/docs/ux/mockups/cmp_12__sk_line.svg
new file mode 100644
index 0000000000..6535c9fe77
--- /dev/null
+++ b/docs/ux/mockups/cmp_12__sk_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/cmp_8__edge_str.svg b/docs/ux/mockups/cmp_8__edge_str.svg
new file mode 100644
index 0000000000..2c41ce74fc
--- /dev/null
+++ b/docs/ux/mockups/cmp_8__edge_str.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/cmp_8__face_planar.svg b/docs/ux/mockups/cmp_8__face_planar.svg
new file mode 100644
index 0000000000..5a58e2fb72
--- /dev/null
+++ b/docs/ux/mockups/cmp_8__face_planar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/cmp_8__sk_line.svg b/docs/ux/mockups/cmp_8__sk_line.svg
new file mode 100644
index 0000000000..4ca4000d59
--- /dev/null
+++ b/docs/ux/mockups/cmp_8__sk_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__art.svg b/docs/ux/mockups/fresh__art.svg
new file mode 100644
index 0000000000..a54dd49b43
--- /dev/null
+++ b/docs/ux/mockups/fresh__art.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__art__modify.svg b/docs/ux/mockups/fresh__art__modify.svg
new file mode 100644
index 0000000000..184611c682
--- /dev/null
+++ b/docs/ux/mockups/fresh__art__modify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__bodies_2.svg b/docs/ux/mockups/fresh__bodies_2.svg
new file mode 100644
index 0000000000..f282bef03a
--- /dev/null
+++ b/docs/ux/mockups/fresh__bodies_2.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__body_sheet.svg b/docs/ux/mockups/fresh__body_sheet.svg
new file mode 100644
index 0000000000..635b2f6aa6
--- /dev/null
+++ b/docs/ux/mockups/fresh__body_sheet.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__body_sheet__modify.svg b/docs/ux/mockups/fresh__body_sheet__modify.svg
new file mode 100644
index 0000000000..0cd330046d
--- /dev/null
+++ b/docs/ux/mockups/fresh__body_sheet__modify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__body_solid.svg b/docs/ux/mockups/fresh__body_solid.svg
new file mode 100644
index 0000000000..ef21e2b8e2
--- /dev/null
+++ b/docs/ux/mockups/fresh__body_solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__body_solid__modify.svg b/docs/ux/mockups/fresh__body_solid__modify.svg
new file mode 100644
index 0000000000..8b5d1485a0
--- /dev/null
+++ b/docs/ux/mockups/fresh__body_solid__modify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__coordsys.svg b/docs/ux/mockups/fresh__coordsys.svg
new file mode 100644
index 0000000000..e20d08d000
--- /dev/null
+++ b/docs/ux/mockups/fresh__coordsys.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__coordsys__modify.svg b/docs/ux/mockups/fresh__coordsys__modify.svg
new file mode 100644
index 0000000000..b6b4871d16
--- /dev/null
+++ b/docs/ux/mockups/fresh__coordsys__modify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__datum_axis.svg b/docs/ux/mockups/fresh__datum_axis.svg
new file mode 100644
index 0000000000..8174807ceb
--- /dev/null
+++ b/docs/ux/mockups/fresh__datum_axis.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__datum_axis__modify.svg b/docs/ux/mockups/fresh__datum_axis__modify.svg
new file mode 100644
index 0000000000..cba7005358
--- /dev/null
+++ b/docs/ux/mockups/fresh__datum_axis__modify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__datum_plane.svg b/docs/ux/mockups/fresh__datum_plane.svg
new file mode 100644
index 0000000000..ffabd59969
--- /dev/null
+++ b/docs/ux/mockups/fresh__datum_plane.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__datum_plane__modify.svg b/docs/ux/mockups/fresh__datum_plane__modify.svg
new file mode 100644
index 0000000000..9ac26e4ce1
--- /dev/null
+++ b/docs/ux/mockups/fresh__datum_plane__modify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__datum_plane__reference.svg b/docs/ux/mockups/fresh__datum_plane__reference.svg
new file mode 100644
index 0000000000..b42479441a
--- /dev/null
+++ b/docs/ux/mockups/fresh__datum_plane__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__edge_circ.svg b/docs/ux/mockups/fresh__edge_circ.svg
new file mode 100644
index 0000000000..3e72544c04
--- /dev/null
+++ b/docs/ux/mockups/fresh__edge_circ.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__edge_str.svg b/docs/ux/mockups/fresh__edge_str.svg
new file mode 100644
index 0000000000..c4e54d5335
--- /dev/null
+++ b/docs/ux/mockups/fresh__edge_str.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__edge_str__reference.svg b/docs/ux/mockups/fresh__edge_str__reference.svg
new file mode 100644
index 0000000000..80dd7a877a
--- /dev/null
+++ b/docs/ux/mockups/fresh__edge_str__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__face_cyl.svg b/docs/ux/mockups/fresh__face_cyl.svg
new file mode 100644
index 0000000000..d2335e4177
--- /dev/null
+++ b/docs/ux/mockups/fresh__face_cyl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__face_cyl__reference.svg b/docs/ux/mockups/fresh__face_cyl__reference.svg
new file mode 100644
index 0000000000..7cb83afb56
--- /dev/null
+++ b/docs/ux/mockups/fresh__face_cyl__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__face_other.svg b/docs/ux/mockups/fresh__face_other.svg
new file mode 100644
index 0000000000..d8dcfb96ab
--- /dev/null
+++ b/docs/ux/mockups/fresh__face_other.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__face_planar.svg b/docs/ux/mockups/fresh__face_planar.svg
new file mode 100644
index 0000000000..a66268fc9a
--- /dev/null
+++ b/docs/ux/mockups/fresh__face_planar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__face_planar__reference.svg b/docs/ux/mockups/fresh__face_planar__reference.svg
new file mode 100644
index 0000000000..9aa608a5e7
--- /dev/null
+++ b/docs/ux/mockups/fresh__face_planar__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__none.svg b/docs/ux/mockups/fresh__none.svg
new file mode 100644
index 0000000000..56ac028b74
--- /dev/null
+++ b/docs/ux/mockups/fresh__none.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__none__reference.svg b/docs/ux/mockups/fresh__none__reference.svg
new file mode 100644
index 0000000000..0492aa6de0
--- /dev/null
+++ b/docs/ux/mockups/fresh__none__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_2ent.svg b/docs/ux/mockups/fresh__sk_2ent.svg
new file mode 100644
index 0000000000..dd02122149
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_2ent.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_2ent__create.svg b/docs/ux/mockups/fresh__sk_2ent__create.svg
new file mode 100644
index 0000000000..98e984bff2
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_2ent__create.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_2ent__dressup.svg b/docs/ux/mockups/fresh__sk_2ent__dressup.svg
new file mode 100644
index 0000000000..926594bb86
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_2ent__dressup.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_2ent__modify.svg b/docs/ux/mockups/fresh__sk_2ent__modify.svg
new file mode 100644
index 0000000000..242332967e
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_2ent__modify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_2ent__reference.svg b/docs/ux/mockups/fresh__sk_2ent__reference.svg
new file mode 100644
index 0000000000..aa105c3a9e
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_2ent__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_arc.svg b/docs/ux/mockups/fresh__sk_arc.svg
new file mode 100644
index 0000000000..0712508411
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_arc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_arc__create.svg b/docs/ux/mockups/fresh__sk_arc__create.svg
new file mode 100644
index 0000000000..d22d916e60
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_arc__create.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_arc__modify.svg b/docs/ux/mockups/fresh__sk_arc__modify.svg
new file mode 100644
index 0000000000..cf3fc2450f
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_arc__modify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_arc__reference.svg b/docs/ux/mockups/fresh__sk_arc__reference.svg
new file mode 100644
index 0000000000..cfbe0a1042
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_arc__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_line.svg b/docs/ux/mockups/fresh__sk_line.svg
new file mode 100644
index 0000000000..e40d823e13
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_line__create.svg b/docs/ux/mockups/fresh__sk_line__create.svg
new file mode 100644
index 0000000000..1a6f7b561c
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_line__create.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_line__dressup.svg b/docs/ux/mockups/fresh__sk_line__dressup.svg
new file mode 100644
index 0000000000..3fce662ba1
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_line__dressup.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_line__modify.svg b/docs/ux/mockups/fresh__sk_line__modify.svg
new file mode 100644
index 0000000000..26318535ef
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_line__modify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_line__reference.svg b/docs/ux/mockups/fresh__sk_line__reference.svg
new file mode 100644
index 0000000000..2abc91fc99
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_line__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_loop.svg b/docs/ux/mockups/fresh__sk_loop.svg
new file mode 100644
index 0000000000..58eaa9e4fb
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_loop.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_loop__add.svg b/docs/ux/mockups/fresh__sk_loop__add.svg
new file mode 100644
index 0000000000..c6e6c90701
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_loop__add.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_loop__modify.svg b/docs/ux/mockups/fresh__sk_loop__modify.svg
new file mode 100644
index 0000000000..416e62d7cd
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_loop__modify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_none.svg b/docs/ux/mockups/fresh__sk_none.svg
new file mode 100644
index 0000000000..ce7604c47d
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_none.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_none__create.svg b/docs/ux/mockups/fresh__sk_none__create.svg
new file mode 100644
index 0000000000..06994f9517
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_none__create.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_none__reference.svg b/docs/ux/mockups/fresh__sk_none__reference.svg
new file mode 100644
index 0000000000..e4e558bbd9
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_none__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_point.svg b/docs/ux/mockups/fresh__sk_point.svg
new file mode 100644
index 0000000000..65acf94ac7
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_point.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_point__create.svg b/docs/ux/mockups/fresh__sk_point__create.svg
new file mode 100644
index 0000000000..fbe094d147
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_point__create.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__sk_point__reference.svg b/docs/ux/mockups/fresh__sk_point__reference.svg
new file mode 100644
index 0000000000..8b02eba29f
--- /dev/null
+++ b/docs/ux/mockups/fresh__sk_point__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__vertex.svg b/docs/ux/mockups/fresh__vertex.svg
new file mode 100644
index 0000000000..1e1661d6aa
--- /dev/null
+++ b/docs/ux/mockups/fresh__vertex.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/fresh__vertex__reference.svg b/docs/ux/mockups/fresh__vertex__reference.svg
new file mode 100644
index 0000000000..aad3d46b6e
--- /dev/null
+++ b/docs/ux/mockups/fresh__vertex__reference.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/gen_offer_mockups.py b/docs/ux/mockups/gen_offer_mockups.py
new file mode 100644
index 0000000000..b2379df846
--- /dev/null
+++ b/docs/ux/mockups/gen_offer_mockups.py
@@ -0,0 +1,736 @@
+#!/usr/bin/env python3
+"""Render the object-driven tool offer from docs/ux/tool_atlas.json.
+
+Fork-neutral on purpose: this file and everything it emits name no product, so the
+two forks carry byte-identical copies (the charter itself is the only doc that
+substitutes the product name).
+
+Every mockup in docs/ux/mockups/ and the review page docs/ux/offer_atlas.html are
+generated by this script. Nothing is hand-drawn: the point of the offer is that a
+tool's address never changes, and a human drawing 40-odd states by hand is exactly
+how an address quietly changes.
+
+ python3 docs/ux/mockups/gen_offer_mockups.py
+
+It fails loudly rather than rendering a broken map: an unknown slot, an unknown
+selection id in `accepts`, or one address holding two different verbs for the same
+selection is an error, not a warning.
+
+SVG on purpose. There is no rsvg/inkscape/cairosvg on the build box and
+ImageMagick would rasterise through its own weak internal renderer; SVG renders
+exactly in a browser at any zoom and stays diffable in git. Rasterise later if
+slides need it.
+"""
+
+import json
+import re
+import math
+import os
+import sys
+from collections import defaultdict
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+UX = os.path.dirname(HERE)
+ATLAS = os.path.join(UX, "tool_atlas.json")
+
+# The reach target from the charter (L11 / 6.1): if the ring plus a real part do not
+# fit here, the design has failed before it is built.
+W, H = 1366, 768
+
+C = {
+ "app": "#1a1d21", "chrome": "#23272d", "panel": "#20242a", "line": "#31363d",
+ "vp0": "#2f353d", "vp1": "#242930", "grid": "#333a43",
+ "text": "#e7ecf1", "muted": "#8e9aa7", "dim": "#5f6a76",
+ "top": "#93a1b1", "left": "#6f7b89", "right": "#5a6472", "edge": "#39414b",
+ "hi": "#f5a623", "hi_soft": "#f5a62333", "sheet": "#7fa8c9",
+ "ring": "#171a1e", "chip": "#2e343c", "chip_line": "#3c444e",
+ "key": "#454f5b", "accent": "#4f9bd9", "empty": "#2a2f36",
+}
+
+
+# ---------------------------------------------------------------- glyphs
+# Each glyph draws inside a 24x24 box centred on (0,0) — i.e. -12..12.
+def _g(body):
+ return body
+
+
+GLYPHS = {
+ # model
+ "sketch": '',
+ "extrude": '',
+ "revolve": '',
+ "sweep": '',
+ "loft": '',
+ "thicken": '',
+ "rib": '',
+ "boolean": '',
+ "hole": '',
+ "thread": '',
+ "shell": '',
+ "cut": '',
+ "split": '',
+ "fillet": '',
+ "chamfer": '',
+ "draft": '',
+ "surf_off": '',
+ "pattern": '',
+ "mirror": '',
+ "pat_curve": '',
+ "move": '',
+ "mate": '',
+ "align": '',
+ "plane": '',
+ "axis": '',
+ "csys": '',
+ "helix": '',
+ "project": '',
+ "measure": '',
+ "mass": '',
+ "interfere": '',
+ "edit": '',
+ "del_face": '',
+ "colour": '',
+ "delete": '',
+ # sketch primitives
+ "sk_line": '',
+ "sk_rect": '',
+ "sk_circle": '',
+ "sk_arc": '',
+ "sk_slot": '',
+ "sk_ell": '',
+ "sk_spline": '',
+ "sk_poly": '',
+ "sk_point": '',
+ "sk_offset": '',
+ "sk_trim": '',
+ "sk_ext": '',
+ "sk_mir": '',
+ "sk_move": '',
+ "sk_dim": '',
+ "sk_lock": '',
+ "sk_constr": '',
+ # families (used when a slot holds more than one verb)
+ "fam_create": '',
+ "fam_add": '',
+ "fam_remove": '',
+ "fam_dressup": '',
+ "fam_repeat": '',
+ "fam_transform": '',
+ "fam_reference": '',
+ # Deliberately NOT the bare pencil: create/sketch already owns that shape and the two slots
+ # are adjacent (N and NW). A pencil over a solid reads as "change the thing that exists".
+ "fam_modify": ''
+ '',
+}
+
+VERB_GLYPH = {
+ "sketch": "sketch", "extrude": "extrude", "revolve": "revolve", "sweep": "sweep",
+ "loft": "loft", "thicken": "thicken", "rib": "rib", "boolean": "boolean",
+ "surf_extrude": "extrude", "surf_revolve": "revolve", "surf_loft": "loft",
+ "surf_fill": "surf_off", "thicken_surf": "thicken", "hole": "hole", "thread": "thread",
+ "shell": "shell", "cut": "cut", "split": "split", "fillet": "fillet",
+ "chamfer": "chamfer", "draft": "draft", "surf_offset": "surf_off",
+ "pattern": "pattern", "mirror": "mirror", "pat_curve": "pat_curve",
+ "transform": "move", "mate": "mate", "align": "align", "plane": "plane",
+ "axis": "axis", "coordsys_v": "csys", "helix": "helix", "project": "project",
+ "measure": "measure", "mass_props": "mass", "interference": "interfere",
+ "edit_feature": "edit", "delete_face": "del_face", "colour": "colour", "delete": "delete",
+ "sk_line_t": "sk_line", "sk_rect": "sk_rect", "sk_circle": "sk_circle",
+ "sk_arc_t": "sk_arc", "sk_slot": "sk_slot", "sk_ellipse": "sk_ell",
+ "sk_spline": "sk_spline", "sk_polygon": "sk_poly", "sk_point_t": "sk_point",
+ "sk_offset": "sk_offset", "sk_trim": "sk_trim", "sk_fillet": "fillet",
+ "sk_chamfer": "chamfer", "sk_mirror": "sk_mir", "sk_move": "sk_move",
+ "sk_dimension": "sk_dim", "sk_constrain": "sk_lock", "sk_construct": "sk_constr",
+ "sk_extend": "sk_ext", "sk_delete": "delete",
+}
+
+
+def glyph(name, cx, cy, colour, scale=1.0, sw=1.6):
+ body = GLYPHS.get(name, '')
+ return (f'{body}')
+
+
+# ---------------------------------------------------------------- iso scene
+COS30, SIN30 = math.cos(math.radians(30)), math.sin(math.radians(30))
+
+
+def iso(x, y, z, ox, oy, s=1.0):
+ return (ox + (x - y) * COS30 * s, oy + (x + y) * SIN30 * s - z * s)
+
+
+def poly(pts, fill, stroke=None, extra=""):
+ d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
+ # Only emit our own stroke-width when the caller has not supplied one: a duplicate
+ # attribute is last-one-wins in a browser but invalid markup, and it is exactly the
+ # kind of thing a stricter renderer refuses outright.
+ if stroke:
+ st = f' stroke="{stroke}"' + ("" if "stroke-width" in extra else ' stroke-width="1"')
+ else:
+ st = ' stroke="none"'
+ return f''
+
+
+def box(ox, oy, w=150, d=105, h=52, s=1.0, hi=None):
+ """Three visible faces of a box. `hi` names the face/edge to highlight."""
+ P = lambda x, y, z: iso(x, y, z, ox, oy, s)
+ top = [P(0, 0, h), P(w, 0, h), P(w, d, h), P(0, d, h)]
+ left = [P(0, d, h), P(w, d, h), P(w, d, 0), P(0, d, 0)]
+ right = [P(w, 0, h), P(w, d, h), P(w, d, 0), P(w, 0, 0)]
+ out = [
+ poly(left, C["left"], C["edge"]),
+ poly(right, C["right"], C["edge"]),
+ poly(top, C["hi"] if hi == "top" else C["top"], C["edge"]),
+ ]
+ if hi == "top":
+ out.append(poly(top, "none", "#ffd07a", ' stroke-width="2.5"'))
+ if hi == "front_edge":
+ a, b = P(0, d, h), P(w, d, h)
+ out.append(f'')
+ if hi == "vertex":
+ v = P(w, d, h)
+ out.append(f'')
+ if hi == "whole":
+ out.append(poly(top, "none", "#ffd07a", ' stroke-width="2.5"'))
+ out.append(poly(left, "none", "#ffd07a", ' stroke-width="2.5"'))
+ out.append(poly(right, "none", "#ffd07a", ' stroke-width="2.5"'))
+ return "".join(out), P
+
+
+def bore(P, cx, cy, h, r=22, s=1.0, hi=False):
+ """A cylindrical bore through the top face, drawn as ellipse + wall."""
+ c = P(cx, cy, h)
+ rx, ry = r * COS30 * 2 * s, r * SIN30 * 2 * s
+ col = C["hi"] if hi else "#20252b"
+ wall = C["hi"] if hi == "wall" else "#3b434d"
+ return (f''
+ f''
+ + (f'' if hi else "")), c
+
+
+def scene(kind, ox, oy):
+ """Return (svg, anchor) for a selection kind. Anchor = where the ring centres."""
+ s = 1.0
+ if kind == "origin":
+ # A document with nothing in it: the three origin planes are all there is to click.
+ P = lambda x, y, z: iso(x, y, z, ox, oy, s)
+ g = poly([P(-70, -70, 0), P(70, -70, 0), P(70, 70, 0), P(-70, 70, 0)],
+ "#4f9bd91f", "#4f9bd977", ' stroke-width="1.5" stroke-dasharray="6 4"')
+ g += poly([P(-70, 0, -70), P(70, 0, -70), P(70, 0, 70), P(-70, 0, 70)],
+ "#e05c5c14", "#e05c5c66", ' stroke-width="1.5" stroke-dasharray="6 4"')
+ g += poly([P(0, -70, -70), P(0, 70, -70), P(0, 70, 70), P(0, -70, 70)],
+ "#5ce07a14", "#5ce07a66", ' stroke-width="1.5" stroke-dasharray="6 4"')
+ return g, P(0, 0, 0)
+ if kind == "empty":
+ g, P = box(ox, oy, s=s)
+ return g, P(75, 52, 26)
+ if kind == "box_top":
+ g, P = box(ox, oy, s=s, hi="top")
+ return g, P(75, 52, 52)
+ if kind == "box_whole":
+ g, P = box(ox, oy, s=s, hi="whole")
+ return g, P(75, 52, 26)
+ if kind == "box_edge":
+ g, P = box(ox, oy, s=s, hi="front_edge")
+ return g, P(75, 105, 52)
+ if kind == "box_vertex":
+ g, P = box(ox, oy, s=s, hi="vertex")
+ return g, P(150, 105, 52)
+ if kind in ("box_bore", "box_bore_rim"):
+ g, P = box(ox, oy, s=s)
+ b, c = bore(P, 75, 52, 52, hi=("wall" if kind == "box_bore" else True))
+ return g + b, c
+ if kind == "box_fillet_face":
+ g, P = box(ox, oy, s=s)
+ a, b = P(0, 0, 52), P(0, 105, 52)
+ g += (f'')
+ return g, P(0, 52, 52)
+ if kind == "sheet":
+ P = lambda x, y, z: iso(x, y, z, ox, oy, s)
+ pts = [P(0, 0, 40), P(150, 0, 55), P(150, 105, 30), P(0, 105, 18)]
+ return (poly(pts, C["sheet"], "#ffd07a", ' stroke-width="2.5" opacity="0.85"'),
+ P(75, 52, 36))
+ if kind == "two_boxes":
+ g1, P1 = box(ox - 60, oy - 10, w=110, d=80, h=44, hi="whole")
+ g2, P2 = box(ox + 70, oy + 26, w=95, d=70, h=60, hi="whole")
+ return g1 + g2, (ox + 42, oy + 6)
+ if kind == "datum":
+ P = lambda x, y, z: iso(x, y, z, ox, oy, s)
+ g, _ = box(ox, oy, s=s)
+ pts = [P(-25, -25, 70), P(175, -25, 70), P(175, 130, 70), P(-25, 130, 70)]
+ g += poly(pts, "#4f9bd933", C["hi"], ' stroke-width="2.5" stroke-dasharray="6 4"')
+ return g, P(75, 52, 70)
+ if kind == "axis":
+ g, P = box(ox, oy, s=s)
+ a, b = P(-30, 52, 52), P(180, 52, 52)
+ g += (f'')
+ return g, P(75, 52, 52)
+ if kind == "csys":
+ P = lambda x, y, z: iso(x, y, z, ox, oy, s)
+ g, _ = box(ox, oy, s=s)
+ o = P(0, 0, 52)
+ for tgt, col in ((P(60, 0, 52), "#e05c5c"), (P(0, 60, 52), "#5ce07a"), (P(0, 0, 112), "#5c9ce0")):
+ g += (f'')
+ g += f''
+ return g, o
+ if kind == "art":
+ P = lambda x, y, z: iso(x, y, z, ox, oy, s)
+ g, _ = box(ox, oy, s=s)
+ c = P(75, 52, 52)
+ g += (f''
+ f'ABC')
+ return g, c
+ if kind == "loop":
+ P = lambda x, y, z: iso(x, y, z, ox, oy, s)
+ g, _ = box(ox, oy, s=s)
+ pts = [P(28, 22, 52), P(122, 22, 52), P(122, 83, 52), P(28, 83, 52)]
+ g += poly(pts, C["hi_soft"], C["hi"], ' stroke-width="3"')
+ return g, P(75, 52, 52)
+ # --- sketch mode: flat, camera normal to the plane
+ gx, gy = ox - 30, oy - 10
+ grid = "".join(
+ f''
+ for i in range(11)) + "".join(
+ f''
+ for i in range(16))
+ if kind == "sk_empty":
+ return grid, (gx, gy)
+ if kind == "sk_line":
+ return (grid + f''
+ f''
+ f'', (gx, gy + 10))
+ if kind == "sk_arc":
+ return (grid + f'', (gx, gy))
+ if kind == "sk_point":
+ return (grid + f'', (gx, gy))
+ if kind == "sk_two":
+ return (grid + f''
+ f'', (gx, gy + 5))
+ return grid, (gx, gy)
+
+
+# ---------------------------------------------------------------- app chrome
+def chrome(title, status, mode="model", empty_doc=False):
+ tabs = ["Prepare", "Preview", "Design"]
+ g = [f'',
+ f''
+ f''
+ f'',
+ f'',
+ f'']
+ x = 24
+ for t in tabs:
+ on = (t == "Design")
+ g.append(f'{t}')
+ if on:
+ g.append(f'')
+ x += len(t) * 9 + 34
+ # toolbar
+ g.append(f''
+ f'')
+ fams = (["sketch", "extrude", "hole", "fillet", "pattern", "boolean", "plane", "move", "measure"]
+ if mode == "model" else
+ ["sk_line", "sk_rect", "sk_circle", "sk_arc", "sk_slot", "sk_spline", "sk_dim", "sk_trim", "sk_lock"])
+ for i, gl in enumerate(fams):
+ cx = 40 + i * 46
+ g.append(f'')
+ g.append(glyph(gl, cx, 68, C["muted"], 0.62, 1.7))
+ # left rail
+ g.append(f''
+ f'')
+ g.append(f'FEATURES')
+ # A "fresh document" mockup that shows six existing features is a lie, and it is the one
+ # state the group will look at hardest — it is the first-run picture (B5).
+ tree = ([] if empty_doc else
+ [("Sketch1", 0), ("Extrude1", 0), ("Hole1", 1), ("Fillet1", 1), ("Sketch2", 0), ("Extrude2", 0)])
+ if empty_doc:
+ g.append(f'nothing yet')
+ for i, (n, ind) in enumerate(tree):
+ yy = 150 + i * 26
+ g.append(glyph("sketch" if n.startswith("Sketch") else "extrude", 30 + ind * 14, yy - 4, C["dim"], 0.42, 1.8))
+ g.append(f'{n}')
+ # status bar
+ g.append(f''
+ f'{status}')
+ g.append(f'{title}')
+ return "".join(g)
+
+
+# ---------------------------------------------------------------- the ring
+def ring(cx, cy, items, capacity, sel_name, radius=134):
+ """items: dict angle_index -> (glyph, label, key, count) or None for an empty slot."""
+ # The scrim is deliberately faint. §4.1: the offer "never blocks the view of what it acts
+ # on" — at 0.55 the disc swallowed the very face the ring was opened on, which is the rule
+ # failing in its own mockup.
+ g = [f'',
+ f'',
+ f'']
+ step = 360.0 / capacity
+ for i in range(capacity):
+ ang = math.radians(-90 + i * step)
+ px, py = cx + radius * math.cos(ang), cy + radius * math.sin(ang)
+ it = items.get(i)
+ if not it:
+ g.append(f'')
+ continue
+ gl, label, key, count = it
+ g.append(f'')
+ g.append(glyph(gl, px, py - 2, C["text"], 0.92, 1.7))
+ g.append(f'{label}')
+ if key:
+ kw = 13 + len(key) * 6.6
+ g.append(f''
+ f'{key}')
+ if count and count > 1:
+ g.append(f''
+ f'{count}')
+ # The selection name sits on the LOWER rim of the centre hole, not above it: above, it
+ # landed on top of the north slot's shortcut chip and hid it. Below, the pick stays
+ # visible through the hole and the pill is clear of the south chip at cy+104.
+ w = 13 + len(sel_name) * 6.9
+ g.append(f''
+ f'{sel_name}')
+ return "".join(g)
+
+
+# ---------------------------------------------------------------- enumeration
+def load():
+ with open(ATLAS, encoding="utf-8") as f:
+ return json.load(f)
+
+
+def validate(A):
+ slot_ids = {s["id"] for s in A["slots"]}
+ sel_ids = {s["id"] for s in A["selections"]}
+ errs = []
+ seen = {}
+ for v in A["verbs"]:
+ if v["slot"] not in slot_ids:
+ errs.append(f'{v["id"]}: unknown slot {v["slot"]!r}')
+ for a in v["accepts"]:
+ if a not in sel_ids:
+ errs.append(f'{v["id"]}: accepts unknown selection {a!r}')
+ if v["id"] in seen:
+ errs.append(f'{v["id"]}: duplicate verb id')
+ seen[v["id"]] = v["slot"]
+ if errs:
+ sys.exit("tool_atlas.json is inconsistent:\n " + "\n ".join(errs))
+ return slot_ids, sel_ids
+
+
+def eligible(A, sel_id, doc):
+ sel = next(s for s in A["selections"] if s["id"] == sel_id)
+ out = []
+ for v in A["verbs"]:
+ if v.get("mode", "model") != sel["mode"]:
+ continue
+ if sel_id not in v["accepts"]:
+ continue
+ n = v.get("needs") or {}
+ if n.get("bodies", 0) > doc["bodies"]:
+ continue
+ if n.get("sketches", 0) > doc["sketches"]:
+ continue
+ if n.get("sheet") and not doc["sheet"]:
+ continue
+ out.append(v)
+ return out
+
+
+def by_slot(A, verbs):
+ order = [s["id"] for s in A["slots"]]
+ d = defaultdict(list)
+ for v in verbs:
+ d[v["slot"]].append(v)
+ return {k: d[k] for k in order if d[k]}
+
+
+def ring_items(A, grouped):
+ order = [s["id"] for s in A["slots"]]
+ slot_label = {s["id"]: s["label"] for s in A["slots"]}
+ items = {}
+ for i, sid in enumerate(order):
+ vs = grouped.get(sid)
+ if not vs:
+ continue
+ if len(vs) == 1:
+ v = vs[0]
+ items[i] = (VERB_GLYPH.get(v["id"], "fam_" + sid), v["name"], v.get("key"), 1)
+ else:
+ items[i] = ("fam_" + sid, slot_label[sid], None, len(vs))
+ return items
+
+
+# ---------------------------------------------------------------- rendering
+OVERFLOWS = [] # (selection, doc state, family, verbs that did not fit the sub-ring)
+
+
+def svg_doc(inner):
+ return (f'')
+
+
+def render_state(A, sel, doc, capacity=8, secondary=None):
+ verbs = eligible(A, sel["id"], doc)
+ grouped = by_slot(A, verbs)
+ anchor_x, anchor_y = 810, 400
+ fresh = doc["bodies"] == 0 and doc["sketches"] == 0
+ shape = "origin" if (fresh and sel["shape"] == "empty") else sel["shape"]
+ art, (ax, ay) = scene(shape, anchor_x - 90, anchor_y - 40)
+ if secondary:
+ vs = grouped[secondary]
+ # A sub-ring ANCHORS ON ITS PARENT'S DIRECTION. Fanning from north instead put Extrude
+ # at N — Create's address in the primary map — so the second level contradicted the
+ # first. Anchored, an address is two consistent strokes: Add material is NE, and the
+ # first verb of Add material is NE again.
+ base = [s["id"] for s in A["slots"]].index(secondary)
+ overflow = []
+ if len(vs) > capacity:
+ # Do NOT wrap: (base+i) % capacity would quietly put verb 9 on top of verb 1, which
+ # is the invariant failing silently — the one outcome worse than an ugly ring. Show
+ # what fits, mark the rest, and report it.
+ overflow = vs[capacity - 1:]
+ vs = vs[:capacity - 1]
+ items = {(base + i) % capacity:
+ (VERB_GLYPH.get(v["id"], "fam_" + secondary), v["name"], v.get("key"), 1)
+ for i, v in enumerate(vs)}
+ if overflow:
+ items[(base + capacity - 1) % capacity] = ("fam_" + secondary, "More", None, len(overflow))
+ OVERFLOWS.append((sel["name"], doc["id"], secondary, [v["name"] for v in overflow]))
+ label = next(s["label"] for s in A["slots"] if s["id"] == secondary)
+ sel_name = f'{sel["name"]} · {label}'
+ status = f'{label} — pick one'
+ else:
+ items = ring_items(A, grouped)
+ sel_name = sel["name"]
+ status = ("Click a face or a reference plane, then a tool"
+ if sel["id"] == "none" else f'{sel["name"]} selected — pick what to do with it')
+ inner = chrome(f'{doc["name"]} · {capacity} slots', status, sel["mode"], empty_doc=fresh)
+ inner += art
+ inner += ring(ax, ay, items, capacity, sel_name)
+ return svg_doc(inner), verbs, grouped
+
+
+def main():
+ A = load()
+ validate(A)
+ docs = {d["id"]: d for d in A["doc_states"]}
+ outdir = HERE
+ rows, files = [], []
+ total_primary = total_secondary = 0
+ fill_sum = fill_n = 0
+
+ for d in A["doc_states"]:
+ for sel in A["selections"]:
+ svg, verbs, grouped = render_state(A, sel, d)
+ name = f'{d["id"]}__{sel["id"]}.svg'
+ with open(os.path.join(outdir, name), "w", encoding="utf-8") as f:
+ f.write(svg)
+ files.append((name, f'{sel["name"]} — {d["name"]}'))
+ total_primary += 1
+ fill_sum += len(grouped)
+ fill_n += 1
+ secondaries = []
+ for sid, vs in grouped.items():
+ if len(vs) > 1:
+ s2, _, _ = render_state(A, sel, d, secondary=sid)
+ n2 = f'{d["id"]}__{sel["id"]}__{sid}.svg'
+ with open(os.path.join(outdir, n2), "w", encoding="utf-8") as f:
+ f.write(s2)
+ label = next(s["label"] for s in A["slots"] if s["id"] == sid)
+ files.append((n2, f'{sel["name"]} — {label} ring'))
+ secondaries.append(sid)
+ total_secondary += 1
+ rows.append({
+ "doc": d["name"], "sel": sel["name"], "mode": sel["mode"],
+ "verbs": len(verbs), "slots": len(grouped),
+ "second": len(secondaries),
+ "detail": {sid: [v["name"] for v in vs] for sid, vs in grouped.items()},
+ })
+
+ # comparison sheet: 8 vs 12 slots on the same three selections
+ for cap in (8, 12):
+ for sid in ("face_planar", "edge_str", "sk_line"):
+ sel = next(s for s in A["selections"] if s["id"] == sid)
+ svg, _, _ = render_state(A, sel, docs["rich"], capacity=cap)
+ n = f'cmp_{cap}__{sid}.svg'
+ with open(os.path.join(outdir, n), "w", encoding="utf-8") as f:
+ f.write(svg)
+
+ write_atlas(A, rows, files, total_primary, total_secondary,
+ fill_sum / max(1, fill_n))
+ print(f"{total_primary} primary + {total_secondary} secondary = "
+ f"{total_primary+total_secondary} offer states rendered")
+ print(f"mean populated slots per primary ring: {fill_sum/max(1,fill_n):.2f} of 8")
+ if OVERFLOWS:
+ seen = {(f, tuple(v)) for _, _, f, v in OVERFLOWS}
+ print(f"OVERFLOW: {len(OVERFLOWS)} sub-rings did not fit 8 slots — "
+ f"{len(seen)} distinct case(s):")
+ for f, v in sorted(seen):
+ print(f" {f}: {', '.join(v)} pushed behind 'More'")
+
+
+def write_atlas(A, rows, files, n_prim, n_sec, mean_fill):
+ gui_missing = [v["name"] for v in A["verbs"] if not v.get("gui", True)]
+ esc = lambda s: (s.replace("&", "&").replace("<", "<").replace(">", ">"))
+ cards = "".join(
+ f'{esc(t)}'
+ for n, t in files)
+ cmp8 = "".join(f''
+ f'8 slots — {s}'
+ for s in ("face_planar", "edge_str", "sk_line"))
+ cmp12 = "".join(f''
+ f'12 slots — {s}'
+ for s in ("face_planar", "edge_str", "sk_line"))
+ if OVERFLOWS:
+ cases = sorted({(f, tuple(v)) for _, _, f, v in OVERFLOWS})
+ over_html = ("Measured, not predicted: " + "; ".join(
+ f'the {esc(f)} sub-ring needs {8 + len(v)} addresses, so '
+ f'{esc(", ".join(v))} {"is" if len(v) == 1 else "are"} pushed behind a “More” slot'
+ for f, v in cases) +
+ ". This is the decision the ring size actually turns on — either a verb moves to "
+ "another family, or the tail goes to a third level, or the ring is not eight. "
+ "It affects sketch mode only; every model-mode family fits.")
+ else:
+ over_html = "No sub-ring exceeds the ring capacity. Eight slots is sufficient everywhere."
+ trs = "".join(
+ f'
{esc(r["sel"])}
{esc(r["doc"])}
{r["verbs"]}
'
+ f'
{r["slots"]}/8
{r["second"]}
'
+ f'
{esc(" · ".join(k + ": " + ", ".join(v) for k, v in r["detail"].items()))}
'
+ for r in rows)
+ html = f"""Design tab — offer atlas
+
+
+
The offer — atlas of every state
+
Every state of the object-driven tool offer, generated from
+ docs/ux/tool_atlas.json. The invariant under test: a verb has one address, that
+ address is the same in every selection where it appears, and slots that do not apply are drawn
+ empty rather than compacted.
+
+
{len(A["verbs"])}verbs mapped
+
{len(A["selections"])}selection kinds
+
{n_prim}primary rings
+
{n_sec}secondary rings
+
{n_prim+n_sec}states total
+
{mean_fill:.1f}/8mean slots filled
+
{len(gui_missing)}verbs with no GUI yet
+
+
+
+
Decision 1 — ring capacity
+
The same three selections at eight and at twelve. Eight keeps 45° between neighbours, which is
+ the reliable eyes-free pointing threshold and maps 1:1 to the numpad; twelve buys direct addresses
+ for more verbs at 30° spacing and positions that stop being nameable.
+
{cmp8}
+
{cmp12}
+
+
Decision 2 — one map or one per mode
+
These renders use ONE shared map: sketch verbs occupy the same eight families as model verbs,
+ so dress-up is south-east whether you picked a solid edge or a sketch line. Compare the sketch-mode
+ states below against the model-mode ones — if a verb that exists in both modes ever appears at two
+ addresses, the shared map has failed and the split map is the answer.
+
+
The matrix — every selection, every document state
+
+
Selection
Document
Verbs
Slots
2nd rings
What lands where
+ {trs}
+
+
+
Overflow — the one place eight slots is not enough
+
{over_html}
+
+
Not in the offer
+
{esc(", ".join(A["chrome_only"]["items"]))} — these act on the document, not on a selection,
+ so they stay in chrome. Verbs with kernel support but no GUI today, which still hold an address:
+ {esc(", ".join(gui_missing))}.
+
+
All states
+
{cards}
+
+"""
+ with open(os.path.join(UX, "offer_atlas.html"), "w", encoding="utf-8") as f:
+ f.write(html)
+
+ # A second, SELF-CONTAINED copy for review outside the repo: the artifact host blocks
+ # every external request, so relative would silently show nothing. Curated
+ # rather than all 113 states — the whole set inlined is 1.5 MB, which is a poor thing to
+ # send to the low-end machine this design is meant to serve (6.1).
+ def inline(name):
+ p = os.path.join(HERE, name)
+ if not os.path.exists(p):
+ return ""
+ s = open(p, encoding="utf-8").read()
+ return s.replace("