#!/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'{inner}') 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)}
{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

{trs}
SelectionDocumentVerbsSlots2nd ringsWhat lands where

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("{inline(n)}
{esc(t)}
' for n, t in picks if inline(n)) head, _, tail = html.partition('

All states

') # The comparison grids in the head use , which resolves to nothing # once the page is served on its own. Inline those too rather than shipping empty frames. head = re.sub(r']*>', lambda m: inline(m.group(1)), head) inline_html = head + '

The states

\n
' + figs + '
\n' with open(os.path.join(UX, "offer_atlas_inline.html"), "w", encoding="utf-8") as f: f.write(inline_html) if __name__ == "__main__": main()