|
|
@@ -0,0 +1,157 @@
|
|
|
+"""
|
|
|
+Studio app icons from Lucide glyphs, with the glyph flattened to one outline.
|
|
|
+
|
|
|
+Lucide draws a glyph as several stroked paths. Layered with a translucent gradient and a 10% shadow copy, every
|
|
|
+overlap between two strokes shows twice as dark. So each stroke is expanded to its outline (2 units wide, round
|
|
|
+caps and joins, as Lucide draws it), all outlines are united into one shape, and that shape is filled instead.
|
|
|
+
|
|
|
+Usage: python3 scripts/studio_icon_from_lucide.py <repo root> (needs: pip install shapely svgpathtools)
|
|
|
+"""
|
|
|
+import math
|
|
|
+import re
|
|
|
+import sys
|
|
|
+import xml.etree.ElementTree as ET
|
|
|
+
|
|
|
+from shapely.geometry import LineString, Polygon, MultiPolygon
|
|
|
+from shapely.ops import unary_union
|
|
|
+from svgpathtools import parse_path
|
|
|
+
|
|
|
+ROOT = sys.argv[1].rstrip('/')
|
|
|
+LUCIDE = ROOT + '/plugins_public/lucide/icons/'
|
|
|
+
|
|
|
+PLATES = {
|
|
|
+ 'gray': ('#71717A', '#3F3F46'),
|
|
|
+ 'green': ('#059669', '#065F46'),
|
|
|
+ 'orange': ('#D97706', '#92400E'),
|
|
|
+ 'purple': ('#7C3AED', '#5B21B6'),
|
|
|
+}
|
|
|
+
|
|
|
+ICONS = [
|
|
|
+ ('modules/boonex/api/template/images/icons/std-icon.svg', 'api', 'gray', 'webhook', 80),
|
|
|
+ ('modules/boonex/anon_follow/template/images/icons/std-icon.svg', 'anon_follow', 'orange', 'hat-glasses', 80),
|
|
|
+ ('modules/boonex/decorous/template/images/icons/std-icon.svg', 'decorous', 'purple', 'panels-top-left', 80),
|
|
|
+ ('modules/boonex/editor/template/images/icons/std-icon.svg', 'editor', 'gray', 'text-initial', 80),
|
|
|
+ ('modules/boonex/lucid/template/images/icons/std-icon.svg', 'lucid', 'purple', 'layout-panel-top', 80),
|
|
|
+ ('modules/boonex/mapshow/template/images/icons/std-icon.svg', 'mapshow', 'green', 'map', 80),
|
|
|
+ ('studio/template/images/modules/bx_polls.svg', 'polls', 'green', 'vote', 72),
|
|
|
+]
|
|
|
+
|
|
|
+STROKE = 2.0 # Lucide stroke width, in its 24-unit space
|
|
|
+STEP = 0.02 # sampling step along curves
|
|
|
+TOL = 0.003 # simplification tolerance of the united outline
|
|
|
+
|
|
|
+
|
|
|
+def element_to_d(el):
|
|
|
+ """A Lucide shape element as a path 'd' string."""
|
|
|
+ tag = el.tag.split('}')[-1]
|
|
|
+ a = el.attrib
|
|
|
+ f = lambda k, d='0': float(a.get(k, d))
|
|
|
+ if tag == 'path':
|
|
|
+ return a['d']
|
|
|
+ if tag == 'circle':
|
|
|
+ cx, cy, r = f('cx'), f('cy'), f('r')
|
|
|
+ return f'M{cx - r},{cy} A{r},{r} 0 1 0 {cx + r},{cy} A{r},{r} 0 1 0 {cx - r},{cy} Z'
|
|
|
+ if tag == 'ellipse':
|
|
|
+ cx, cy, rx, ry = f('cx'), f('cy'), f('rx'), f('ry')
|
|
|
+ return f'M{cx - rx},{cy} A{rx},{ry} 0 1 0 {cx + rx},{cy} A{rx},{ry} 0 1 0 {cx - rx},{cy} Z'
|
|
|
+ if tag == 'rect':
|
|
|
+ x, y, w, h = f('x'), f('y'), f('width'), f('height')
|
|
|
+ rx = f('rx', a.get('ry', '0'))
|
|
|
+ ry = f('ry', str(rx))
|
|
|
+ if rx == 0 and ry == 0:
|
|
|
+ return f'M{x},{y} h{w} v{h} h{-w} Z'
|
|
|
+ return (f'M{x + rx},{y} h{w - 2 * rx} a{rx},{ry} 0 0 1 {rx},{ry} v{h - 2 * ry} a{rx},{ry} 0 0 1 {-rx},{ry} '
|
|
|
+ f'h{-(w - 2 * rx)} a{rx},{ry} 0 0 1 {-rx},{-ry} v{-(h - 2 * ry)} a{rx},{ry} 0 0 1 {rx},{-ry} Z')
|
|
|
+ if tag == 'line':
|
|
|
+ return f'M{f("x1")},{f("y1")} L{f("x2")},{f("y2")}'
|
|
|
+ if tag in ('polyline', 'polygon'):
|
|
|
+ pts = re.findall(r'-?\d*\.?\d+', a['points'])
|
|
|
+ d = 'M' + ' L'.join(f'{pts[i]},{pts[i + 1]}' for i in range(0, len(pts), 2))
|
|
|
+ return d + (' Z' if tag == 'polygon' else '')
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def sample(d):
|
|
|
+ """Each continuous subpath of 'd' as a list of (x, y) points."""
|
|
|
+ path = parse_path(d)
|
|
|
+ runs = []
|
|
|
+ for sub in path.continuous_subpaths():
|
|
|
+ pts = []
|
|
|
+ for seg in sub:
|
|
|
+ n = max(2, int(math.ceil(seg.length() / STEP)))
|
|
|
+ for i in range(n):
|
|
|
+ p = seg.point(i / n)
|
|
|
+ pts.append((p.real, p.imag))
|
|
|
+ end = sub[-1].point(1.0)
|
|
|
+ pts.append((end.real, end.imag))
|
|
|
+ runs.append(pts)
|
|
|
+ return runs
|
|
|
+
|
|
|
+
|
|
|
+def glyph_outline(name):
|
|
|
+ root = ET.parse(LUCIDE + name + '.svg').getroot()
|
|
|
+ shapes = []
|
|
|
+ for el in root.iter():
|
|
|
+ d = element_to_d(el)
|
|
|
+ if not d:
|
|
|
+ continue
|
|
|
+ for pts in sample(d):
|
|
|
+ if len(pts) < 2:
|
|
|
+ continue
|
|
|
+ shapes.append(LineString(pts).buffer(STROKE / 2, quad_segs=48, cap_style='round', join_style='round'))
|
|
|
+ shape = unary_union(shapes).simplify(TOL, preserve_topology=True)
|
|
|
+ return shape
|
|
|
+
|
|
|
+
|
|
|
+def ring_to_d(coords):
|
|
|
+ parts = []
|
|
|
+ for i, (x, y) in enumerate(coords[:-1]):
|
|
|
+ parts.append(('M' if i == 0 else 'L') + f'{x:.2f} {y:.2f}')
|
|
|
+ return ' '.join(parts) + ' Z'
|
|
|
+
|
|
|
+
|
|
|
+def shape_to_d(shape):
|
|
|
+ polys = shape.geoms if isinstance(shape, MultiPolygon) else [shape]
|
|
|
+ d = []
|
|
|
+ for poly in polys:
|
|
|
+ d.append(ring_to_d(list(poly.exterior.coords)))
|
|
|
+ for ring in poly.interiors:
|
|
|
+ d.append(ring_to_d(list(ring.coords)))
|
|
|
+ return ' '.join(d)
|
|
|
+
|
|
|
+
|
|
|
+def icon(uid, plate, d, size):
|
|
|
+ c1, c2 = PLATES[plate]
|
|
|
+ if size == 80:
|
|
|
+ t, ts, s = '13.6 13.6', '13.6 14.6', '2.2'
|
|
|
+ else:
|
|
|
+ t, ts, s = '12 12', '12 13', '2'
|
|
|
+ return f'''<svg width="{size}" height="{size}" viewBox="0 0 {size} {size}" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
|
+<rect width="{size}" height="{size}" fill="url(#paint0_linear_{uid})"/>
|
|
|
+<g transform="translate({t}) scale({s})">
|
|
|
+<path fill-rule="evenodd" fill="url(#paint1_linear_{uid})" d="{d}"/>
|
|
|
+</g>
|
|
|
+<g transform="translate({ts}) scale({s})">
|
|
|
+<path fill-rule="evenodd" fill="black" fill-opacity="0.1" d="{d}"/>
|
|
|
+</g>
|
|
|
+<defs>
|
|
|
+<linearGradient id="paint0_linear_{uid}" x1="{size / 2:g}" y1="0" x2="{size / 2:g}" y2="{size}" gradientUnits="userSpaceOnUse">
|
|
|
+<stop stop-color="{c1}"/>
|
|
|
+<stop offset="1" stop-color="{c2}"/>
|
|
|
+</linearGradient>
|
|
|
+<linearGradient id="paint1_linear_{uid}" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
|
|
|
+<stop stop-color="white"/>
|
|
|
+<stop offset="1" stop-color="white" stop-opacity="0.6"/>
|
|
|
+</linearGradient>
|
|
|
+</defs>
|
|
|
+</svg>
|
|
|
+'''
|
|
|
+
|
|
|
+
|
|
|
+for rel, uid, plate, glyph, size in ICONS:
|
|
|
+ shape = glyph_outline(glyph)
|
|
|
+ d = shape_to_d(shape)
|
|
|
+ n = 1 if isinstance(shape, Polygon) else len(shape.geoms)
|
|
|
+ with open(ROOT + '/' + rel, 'w') as fh:
|
|
|
+ fh.write(icon(uid, plate, d, size))
|
|
|
+ print(f'{rel}: {glyph} -> {n} outline(s), {len(d)} chars')
|