feat: ui rework and gear generator
BIN
assets/images/gears/png/filled/chainring_05_filled.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
assets/images/gears/png/filled/chainring_06_filled.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
assets/images/gears/png/filled/chainring_07_filled.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
assets/images/gears/png/filled/chainring_08_filled.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
assets/images/gears/png/filled/chainring_09_filled.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
assets/images/gears/png/filled/chainring_10_filled.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
assets/images/gears/png/filled/chainring_11_filled.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
assets/images/gears/png/filled/chainring_12_filled.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
assets/images/gears/png/filled/chainring_13_filled.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
assets/images/gears/png/filled/chainring_14_filled.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
assets/images/gears/png/filled/chainring_15_filled.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
assets/images/gears/png/filled/chainring_16_filled.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
assets/images/gears/png/filled/chainring_17_filled.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
assets/images/gears/png/filled/chainring_18_filled.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
assets/images/gears/png/filled/chainring_19_filled.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
assets/images/gears/png/filled/chainring_20_filled.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
assets/images/gears/png/filled/chainring_21_filled.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
assets/images/gears/png/filled/chainring_22_filled.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
assets/images/gears/png/filled/chainring_23_filled.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
assets/images/gears/png/filled/chainring_24_filled.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/images/gears/png/filled/chainring_25_filled.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/images/gears/png/filled/chainring_26_filled.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/images/gears/png/filled/chainring_27_filled.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/images/gears/png/filled/chainring_28_filled.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/images/gears/png/filled/chainring_29_filled.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/images/gears/png/filled/chainring_30_filled.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/images/gears/png/filled/chainring_31_filled.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/images/gears/png/filled/chainring_32_filled.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/images/gears/png/filled/chainring_33_filled.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/images/gears/png/filled/chainring_34_filled.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/images/gears/png/filled/chainring_35_filled.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/images/gears/png/filled/chainring_36_filled.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/images/gears/png/filled/chainring_37_filled.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/gears/png/filled/chainring_38_filled.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/images/gears/png/filled/chainring_39_filled.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/images/gears/png/filled/chainring_40_filled.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/images/gears/png/filled/chainring_41_filled.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/images/gears/png/filled/chainring_42_filled.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/images/gears/png/filled/chainring_43_filled.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/images/gears/png/filled/chainring_44_filled.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/images/gears/png/filled/chainring_45_filled.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/images/gears/png/filled/chainring_46_filled.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/images/gears/png/filled/chainring_47_filled.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/gears/png/filled/chainring_48_filled.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/gears/png/filled/chainring_49_filled.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/gears/png/filled/chainring_50_filled.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/gears/png/filled/chainring_51_filled.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/gears/png/filled/chainring_52_filled.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/gears/png/filled/chainring_53_filled.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/images/gears/png/filled/chainring_54_filled.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/images/gears/png/filled/chainring_55_filled.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/images/gears/png/filled/chainring_56_filled.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/images/gears/png/filled/chainring_57_filled.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/images/gears/png/filled/chainring_58_filled.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/images/gears/png/filled/chainring_59_filled.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/images/gears/png/filled/chainring_60_filled.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
492
chainring_generator/generate_chainrings.py
Normal file
@ -0,0 +1,492 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Canvas:
|
||||
width: int = 588
|
||||
height: int = 495
|
||||
cx: float = 304.0
|
||||
cy: float = 256.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Style:
|
||||
background: str = "#3d3d3d"
|
||||
material: str = "#eeeeee"
|
||||
outline: str = "#f4f4f4"
|
||||
outline_width: float = 3.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Geometry:
|
||||
root_radius: float = 213.0
|
||||
tooth_depth: float = 17.5
|
||||
reference_teeth: int = 60
|
||||
pitch_radius_offset: float = 8.0
|
||||
tooth_sharpness: float = 1.32
|
||||
low_tooth_threshold: int = 25
|
||||
low_tooth_half_width: float = 0.30
|
||||
low_tooth_cusp: float = 0.55
|
||||
central_hole_radius: float = 46.0
|
||||
bolt_hole_radius: float = 16.5
|
||||
material_margin: float = 8.0
|
||||
bolt_hole_min_teeth: int = 25
|
||||
decorative_cutout_min_scale: float = 0.62
|
||||
samples_per_tooth: int = 28
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SprocketSizing:
|
||||
pitch_radius: float
|
||||
calculated_root_radius: float
|
||||
root_radius: float
|
||||
tooth_depth: float
|
||||
outer_radius: float
|
||||
decorative_scale: float
|
||||
bolt_scale: float
|
||||
include_bolt_holes: bool
|
||||
include_decorative_cutouts: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Cutout:
|
||||
kind: str
|
||||
cx: float
|
||||
cy: float
|
||||
rx: float
|
||||
ry: float | None = None
|
||||
rotation: float = 0.0
|
||||
|
||||
|
||||
CANVAS = Canvas()
|
||||
STYLE = Style()
|
||||
GEOMETRY = Geometry()
|
||||
|
||||
|
||||
def fmt(value: float) -> str:
|
||||
return f"{value:.3f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def polygon_path(points: list[tuple[float, float]]) -> str:
|
||||
start = points[0]
|
||||
rest = points[1:]
|
||||
commands = [f"M {fmt(start[0])} {fmt(start[1])}"]
|
||||
commands.extend(f"L {fmt(x)} {fmt(y)}" for x, y in rest)
|
||||
commands.append("Z")
|
||||
return " ".join(commands)
|
||||
|
||||
|
||||
def circle_points(cx: float, cy: float, radius: float, samples: int = 144) -> list[tuple[float, float]]:
|
||||
return [
|
||||
(cx + math.cos(t) * radius, cy + math.sin(t) * radius)
|
||||
for t in np.linspace(0.0, 2.0 * math.pi, samples, endpoint=False)
|
||||
]
|
||||
|
||||
|
||||
def ellipse_points(
|
||||
cx: float,
|
||||
cy: float,
|
||||
rx: float,
|
||||
ry: float,
|
||||
rotation_deg: float = 0.0,
|
||||
samples: int = 192,
|
||||
) -> list[tuple[float, float]]:
|
||||
rotation = math.radians(rotation_deg)
|
||||
cos_r = math.cos(rotation)
|
||||
sin_r = math.sin(rotation)
|
||||
points = []
|
||||
for t in np.linspace(0.0, 2.0 * math.pi, samples, endpoint=False):
|
||||
x = math.cos(t) * rx
|
||||
y = math.sin(t) * ry
|
||||
points.append((cx + x * cos_r - y * sin_r, cy + x * sin_r + y * cos_r))
|
||||
return points
|
||||
|
||||
|
||||
DECORATIVE_CUTOUT_SPECS = (
|
||||
("ellipse", 0.0, -141.0, 121.0, 41.0, 0.0),
|
||||
("ellipse", -109.0, 58.0, 106.0, 41.0, 63.0),
|
||||
("ellipse", 109.0, 58.0, 106.0, 41.0, -63.0),
|
||||
)
|
||||
|
||||
BOLT_HOLE_OFFSETS = (
|
||||
(-134.0, -76.0),
|
||||
(134.0, -76.0),
|
||||
(0.0, 157.0),
|
||||
)
|
||||
|
||||
REFERENCE_BOLT_OFFSET_RADIUS = max(math.hypot(dx, dy) for dx, dy in BOLT_HOLE_OFFSETS)
|
||||
|
||||
|
||||
def decorative_reference_extent() -> float:
|
||||
extent = 0.0
|
||||
for _, dx, dy, rx, ry, rotation in DECORATIVE_CUTOUT_SPECS:
|
||||
points = ellipse_points(dx, dy, rx, ry, rotation, 240)
|
||||
extent = max(extent, *(math.hypot(x, y) for x, y in points))
|
||||
return extent
|
||||
|
||||
|
||||
DECORATIVE_REFERENCE_EXTENT = decorative_reference_extent()
|
||||
|
||||
|
||||
def clamp(value: float, minimum: float, maximum: float) -> float:
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
def calculated_pitch_radius(teeth: int, geometry: Geometry = GEOMETRY) -> float:
|
||||
reference_pitch_radius = geometry.root_radius + geometry.pitch_radius_offset
|
||||
return reference_pitch_radius * math.sin(math.pi / geometry.reference_teeth) / math.sin(math.pi / teeth)
|
||||
|
||||
|
||||
def sprocket_sizing(teeth: int, geometry: Geometry = GEOMETRY) -> SprocketSizing:
|
||||
pitch_radius = calculated_pitch_radius(teeth, geometry)
|
||||
calculated_root_radius = pitch_radius - geometry.pitch_radius_offset
|
||||
central_min_root_radius = geometry.central_hole_radius + geometry.material_margin
|
||||
|
||||
include_bolt_holes = teeth >= geometry.bolt_hole_min_teeth
|
||||
if include_bolt_holes:
|
||||
bolt_min_radius = geometry.central_hole_radius + geometry.bolt_hole_radius + geometry.material_margin
|
||||
bolt_min_root_radius = bolt_min_radius + geometry.bolt_hole_radius + geometry.material_margin
|
||||
min_root_radius = max(central_min_root_radius, bolt_min_root_radius)
|
||||
else:
|
||||
min_root_radius = central_min_root_radius
|
||||
|
||||
root_radius = max(calculated_root_radius, min_root_radius)
|
||||
outer_radius = root_radius + geometry.tooth_depth
|
||||
|
||||
max_bolt_offset = max(0.0, root_radius - geometry.bolt_hole_radius - geometry.material_margin)
|
||||
if include_bolt_holes:
|
||||
min_bolt_offset = geometry.central_hole_radius + geometry.bolt_hole_radius + geometry.material_margin
|
||||
bolt_offset = clamp(max_bolt_offset, min_bolt_offset, REFERENCE_BOLT_OFFSET_RADIUS)
|
||||
bolt_scale = bolt_offset / REFERENCE_BOLT_OFFSET_RADIUS
|
||||
else:
|
||||
bolt_scale = 0.0
|
||||
|
||||
decorative_scale = clamp((root_radius - geometry.material_margin) / DECORATIVE_REFERENCE_EXTENT, 0.0, 1.0)
|
||||
include_decorative_cutouts = decorative_scale >= geometry.decorative_cutout_min_scale
|
||||
|
||||
return SprocketSizing(
|
||||
pitch_radius=pitch_radius,
|
||||
calculated_root_radius=calculated_root_radius,
|
||||
root_radius=root_radius,
|
||||
tooth_depth=geometry.tooth_depth,
|
||||
outer_radius=outer_radius,
|
||||
decorative_scale=decorative_scale,
|
||||
bolt_scale=bolt_scale,
|
||||
include_bolt_holes=include_bolt_holes,
|
||||
include_decorative_cutouts=include_decorative_cutouts,
|
||||
)
|
||||
|
||||
|
||||
def outer_points(
|
||||
teeth: int,
|
||||
geometry: Geometry = GEOMETRY,
|
||||
canvas: Canvas = CANVAS,
|
||||
radius_delta: float = 0.0,
|
||||
) -> list[tuple[float, float]]:
|
||||
samples = teeth * geometry.samples_per_tooth
|
||||
sizing = sprocket_sizing(teeth, geometry)
|
||||
points = []
|
||||
phase_offset = -math.pi / 2.0
|
||||
|
||||
for index in range(samples):
|
||||
theta = phase_offset + 2.0 * math.pi * index / samples
|
||||
tooth_phase = (index % geometry.samples_per_tooth) / geometry.samples_per_tooth
|
||||
radius = sizing.root_radius + sizing.tooth_depth * tooth_profile(teeth, tooth_phase, geometry) + radius_delta
|
||||
points.append((canvas.cx + math.cos(theta) * radius, canvas.cy + math.sin(theta) * radius))
|
||||
|
||||
return points
|
||||
|
||||
|
||||
def tooth_profile(teeth: int, phase: float, geometry: Geometry = GEOMETRY) -> float:
|
||||
if teeth < geometry.low_tooth_threshold:
|
||||
distance_from_peak = abs(phase - 0.5)
|
||||
if distance_from_peak >= geometry.low_tooth_half_width:
|
||||
return 0.0
|
||||
return 1.0 - (distance_from_peak / geometry.low_tooth_half_width) ** geometry.low_tooth_cusp
|
||||
|
||||
smooth_profile = 0.5 - 0.5 * math.cos(2.0 * math.pi * phase)
|
||||
return smooth_profile**geometry.tooth_sharpness
|
||||
|
||||
|
||||
def offset_cutout(
|
||||
kind: str,
|
||||
dx: float,
|
||||
dy: float,
|
||||
rx: float,
|
||||
ry: float | None,
|
||||
rotation: float,
|
||||
center_scale: float,
|
||||
radius_scale: float,
|
||||
canvas: Canvas = CANVAS,
|
||||
) -> Cutout:
|
||||
scaled_ry = None if ry is None else ry * radius_scale
|
||||
return Cutout(
|
||||
kind,
|
||||
canvas.cx + dx * center_scale,
|
||||
canvas.cy + dy * center_scale,
|
||||
rx * radius_scale,
|
||||
scaled_ry,
|
||||
rotation,
|
||||
)
|
||||
|
||||
|
||||
def cutouts_for_teeth(teeth: int, canvas: Canvas = CANVAS, geometry: Geometry = GEOMETRY) -> list[Cutout]:
|
||||
sizing = sprocket_sizing(teeth, geometry)
|
||||
return [
|
||||
*(
|
||||
offset_cutout(kind, dx, dy, rx, ry, rotation, sizing.decorative_scale, sizing.decorative_scale, canvas)
|
||||
for kind, dx, dy, rx, ry, rotation in DECORATIVE_CUTOUT_SPECS
|
||||
if sizing.include_decorative_cutouts
|
||||
),
|
||||
offset_cutout("circle", 0.0, 0.0, geometry.central_hole_radius, None, 0.0, 1.0, 1.0, canvas),
|
||||
*(
|
||||
offset_cutout("circle", dx, dy, geometry.bolt_hole_radius, None, 0.0, sizing.bolt_scale, 1.0, canvas)
|
||||
for dx, dy in BOLT_HOLE_OFFSETS
|
||||
if sizing.include_bolt_holes
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def cutout_path(cutout: Cutout, delta: float = 0.0, reverse: bool = False) -> str:
|
||||
if cutout.kind == "circle":
|
||||
points = circle_points(cutout.cx, cutout.cy, cutout.rx + delta, 160)
|
||||
elif cutout.kind == "ellipse":
|
||||
assert cutout.ry is not None
|
||||
points = ellipse_points(
|
||||
cutout.cx,
|
||||
cutout.cy,
|
||||
cutout.rx + delta,
|
||||
cutout.ry + delta,
|
||||
cutout.rotation,
|
||||
240,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported cutout kind: {cutout.kind}")
|
||||
|
||||
if reverse:
|
||||
points = list(reversed(points))
|
||||
return polygon_path(points)
|
||||
|
||||
|
||||
def cutout_paths_for_teeth(teeth: int, canvas: Canvas = CANVAS) -> list[str]:
|
||||
return [cutout_path(cutout) for cutout in cutouts_for_teeth(teeth, canvas)]
|
||||
|
||||
|
||||
def filled_svg(teeth: int, canvas: Canvas = CANVAS, style: Style = STYLE) -> str:
|
||||
outer = polygon_path(outer_points(teeth))
|
||||
cutouts = " ".join(cutout_paths_for_teeth(teeth, canvas))
|
||||
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{canvas.width}" height="{canvas.height}" viewBox="0 0 {canvas.width} {canvas.height}">
|
||||
<path d="{outer} {cutouts}" fill="{style.material}" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
def outline_svg(teeth: int, canvas: Canvas = CANVAS, style: Style = STYLE) -> str:
|
||||
half_width = style.outline_width / 2.0
|
||||
outer = polygon_path(outer_points(teeth, radius_delta=half_width))
|
||||
inner = polygon_path(list(reversed(outer_points(teeth, radius_delta=-half_width))))
|
||||
paths = [f"{outer} {inner}"]
|
||||
for cutout in cutouts_for_teeth(teeth, canvas):
|
||||
paths.append(f"{cutout_path(cutout, half_width)} {cutout_path(cutout, -half_width, reverse=True)}")
|
||||
|
||||
path_elements = "\n ".join(
|
||||
f'<path d="{path}" fill="{style.outline}" fill-rule="evenodd"/>'
|
||||
for path in paths
|
||||
)
|
||||
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{canvas.width}" height="{canvas.height}" viewBox="0 0 {canvas.width} {canvas.height}">
|
||||
{path_elements}
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
def render_png(svg_path: Path, png_path: Path) -> None:
|
||||
magick = shutil.which("magick") or shutil.which("convert")
|
||||
if magick is None:
|
||||
raise RuntimeError("ImageMagick is required for SVG-to-PNG conversion, but neither 'magick' nor 'convert' is on PATH.")
|
||||
|
||||
png_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
magick,
|
||||
"-background",
|
||||
"none",
|
||||
"-density",
|
||||
"192",
|
||||
str(svg_path),
|
||||
"-resize",
|
||||
f"{CANVAS.width}x{CANVAS.height}!",
|
||||
str(png_path),
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def write_asset(teeth: int, variant: str, svg_root: Path, png_root: Path, png: bool = True) -> tuple[Path, Path | None]:
|
||||
if variant == "filled":
|
||||
svg_text = filled_svg(teeth)
|
||||
elif variant == "outline":
|
||||
svg_text = outline_svg(teeth)
|
||||
else:
|
||||
raise ValueError(f"Unsupported variant: {variant}")
|
||||
|
||||
stem = f"chainring_{teeth:02d}_{variant}"
|
||||
svg_path = svg_root / variant / f"{stem}.svg"
|
||||
png_path = png_root / variant / f"{stem}.png"
|
||||
svg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
svg_path.write_text(svg_text, encoding="utf-8")
|
||||
|
||||
if png:
|
||||
render_png(svg_path, png_path)
|
||||
return svg_path, png_path
|
||||
|
||||
return svg_path, None
|
||||
|
||||
|
||||
def generate_all(min_teeth: int, max_teeth: int, output_dir: Path, png: bool = True) -> list[Path]:
|
||||
svg_root = output_dir / "svg"
|
||||
png_root = output_dir / "png"
|
||||
png_paths: list[Path] = []
|
||||
|
||||
for teeth in range(min_teeth, max_teeth + 1):
|
||||
for variant in ("filled", "outline"):
|
||||
_, png_path = write_asset(teeth, variant, svg_root, png_root, png=png)
|
||||
if png_path is not None:
|
||||
png_paths.append(png_path)
|
||||
|
||||
return png_paths
|
||||
|
||||
|
||||
def write_radii_manifest(min_teeth: int, max_teeth: int, output_path: Path) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fields = [
|
||||
"teeth",
|
||||
"pitch_radius_px",
|
||||
"calculated_root_radius_px",
|
||||
"root_radius_px",
|
||||
"tooth_depth_px",
|
||||
"outer_radius_px",
|
||||
"bolt_scale",
|
||||
"decorative_scale",
|
||||
"bolt_holes",
|
||||
"decorative_cutouts",
|
||||
]
|
||||
with output_path.open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
for teeth in range(min_teeth, max_teeth + 1):
|
||||
sizing = sprocket_sizing(teeth)
|
||||
writer.writerow(
|
||||
{
|
||||
"teeth": teeth,
|
||||
"pitch_radius_px": f"{sizing.pitch_radius:.3f}",
|
||||
"calculated_root_radius_px": f"{sizing.calculated_root_radius:.3f}",
|
||||
"root_radius_px": f"{sizing.root_radius:.3f}",
|
||||
"tooth_depth_px": f"{sizing.tooth_depth:.3f}",
|
||||
"outer_radius_px": f"{sizing.outer_radius:.3f}",
|
||||
"bolt_scale": f"{sizing.bolt_scale:.3f}",
|
||||
"decorative_scale": f"{sizing.decorative_scale:.3f}",
|
||||
"bolt_holes": int(sizing.include_bolt_holes),
|
||||
"decorative_cutouts": int(sizing.include_decorative_cutouts),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def make_contact_sheet(png_paths: list[Path], output_path: Path, columns: int = 6) -> None:
|
||||
if not png_paths:
|
||||
return
|
||||
|
||||
thumbs = []
|
||||
thumb_size = (196, 165)
|
||||
for path in png_paths:
|
||||
image = Image.open(path).convert("RGBA")
|
||||
image.thumbnail(thumb_size, Image.Resampling.LANCZOS)
|
||||
tile = Image.new("RGBA", thumb_size, (61, 61, 61, 255))
|
||||
x = (thumb_size[0] - image.width) // 2
|
||||
y = (thumb_size[1] - image.height) // 2
|
||||
tile.alpha_composite(image, (x, y))
|
||||
thumbs.append(tile)
|
||||
|
||||
rows = math.ceil(len(thumbs) / columns)
|
||||
sheet = Image.new("RGBA", (columns * thumb_size[0], rows * thumb_size[1]), (36, 36, 36, 255))
|
||||
for index, thumb in enumerate(thumbs):
|
||||
x = (index % columns) * thumb_size[0]
|
||||
y = (index // columns) * thumb_size[1]
|
||||
sheet.alpha_composite(thumb, (x, y))
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(output_path)
|
||||
|
||||
|
||||
def compare_to_reference(candidate_path: Path, reference_path: Path) -> dict[str, float]:
|
||||
candidate = Image.open(candidate_path).convert("L")
|
||||
reference = Image.open(reference_path).convert("L").resize(candidate.size, Image.Resampling.LANCZOS)
|
||||
|
||||
candidate_arr = np.asarray(ImageOps.autocontrast(candidate), dtype=np.float32) / 255.0
|
||||
reference_arr = np.asarray(ImageOps.autocontrast(reference), dtype=np.float32) / 255.0
|
||||
diff = candidate_arr - reference_arr
|
||||
|
||||
candidate_edges = np.abs(np.gradient(candidate_arr)[0]) + np.abs(np.gradient(candidate_arr)[1])
|
||||
reference_edges = np.abs(np.gradient(reference_arr)[0]) + np.abs(np.gradient(reference_arr)[1])
|
||||
edge_diff = candidate_edges - reference_edges
|
||||
|
||||
return {
|
||||
"mse": float(np.mean(diff**2)),
|
||||
"mae": float(np.mean(np.abs(diff))),
|
||||
"edge_mae": float(np.mean(np.abs(edge_diff))),
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Generate chainring SVG and PNG assets with fixed reference-like inner cutouts.")
|
||||
parser.add_argument("--min-teeth", type=int, default=5)
|
||||
parser.add_argument("--max-teeth", type=int, default=60)
|
||||
parser.add_argument("--output", type=Path, default=Path("out"))
|
||||
parser.add_argument("--no-png", action="store_true")
|
||||
parser.add_argument("--contact-sheet", action="store_true")
|
||||
parser.add_argument("--reference", type=Path, help="Optional local reference image for reporting similarity metrics.")
|
||||
parser.add_argument("--reference-teeth", type=int, default=44)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.min_teeth < 3:
|
||||
raise ValueError("--min-teeth must be at least 3.")
|
||||
if args.max_teeth < args.min_teeth:
|
||||
raise ValueError("--max-teeth must be greater than or equal to --min-teeth.")
|
||||
|
||||
png_paths = generate_all(args.min_teeth, args.max_teeth, args.output, png=not args.no_png)
|
||||
write_radii_manifest(args.min_teeth, args.max_teeth, args.output / "radii.csv")
|
||||
|
||||
if args.contact_sheet and png_paths:
|
||||
filled_samples = [path for path in png_paths if path.parent.name == "filled"]
|
||||
outline_samples = [path for path in png_paths if path.parent.name == "outline"]
|
||||
make_contact_sheet(filled_samples, args.output / "contact_sheet_filled.png")
|
||||
make_contact_sheet(outline_samples, args.output / "contact_sheet_outline.png")
|
||||
|
||||
if args.reference and not args.no_png:
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp_root = Path(tmp)
|
||||
_, candidate = write_asset(args.reference_teeth, "filled", tmp_root / "svg", tmp_root / "png", png=True)
|
||||
assert candidate is not None
|
||||
metrics = compare_to_reference(candidate, args.reference)
|
||||
print(
|
||||
"reference_metrics "
|
||||
+ " ".join(f"{name}={value:.6f}" for name, value in metrics.items())
|
||||
)
|
||||
|
||||
print(f"generated teeth={args.min_teeth}..{args.max_teeth} output={args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
chainring_generator/out/contact_sheet_filled.png
Normal file
|
After Width: | Height: | Size: 455 KiB |
BIN
chainring_generator/out/contact_sheet_outline.png
Normal file
|
After Width: | Height: | Size: 490 KiB |
BIN
chainring_generator/out/png/filled/chainring_05_filled.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
chainring_generator/out/png/filled/chainring_06_filled.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
chainring_generator/out/png/filled/chainring_07_filled.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
chainring_generator/out/png/filled/chainring_08_filled.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
chainring_generator/out/png/filled/chainring_09_filled.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
chainring_generator/out/png/filled/chainring_10_filled.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
chainring_generator/out/png/filled/chainring_11_filled.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
chainring_generator/out/png/filled/chainring_12_filled.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
chainring_generator/out/png/filled/chainring_13_filled.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
chainring_generator/out/png/filled/chainring_14_filled.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
chainring_generator/out/png/filled/chainring_15_filled.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
chainring_generator/out/png/filled/chainring_16_filled.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
chainring_generator/out/png/filled/chainring_17_filled.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
chainring_generator/out/png/filled/chainring_18_filled.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
chainring_generator/out/png/filled/chainring_19_filled.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
chainring_generator/out/png/filled/chainring_20_filled.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
chainring_generator/out/png/filled/chainring_21_filled.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
chainring_generator/out/png/filled/chainring_22_filled.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
chainring_generator/out/png/filled/chainring_23_filled.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
chainring_generator/out/png/filled/chainring_24_filled.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
chainring_generator/out/png/filled/chainring_25_filled.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
chainring_generator/out/png/filled/chainring_26_filled.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
chainring_generator/out/png/filled/chainring_27_filled.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
chainring_generator/out/png/filled/chainring_28_filled.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
chainring_generator/out/png/filled/chainring_29_filled.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
chainring_generator/out/png/filled/chainring_30_filled.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
chainring_generator/out/png/filled/chainring_31_filled.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
chainring_generator/out/png/filled/chainring_32_filled.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
chainring_generator/out/png/filled/chainring_33_filled.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
chainring_generator/out/png/filled/chainring_34_filled.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
chainring_generator/out/png/filled/chainring_35_filled.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
chainring_generator/out/png/filled/chainring_36_filled.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
chainring_generator/out/png/filled/chainring_37_filled.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
chainring_generator/out/png/filled/chainring_38_filled.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
chainring_generator/out/png/filled/chainring_39_filled.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
chainring_generator/out/png/filled/chainring_40_filled.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
chainring_generator/out/png/filled/chainring_41_filled.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
chainring_generator/out/png/filled/chainring_42_filled.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
chainring_generator/out/png/filled/chainring_43_filled.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
chainring_generator/out/png/filled/chainring_44_filled.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
chainring_generator/out/png/filled/chainring_45_filled.png
Normal file
|
After Width: | Height: | Size: 52 KiB |