mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-20 23:42:54 +00:00
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.
885 lines
50 KiB
Python
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("&", "&").replace("<", "<").replace(">", ">"))
|
|
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()
|