#!/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
def menu(cx, cy, header, rows, submenu=None, sub_at=None):
"""Vertical list form of the offer. rows: (glyph, name, key, count, enabled, reason).
The invariant is unchanged — a verb has one permanent row index, and rows that do not
apply are DISABLED IN PLACE, never removed. What changes against the ring is what an
unavailable slot can say: an empty circle says nothing, a greyed row says its own name
and the reason it is grey, in the words the product already ships.
"""
RW, RH, HD = 324, 34, 38
# The reason line is the whole point of a disabled row, so it must FIT: at 10px italic a
# glyph is ~4.9px, and anything past the box edge is a promise the layout does not keep.
fit = int((RW - 62) / 4.9)
x, y = cx + 26, cy - 30
h = HD + len(rows) * RH + 10
if y + h > H - 44:
y = max(100, H - 44 - h)
g = [f'',
f'',
f'{header.upper()}',
f'']
# a leader from the pick point to the menu, so the list is visibly ABOUT that geometry
g.insert(0, f'')
g.insert(0, f'')
for i, (gl, name, key, count, on, reason) in enumerate(rows):
ry = y + HD + i * RH
op = "1" if on else "0.34"
if on and i == 0:
g.append(f'')
g.append(f'')
g.append(glyph(gl, x + 26, ry + RH / 2, C["text"], 0.72, 1.7))
g.append(f'{name}')
if key:
kw = 13 + len(key) * 6.6
g.append(f''
f'{key}')
elif count and count > 1:
g.append(f'')
g.append(f'{count}')
g.append('')
if not on and reason:
r = reason if len(reason) <= fit else reason[:fit - 1].rstrip(" ,—-") + "…"
g.append(f'{r}')
if submenu:
sy = y + HD + (sub_at or 0) * RH - 6
sh = 12 + len(submenu) * RH
sx = x + RW + 8
g.append(f'')
g.append(f'')
for i, (gl, name, key, _c, on, _r) in enumerate(submenu):
ry = sy + 6 + i * RH
g.append(f'')
g.append(glyph(gl, sx + 24, ry + RH / 2, C["text"], 0.72, 1.7))
g.append(f'{name}')
if key:
kw = 13 + len(key) * 6.6
g.append(f''
f'{key}')
g.append('')
return "".join(g)
OVERFLOWS = [] # (selection, doc state, family, verbs that did not fit the sub-ring)
def svg_doc(inner):
return (f'')
def render_list_state(A, sel, doc, expand=None):
"""The vertical-list form: every family row always present, in the same order, with the
ones that do not apply disabled and carrying their reason."""
verbs = eligible(A, sel["id"], doc)
grouped = by_slot(A, verbs)
fresh = doc["bodies"] == 0 and doc["sketches"] == 0
shape = "origin" if (fresh and sel["shape"] == "empty") else sel["shape"]
art, (ax, ay) = scene(shape, 620, 360)
rows, sub, sub_at = [], None, None
for idx, s in enumerate(A["slots"]):
vs = grouped.get(s["id"], [])
if len(vs) == 1:
v = vs[0]
rows.append((VERB_GLYPH.get(v["id"], "fam_" + s["id"]), v["name"], v.get("key"), 1, True, None))
elif len(vs) > 1:
rows.append(("fam_" + s["id"], s["label"], None, len(vs), True, None))
if expand == s["id"]:
sub_at = idx
sub = [(VERB_GLYPH.get(v["id"], "fam_" + s["id"]), v["name"], v.get("key"), 1, True, None)
for v in vs]
else:
# Disabled in place, with the product's own refusal text — the thing an empty
# slot in the ring could never say.
cands = [v for v in A["verbs"]
if v["slot"] == s["id"] and v.get("mode", "model") == sel["mode"]]
why = next((v["refusal"] for v in cands if v.get("refusal")), None)
rows.append(("fam_" + s["id"], s["label"], None, 0, False, why))
status = ("Right-click the geometry to see what you can do with it" if not expand
else f'{sel["name"]} — pick one')
inner = chrome(f'{doc["name"]} · vertical list', status, sel["mode"], empty_doc=fresh)
inner += art
inner += menu(ax, ay, sel["name"], rows, sub, sub_at)
return svg_doc(inner)
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()},
})
# Form-factor comparison: the SAME state as a ring and as a vertical list.
for sid in ("face_planar", "edge_str", "body_solid", "sk_line", "none"):
sel = next(s for s in A["selections"] if s["id"] == sid)
d = docs["fresh"] if sid == "none" else docs["rich"]
with open(os.path.join(outdir, f"list__{sid}.svg"), "w", encoding="utf-8") as f:
f.write(render_list_state(A, sel, d))
for sid, fam in (("face_planar", "add"), ("sk_none", "create")):
sel = next(s for s in A["selections"] if s["id"] == sid)
with open(os.path.join(outdir, f"list__{sid}__{fam}.svg"), "w", encoding="utf-8") as f:
f.write(render_list_state(A, sel, docs["rich"], expand=fam))
# 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 0 — ring or vertical list
The live question. Both forms carry the SAME map and the same invariant — fixed order, never
re-sorted, nothing compacted; only the geometry differs. Left-click selects; right-click opens the
offer. What the list buys: a disabled row can state its own reason in the words the product
already ships, where an empty slot in a ring is mute; nine sketch primitives fit without an
overflow; shortcuts line up in a readable column; long translated names fit; and it is navigable
by arrow key and by screen reader, which a radial is not. What it costs: no equidistant flick
gesture, and travel to the last row is longer than to the nearest direction. Pairs below —
list first, the same state as a ring second.
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("