52d94303bc
ci / test (push) Has been cancelled
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.
284 lines
12 KiB
Python
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()
|