Files
willtools-demo/tools/make_icons.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

284 lines
12 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:
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)
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.
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)
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
# 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 = (
('Tools.panel/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),
)
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()