Files
OrcaSlicer/docs/ux/mockups/gen_offer_mockups.py
T
Tommaso Bianchi c4990ee956 docs/ux: draw the offer as a vertical list too, and it wins
Tommaso was not sure about the ring and proposed a vertical list: left-click
selects, right-click exposes icon / name / shortcut. Drawn, it is better, and
the reasons are visible in the renders rather than arguable.

THE DISABLED ROW CAN SPEAK. This is the one that decides it. A ring slot that
does not apply is an empty circle: it says nothing, and on a fresh document six
of the eight are empty. A list row that does not apply is greyed IN PLACE with
its own name and its own reason — "Create a sketch, or pick a solid face,
first", "Create a solid body to pattern first" — which are strings the product
already ships and which tool_atlas.json already carries. The first-run picture
stops being a mostly-empty ring and becomes a map of what the product does and
what you must do first. For the audience section 2 puts first, that is the
whole ballgame.

THE OVERFLOW DISAPPEARS. Sketch Create needs nine addresses; a ring of eight
pushed Polygon and Point behind a "More" slot. Nine rows is just nine rows. The
one measured defect in the ring design is not a defect in this one.

SHORTCUTS READ AS A COLUMN. Right-aligned in a list they stack into something
the eye learns passively, which is exactly the graduation path 4.1 claims —
and it is the mechanism by which the power user Tommaso describes stops opening
the menu at all. Around a ring the same keys are eight loose chips.

Also, unglamorously: long translated names fit, arrow keys and screen readers
work natively where a radial needs special handling, and a 324px box costs the
1366x768 machine far less than a 380px disc over the model.

What the ring keeps: equidistant targets and a future flick gesture. Since the
brief is that power users live on the keyboard, that buys less than it looks.

The invariant is untouched — same eight families, same fixed order, nothing
re-sorted, nothing compacted. Only the geometry changed, which is the point:
the map survived a change of form factor, so it was a real map.

Both forms are now rendered side by side for the same states, and the atlas
opens with the pairs.

snaporca-96r.
2026-07-31 11:50:40 +02:00

885 lines
50 KiB
Python

#!/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": '<path d="M-8 8 L-5 7 L7 -5 L5 -7 L-7 5 Z"/><path d="M-8 8 L-6.5 6.5"/>',
"extrude": '<rect x="-8" y="2" width="16" height="6" rx="1"/><path d="M0 0 L0 -8 M-4 -4 L0 -8 L4 -4"/>',
"revolve": '<path d="M-7 6 A9 9 0 1 1 7 6" fill="none"/><path d="M7 6 L7 1 M7 6 L2.5 6"/><path d="M0 8 L0 -9" stroke-dasharray="3 2"/>',
"sweep": '<ellipse cx="-6" cy="0" rx="3" ry="6" fill="none"/><path d="M-6 -6 C2 -8 6 0 8 6" fill="none"/>',
"loft": '<ellipse cx="-6" cy="2" rx="3.5" ry="6" fill="none"/><ellipse cx="6" cy="-2" rx="2" ry="4" fill="none"/><path d="M-6 -4 L6 -6 M-6 8 L6 2" fill="none"/>',
"thicken": '<path d="M-9 3 L0 -2 L9 3" fill="none"/><path d="M-9 7 L0 2 L9 7" fill="none"/><path d="M-9 3 L-9 7 M9 3 L9 7"/>',
"rib": '<path d="M-9 7 L9 7" /><path d="M-2 7 L-2 -6 L2 -6 L2 7 Z" fill="none"/>',
"boolean": '<circle cx="-4" cy="0" r="6" fill="none"/><circle cx="4" cy="0" r="6" fill="none"/>',
"hole": '<rect x="-9" y="-6" width="18" height="12" rx="1" fill="none"/><circle cx="0" cy="0" r="3.2" fill="none"/><path d="M0 -5 L0 5 M-5 0 L5 0" stroke-dasharray="2 2"/>',
"thread": '<path d="M-4 -8 L4 -6 M-4 -4 L4 -2 M-4 0 L4 2 M-4 4 L4 6" fill="none"/><path d="M-4 -9 L-4 8 M4 -7 L4 9"/>',
"shell": '<rect x="-9" y="-7" width="18" height="14" rx="1" fill="none"/><rect x="-5.5" y="-3.5" width="11" height="10.5" rx="1" fill="none" stroke-dasharray="2 2"/>',
"cut": '<rect x="-8" y="-6" width="16" height="12" rx="1" fill="none"/><path d="M-11 4 L11 -4"/>',
"split": '<rect x="-8" y="-6" width="16" height="12" rx="1" fill="none"/><path d="M0 -9 L0 9" stroke-dasharray="3 2"/>',
"fillet": '<path d="M-9 8 L-9 -1 A8 8 0 0 1 -1 -9 L8 -9" fill="none"/><path d="M-9 8 L8 8 L8 -9" fill="none" stroke-dasharray="2 3"/>',
"chamfer": '<path d="M-9 8 L-9 -2 L-2 -9 L8 -9" fill="none"/><path d="M-9 8 L8 8 L8 -9" fill="none" stroke-dasharray="2 3"/>',
"draft": '<path d="M-8 8 L-4 -8 L4 -8 L8 8 Z" fill="none"/><path d="M-4 -8 L-4 8" stroke-dasharray="2 2"/>',
"surf_off": '<path d="M-9 2 C-3 -4 3 6 9 0" fill="none"/><path d="M-9 7 C-3 1 3 11 9 5" fill="none"/>',
"pattern": '<rect x="-9" y="-9" width="7" height="7" rx="1"/><rect x="2" y="-9" width="7" height="7" rx="1" fill="none"/><rect x="-9" y="2" width="7" height="7" rx="1" fill="none"/><rect x="2" y="2" width="7" height="7" rx="1" fill="none"/>',
"mirror": '<path d="M0 -10 L0 10" stroke-dasharray="3 2"/><path d="M-3 -6 L-9 0 L-3 6 Z"/><path d="M3 -6 L9 0 L3 6 Z" fill="none"/>',
"pat_curve": '<path d="M-10 6 C-4 -8 4 8 10 -6" fill="none"/><circle cx="-7" cy="0" r="2.4"/><circle cx="0" cy="0.5" r="2.4"/><circle cx="7" cy="0" r="2.4"/>',
"move": '<path d="M0 -10 L0 10 M-10 0 L10 0"/><path d="M0 -10 L-3 -6 M0 -10 L3 -6 M0 10 L-3 6 M0 10 L3 6 M-10 0 L-6 -3 M-10 0 L-6 3 M10 0 L6 -3 M10 0 L6 3"/>',
"mate": '<circle cx="-5" cy="0" r="4" fill="none"/><circle cx="5" cy="0" r="4" fill="none"/><path d="M-1 0 L1 0"/>',
"align": '<path d="M-10 -8 L-10 8"/><rect x="-6" y="-6" width="7" height="5" rx="1" fill="none"/><rect x="-6" y="2" width="12" height="5" rx="1" fill="none"/>',
"plane": '<path d="M-10 4 L-2 -6 L10 -4 L2 6 Z" fill="none"/>',
"axis": '<path d="M-10 7 L10 -7"/><circle cx="-10" cy="7" r="2"/><circle cx="10" cy="-7" r="2"/>',
"csys": '<path d="M-6 6 L-6 -8 M-6 6 L8 6 M-6 6 L-11 10"/><path d="M-6 -8 L-8 -5 M-6 -8 L-4 -5 M8 6 L5 4 M8 6 L5 8"/>',
"helix": '<path d="M-5 -9 C5 -7 5 -3 -5 -1 C5 1 5 5 -5 7" fill="none"/><path d="M-5 9 L-5 -10" stroke-dasharray="2 2"/>',
"project": '<path d="M-10 6 L-2 10 L10 6 L2 2 Z" fill="none"/><rect x="-5" y="-10" width="9" height="6" rx="1" fill="none"/><path d="M0 -3 L0 1 M-2.5 -1 L0 1.6 L2.5 -1"/>',
"measure": '<rect x="-10" y="-4" width="20" height="8" rx="1" fill="none"/><path d="M-5 -4 L-5 0 M0 -4 L0 1 M5 -4 L5 0"/>',
"mass": '<path d="M0 -9 L9 5 L-9 5 Z" fill="none"/><circle cx="0" cy="0" r="1.8"/>',
"interfere": '<circle cx="-4" cy="0" r="6" fill="none"/><circle cx="4" cy="0" r="6" fill="none"/><path d="M-1 -5 A6 6 0 0 0 -1 5 A6 6 0 0 0 -1 -5"/>',
"edit": '<path d="M-9 9 L-6 8 L6 -4 L4 -6 L-8 6 Z"/><path d="M3 -7 L6 -10 L9 -7 L6 -4 Z" fill="none"/>',
"del_face": '<path d="M-9 3 L0 -2 L9 3 L0 8 Z" fill="none" stroke-dasharray="2 2"/><path d="M-4 -9 L4 -9 M-3 -9 L-3 -4 L3 -4 L3 -9"/>',
"colour": '<circle cx="0" cy="0" r="9" fill="none"/><path d="M0 -9 A9 9 0 0 1 0 9 Z"/>',
"delete": '<path d="M-7 -5 L7 -5 M-5 -5 L-5 8 L5 8 L5 -5 M-2 -5 L-2 -8 L2 -8 L2 -5"/>',
# sketch primitives
"sk_line": '<path d="M-9 7 L9 -7"/><circle cx="-9" cy="7" r="2"/><circle cx="9" cy="-7" r="2"/>',
"sk_rect": '<rect x="-9" y="-6" width="18" height="12" rx="0.5" fill="none"/><circle cx="-9" cy="-6" r="2"/><circle cx="9" cy="6" r="2"/>',
"sk_circle": '<circle cx="0" cy="0" r="8" fill="none"/><circle cx="0" cy="0" r="1.6"/>',
"sk_arc": '<path d="M-8 5 A9 9 0 0 1 8 5" fill="none"/><circle cx="-8" cy="5" r="2"/><circle cx="8" cy="5" r="2"/>',
"sk_slot": '<path d="M-4 -5 A5 5 0 0 0 -4 5 L4 5 A5 5 0 0 0 4 -5 Z" fill="none"/><path d="M-4 0 L4 0" stroke-dasharray="2 2"/>',
"sk_ell": '<ellipse cx="0" cy="0" rx="9" ry="5.5" fill="none"/>',
"sk_spline": '<path d="M-10 5 C-5 -10 5 10 10 -5" fill="none"/><circle cx="-10" cy="5" r="1.8"/><circle cx="10" cy="-5" r="1.8"/>',
"sk_poly": '<path d="M0 -9 L8 -3 L5 7 L-5 7 L-8 -3 Z" fill="none"/>',
"sk_point": '<circle cx="0" cy="0" r="3"/><path d="M-9 0 L-5 0 M5 0 L9 0 M0 -9 L0 -5 M0 5 L0 9"/>',
"sk_offset": '<path d="M-9 3 L-3 -6 L9 -4" fill="none"/><path d="M-9 8 L-2 -1 L9 1" fill="none" stroke-dasharray="2 2"/>',
"sk_trim": '<circle cx="-6" cy="6" r="2.6" fill="none"/><circle cx="-6" cy="-6" r="2.6" fill="none"/><path d="M-4 4 L8 -6 M-4 -4 L8 6"/>',
"sk_ext": '<path d="M-10 0 L2 0" /><path d="M4 0 L10 0" stroke-dasharray="2 2"/><path d="M6 -3 L10 0 L6 3" fill="none"/>',
"sk_mir": '<path d="M0 -10 L0 10" stroke-dasharray="3 2"/><path d="M-3 -6 L-9 0 L-3 6 Z"/><path d="M3 -6 L9 0 L3 6 Z" fill="none"/>',
"sk_move": '<path d="M0 -10 L0 10 M-10 0 L10 0"/><path d="M0 -10 L-3 -6 M0 -10 L3 -6 M0 10 L-3 6 M0 10 L3 6 M-10 0 L-6 -3 M-10 0 L-6 3 M10 0 L6 -3 M10 0 L6 3"/>',
"sk_dim": '<path d="M-9 -6 L-9 6 M9 -6 L9 6 M-9 0 L9 0"/><path d="M-9 0 L-5 -3 M-9 0 L-5 3 M9 0 L5 -3 M9 0 L5 3"/>',
"sk_lock": '<rect x="-6" y="-1" width="12" height="9" rx="1.5" fill="none"/><path d="M-3.5 -1 L-3.5 -5 A3.5 3.5 0 0 1 3.5 -5 L3.5 -1" fill="none"/>',
"sk_constr": '<path d="M-10 4 L10 -4" stroke-dasharray="4 3"/>',
# families (used when a slot holds more than one verb)
"fam_create": '<path d="M-8 8 L-5 7 L7 -5 L5 -7 L-7 5 Z"/>',
"fam_add": '<rect x="-8" y="2" width="16" height="6" rx="1"/><path d="M0 0 L0 -8 M-4 -4 L0 -8 L4 -4"/>',
"fam_remove": '<rect x="-9" y="-6" width="18" height="12" rx="1" fill="none"/><circle cx="0" cy="0" r="3.2" fill="none"/>',
"fam_dressup": '<path d="M-9 8 L-9 -1 A8 8 0 0 1 -1 -9 L8 -9" fill="none"/>',
"fam_repeat": '<rect x="-9" y="-9" width="7" height="7" rx="1"/><rect x="2" y="-9" width="7" height="7" rx="1" fill="none"/><rect x="-9" y="2" width="7" height="7" rx="1" fill="none"/><rect x="2" y="2" width="7" height="7" rx="1" fill="none"/>',
"fam_transform": '<path d="M0 -10 L0 10 M-10 0 L10 0"/><path d="M0 -10 L-3 -6 M0 -10 L3 -6 M-10 0 L-6 -3 M-10 0 L-6 3"/>',
"fam_reference": '<path d="M-10 4 L-2 -6 L10 -4 L2 6 Z" fill="none"/>',
# 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": '<rect x="-10" y="-2" width="13" height="11" rx="1" fill="none"/>'
'<path d="M-2 5 L0 4.5 L9 -5 L7 -7 L-2.5 2.5 Z"/>',
}
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, '<circle cx="0" cy="0" r="7" fill="none"/>')
return (f'<g transform="translate({cx:.1f},{cy:.1f}) scale({scale:.3f})" '
f'stroke="{colour}" fill="{colour}" stroke-width="{sw:.2f}" '
f'stroke-linecap="round" stroke-linejoin="round">{body}</g>')
# ---------------------------------------------------------------- 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'<polygon points="{d}" fill="{fill}"{st}{extra}/>'
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'<line x1="{a[0]:.1f}" y1="{a[1]:.1f}" x2="{b[0]:.1f}" y2="{b[1]:.1f}" '
f'stroke="{C["hi"]}" stroke-width="5" stroke-linecap="round"/>')
if hi == "vertex":
v = P(w, d, h)
out.append(f'<circle cx="{v[0]:.1f}" cy="{v[1]:.1f}" r="6" fill="{C["hi"]}"/>')
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'<ellipse cx="{c[0]:.1f}" cy="{c[1]:.1f}" rx="{rx:.1f}" ry="{ry:.1f}" '
f'fill="{wall}" stroke="{C["edge"]}"/>'
f'<ellipse cx="{c[0]:.1f}" cy="{c[1]+7:.1f}" rx="{rx*0.92:.1f}" ry="{ry*0.92:.1f}" '
f'fill="#171b20" stroke="none"/>'
+ (f'<ellipse cx="{c[0]:.1f}" cy="{c[1]:.1f}" rx="{rx:.1f}" ry="{ry:.1f}" '
f'fill="none" stroke="{C["hi"]}" stroke-width="3"/>' 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'<path d="M{a[0]:.1f} {a[1]:.1f} L{b[0]:.1f} {b[1]:.1f}" stroke="{C["hi"]}" '
f'stroke-width="12" stroke-linecap="round" opacity="0.85" fill="none"/>')
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'<line x1="{a[0]:.1f}" y1="{a[1]:.1f}" x2="{b[0]:.1f}" y2="{b[1]:.1f}" '
f'stroke="{C["hi"]}" stroke-width="3" stroke-dasharray="10 5"/>')
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'<line x1="{o[0]:.1f}" y1="{o[1]:.1f}" x2="{tgt[0]:.1f}" y2="{tgt[1]:.1f}" '
f'stroke="{col}" stroke-width="3"/>')
g += f'<circle cx="{o[0]:.1f}" cy="{o[1]:.1f}" r="5" fill="{C["hi"]}"/>'
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'<g transform="translate({c[0]:.1f},{c[1]:.1f}) skewX(-30) scale(1,0.58)">'
f'<text x="0" y="10" text-anchor="middle" font-family="Georgia,serif" '
f'font-size="46" fill="{C["hi"]}" stroke="#ffd07a" stroke-width="1">ABC</text></g>')
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'<line x1="{gx-170}" y1="{gy-110+i*22}" x2="{gx+170}" y2="{gy-110+i*22}" stroke="{C["grid"]}" stroke-width="0.6"/>'
for i in range(11)) + "".join(
f'<line x1="{gx-170+i*22}" y1="{gy-110}" x2="{gx-170+i*22}" y2="{gy+110}" stroke="{C["grid"]}" stroke-width="0.6"/>'
for i in range(16))
if kind == "sk_empty":
return grid, (gx, gy)
if kind == "sk_line":
return (grid + f'<line x1="{gx-120}" y1="{gy+60}" x2="{gx+120}" y2="{gy-40}" '
f'stroke="{C["hi"]}" stroke-width="4" stroke-linecap="round"/>'
f'<circle cx="{gx-120}" cy="{gy+60}" r="5" fill="{C["hi"]}"/>'
f'<circle cx="{gx+120}" cy="{gy-40}" r="5" fill="{C["hi"]}"/>', (gx, gy + 10))
if kind == "sk_arc":
return (grid + f'<circle cx="{gx}" cy="{gy}" r="88" fill="none" stroke="{C["hi"]}" '
f'stroke-width="4"/><circle cx="{gx}" cy="{gy}" r="4" fill="{C["hi"]}"/>', (gx, gy))
if kind == "sk_point":
return (grid + f'<circle cx="{gx}" cy="{gy}" r="7" fill="{C["hi"]}"/>', (gx, gy))
if kind == "sk_two":
return (grid + f'<line x1="{gx-120}" y1="{gy+70}" x2="{gx+40}" y2="{gy+70}" stroke="{C["hi"]}" stroke-width="4" stroke-linecap="round"/>'
f'<line x1="{gx+40}" y1="{gy+70}" x2="{gx+40}" y2="{gy-60}" stroke="{C["hi"]}" stroke-width="4" stroke-linecap="round"/>', (gx, gy + 5))
return grid, (gx, gy)
# ---------------------------------------------------------------- app chrome
def chrome(title, status, mode="model", empty_doc=False):
tabs = ["Prepare", "Preview", "Design"]
g = [f'<rect width="{W}" height="{H}" fill="{C["app"]}"/>',
f'<defs><radialGradient id="vp" cx="50%" cy="42%" r="75%">'
f'<stop offset="0%" stop-color="{C["vp0"]}"/>'
f'<stop offset="100%" stop-color="{C["vp1"]}"/></radialGradient></defs>',
f'<rect x="248" y="92" width="{W-248}" height="{H-92-30}" fill="url(#vp)"/>',
f'<rect width="{W}" height="44" fill="{C["chrome"]}"/>']
x = 24
for t in tabs:
on = (t == "Design")
g.append(f'<text x="{x}" y="28" font-family="Inter,DejaVu Sans,sans-serif" font-size="14" '
f'font-weight="{600 if on else 400}" fill="{C["text"] if on else C["dim"]}">{t}</text>')
if on:
g.append(f'<rect x="{x-4}" y="38" width="{len(t)*8+8}" height="3" rx="1.5" fill="{C["accent"]}"/>')
x += len(t) * 9 + 34
# toolbar
g.append(f'<rect y="44" width="{W}" height="48" fill="{C["panel"]}"/>'
f'<line x1="0" y1="92" x2="{W}" y2="92" stroke="{C["line"]}"/>')
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'<rect x="{cx-16}" y="52" width="32" height="32" rx="6" fill="{C["chip"]}" opacity="0.55"/>')
g.append(glyph(gl, cx, 68, C["muted"], 0.62, 1.7))
# left rail
g.append(f'<rect x="0" y="92" width="248" height="{H-92-30}" fill="{C["panel"]}"/>'
f'<line x1="248" y1="92" x2="248" y2="{H-30}" stroke="{C["line"]}"/>')
g.append(f'<text x="18" y="122" font-family="Inter,DejaVu Sans,sans-serif" font-size="11" '
f'letter-spacing="1.4" fill="{C["dim"]}">FEATURES</text>')
# 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'<text x="18" y="152" font-family="Inter,DejaVu Sans,sans-serif" font-size="12.5" '
f'font-style="italic" fill="{C["dim"]}">nothing yet</text>')
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'<text x="{46+ind*14}" y="{yy}" font-family="Inter,DejaVu Sans,sans-serif" '
f'font-size="12.5" fill="{C["muted"]}">{n}</text>')
# status bar
g.append(f'<rect y="{H-30}" width="{W}" height="30" fill="{C["chrome"]}"/>'
f'<text x="18" y="{H-10}" font-family="Inter,DejaVu Sans,sans-serif" font-size="12.5" '
f'fill="{C["muted"]}">{status}</text>')
g.append(f'<text x="{W-18}" y="{H-10}" text-anchor="end" font-family="Inter,DejaVu Sans,sans-serif" '
f'font-size="11.5" fill="{C["dim"]}">{title}</text>')
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'<circle cx="{cx}" cy="{cy}" r="{radius+52}" fill="{C["ring"]}" opacity="0.22"/>',
f'<circle cx="{cx}" cy="{cy}" r="{radius+52}" fill="none" stroke="{C["chip_line"]}" opacity="0.5"/>',
f'<circle cx="{cx}" cy="{cy}" r="46" fill="none" stroke="{C["chip_line"]}" '
f'stroke-dasharray="3 4" opacity="0.85"/>']
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'<circle cx="{px:.1f}" cy="{py:.1f}" r="21" fill="{C["empty"]}" opacity="0.5" '
f'stroke="{C["chip_line"]}" stroke-dasharray="3 3"/>')
continue
gl, label, key, count = it
g.append(f'<rect x="{px-30:.1f}" y="{py-30:.1f}" width="60" height="60" rx="14" '
f'fill="{C["chip"]}" stroke="{C["chip_line"]}"/>')
g.append(glyph(gl, px, py - 2, C["text"], 0.92, 1.7))
g.append(f'<text x="{px:.1f}" y="{py+48:.1f}" text-anchor="middle" '
f'font-family="Inter,DejaVu Sans,sans-serif" font-size="12.5" font-weight="500" '
f'fill="{C["text"]}">{label}</text>')
if key:
kw = 13 + len(key) * 6.6
g.append(f'<rect x="{px-kw/2:.1f}" y="{py+55:.1f}" width="{kw:.1f}" height="17" rx="4.5" '
f'fill="{C["key"]}"/>'
f'<text x="{px:.1f}" y="{py+67:.1f}" text-anchor="middle" '
f'font-family="Inter,DejaVu Sans,sans-serif" font-size="10.5" '
f'fill="{C["text"]}">{key}</text>')
if count and count > 1:
g.append(f'<circle cx="{px+22:.1f}" cy="{py-22:.1f}" r="10" fill="{C["accent"]}"/>'
f'<text x="{px+22:.1f}" y="{py-18:.1f}" text-anchor="middle" '
f'font-family="Inter,DejaVu Sans,sans-serif" font-size="11" font-weight="600" '
f'fill="#0f1319">{count}</text>')
# 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'<rect x="{cx-w/2:.1f}" y="{cy+32:.1f}" width="{w:.1f}" height="22" rx="11" '
f'fill="{C["ring"]}" opacity="0.92" stroke="{C["chip_line"]}"/>'
f'<text x="{cx:.1f}" y="{cy+47:.1f}" text-anchor="middle" '
f'font-family="Inter,DejaVu Sans,sans-serif" font-size="12" fill="{C["text"]}">{sel_name}</text>')
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'<rect x="{x+3}" y="{y+4}" width="{RW}" height="{h}" rx="12" fill="#000" opacity="0.35"/>',
f'<rect x="{x}" y="{y}" width="{RW}" height="{h}" rx="12" fill="{C["chrome"]}" '
f'stroke="{C["chip_line"]}"/>',
f'<text x="{x+16}" y="{y+24}" font-family="Inter,DejaVu Sans,sans-serif" font-size="11.5" '
f'letter-spacing="1.1" fill="{C["dim"]}">{header.upper()}</text>',
f'<line x1="{x+1}" y1="{y+HD-6}" x2="{x+RW-1}" y2="{y+HD-6}" stroke="{C["line"]}"/>']
# a leader from the pick point to the menu, so the list is visibly ABOUT that geometry
g.insert(0, f'<path d="M{cx} {cy} L{x} {y+HD+16}" stroke="{C["chip_line"]}" '
f'stroke-dasharray="2 3" fill="none"/>')
g.insert(0, f'<circle cx="{cx}" cy="{cy}" r="5" fill="none" stroke="{C["hi"]}" stroke-width="2"/>')
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'<rect x="{x+5}" y="{ry+2}" width="{RW-10}" height="{RH-4}" rx="7" '
f'fill="{C["chip"]}"/>')
g.append(f'<g opacity="{op}">')
g.append(glyph(gl, x + 26, ry + RH / 2, C["text"], 0.72, 1.7))
g.append(f'<text x="{x+48}" y="{ry+RH/2+4.5}" font-family="Inter,DejaVu Sans,sans-serif" '
f'font-size="13.5" fill="{C["text"]}">{name}</text>')
if key:
kw = 13 + len(key) * 6.6
g.append(f'<rect x="{x+RW-18-kw:.1f}" y="{ry+RH/2-9:.1f}" width="{kw:.1f}" height="18" '
f'rx="4.5" fill="{C["key"]}"/>'
f'<text x="{x+RW-18-kw/2:.1f}" y="{ry+RH/2+4:.1f}" text-anchor="middle" '
f'font-family="Inter,DejaVu Sans,sans-serif" font-size="10.5" '
f'fill="{C["text"]}">{key}</text>')
elif count and count > 1:
g.append(f'<path d="M{x+RW-24} {ry+RH/2-5} L{x+RW-19} {ry+RH/2} L{x+RW-24} {ry+RH/2+5}" '
f'fill="none" stroke="{C["muted"]}" stroke-width="1.6" stroke-linecap="round"/>')
g.append(f'<text x="{x+RW-38}" y="{ry+RH/2+4}" text-anchor="end" '
f'font-family="Inter,DejaVu Sans,sans-serif" font-size="11.5" '
f'fill="{C["dim"]}">{count}</text>')
g.append('</g>')
if not on and reason:
r = reason if len(reason) <= fit else reason[:fit - 1].rstrip(" ,—-") + "…"
g.append(f'<text x="{x+48}" y="{ry+RH/2+16}" font-family="Inter,DejaVu Sans,sans-serif" '
f'font-size="10" font-style="italic" fill="{C["dim"]}">{r}</text>')
if submenu:
sy = y + HD + (sub_at or 0) * RH - 6
sh = 12 + len(submenu) * RH
sx = x + RW + 8
g.append(f'<rect x="{sx+3}" y="{sy+4}" width="{RW-30}" height="{sh}" rx="12" fill="#000" opacity="0.35"/>')
g.append(f'<rect x="{sx}" y="{sy}" width="{RW-30}" height="{sh}" rx="12" '
f'fill="{C["chrome"]}" stroke="{C["chip_line"]}"/>')
for i, (gl, name, key, _c, on, _r) in enumerate(submenu):
ry = sy + 6 + i * RH
g.append(f'<g opacity="{"1" if on else "0.34"}">')
g.append(glyph(gl, sx + 24, ry + RH / 2, C["text"], 0.72, 1.7))
g.append(f'<text x="{sx+44}" y="{ry+RH/2+4.5}" font-family="Inter,DejaVu Sans,sans-serif" '
f'font-size="13.5" fill="{C["text"]}">{name}</text>')
if key:
kw = 13 + len(key) * 6.6
g.append(f'<rect x="{sx+RW-48-kw:.1f}" y="{ry+RH/2-9:.1f}" width="{kw:.1f}" height="18" '
f'rx="4.5" fill="{C["key"]}"/>'
f'<text x="{sx+RW-48-kw/2:.1f}" y="{ry+RH/2+4:.1f}" text-anchor="middle" '
f'font-family="Inter,DejaVu Sans,sans-serif" font-size="10.5" '
f'fill="{C["text"]}">{key}</text>')
g.append('</g>')
return "".join(g)
OVERFLOWS = [] # (selection, doc state, family, verbs that did not fit the sub-ring)
def svg_doc(inner):
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
f'viewBox="0 0 {W} {H}">{inner}</svg>')
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
cards = "".join(
f'<figure><img src="mockups/{n}" alt="{esc(t)}" loading="lazy"><figcaption>{esc(t)}</figcaption></figure>'
for n, t in files)
cmp8 = "".join(f'<figure><img src="mockups/cmp_8__{s}.svg" loading="lazy">'
f'<figcaption>8 slots — {s}</figcaption></figure>'
for s in ("face_planar", "edge_str", "sk_line"))
cmp12 = "".join(f'<figure><img src="mockups/cmp_12__{s}.svg" loading="lazy">'
f'<figcaption>12 slots — {s}</figcaption></figure>'
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 <b>{esc(f)}</b> 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'<tr><td>{esc(r["sel"])}</td><td>{esc(r["doc"])}</td><td class="n">{r["verbs"]}</td>'
f'<td class="n">{r["slots"]}/8</td><td class="n">{r["second"]}</td>'
f'<td class="d">{esc(" · ".join(k + ": " + ", ".join(v) for k, v in r["detail"].items()))}</td></tr>'
for r in rows)
html = f"""<!doctype html><meta charset="utf-8"><title>Design tab — offer atlas</title>
<style>
:root{{color-scheme:dark;--bg:#15181c;--panel:#1c2026;--line:#2c323a;--tx:#e7ecf1;--mu:#93a0ad;--ac:#4f9bd9}}
body{{margin:0;background:var(--bg);color:var(--tx);font:15px/1.55 Inter,system-ui,sans-serif}}
header{{padding:38px 40px 22px;border-bottom:1px solid var(--line)}}
h1{{margin:0 0 6px;font-size:26px;letter-spacing:-.02em}}
h2{{margin:38px 0 12px;font-size:18px}} p{{color:var(--mu);max-width:78ch}}
main{{padding:0 40px 60px}}
.stats{{display:flex;gap:14px;flex-wrap:wrap;margin:18px 0 6px}}
.stat{{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 16px;min-width:130px}}
.stat b{{display:block;font-size:24px}} .stat span{{color:var(--mu);font-size:12px}}
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(430px,1fr));gap:18px}}
figure{{margin:0;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}}
figure img{{width:100%;display:block}}
figcaption{{padding:9px 13px;font-size:13px;color:var(--mu);border-top:1px solid var(--line)}}
table{{border-collapse:collapse;width:100%;font-size:13px;margin-top:10px}}
th,td{{border-bottom:1px solid var(--line);padding:7px 10px;text-align:left;vertical-align:top}}
th{{color:var(--mu);font-weight:500}} td.n{{text-align:right;font-variant-numeric:tabular-nums}}
td.d{{color:var(--mu);font-size:12px}}
.wrap{{overflow-x:auto}}
</style>
<header>
<h1>The offer — atlas of every state</h1>
<p>Every state of the object-driven tool offer, generated from
<code>docs/ux/tool_atlas.json</code>. 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.</p>
<div class="stats">
<div class="stat"><b>{len(A["verbs"])}</b><span>verbs mapped</span></div>
<div class="stat"><b>{len(A["selections"])}</b><span>selection kinds</span></div>
<div class="stat"><b>{n_prim}</b><span>primary rings</span></div>
<div class="stat"><b>{n_sec}</b><span>secondary rings</span></div>
<div class="stat"><b>{n_prim+n_sec}</b><span>states total</span></div>
<div class="stat"><b>{mean_fill:.1f}/8</b><span>mean slots filled</span></div>
<div class="stat"><b>{len(gui_missing)}</b><span>verbs with no GUI yet</span></div>
</div>
</header>
<main>
<h2>Decision 0 — ring or vertical list</h2>
<p>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.</p>
<h2>Decision 1 — ring capacity</h2>
<p>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.</p>
<div class="grid">{cmp8}</div>
<div class="grid" style="margin-top:18px">{cmp12}</div>
<h2>Decision 2 — one map or one per mode</h2>
<p>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.</p>
<h2>The matrix — every selection, every document state</h2>
<div class="wrap"><table>
<tr><th>Selection</th><th>Document</th><th>Verbs</th><th>Slots</th><th>2nd rings</th><th>What lands where</th></tr>
{trs}
</table></div>
<h2>Overflow — the one place eight slots is not enough</h2>
<p>{over_html}</p>
<h2>Not in the offer</h2>
<p>{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))}.</p>
<h2>All states</h2>
<div class="grid">{cards}</div>
</main>
"""
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 <img src> 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("<svg ", '<svg style="width:100%;height:auto;display:block" ', 1)
# Form factor first: this is the live decision, so it opens the page.
picks = [
("list__none.svg", "LIST · fresh document — every family present, the unavailable ones say why"),
("fresh__none.svg", "RING · the same state — an empty slot cannot say anything"),
("list__face_planar.svg", "LIST · planar face"),
("rich__face_planar.svg", "RING · planar face"),
("list__sk_none__create.svg", "LIST · sketch Create submenu — all 9 primitives fit, no overflow"),
("rich__sk_none__create.svg", "RING · the same submenu — 2 verbs pushed behind “More”"),
("list__face_planar__add.svg", "LIST · planar face, Add material submenu"),
("rich__face_planar__add.svg", "RING · planar face, Add material sub-ring"),
("list__body_solid.svg", "LIST · solid body"),
("list__edge_str.svg", "LIST · straight edge"),
("list__sk_line.svg", "LIST · sketch line"),
]
picks += [(f'rich__{s["id"]}.svg', "RING · " + s["name"]) for s in A["selections"]]
picks.insert(0, ("fresh__none.svg", "Fresh document, nothing selected — the first-run picture"))
picks += [("rich__face_planar__add.svg", "Planar face · Add material sub-ring"),
("rich__face_planar__reference.svg", "Planar face · Reference sub-ring"),
("rich__sk_none__create.svg", "Sketch · Create sub-ring (the overflow case)"),
("rich__body_solid__remove.svg", "Solid body · Remove sub-ring"),
("cmp_8__face_planar.svg", "8 slots — planar face"),
("cmp_12__face_planar.svg", "12 slots — planar face"),
("cmp_8__sk_line.svg", "8 slots — sketch line"),
("cmp_12__sk_line.svg", "12 slots — sketch line")]
figs = "".join(f'<figure>{inline(n)}<figcaption>{esc(t)}</figcaption></figure>'
for n, t in picks if inline(n))
head, _, tail = html.partition('<h2>All states</h2>')
# The comparison grids in the head use <img src="mockups/...">, which resolves to nothing
# once the page is served on its own. Inline those too rather than shipping empty frames.
head = re.sub(r'<img src="mockups/([^"]+)"[^>]*>',
lambda m: inline(m.group(1)), head)
inline_html = head + '<h2>The states</h2>\n<div class="grid">' + figs + '</div></main>\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()