stammtisch-hersbruck/design/logo/build.py
Daniel Michelberger 279796111c wip: Der abgebrochene Kurbeler-Umbau, wie er hier liegengeblieben ist
Kein fertiger Stand, sondern ein Zwischenstand: Hier wurde im September 2026
angefangen, die App auf „Kurbeler" umzubenennen — Wortmarke, Icons, brand.ts,
Benachrichtigungen, das Benutzerverzeichnis. Weitergegangen ist es dann im
eigenen Repo `~/Dokumente/Projekte/kurbeler`, das die vollständige Historie
dieses Repos mitgenommen hat.

Committet wird das hier nicht, weil es gebraucht würde, sondern damit es nicht
als Haufen unversionierter Dateien im Arbeitsverzeichnis verrottet. Wer in
zwei Jahren nachsieht, findet so eine Geschichte statt eines Rätsels — und das
Stammtisch-Wappen, das nur hier je existiert hat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTw6xVYgjMy9AQtfs1Gdyu
2026-09-11 15:42:33 +02:00

186 lines
8 KiB
Python

#!/usr/bin/env python3
"""
Erzeugt saemtliche Logodateien aus einer einzigen Geometrie.
Die Form entsteht nicht von Hand, sondern aus Punkten und Radien: ein
Kurbelarm ist die Huellkurve zweier Kreise (dick am Tretlager, duenn am
Pedal), die Bohrungen sind gegenlaeufig gewickelte Kreise im selben Pfad.
Wer die Marke aendert, aendert die Zahlen hier oben und laesst das Skript
laufen — dann stimmen App-Symbol, Schriftzug und PNG wieder zueinander.
python3 design/logo/build.py
Braucht Inkscape (Vereinigung der Teilformen) und rsvg-convert (PNG).
Der Schriftzug liegt als fertiger Pfad in `word.d`; er stammt aus Inter
ExtraBold, in Kurven gewandelt, und wird hier nur noch skaliert.
"""
import json, math, os, re, subprocess
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
STATIC = os.path.join(ROOT, "frontend", "static")
YELLOW = "#eab308" # yellow-500, die Themenfarbe der App
INK = "#18181b" # zinc-900
PAPER = "#fafaf9"
# Schriftzug: Inter ExtraBold, 128 px, Laufweite -3, in Pfade gewandelt.
WORD = open(os.path.join(HERE, "word.d")).read().strip()
WORD_BOX = dict(x=17.0455, y=46.9091, w=517.182, h=94.4091)
def hull(p1, r1, p2, r2):
"""Aussenkontur zweier Kreise — ein kegeliger Kurbelarm."""
(x1, y1), (x2, y2) = p1, p2
dx, dy = x2 - x1, y2 - y1
th = math.atan2(dy, dx)
be = math.acos(max(-1, min(1, (r1 - r2) / math.hypot(dx, dy))))
at = lambda c, r, a: (c[0] + r * math.cos(a), c[1] + r * math.sin(a))
A, B = at(p1, r1, th + be), at(p2, r2, th + be)
C, D = at(p2, r2, th - be), at(p1, r1, th - be)
return (f'M{A[0]:.3f} {A[1]:.3f}L{B[0]:.3f} {B[1]:.3f}'
f'A{r2} {r2} 0 0 0 {C[0]:.3f} {C[1]:.3f}'
f'L{D[0]:.3f} {D[1]:.3f}A{r1} {r1} 0 0 0 {A[0]:.3f} {A[1]:.3f}Z')
def bore(c, r):
"""Bohrung: gegenlaeufig gewickelt, damit sie im selben Pfad ein Loch ist."""
x, y = c
return (f'M{x - r:.3f} {y:.3f}A{r} {r} 0 1 1 {x + r:.3f} {y:.3f}'
f'A{r} {r} 0 1 1 {x - r:.3f} {y:.3f}Z')
def rrect(x, y, w, h, r):
return (f'M{x + r} {y}H{x + w - r}A{r} {r} 0 0 1 {x + w} {y + r}V{y + h - r}'
f'A{r} {r} 0 0 1 {x + w - r} {y + h}H{x + r}A{r} {r} 0 0 1 {x} {y + h - r}'
f'V{y + r}A{r} {r} 0 0 1 {x + r} {y}Z')
def union(name, parts):
"""Teilformen zu einem Umriss verschmelzen — sonst zeigen sich an den
Ueberlappungen Nahtkanten, und die Bohrungen wuerden dort zufaellig
wieder zuwachsen."""
body = "".join(f'<path id="p{i}" d="{d}" fill="#000"/>' for i, d in enumerate(parts))
src = os.path.join(HERE, f".{name}-parts.svg")
dst = os.path.join(HERE, f".{name}-union.svg")
open(src, "w").write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" '
f'width="64" height="64">{body}</svg>')
subprocess.run(["inkscape", src, "--actions",
f"select-all;path-union;export-plain-svg;"
f"export-filename:{dst};export-do"], capture_output=True)
ds = re.findall(r'\sd="([^"]+)"', open(dst).read(), re.S)
assert len(ds) == 1, f"{name}: {len(ds)} Pfade statt einem"
os.remove(src); os.remove(dst)
return " ".join(ds[0].split())
# --- Die drei Varianten -----------------------------------------------------
# Tretlager J, Pedalaugen UP/LO. Die Arme sind am Lager dick (8.5) und am
# Pedal duenn (5.5) — so herum ist eine Kurbel gebaut.
J, UP, LO = (18.5, 32), (45, 13), (45, 51)
VARIANTS = {
"kurbel-k": dict(
name="Kurbel-K",
parts=[rrect(10, 10.5, 9.5, 43, 4.75), hull(J, 8.5, UP, 5.5), hull(J, 8.5, LO, 5.5)],
bores=[(J, 4.4), (UP, 2.7), (LO, 2.7)],
),
"kurbelarm": dict(
name="Kurbelarm",
parts=[hull((23, 14), 9.5, (41, 50), 6.2)],
bores=[((23, 14), 4.5), ((41, 50), 3.0)],
),
"garnitur": dict(
name="Kurbelgarnitur",
parts=[hull((32, 32), 10, (16.5, 47.5), 6.2), hull((32, 32), 10, (47.5, 16.5), 6.2)],
bores=[((32, 32), 4.8), ((16.5, 47.5), 3.0), ((47.5, 16.5), 3.0)],
),
}
def ink_box(path):
"""Tintenrahmen der fertigen Form — fuers optische Zentrieren."""
tmp = os.path.join(HERE, ".measure.svg")
open(tmp, "w").write(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" '
f'width="64" height="64"><path d="{path}" fill="#000"/></svg>')
out = subprocess.run(["inkscape", tmp, "--query-all"], capture_output=True, text=True).stdout
os.remove(tmp)
x, y, w, h = (float(v) for v in out.splitlines()[0].split(",")[1:5])
return x, y, x + w, y + h
def svg(w, h, body, view=None):
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{view or f"0 0 {w} {h}"}" '
f'width="{w}" height="{h}">{body}</svg>\n')
def build(key, spec, outdir):
path = union(key, spec["parts"]) + "".join(bore(c, r) for c, r in spec["bores"])
x0, y0, x1, y1 = ink_box(path)
mid = lambda s: (32 - (x0 + x1) / 2 * s, 32 - (y0 + y1) / 2 * s)
def centred(scale=1.0):
dx, dy = mid(scale)
return f'translate({dx:.4f} {dy:.4f}) scale({scale})'
def word_at(colour, cap, x):
s = cap / WORD_BOX["h"]
return (f'<g transform="translate({x - WORD_BOX["x"] * s:.3f} '
f'{32 - cap / 2 - WORD_BOX["y"] * s:.3f}) scale({s:.5f})">'
f'<path fill="{colour}" d="{WORD}"/></g>'), WORD_BOX["w"] * s
files = {}
files["mark.svg"] = svg(64, 64,
f'<g transform="{centred()}"><path fill="currentColor" d="{path}"/></g>')
# Die Kachel: Marke auf 84 %, damit sie nicht an die Rundung stoesst.
files["icon.svg"] = svg(64, 64,
f'<rect width="64" height="64" rx="14" fill="{YELLOW}"/>'
f'<g transform="{centred(0.84)}"><path fill="{INK}" d="{path}"/></g>')
files["icon-dark.svg"] = svg(64, 64,
f'<rect width="64" height="64" rx="14" fill="{INK}"/>'
f'<g transform="{centred(0.84)}"><path fill="{YELLOW}" d="{path}"/></g>')
# Randlos fuer Android: 62 %, alles ausserhalb kann beschnitten werden.
files["icon-maskable.svg"] = svg(64, 64,
f'<rect width="64" height="64" fill="{YELLOW}"/>'
f'<g transform="{centred(0.62)}"><path fill="{INK}" d="{path}"/></g>')
for fn, mc, tc in (("logo.svg", YELLOW, INK),
("logo-dark.svg", YELLOW, PAPER),
("logo-mono.svg", "currentColor", "currentColor")):
g, ww = word_at(tc, 38, x1 + 15)
files[fn] = svg(round(x1 + 15 + ww - x0, 2), 64,
f'<path fill="{mc}" d="{path}"/>{g}',
view=f'{x0:g} 0 {x1 + 15 + ww - x0:.2f} 64')
os.makedirs(outdir, exist_ok=True)
for fn, content in files.items():
open(os.path.join(outdir, fn), "w").write(content)
for px in (192, 512):
subprocess.run(["rsvg-convert", "-w", str(px), "-h", str(px),
os.path.join(outdir, "icon.svg"),
"-o", os.path.join(outdir, f"icon-{px}.png")], check=True)
return path, (x0, y0, x1, y1)
if __name__ == "__main__":
geometry = {}
for key, spec in VARIANTS.items():
path, box = build(key, spec, os.path.join(HERE, key))
geometry[key] = dict(path=path, box=box)
print(f"{key:12} {spec['name']}")
# Variante 01 ist die gesetzte: sie wandert unter ihrem Markennamen nach
# static/ und wird von Manifest, Favicon und den Komponenten benutzt.
live = "kurbel-k"
for src, dst in (("mark.svg", "kurbeler-mark.svg"),
("icon.svg", "kurbeler-icon.svg"),
("icon-maskable.svg", "kurbeler-icon-maskable.svg"),
("logo.svg", "kurbeler-logo.svg"),
("logo-dark.svg", "kurbeler-logo-dark.svg"),
("icon-192.png", "kurbeler-icon-192.png"),
("icon-512.png", "kurbeler-icon-512.png")):
s = os.path.join(HERE, live, src)
open(os.path.join(STATIC, dst), "wb").write(open(s, "rb").read())
print(f"\nnach frontend/static/ kopiert: {live}")
json.dump(geometry, open(os.path.join(HERE, "geometry.json"), "w"), indent=1)