Files
willtools-demo/tools/make_icons.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

364 lines
16 KiB
Python

# -*- coding: utf-8 -*-
"""Ribbon icons, drawn rather than drawn-in-a-paint-program.
python tools/make_icons.py
Needs Pillow. Writes icon.png into the pushbutton folders it knows about and
leaves everything else alone.
The house style, taken from the icons that already existed:
96 x 96 RGBA, a rounded square of ONE flat colour filling the canvas with
a 2px margin and a 20px corner radius, a white glyph on top, nothing else.
No gradients, no outlines, no text.
Colour is the identifier. Every button on the tab gets its own and they are
chosen to survive being 32px next to each other, which is the only size
anybody sees:
GodMod terracotta 217, 119, 87
ExtractDesign blue 45, 108, 178
TicketQC green 43, 138, 90
SelfCheck slate 86, 98, 112
PanelLiftCheck purple 109, 76, 163
ProjectConfig amber 198, 141, 26
SnapshotClose indigo 46, 52, 130
Config moss 55, 118, 45 (the pulldown, not a button)
DesignHub plum 124, 58, 136 (the pulldown, not a button)
PanelAnalysis seagreen 35, 139, 120 (a DesignHub row -- WIP)
Modeling forest 72, 119, 34 (the pulldown, not a button)
BeamAnalysis fern 45, 118, 57 (a DesignHub row -- WIP)
ConnectionAnalysis olive-gold 134, 130, 39 (a DesignHub row -- WIP)
PanelAnalysis sits at hue 169, the midpoint of the widest arc still open once
every hue above (14, 40, 75, 112, 149, 189, 211/212/214, 235, 262, 291, 321,
353) is plotted on the ring -- between TicketQC's green at 149 and Gravity's
teal at 189, 20 degrees from each. The ring is full enough now that 20 is the
best separation left anywhere on it.
Three more landed 2026-09-05, for the ribbon reorganisation that added a
`Modeling.pulldown` (ReinforceSuggest / ReinforcingQC / TicketQC) beside
SnapshotClose, and moved BeamAnalysis / ConnectionAnalysis onto DesignHub as
two more (WIP) rows. Same method as PanelAnalysis: plot every hue already in
use (now including 169) and take the widest remaining arcs.
`Modeling.pulldown` sits at 93, the midpoint of LateralExtract's 75 and
Config's 112 (18-19 degrees each side, the same class of separation
PanelAnalysis accepted at 20). `BeamAnalysis` takes 130, the midpoint of
Config's 112 and TicketQC's 149. `ConnectionAnalysis` takes 57, the midpoint
of ExtractDesign/blue-adjacent amber at 40 and LateralExtract's 75 -- the
only other arc left as wide as those two. All three are pulled darker /
less saturated than their nearest neighbour, the same value-before-hue rule
`archive_icon` and the two pulldowns above already use.
Purple and amber went to those two because they used to sit side by side on
one QC panel and were being pressed by mistake -- opposite hue and opposite
value is as far apart as the palette goes. The 2026-08-30 rebuild put them on
different panels, which fixes the confusion a second way; the colours stay as
they are because the owner has learned them.
THE TWO PULLDOWNS. A `.pulldown` is a component in its own right and it draws
its own `icon.png` (`genericcomps` resolves one for every component, and
`uimaker._produce_ui_pulldown` hands it to `create_pulldown_button`). Without
one it renders BLANK -- `_create_button_group` catches the icon error and only
debug-logs it, so nothing tells you. They are drawn here for that reason, in
the same canvas, palette and supersample as the seven above.
Their hues were not picked by eye. The tab already spends 14, 40, 75, 149,
189, 211/212/214, 235, 262, 321 and 353; the two widest unused arcs in that
ring are 75->149 and 262->321, so these two sit at their midpoints -- 112 and
291 -- which is as far from every existing button as the ring still allows.
Both are taken darker than their nearest neighbour (moss L .32 against
TicketQC's .35, plum L .38 against PanelLift's .47) so the separation is a
value difference before it is a hue difference, the same argument as
`archive_icon`.
Their glyphs are containers rather than tools on purpose: neither pulldown
does anything, it opens. Config is sliders and NOT a gear -- the gear is
ProjectConfig, which is a row inside it, and a gear opening onto a gear reads
as a bug. DesignHub is a hub, which promises no particular tool, which is
right for a button whose two rows are both unbuilt.
Everything is drawn at 4x and resampled. Pillow has no anti-aliased
primitives, and a 96px icon with hard edges looks broken next to the others.
"""
from __future__ import print_function
import math
import os
from PIL import Image, ImageDraw
S = 4 # supersample factor
N = 96 # final size
MARGIN = 2
RADIUS = 20
PURPLE = (109, 76, 163, 255)
AMBER = (198, 141, 26, 255)
INDIGO = (46, 52, 130, 255)
MOSS = (55, 118, 45, 255)
PLUM = (124, 58, 136, 255)
SEAGREEN = (35, 139, 120, 255)
FOREST = (72, 119, 34, 255)
FERN = (45, 118, 57, 255)
OLIVEGOLD = (134, 130, 39, 255)
WHITE = (255, 255, 255, 255)
HERE = os.path.dirname(os.path.abspath(__file__))
TAB = os.path.join(HERE, os.pardir, 'WillTools.extension', 'WillTools.tab')
def canvas(colour):
im = Image.new('RGBA', (N * S, N * S), (0, 0, 0, 0))
d = ImageDraw.Draw(im)
d.rounded_rectangle([MARGIN * S, MARGIN * S,
(N - MARGIN) * S, (N - MARGIN) * S],
radius=RADIUS * S, fill=colour)
return im, d
def lift_icon():
"""A panel hanging from two slings.
Sized for 32px, which is where it actually gets looked at. Three earlier
versions lost there: inserts drawn on the panel turned into a pair of
eyes and the whole thing read as a face -- and at 32px they were grey
mush anyway, complexity paid for and not delivered. A deep panel read as
a box. What survives is a thin slab under a triangle of rigging, which is
the one silhouette nothing else on this ribbon has.
"""
im, d = canvas(PURPLE)
k = S
# shackle: an open ring, so it reads as rigging and not as a bubble
d.ellipse([38 * k, 8 * k, 58 * k, 28 * k], outline=WHITE, width=6 * k)
# slings, landing on the top corners of the panel
for x in (24, 72):
d.line([48 * k, 26 * k, x * k, 56 * k], fill=WHITE, width=6 * k)
# the panel: thin, because a panel is
d.rounded_rectangle([16 * k, 56 * k, 80 * k, 76 * k],
radius=4 * k, fill=WHITE)
return im
def gear_icon():
"""A gear: settings, and shaped like nothing else on the tab."""
im, d = canvas(AMBER)
k = S
cx = cy = 48.0 * k
teeth = 8
r_out, r_root, r_hole = 35.0 * k, 26.5 * k, 12.0 * k
pts = []
pitch = 2 * math.pi / teeth
for i in range(teeth):
base = i * pitch
# flanks at 9% and 41% of the pitch, root for the rest
for frac, r in ((0.00, r_root), (0.09, r_out),
(0.41, r_out), (0.50, r_root)):
a = base + frac * pitch
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
d.polygon(pts, fill=WHITE)
d.ellipse([cx - r_hole, cy - r_hole, cx + r_hole, cy + r_hole], fill=AMBER)
return im
def archive_icon():
"""An arrow going down into an open tray: put it away, then shut the door.
Hue was the constraint. The tab already spends 14 (terracotta), 40
(amber), 75 (olive), 149 (green), 189 (teal), 211/214 (blue and two
slates), 262 (purple), 321 (magenta) and 353 (red), which leaves the
indigo band around 238 genuinely unused. It is nearest to ExtractDesign's
211 blue, so this one is taken much darker and much less green -- navy
against medium blue, at 32px, is a value difference before it is a hue
difference.
The glyph carries more of the load than the colour here on purpose: no
other button on the tab has an arrow, and none has a container. The
silhouette is legible before the colour is.
"""
im, d = canvas(INDIGO)
k = S
# the arrow: shaft, then a head wide enough to survive being 32px
d.rectangle([40 * k, 12 * k, 56 * k, 38 * k], fill=WHITE)
d.polygon([(28 * k, 36 * k), (68 * k, 36 * k), (48 * k, 60 * k)],
fill=WHITE)
# the tray: open at the top, so the arrow reads as going INTO it
d.rectangle([16 * k, 70 * k, 80 * k, 80 * k], fill=WHITE)
d.rectangle([16 * k, 52 * k, 26 * k, 80 * k], fill=WHITE)
d.rectangle([70 * k, 52 * k, 80 * k, 80 * k], fill=WHITE)
return im
def sliders_icon():
"""Three sliders at three settings: this button holds the set-up commands.
NOT a gear. The gear is `gear_icon`, which is ProjectConfig -- the first
row inside this very pulldown -- and a gear that opens onto a gear reads
as a misclick rather than a menu.
Three horizontal bands is the one silhouette nothing else on this tab has;
every other glyph here is centred and roughly square. The knobs sit at
three different positions on purpose: evenly spaced they collapse into a
hamburger menu at 32px, which would promise a menu of anything at all
rather than a menu of settings.
"""
im, d = canvas(MOSS)
k = S
# track, then knob, top to bottom. x is where that knob sits on its track.
for y, x in ((28, 62), (48, 34), (68, 54)):
d.rounded_rectangle([18 * k, (y - 3) * k, 78 * k, (y + 3) * k],
radius=3 * k, fill=WHITE)
# the knob is drawn in the field colour with a white ring, so it stays
# readable where it overlaps its own track
d.ellipse([(x - 10) * k, (y - 10) * k, (x + 10) * k, (y + 10) * k],
fill=MOSS, outline=WHITE, width=5 * k)
return im
def hub_icon():
"""A hub: one centre, three spokes, three nodes.
A container, not a tool. Both rows behind this arrow are buttons-and-docs
only, so a glyph that promised a beam, a load or a moment would be
claiming more than the panel delivers -- what it has to say is "there is
more than one thing under here", and a hub says exactly that.
Three nodes rather than two: two reads as a dumbbell, and the count is not
meant to be a count of the rows (there are two today and the picture must
not go stale when a third arrives).
"""
im, d = canvas(PLUM)
k = S
cx = cy = 48.0 * k
r_hub, r_node, reach = 13.0 * k, 9.0 * k, 30.0 * k
# spokes first, so the nodes sit on top of their own ends
for a in (-math.pi / 2, math.pi / 6, 5 * math.pi / 6):
x, y = cx + reach * math.cos(a), cy + reach * math.sin(a)
d.line([cx, cy, x, y], fill=WHITE, width=6 * k)
d.ellipse([x - r_node, y - r_node, x + r_node, y + r_node], fill=WHITE)
d.ellipse([cx - r_hub, cy - r_hub, cx + r_hub, cy + r_hub], fill=WHITE)
return im
def form_icon():
"""A page of fields, with a small filled tag on one of them.
This button pre-fills an Eriksson-shaped input form from the model
rather than checking anything, which is a different promise than every
other glyph on this tab makes -- none of them is a page. Three plain
field lines read as a form; the short solid tag beside the last one --
smaller than the field it sits beside, so it reads as attached to it
rather than as a fourth line -- is the source chip this button's whole
idea rests on.
"""
im, d = canvas(SEAGREEN)
k = S
d.rounded_rectangle([20 * k, 12 * k, 76 * k, 84 * k],
radius=6 * k, fill=WHITE)
for y in (30, 48, 66):
d.rounded_rectangle([30 * k, (y - 3) * k, 58 * k, (y + 3) * k],
radius=3 * k, fill=SEAGREEN)
# the chip: a small solid tag beside the bottom field, in the page's own
# field colour, standing for the source badge every pre-filled value on
# the real page carries.
d.rounded_rectangle([62 * k, 63 * k, 74 * k, 69 * k], radius=3 * k,
fill=SEAGREEN)
return im
def layers_icon():
"""Three stacked plates: this pulldown holds the modelling tools.
A container, like `hub_icon` and `sliders_icon`, and drawn to look like
neither of them -- three offset rounded bars read as layers of a model
being built up, which is what the three rows behind this arrow actually
do (suggest reinforcing, check it while it is modelled, check it once
it is on a ticket). No spokes, no knobs, no gear: a stack is the one
silhouette this tab does not already use for a pulldown.
"""
im, d = canvas(FOREST)
k = S
for i, y in enumerate((26, 46, 66)):
inset = i * 6
d.rounded_rectangle([(18 + inset) * k, (y - 5) * k,
(78 - inset) * k, (y + 5) * k],
radius=4 * k, fill=WHITE)
return im
def beam_icon():
"""An I-beam cross-section: this row is a structural member check.
Two flanges and a web -- the one profile on this tab that reads as
"beam" rather than "panel" (the panel glyph in `lift_icon` is a thin
slab; this is deliberately a wide flange instead) or "gear"/"hub"
(nothing here is a container). Half-built like GravityLoadChase and
LateralExtract, so it is a placeholder colour, not a promise.
"""
im, d = canvas(FERN)
k = S
d.rectangle([20 * k, 18 * k, 76 * k, 30 * k], fill=WHITE)
d.rectangle([20 * k, 66 * k, 76 * k, 78 * k], fill=WHITE)
d.rectangle([42 * k, 30 * k, 54 * k, 66 * k], fill=WHITE)
return im
def connection_icon():
"""Two plates and a pin: this row checks how members connect.
Two overlapping rounded plates, offset so the overlap itself is
legible at 32px, with a single bolt circle through the middle -- the
one glyph on this tab that reads as a joint rather than a member or a
container. Half-built like its siblings; the colour says so.
"""
im, d = canvas(OLIVEGOLD)
k = S
d.rounded_rectangle([16 * k, 34 * k, 62 * k, 58 * k], radius=6 * k,
fill=WHITE)
d.rounded_rectangle([34 * k, 22 * k, 80 * k, 46 * k], radius=6 * k,
fill=WHITE)
d.ellipse([40 * k, 34 * k, 56 * k, 50 * k], fill=OLIVEGOLD)
return im
# Paths are relative to TAB and they are panel structure, which moves: the
# 2026-08-30 rebuild put two of the first three inside a `*.stack`, and the
# 2026-08-30 one moved all of them again and added the two pulldowns. `main()`
# prints "skipped, no such button" rather than failing, so a stale line here
# is silent -- check the output, not the exit.
#
# The last two are `.pulldown` folders, not buttons. That is not a mistake:
# see the module docstring. Everything else on the tab keeps the icon it
# already has and is deliberately not regenerated from here.
ICONS = (
('Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton', lift_icon),
('Setup.panel/Config.pulldown/ProjectConfig.pushbutton', gear_icon),
('Tools.panel/SnapshotClose.pushbutton', archive_icon),
('Setup.panel/Config.pulldown', sliders_icon),
('Design.panel/DesignHub.pulldown', hub_icon),
('Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton', form_icon),
('Tools.panel/Modeling.pulldown', layers_icon),
('Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton', beam_icon),
('Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton',
connection_icon),
)
def main():
for where, draw in ICONS:
folder = os.path.join(TAB, *where.split('/'))
if not os.path.isdir(folder):
# Still not an error: the ribbon is allowed to move without this
# script being run. But nobody reads an exit code, so say it.
print('skipped, no such component: %s' % where)
continue
path = os.path.join(folder, 'icon.png')
draw().resize((N, N), Image.LANCZOS).save(path, 'PNG', optimize=True)
print('%s (%d bytes)' % (where, os.path.getsize(path)))
if __name__ == '__main__':
main()