# -*- 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()