Files
knotchy/scripts/icons/knot.py
T
2026-09-28 18:28:32 +05:30

123 lines
4.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generates the knot icons' geometry; run via scripts/make-icons.sh."""
import json
import math
import os
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
NAVY = "#132441"
CORAL = "#FF8A5B"
def trefoil(cx, cy, size, gap):
n = 1200
pts = []
for i in range(n):
t = 2 * math.pi * i / n
pts.append((math.sin(t) + 2 * math.sin(2 * t), -(math.cos(t) - 2 * math.cos(2 * t))))
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
mx, my = (max(xs) + min(xs)) / 2, (max(ys) + min(ys)) / 2
span = max(max(xs) - min(xs), max(ys) - min(ys))
p = [(cx + (x - mx) / span * size, cy + (y - my) / span * size) for x, y in pts]
# Self-crossings: points far apart along the curve that land on each other.
cands = []
for i in range(n):
for j in range(i + n // 6, n):
if j - i > n - n // 6:
continue
d = (p[i][0] - p[j][0]) ** 2 + (p[i][1] - p[j][1]) ** 2
if d < (size * 0.004) ** 2:
cands.append((d, i, j))
cands.sort()
crossings = []
for _, i, j in cands:
far = lambda a, b: min(abs(a - b) % n, n - abs(a - b) % n) > 40
if all(far(i, a) and far(j, b) for a, b in crossings):
crossings.append((i, j))
if len(crossings) == 3:
break
assert len(crossings) == 3, crossings
# Alternate crossings go under; hide points within `gap` (straight-line) of each.
visits = sorted([i for i, _ in crossings] + [j for _, j in crossings])
under = visits[1::2]
hidden = set()
for u in under:
for d in range(-n // 10, n // 10 + 1):
k = (u + d) % n
if math.dist(p[k], p[u]) < gap:
hidden.add(k)
strands, cur = [], []
start = next(i for i in range(n) if i in hidden)
for k in range(1, n + 1):
i = (start + k) % n
if i in hidden:
if len(cur) > 1:
strands.append(cur)
cur = []
else:
cur.append(p[i])
if len(cur) > 1:
strands.append(cur)
assert len(strands) == 3
return [s[::3] + [s[-1]] for s in strands]
def path(strand):
return "M" + " L".join(f"{x:.2f},{y:.2f}" for x, y in strand)
# Each gap is half the crossing strand's width, plus the round cap, plus clear space.
ICON_STROKE, GLYPH_STROKE = 8.5, 1.9
icon = trefoil(50, 51, 62, ICON_STROKE + 1.3)
glyph = trefoil(9, 9.3, 14.2, GLYPH_STROKE + 0.6)
os.makedirs(os.path.join(ROOT, "assets"), exist_ok=True)
json.dump({"icon": icon, "iconStroke": ICON_STROKE, "glyph": glyph, "glyphStroke": GLYPH_STROKE,
"navy": NAVY, "coral": CORAL},
open(os.path.join(ROOT, "scripts/icons/knot.json"), "w"))
strokes = "".join(f'<path d="{path(s)}"/>' for s in icon)
open(os.path.join(ROOT, "assets/knotchy-icon.svg"), "w").write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
f'<rect width="100" height="100" rx="22.5" fill="{NAVY}"/>'
f'<g fill="none" stroke="{CORAL}" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round">{strokes}</g></svg>\n')
open(os.path.join(ROOT, "assets/knotchy-mark.svg"), "w").write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="15 18 70 66">'
f'<g fill="none" stroke="{CORAL}" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round">{strokes}</g></svg>\n')
points = ",\n".join(
" [" + ", ".join(f"CGPoint(x: {x:.2f}, y: {y:.2f})" for x, y in s) + "]" for s in glyph)
open(os.path.join(ROOT, "Sources/Knotchy/KnotGlyph.swift"), "w").write(f'''// Generated by scripts/icons/knot.py; run scripts/make-icons.sh to change it.
import AppKit
enum KnotGlyph {{
static let strands: [[CGPoint]] = [
{points},
]
static let menuBarImage: NSImage = {{
let image = NSImage(size: NSSize(width: 18, height: 18), flipped: true) {{ _ in
NSColor.black.setStroke()
for strand in strands {{
let path = NSBezierPath()
path.move(to: strand[0])
for point in strand.dropFirst() {{ path.line(to: point) }}
path.lineWidth = 1.9
path.lineCapStyle = .round
path.lineJoinStyle = .round
path.stroke()
}}
return true
}}
image.isTemplate = true
image.accessibilityDescription = "Knotchy"
return image
}}()
}}
''')
print("knot: 3 strands; wrote knot.json, assets/*.svg, Sources/Knotchy/KnotGlyph.swift")