#!/usr/bin/env python3 """ Generates the Rippr logo concept set as standalone SVGs, then rasterises them. Everything is authored on Android's adaptive-icon canvas (108x108) with the key shapes kept inside the 66-unit safe circle centred at (54,54), so a chosen concept converts to res/drawable/ic_launcher_foreground.xml without re-drawing anything. The figurative geometry is Google's Material Symbols (Apache-2.0, vendored under design/material/) rather than hand-approximated curves. Only the framing — gauge arcs, badge grounds, rings — is authored here, because arcs and circles are the shapes that can be computed exactly instead of eyeballed. """ import math import pathlib import re import subprocess HERE = pathlib.Path(__file__).parent OUT = HERE / "logos" GLYPHS = HERE / "material" PNG = HERE / "preview" CX = CY = 54.0 INK = "#F2F5F8" ACCENT = "#FF5722" DEEP = "#101418" DIM = "#66727E" # Material glyphs are drawn in a 960 box spanning y = -960..0. GLYPH_BOX = 960.0 def glyph_path(name: str) -> str: """Extract the single path's `d` attribute from a vendored Material Symbol.""" svg = (GLYPHS / f"{name}.svg").read_text() m = re.search(r']*\sd="([^"]+)"', svg) if not m: raise ValueError(f"no path found in {name}.svg") return m.group(1) def glyph(name: str, size: float, cx: float = CX, cy: float = CY, fill: str = INK, rotate: float = 0.0) -> str: """Place a Material glyph centred on (cx, cy), scaled to `size` units square.""" s = size / GLYPH_BOX # Map x: 0..960 -> centred; y: -960..0 -> centred. tx = cx - size / 2 ty = cy + size / 2 rot = f' transform="rotate({rotate} {cx} {cy})"' if rotate else "" return ( f'' ) def polar(cx, cy, r, deg): a = math.radians(deg) return cx + r * math.cos(a), cy - r * math.sin(a) def arc(cx, cy, r, start_deg, end_deg): x0, y0 = polar(cx, cy, r, start_deg) x1, y1 = polar(cx, cy, r, end_deg) large = 1 if abs(end_deg - start_deg) > 180 else 0 sweep = 0 if end_deg > start_deg else 1 return f"M{x0:.3f},{y0:.3f} A{r:.3f},{r:.3f} 0 {large} {sweep} {x1:.3f},{y1:.3f}" # -------------------------------------------------------------------------- # A — Apex. Material's `speed` gauge inside a computed tachometer sweep, with the # last quarter of the sweep in accent as a redline. # -------------------------------------------------------------------------- def concept_apex(): parts = [ f'', f'', ] for i in range(11): deg = 215 - i * 25 major = i % 5 == 0 r0 = 34 if major else 37 x0, y0 = polar(CX, CY, r0, deg) x1, y1 = polar(CX, CY, 39.5, deg) parts.append( f'' ) parts.append(glyph("speed", 46)) return "".join(parts) # -------------------------------------------------------------------------- # D — Route. Material's `route` glyph ringed by a thin border, reading as a map # trace rather than an instrument. # -------------------------------------------------------------------------- def concept_route(): return ( f'' + glyph("route", 54, fill=INK) + f'' ) # -------------------------------------------------------------------------- # E — Heading. `navigation` is the cleanest silhouette Material offers and the # only one that stays unambiguous as a flat 24px status-bar icon. # -------------------------------------------------------------------------- def concept_heading(): heading = 20 # degrees clockwise from north # The accent segment marks where the arrow points, so the two read as one object # rather than a ring with an arrow dropped inside it. centre = 90 - heading parts = [ f'', f'', # The glyph's ink sits below its box centre, so nudge up to centre it optically. glyph("navigation", 48, cy=CY - 2, rotate=heading), ] return "".join(parts) CONCEPTS = { "apex": ("Apex", "Tachometer sweep with redline", concept_apex), "route": ("Route", "Recorded trace inside a ring", concept_route), "heading": ("Heading", "Navigation arrow with heading arc", concept_heading), } def svg_doc(body, bg=None): bg_rect = f'' if bg else "" return ( '{bg_rect}{body}' ) def rasterise(): """Render each concept large so the output can actually be reviewed.""" PNG.mkdir(parents=True, exist_ok=True) for key in CONCEPTS: src = OUT / f"rippr-{key}-onbg.svg" dst = PNG / f"{key}.png" subprocess.run( ["rsvg-convert", "-w", "512", "-h", "512", str(src), "-o", str(dst)], check=True, ) # Strips for review. `montage` needs a font for its auto-labels and fails without # one, so compose with +append instead — no text involved. def strip(px: int, name: str): subprocess.run( ["magick"] + [str(PNG / f"{k}.png") for k in CONCEPTS] + ["-resize", f"{px}x{px}", "-background", "#05070A", "-splice", "8x0", "+append", str(PNG / name)], check=True, ) strip(512, "contact-sheet.png") # True launcher and status-bar sizes: where detail either survives or turns to mush. strip(48, "small-48.png") strip(24, "small-24.png") def main(): OUT.mkdir(parents=True, exist_ok=True) for key, (_name, _desc, fn) in CONCEPTS.items(): body = fn() (OUT / f"rippr-{key}.svg").write_text(svg_doc(body)) (OUT / f"rippr-{key}-onbg.svg").write_text(svg_doc(body, bg=DEEP)) print(f"wrote rippr-{key}.svg") rasterise() print(f"rendered PNGs -> {PNG}") if __name__ == "__main__": main()