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