docs/ux: the offer atlas — every tool, every state, drawn
The charter fixed the slot-constancy invariant but carried one hand-written eight-cell table as an illustration, and nothing of the offer exists in the product. Before any GUI code, the group needs the map itself: what verbs there are, what each one needs before it can be offered, and what the ring actually looks like in every situation a user can put it in. tool_atlas.json is the source of truth and it was extracted from the code, not from memory: verbs, shortcuts and the exact refusal strings from DesignPanel's six feat_dropdown call sites and the sk_key table, kernel coverage checked against CadFeatureType, headless coverage against McpControl's dispatch, and the selection kinds taken from the callbacks DesignCanvas actually exposes. It carries each verb's preconditions, so "why is that slot empty" has an answer already written in the product's own words. The eventual C++ table generates from this file too — the map exists once. gen_offer_mockups.py renders it: 20 selection kinds x 2 document states = 40 primary rings, plus 73 sub-rings, plus comparison sheets. 113 states, none of them hand-drawn, because a human drawing 113 rings is exactly how an address quietly changes. Everything is framed at 1366x768, the charter's own reach target, so L11 is tested in the mockups before it is tested in code. Three things the drawing found that the prose had not: - MEAN FILL IS 3.45 OF 8. The empty-slot rule is cheap in argument and expensive on screen; on a fresh document exactly two slots are live. That picture is the anti-clutter thesis made literal and it is the strongest image in the set. - SUB-RINGS MUST ANCHOR ON THEIR PARENT. Fanning them from north put Extrude at N, which is 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 its first verb is NE again. - ONE FAMILY OVERFLOWS, AND ONLY ONE. Sketch-mode Create needs nine addresses on an eight-slot ring. That is the ninth-position pressure the charter predicted, arriving on schedule and measured rather than argued: either Point moves family, or the tail goes to a third level, or the ring is not eight. Every model-mode family fits. The generator refuses to wrap a tenth verb onto a first — silent collision is the one outcome worse than an ugly ring — and reports the overflow instead. Also fixed while looking at renders: the selection pill sat on top of the north slot's shortcut chip and hid it, and the scrim at 0.55 swallowed the very face the ring had been opened on, which is 4.1 failing inside its own mockup. Fork-neutral: the generator and everything it emits name no product, so both forks carry byte-identical copies. snaporca-2is.
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,736 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the object-driven tool offer from docs/ux/tool_atlas.json.
|
||||
|
||||
Fork-neutral on purpose: this file and everything it emits name no product, so the
|
||||
two forks carry byte-identical copies (the charter itself is the only doc that
|
||||
substitutes the product name).
|
||||
|
||||
Every mockup in docs/ux/mockups/ and the review page docs/ux/offer_atlas.html are
|
||||
generated by this script. Nothing is hand-drawn: the point of the offer is that a
|
||||
tool's address never changes, and a human drawing 40-odd states by hand is exactly
|
||||
how an address quietly changes.
|
||||
|
||||
python3 docs/ux/mockups/gen_offer_mockups.py
|
||||
|
||||
It fails loudly rather than rendering a broken map: an unknown slot, an unknown
|
||||
selection id in `accepts`, or one address holding two different verbs for the same
|
||||
selection is an error, not a warning.
|
||||
|
||||
SVG on purpose. There is no rsvg/inkscape/cairosvg on the build box and
|
||||
ImageMagick would rasterise through its own weak internal renderer; SVG renders
|
||||
exactly in a browser at any zoom and stays diffable in git. Rasterise later if
|
||||
slides need it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
UX = os.path.dirname(HERE)
|
||||
ATLAS = os.path.join(UX, "tool_atlas.json")
|
||||
|
||||
# The reach target from the charter (L11 / 6.1): if the ring plus a real part do not
|
||||
# fit here, the design has failed before it is built.
|
||||
W, H = 1366, 768
|
||||
|
||||
C = {
|
||||
"app": "#1a1d21", "chrome": "#23272d", "panel": "#20242a", "line": "#31363d",
|
||||
"vp0": "#2f353d", "vp1": "#242930", "grid": "#333a43",
|
||||
"text": "#e7ecf1", "muted": "#8e9aa7", "dim": "#5f6a76",
|
||||
"top": "#93a1b1", "left": "#6f7b89", "right": "#5a6472", "edge": "#39414b",
|
||||
"hi": "#f5a623", "hi_soft": "#f5a62333", "sheet": "#7fa8c9",
|
||||
"ring": "#171a1e", "chip": "#2e343c", "chip_line": "#3c444e",
|
||||
"key": "#454f5b", "accent": "#4f9bd9", "empty": "#2a2f36",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- glyphs
|
||||
# Each glyph draws inside a 24x24 box centred on (0,0) — i.e. -12..12.
|
||||
def _g(body):
|
||||
return body
|
||||
|
||||
|
||||
GLYPHS = {
|
||||
# model
|
||||
"sketch": '<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
|
||||
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_state(A, sel, doc, capacity=8, secondary=None):
|
||||
verbs = eligible(A, sel["id"], doc)
|
||||
grouped = by_slot(A, verbs)
|
||||
anchor_x, anchor_y = 810, 400
|
||||
fresh = doc["bodies"] == 0 and doc["sketches"] == 0
|
||||
shape = "origin" if (fresh and sel["shape"] == "empty") else sel["shape"]
|
||||
art, (ax, ay) = scene(shape, anchor_x - 90, anchor_y - 40)
|
||||
if secondary:
|
||||
vs = grouped[secondary]
|
||||
# A sub-ring ANCHORS ON ITS PARENT'S DIRECTION. Fanning from north instead put Extrude
|
||||
# at N — Create's address in the primary map — so the second level contradicted the
|
||||
# first. Anchored, an address is two consistent strokes: Add material is NE, and the
|
||||
# first verb of Add material is NE again.
|
||||
base = [s["id"] for s in A["slots"]].index(secondary)
|
||||
overflow = []
|
||||
if len(vs) > capacity:
|
||||
# Do NOT wrap: (base+i) % capacity would quietly put verb 9 on top of verb 1, which
|
||||
# is the invariant failing silently — the one outcome worse than an ugly ring. Show
|
||||
# what fits, mark the rest, and report it.
|
||||
overflow = vs[capacity - 1:]
|
||||
vs = vs[:capacity - 1]
|
||||
items = {(base + i) % capacity:
|
||||
(VERB_GLYPH.get(v["id"], "fam_" + secondary), v["name"], v.get("key"), 1)
|
||||
for i, v in enumerate(vs)}
|
||||
if overflow:
|
||||
items[(base + capacity - 1) % capacity] = ("fam_" + secondary, "More", None, len(overflow))
|
||||
OVERFLOWS.append((sel["name"], doc["id"], secondary, [v["name"] for v in overflow]))
|
||||
label = next(s["label"] for s in A["slots"] if s["id"] == secondary)
|
||||
sel_name = f'{sel["name"]} · {label}'
|
||||
status = f'{label} — pick one'
|
||||
else:
|
||||
items = ring_items(A, grouped)
|
||||
sel_name = sel["name"]
|
||||
status = ("Click a face or a reference plane, then a tool"
|
||||
if sel["id"] == "none" else f'{sel["name"]} selected — pick what to do with it')
|
||||
inner = chrome(f'{doc["name"]} · {capacity} slots', status, sel["mode"], empty_doc=fresh)
|
||||
inner += art
|
||||
inner += ring(ax, ay, items, capacity, sel_name)
|
||||
return svg_doc(inner), verbs, grouped
|
||||
|
||||
|
||||
def main():
|
||||
A = load()
|
||||
validate(A)
|
||||
docs = {d["id"]: d for d in A["doc_states"]}
|
||||
outdir = HERE
|
||||
rows, files = [], []
|
||||
total_primary = total_secondary = 0
|
||||
fill_sum = fill_n = 0
|
||||
|
||||
for d in A["doc_states"]:
|
||||
for sel in A["selections"]:
|
||||
svg, verbs, grouped = render_state(A, sel, d)
|
||||
name = f'{d["id"]}__{sel["id"]}.svg'
|
||||
with open(os.path.join(outdir, name), "w", encoding="utf-8") as f:
|
||||
f.write(svg)
|
||||
files.append((name, f'{sel["name"]} — {d["name"]}'))
|
||||
total_primary += 1
|
||||
fill_sum += len(grouped)
|
||||
fill_n += 1
|
||||
secondaries = []
|
||||
for sid, vs in grouped.items():
|
||||
if len(vs) > 1:
|
||||
s2, _, _ = render_state(A, sel, d, secondary=sid)
|
||||
n2 = f'{d["id"]}__{sel["id"]}__{sid}.svg'
|
||||
with open(os.path.join(outdir, n2), "w", encoding="utf-8") as f:
|
||||
f.write(s2)
|
||||
label = next(s["label"] for s in A["slots"] if s["id"] == sid)
|
||||
files.append((n2, f'{sel["name"]} — {label} ring'))
|
||||
secondaries.append(sid)
|
||||
total_secondary += 1
|
||||
rows.append({
|
||||
"doc": d["name"], "sel": sel["name"], "mode": sel["mode"],
|
||||
"verbs": len(verbs), "slots": len(grouped),
|
||||
"second": len(secondaries),
|
||||
"detail": {sid: [v["name"] for v in vs] for sid, vs in grouped.items()},
|
||||
})
|
||||
|
||||
# comparison sheet: 8 vs 12 slots on the same three selections
|
||||
for cap in (8, 12):
|
||||
for sid in ("face_planar", "edge_str", "sk_line"):
|
||||
sel = next(s for s in A["selections"] if s["id"] == sid)
|
||||
svg, _, _ = render_state(A, sel, docs["rich"], capacity=cap)
|
||||
n = f'cmp_{cap}__{sid}.svg'
|
||||
with open(os.path.join(outdir, n), "w", encoding="utf-8") as f:
|
||||
f.write(svg)
|
||||
|
||||
write_atlas(A, rows, files, total_primary, total_secondary,
|
||||
fill_sum / max(1, fill_n))
|
||||
print(f"{total_primary} primary + {total_secondary} secondary = "
|
||||
f"{total_primary+total_secondary} offer states rendered")
|
||||
print(f"mean populated slots per primary ring: {fill_sum/max(1,fill_n):.2f} of 8")
|
||||
if OVERFLOWS:
|
||||
seen = {(f, tuple(v)) for _, _, f, v in OVERFLOWS}
|
||||
print(f"OVERFLOW: {len(OVERFLOWS)} sub-rings did not fit 8 slots — "
|
||||
f"{len(seen)} distinct case(s):")
|
||||
for f, v in sorted(seen):
|
||||
print(f" {f}: {', '.join(v)} pushed behind 'More'")
|
||||
|
||||
|
||||
def write_atlas(A, rows, files, n_prim, n_sec, mean_fill):
|
||||
gui_missing = [v["name"] for v in A["verbs"] if not v.get("gui", True)]
|
||||
esc = lambda s: (s.replace("&", "&").replace("<", "<").replace(">", ">"))
|
||||
cards = "".join(
|
||||
f'<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 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)
|
||||
|
||||
picks = [(f'rich__{s["id"]}.svg', 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()
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |