diff --git a/assets/images/gears/png/filled/chainring_05_filled.png b/assets/images/gears/png/filled/chainring_05_filled.png new file mode 100644 index 0000000..c3fe5cf Binary files /dev/null and b/assets/images/gears/png/filled/chainring_05_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_06_filled.png b/assets/images/gears/png/filled/chainring_06_filled.png new file mode 100644 index 0000000..a195c73 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_06_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_07_filled.png b/assets/images/gears/png/filled/chainring_07_filled.png new file mode 100644 index 0000000..a6252e3 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_07_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_08_filled.png b/assets/images/gears/png/filled/chainring_08_filled.png new file mode 100644 index 0000000..b6e1be8 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_08_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_09_filled.png b/assets/images/gears/png/filled/chainring_09_filled.png new file mode 100644 index 0000000..79871d8 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_09_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_10_filled.png b/assets/images/gears/png/filled/chainring_10_filled.png new file mode 100644 index 0000000..f262127 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_10_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_11_filled.png b/assets/images/gears/png/filled/chainring_11_filled.png new file mode 100644 index 0000000..6d966f4 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_11_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_12_filled.png b/assets/images/gears/png/filled/chainring_12_filled.png new file mode 100644 index 0000000..bc70da4 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_12_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_13_filled.png b/assets/images/gears/png/filled/chainring_13_filled.png new file mode 100644 index 0000000..d493ee7 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_13_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_14_filled.png b/assets/images/gears/png/filled/chainring_14_filled.png new file mode 100644 index 0000000..90ff845 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_14_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_15_filled.png b/assets/images/gears/png/filled/chainring_15_filled.png new file mode 100644 index 0000000..7e0f921 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_15_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_16_filled.png b/assets/images/gears/png/filled/chainring_16_filled.png new file mode 100644 index 0000000..9bca209 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_16_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_17_filled.png b/assets/images/gears/png/filled/chainring_17_filled.png new file mode 100644 index 0000000..a64a3a4 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_17_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_18_filled.png b/assets/images/gears/png/filled/chainring_18_filled.png new file mode 100644 index 0000000..7f1ac5a Binary files /dev/null and b/assets/images/gears/png/filled/chainring_18_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_19_filled.png b/assets/images/gears/png/filled/chainring_19_filled.png new file mode 100644 index 0000000..754d64f Binary files /dev/null and b/assets/images/gears/png/filled/chainring_19_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_20_filled.png b/assets/images/gears/png/filled/chainring_20_filled.png new file mode 100644 index 0000000..bba642f Binary files /dev/null and b/assets/images/gears/png/filled/chainring_20_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_21_filled.png b/assets/images/gears/png/filled/chainring_21_filled.png new file mode 100644 index 0000000..7753924 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_21_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_22_filled.png b/assets/images/gears/png/filled/chainring_22_filled.png new file mode 100644 index 0000000..8d4dfd1 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_22_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_23_filled.png b/assets/images/gears/png/filled/chainring_23_filled.png new file mode 100644 index 0000000..ac081b2 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_23_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_24_filled.png b/assets/images/gears/png/filled/chainring_24_filled.png new file mode 100644 index 0000000..da45571 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_24_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_25_filled.png b/assets/images/gears/png/filled/chainring_25_filled.png new file mode 100644 index 0000000..2dc89ba Binary files /dev/null and b/assets/images/gears/png/filled/chainring_25_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_26_filled.png b/assets/images/gears/png/filled/chainring_26_filled.png new file mode 100644 index 0000000..d321eb9 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_26_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_27_filled.png b/assets/images/gears/png/filled/chainring_27_filled.png new file mode 100644 index 0000000..8c1a707 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_27_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_28_filled.png b/assets/images/gears/png/filled/chainring_28_filled.png new file mode 100644 index 0000000..51dbe07 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_28_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_29_filled.png b/assets/images/gears/png/filled/chainring_29_filled.png new file mode 100644 index 0000000..09a224d Binary files /dev/null and b/assets/images/gears/png/filled/chainring_29_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_30_filled.png b/assets/images/gears/png/filled/chainring_30_filled.png new file mode 100644 index 0000000..352ab3c Binary files /dev/null and b/assets/images/gears/png/filled/chainring_30_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_31_filled.png b/assets/images/gears/png/filled/chainring_31_filled.png new file mode 100644 index 0000000..68f011b Binary files /dev/null and b/assets/images/gears/png/filled/chainring_31_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_32_filled.png b/assets/images/gears/png/filled/chainring_32_filled.png new file mode 100644 index 0000000..d9bc324 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_32_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_33_filled.png b/assets/images/gears/png/filled/chainring_33_filled.png new file mode 100644 index 0000000..fd3ab33 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_33_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_34_filled.png b/assets/images/gears/png/filled/chainring_34_filled.png new file mode 100644 index 0000000..0e6005a Binary files /dev/null and b/assets/images/gears/png/filled/chainring_34_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_35_filled.png b/assets/images/gears/png/filled/chainring_35_filled.png new file mode 100644 index 0000000..cb54427 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_35_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_36_filled.png b/assets/images/gears/png/filled/chainring_36_filled.png new file mode 100644 index 0000000..1c4d49c Binary files /dev/null and b/assets/images/gears/png/filled/chainring_36_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_37_filled.png b/assets/images/gears/png/filled/chainring_37_filled.png new file mode 100644 index 0000000..f91c893 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_37_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_38_filled.png b/assets/images/gears/png/filled/chainring_38_filled.png new file mode 100644 index 0000000..8f2264d Binary files /dev/null and b/assets/images/gears/png/filled/chainring_38_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_39_filled.png b/assets/images/gears/png/filled/chainring_39_filled.png new file mode 100644 index 0000000..286dff9 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_39_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_40_filled.png b/assets/images/gears/png/filled/chainring_40_filled.png new file mode 100644 index 0000000..bf5a792 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_40_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_41_filled.png b/assets/images/gears/png/filled/chainring_41_filled.png new file mode 100644 index 0000000..076dfac Binary files /dev/null and b/assets/images/gears/png/filled/chainring_41_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_42_filled.png b/assets/images/gears/png/filled/chainring_42_filled.png new file mode 100644 index 0000000..6a16e15 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_42_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_43_filled.png b/assets/images/gears/png/filled/chainring_43_filled.png new file mode 100644 index 0000000..efcb22b Binary files /dev/null and b/assets/images/gears/png/filled/chainring_43_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_44_filled.png b/assets/images/gears/png/filled/chainring_44_filled.png new file mode 100644 index 0000000..271b9c3 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_44_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_45_filled.png b/assets/images/gears/png/filled/chainring_45_filled.png new file mode 100644 index 0000000..aebbc68 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_45_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_46_filled.png b/assets/images/gears/png/filled/chainring_46_filled.png new file mode 100644 index 0000000..b10a502 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_46_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_47_filled.png b/assets/images/gears/png/filled/chainring_47_filled.png new file mode 100644 index 0000000..b24c821 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_47_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_48_filled.png b/assets/images/gears/png/filled/chainring_48_filled.png new file mode 100644 index 0000000..27174a0 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_48_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_49_filled.png b/assets/images/gears/png/filled/chainring_49_filled.png new file mode 100644 index 0000000..e18b686 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_49_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_50_filled.png b/assets/images/gears/png/filled/chainring_50_filled.png new file mode 100644 index 0000000..5a4457e Binary files /dev/null and b/assets/images/gears/png/filled/chainring_50_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_51_filled.png b/assets/images/gears/png/filled/chainring_51_filled.png new file mode 100644 index 0000000..30b189d Binary files /dev/null and b/assets/images/gears/png/filled/chainring_51_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_52_filled.png b/assets/images/gears/png/filled/chainring_52_filled.png new file mode 100644 index 0000000..c3fa000 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_52_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_53_filled.png b/assets/images/gears/png/filled/chainring_53_filled.png new file mode 100644 index 0000000..af75472 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_53_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_54_filled.png b/assets/images/gears/png/filled/chainring_54_filled.png new file mode 100644 index 0000000..affd4bf Binary files /dev/null and b/assets/images/gears/png/filled/chainring_54_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_55_filled.png b/assets/images/gears/png/filled/chainring_55_filled.png new file mode 100644 index 0000000..2dd2463 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_55_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_56_filled.png b/assets/images/gears/png/filled/chainring_56_filled.png new file mode 100644 index 0000000..ee151d1 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_56_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_57_filled.png b/assets/images/gears/png/filled/chainring_57_filled.png new file mode 100644 index 0000000..08adfcb Binary files /dev/null and b/assets/images/gears/png/filled/chainring_57_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_58_filled.png b/assets/images/gears/png/filled/chainring_58_filled.png new file mode 100644 index 0000000..b37a7bf Binary files /dev/null and b/assets/images/gears/png/filled/chainring_58_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_59_filled.png b/assets/images/gears/png/filled/chainring_59_filled.png new file mode 100644 index 0000000..05aabc4 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_59_filled.png differ diff --git a/assets/images/gears/png/filled/chainring_60_filled.png b/assets/images/gears/png/filled/chainring_60_filled.png new file mode 100644 index 0000000..7730191 Binary files /dev/null and b/assets/images/gears/png/filled/chainring_60_filled.png differ diff --git a/chainring_generator/generate_chainrings.py b/chainring_generator/generate_chainrings.py new file mode 100644 index 0000000..72ea707 --- /dev/null +++ b/chainring_generator/generate_chainrings.py @@ -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""" + + +""" + + +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'' + for path in paths + ) + return f""" + {path_elements} + +""" + + +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() diff --git a/chainring_generator/out/contact_sheet_filled.png b/chainring_generator/out/contact_sheet_filled.png new file mode 100644 index 0000000..eab5182 Binary files /dev/null and b/chainring_generator/out/contact_sheet_filled.png differ diff --git a/chainring_generator/out/contact_sheet_outline.png b/chainring_generator/out/contact_sheet_outline.png new file mode 100644 index 0000000..f8a745c Binary files /dev/null and b/chainring_generator/out/contact_sheet_outline.png differ diff --git a/chainring_generator/out/png/filled/chainring_05_filled.png b/chainring_generator/out/png/filled/chainring_05_filled.png new file mode 100644 index 0000000..573eef4 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_05_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_06_filled.png b/chainring_generator/out/png/filled/chainring_06_filled.png new file mode 100644 index 0000000..8345b4a Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_06_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_07_filled.png b/chainring_generator/out/png/filled/chainring_07_filled.png new file mode 100644 index 0000000..f0e1ec6 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_07_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_08_filled.png b/chainring_generator/out/png/filled/chainring_08_filled.png new file mode 100644 index 0000000..c6896fc Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_08_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_09_filled.png b/chainring_generator/out/png/filled/chainring_09_filled.png new file mode 100644 index 0000000..911ac0f Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_09_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_10_filled.png b/chainring_generator/out/png/filled/chainring_10_filled.png new file mode 100644 index 0000000..9457e80 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_10_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_11_filled.png b/chainring_generator/out/png/filled/chainring_11_filled.png new file mode 100644 index 0000000..bb2a86a Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_11_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_12_filled.png b/chainring_generator/out/png/filled/chainring_12_filled.png new file mode 100644 index 0000000..b59a140 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_12_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_13_filled.png b/chainring_generator/out/png/filled/chainring_13_filled.png new file mode 100644 index 0000000..27c81fb Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_13_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_14_filled.png b/chainring_generator/out/png/filled/chainring_14_filled.png new file mode 100644 index 0000000..36ccf96 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_14_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_15_filled.png b/chainring_generator/out/png/filled/chainring_15_filled.png new file mode 100644 index 0000000..2192367 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_15_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_16_filled.png b/chainring_generator/out/png/filled/chainring_16_filled.png new file mode 100644 index 0000000..532d53d Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_16_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_17_filled.png b/chainring_generator/out/png/filled/chainring_17_filled.png new file mode 100644 index 0000000..c079f66 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_17_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_18_filled.png b/chainring_generator/out/png/filled/chainring_18_filled.png new file mode 100644 index 0000000..0eade72 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_18_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_19_filled.png b/chainring_generator/out/png/filled/chainring_19_filled.png new file mode 100644 index 0000000..0362994 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_19_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_20_filled.png b/chainring_generator/out/png/filled/chainring_20_filled.png new file mode 100644 index 0000000..d8a2806 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_20_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_21_filled.png b/chainring_generator/out/png/filled/chainring_21_filled.png new file mode 100644 index 0000000..bebc538 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_21_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_22_filled.png b/chainring_generator/out/png/filled/chainring_22_filled.png new file mode 100644 index 0000000..3428441 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_22_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_23_filled.png b/chainring_generator/out/png/filled/chainring_23_filled.png new file mode 100644 index 0000000..78fb9a9 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_23_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_24_filled.png b/chainring_generator/out/png/filled/chainring_24_filled.png new file mode 100644 index 0000000..51c95b0 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_24_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_25_filled.png b/chainring_generator/out/png/filled/chainring_25_filled.png new file mode 100644 index 0000000..fb72398 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_25_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_26_filled.png b/chainring_generator/out/png/filled/chainring_26_filled.png new file mode 100644 index 0000000..b00f74b Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_26_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_27_filled.png b/chainring_generator/out/png/filled/chainring_27_filled.png new file mode 100644 index 0000000..4cf31eb Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_27_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_28_filled.png b/chainring_generator/out/png/filled/chainring_28_filled.png new file mode 100644 index 0000000..17a2f01 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_28_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_29_filled.png b/chainring_generator/out/png/filled/chainring_29_filled.png new file mode 100644 index 0000000..58d3a2a Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_29_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_30_filled.png b/chainring_generator/out/png/filled/chainring_30_filled.png new file mode 100644 index 0000000..988162e Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_30_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_31_filled.png b/chainring_generator/out/png/filled/chainring_31_filled.png new file mode 100644 index 0000000..11dd068 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_31_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_32_filled.png b/chainring_generator/out/png/filled/chainring_32_filled.png new file mode 100644 index 0000000..60dfa1e Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_32_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_33_filled.png b/chainring_generator/out/png/filled/chainring_33_filled.png new file mode 100644 index 0000000..5f39870 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_33_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_34_filled.png b/chainring_generator/out/png/filled/chainring_34_filled.png new file mode 100644 index 0000000..c85fe93 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_34_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_35_filled.png b/chainring_generator/out/png/filled/chainring_35_filled.png new file mode 100644 index 0000000..e62a73f Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_35_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_36_filled.png b/chainring_generator/out/png/filled/chainring_36_filled.png new file mode 100644 index 0000000..13d035a Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_36_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_37_filled.png b/chainring_generator/out/png/filled/chainring_37_filled.png new file mode 100644 index 0000000..324bdb6 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_37_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_38_filled.png b/chainring_generator/out/png/filled/chainring_38_filled.png new file mode 100644 index 0000000..0375d28 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_38_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_39_filled.png b/chainring_generator/out/png/filled/chainring_39_filled.png new file mode 100644 index 0000000..e52784b Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_39_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_40_filled.png b/chainring_generator/out/png/filled/chainring_40_filled.png new file mode 100644 index 0000000..bd824a8 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_40_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_41_filled.png b/chainring_generator/out/png/filled/chainring_41_filled.png new file mode 100644 index 0000000..584850e Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_41_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_42_filled.png b/chainring_generator/out/png/filled/chainring_42_filled.png new file mode 100644 index 0000000..c91343f Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_42_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_43_filled.png b/chainring_generator/out/png/filled/chainring_43_filled.png new file mode 100644 index 0000000..676c16c Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_43_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_44_filled.png b/chainring_generator/out/png/filled/chainring_44_filled.png new file mode 100644 index 0000000..5004874 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_44_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_45_filled.png b/chainring_generator/out/png/filled/chainring_45_filled.png new file mode 100644 index 0000000..8601530 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_45_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_46_filled.png b/chainring_generator/out/png/filled/chainring_46_filled.png new file mode 100644 index 0000000..da80297 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_46_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_47_filled.png b/chainring_generator/out/png/filled/chainring_47_filled.png new file mode 100644 index 0000000..c76b09c Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_47_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_48_filled.png b/chainring_generator/out/png/filled/chainring_48_filled.png new file mode 100644 index 0000000..6cab4cb Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_48_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_49_filled.png b/chainring_generator/out/png/filled/chainring_49_filled.png new file mode 100644 index 0000000..d705937 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_49_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_50_filled.png b/chainring_generator/out/png/filled/chainring_50_filled.png new file mode 100644 index 0000000..d27fbc8 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_50_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_51_filled.png b/chainring_generator/out/png/filled/chainring_51_filled.png new file mode 100644 index 0000000..45866a4 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_51_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_52_filled.png b/chainring_generator/out/png/filled/chainring_52_filled.png new file mode 100644 index 0000000..93df2ad Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_52_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_53_filled.png b/chainring_generator/out/png/filled/chainring_53_filled.png new file mode 100644 index 0000000..0d70bcd Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_53_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_54_filled.png b/chainring_generator/out/png/filled/chainring_54_filled.png new file mode 100644 index 0000000..3d5f9d3 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_54_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_55_filled.png b/chainring_generator/out/png/filled/chainring_55_filled.png new file mode 100644 index 0000000..58b42ef Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_55_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_56_filled.png b/chainring_generator/out/png/filled/chainring_56_filled.png new file mode 100644 index 0000000..2538ba3 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_56_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_57_filled.png b/chainring_generator/out/png/filled/chainring_57_filled.png new file mode 100644 index 0000000..708e86b Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_57_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_58_filled.png b/chainring_generator/out/png/filled/chainring_58_filled.png new file mode 100644 index 0000000..07e1d5c Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_58_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_59_filled.png b/chainring_generator/out/png/filled/chainring_59_filled.png new file mode 100644 index 0000000..a648722 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_59_filled.png differ diff --git a/chainring_generator/out/png/filled/chainring_60_filled.png b/chainring_generator/out/png/filled/chainring_60_filled.png new file mode 100644 index 0000000..3c3c7b1 Binary files /dev/null and b/chainring_generator/out/png/filled/chainring_60_filled.png differ diff --git a/chainring_generator/out/png/outline/chainring_05_outline.png b/chainring_generator/out/png/outline/chainring_05_outline.png new file mode 100644 index 0000000..1d8250e Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_05_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_06_outline.png b/chainring_generator/out/png/outline/chainring_06_outline.png new file mode 100644 index 0000000..7cda1e8 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_06_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_07_outline.png b/chainring_generator/out/png/outline/chainring_07_outline.png new file mode 100644 index 0000000..31ab514 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_07_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_08_outline.png b/chainring_generator/out/png/outline/chainring_08_outline.png new file mode 100644 index 0000000..d22c9ec Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_08_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_09_outline.png b/chainring_generator/out/png/outline/chainring_09_outline.png new file mode 100644 index 0000000..fffa82e Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_09_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_10_outline.png b/chainring_generator/out/png/outline/chainring_10_outline.png new file mode 100644 index 0000000..06a27ab Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_10_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_11_outline.png b/chainring_generator/out/png/outline/chainring_11_outline.png new file mode 100644 index 0000000..3aad9a3 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_11_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_12_outline.png b/chainring_generator/out/png/outline/chainring_12_outline.png new file mode 100644 index 0000000..ed22034 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_12_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_13_outline.png b/chainring_generator/out/png/outline/chainring_13_outline.png new file mode 100644 index 0000000..b4eab59 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_13_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_14_outline.png b/chainring_generator/out/png/outline/chainring_14_outline.png new file mode 100644 index 0000000..f3d8927 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_14_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_15_outline.png b/chainring_generator/out/png/outline/chainring_15_outline.png new file mode 100644 index 0000000..6b32e1b Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_15_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_16_outline.png b/chainring_generator/out/png/outline/chainring_16_outline.png new file mode 100644 index 0000000..9ad324d Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_16_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_17_outline.png b/chainring_generator/out/png/outline/chainring_17_outline.png new file mode 100644 index 0000000..4bd1f31 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_17_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_18_outline.png b/chainring_generator/out/png/outline/chainring_18_outline.png new file mode 100644 index 0000000..f5110cf Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_18_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_19_outline.png b/chainring_generator/out/png/outline/chainring_19_outline.png new file mode 100644 index 0000000..3b95be6 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_19_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_20_outline.png b/chainring_generator/out/png/outline/chainring_20_outline.png new file mode 100644 index 0000000..79b1317 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_20_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_21_outline.png b/chainring_generator/out/png/outline/chainring_21_outline.png new file mode 100644 index 0000000..05608a8 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_21_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_22_outline.png b/chainring_generator/out/png/outline/chainring_22_outline.png new file mode 100644 index 0000000..911e0fe Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_22_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_23_outline.png b/chainring_generator/out/png/outline/chainring_23_outline.png new file mode 100644 index 0000000..1fb6cf6 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_23_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_24_outline.png b/chainring_generator/out/png/outline/chainring_24_outline.png new file mode 100644 index 0000000..5285305 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_24_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_25_outline.png b/chainring_generator/out/png/outline/chainring_25_outline.png new file mode 100644 index 0000000..5ddb784 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_25_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_26_outline.png b/chainring_generator/out/png/outline/chainring_26_outline.png new file mode 100644 index 0000000..4e08b88 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_26_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_27_outline.png b/chainring_generator/out/png/outline/chainring_27_outline.png new file mode 100644 index 0000000..2f8d191 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_27_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_28_outline.png b/chainring_generator/out/png/outline/chainring_28_outline.png new file mode 100644 index 0000000..c9b3fd3 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_28_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_29_outline.png b/chainring_generator/out/png/outline/chainring_29_outline.png new file mode 100644 index 0000000..7c04686 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_29_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_30_outline.png b/chainring_generator/out/png/outline/chainring_30_outline.png new file mode 100644 index 0000000..4366899 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_30_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_31_outline.png b/chainring_generator/out/png/outline/chainring_31_outline.png new file mode 100644 index 0000000..28afc05 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_31_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_32_outline.png b/chainring_generator/out/png/outline/chainring_32_outline.png new file mode 100644 index 0000000..196ec28 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_32_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_33_outline.png b/chainring_generator/out/png/outline/chainring_33_outline.png new file mode 100644 index 0000000..36b595d Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_33_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_34_outline.png b/chainring_generator/out/png/outline/chainring_34_outline.png new file mode 100644 index 0000000..15bad48 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_34_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_35_outline.png b/chainring_generator/out/png/outline/chainring_35_outline.png new file mode 100644 index 0000000..d34e652 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_35_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_36_outline.png b/chainring_generator/out/png/outline/chainring_36_outline.png new file mode 100644 index 0000000..fd7b16f Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_36_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_37_outline.png b/chainring_generator/out/png/outline/chainring_37_outline.png new file mode 100644 index 0000000..6e33c33 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_37_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_38_outline.png b/chainring_generator/out/png/outline/chainring_38_outline.png new file mode 100644 index 0000000..72030d6 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_38_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_39_outline.png b/chainring_generator/out/png/outline/chainring_39_outline.png new file mode 100644 index 0000000..fdeae4a Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_39_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_40_outline.png b/chainring_generator/out/png/outline/chainring_40_outline.png new file mode 100644 index 0000000..19c1d0a Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_40_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_41_outline.png b/chainring_generator/out/png/outline/chainring_41_outline.png new file mode 100644 index 0000000..4370a70 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_41_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_42_outline.png b/chainring_generator/out/png/outline/chainring_42_outline.png new file mode 100644 index 0000000..457ea26 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_42_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_43_outline.png b/chainring_generator/out/png/outline/chainring_43_outline.png new file mode 100644 index 0000000..d403860 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_43_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_44_outline.png b/chainring_generator/out/png/outline/chainring_44_outline.png new file mode 100644 index 0000000..b63929d Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_44_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_45_outline.png b/chainring_generator/out/png/outline/chainring_45_outline.png new file mode 100644 index 0000000..fe317de Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_45_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_46_outline.png b/chainring_generator/out/png/outline/chainring_46_outline.png new file mode 100644 index 0000000..beb99bd Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_46_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_47_outline.png b/chainring_generator/out/png/outline/chainring_47_outline.png new file mode 100644 index 0000000..6bd1d6e Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_47_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_48_outline.png b/chainring_generator/out/png/outline/chainring_48_outline.png new file mode 100644 index 0000000..8de7e64 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_48_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_49_outline.png b/chainring_generator/out/png/outline/chainring_49_outline.png new file mode 100644 index 0000000..49ec37e Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_49_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_50_outline.png b/chainring_generator/out/png/outline/chainring_50_outline.png new file mode 100644 index 0000000..1ba0ebc Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_50_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_51_outline.png b/chainring_generator/out/png/outline/chainring_51_outline.png new file mode 100644 index 0000000..389483a Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_51_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_52_outline.png b/chainring_generator/out/png/outline/chainring_52_outline.png new file mode 100644 index 0000000..9ea77d6 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_52_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_53_outline.png b/chainring_generator/out/png/outline/chainring_53_outline.png new file mode 100644 index 0000000..5a1f15e Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_53_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_54_outline.png b/chainring_generator/out/png/outline/chainring_54_outline.png new file mode 100644 index 0000000..549a2c7 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_54_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_55_outline.png b/chainring_generator/out/png/outline/chainring_55_outline.png new file mode 100644 index 0000000..b34bdfd Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_55_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_56_outline.png b/chainring_generator/out/png/outline/chainring_56_outline.png new file mode 100644 index 0000000..3355b10 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_56_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_57_outline.png b/chainring_generator/out/png/outline/chainring_57_outline.png new file mode 100644 index 0000000..ab9b095 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_57_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_58_outline.png b/chainring_generator/out/png/outline/chainring_58_outline.png new file mode 100644 index 0000000..868b1af Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_58_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_59_outline.png b/chainring_generator/out/png/outline/chainring_59_outline.png new file mode 100644 index 0000000..2848d15 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_59_outline.png differ diff --git a/chainring_generator/out/png/outline/chainring_60_outline.png b/chainring_generator/out/png/outline/chainring_60_outline.png new file mode 100644 index 0000000..a722040 Binary files /dev/null and b/chainring_generator/out/png/outline/chainring_60_outline.png differ diff --git a/chainring_generator/out/radii.csv b/chainring_generator/out/radii.csv new file mode 100644 index 0000000..25096aa --- /dev/null +++ b/chainring_generator/out/radii.csv @@ -0,0 +1,57 @@ +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 +5,19.678,11.678,54.000,17.500,71.500,0.000,0.239,0,0 +6,23.132,15.132,54.000,17.500,71.500,0.000,0.239,0,0 +7,26.657,18.657,54.000,17.500,71.500,0.000,0.239,0,0 +8,30.224,22.224,54.000,17.500,71.500,0.000,0.239,0,0 +9,33.817,25.817,54.000,17.500,71.500,0.000,0.239,0,0 +10,37.429,29.429,54.000,17.500,71.500,0.000,0.239,0,0 +11,41.054,33.054,54.000,17.500,71.500,0.000,0.239,0,0 +12,44.689,36.689,54.000,17.500,71.500,0.000,0.239,0,0 +13,48.331,40.331,54.000,17.500,71.500,0.000,0.239,0,0 +14,51.978,43.978,54.000,17.500,71.500,0.000,0.239,0,0 +15,55.631,47.631,54.000,17.500,71.500,0.000,0.239,0,0 +16,59.287,51.287,54.000,17.500,71.500,0.000,0.239,0,0 +17,62.946,54.946,54.946,17.500,72.446,0.000,0.244,0,0 +18,66.607,58.607,58.607,17.500,76.107,0.000,0.263,0,0 +19,70.271,62.271,62.271,17.500,79.771,0.000,0.282,0,0 +20,73.937,65.937,65.937,17.500,83.437,0.000,0.301,0,0 +21,77.604,69.604,69.604,17.500,87.104,0.000,0.320,0,0 +22,81.272,73.272,73.272,17.500,90.772,0.000,0.339,0,0 +23,84.942,76.942,76.942,17.500,94.442,0.000,0.358,0,0 +24,88.612,80.612,80.612,17.500,98.112,0.000,0.377,0,0 +25,92.284,84.284,95.000,17.500,112.500,0.449,0.452,1,0 +26,95.956,87.956,95.000,17.500,112.500,0.449,0.452,1,0 +27,99.629,91.629,95.000,17.500,112.500,0.449,0.452,1,0 +28,103.303,95.303,95.303,17.500,112.803,0.451,0.453,1,0 +29,106.977,98.977,98.977,17.500,116.477,0.474,0.472,1,0 +30,110.652,102.652,102.652,17.500,120.152,0.498,0.491,1,0 +31,114.327,106.327,106.327,17.500,123.827,0.521,0.510,1,0 +32,118.002,110.002,110.002,17.500,127.502,0.545,0.530,1,0 +33,121.678,113.678,113.678,17.500,131.178,0.568,0.549,1,0 +34,125.354,117.354,117.354,17.500,134.854,0.591,0.568,1,0 +35,129.031,121.031,121.031,17.500,138.531,0.615,0.587,1,0 +36,132.708,124.708,124.708,17.500,142.208,0.638,0.606,1,0 +37,136.385,128.385,128.385,17.500,145.885,0.662,0.625,1,1 +38,140.062,132.062,132.062,17.500,149.562,0.685,0.644,1,1 +39,143.740,135.740,135.740,17.500,153.240,0.709,0.663,1,1 +40,147.418,139.418,139.418,17.500,156.918,0.732,0.682,1,1 +41,151.095,143.095,143.095,17.500,160.595,0.755,0.701,1,1 +42,154.774,146.774,146.774,17.500,164.274,0.779,0.720,1,1 +43,158.452,150.452,150.452,17.500,167.952,0.802,0.740,1,1 +44,162.130,154.130,154.130,17.500,171.630,0.826,0.759,1,1 +45,165.809,157.809,157.809,17.500,175.309,0.849,0.778,1,1 +46,169.488,161.488,161.488,17.500,178.988,0.873,0.797,1,1 +47,173.166,165.166,165.166,17.500,182.666,0.896,0.816,1,1 +48,176.845,168.845,168.845,17.500,186.345,0.919,0.835,1,1 +49,180.525,172.525,172.525,17.500,190.025,0.943,0.854,1,1 +50,184.204,176.204,176.204,17.500,193.704,0.966,0.873,1,1 +51,187.883,179.883,179.883,17.500,197.383,0.990,0.892,1,1 +52,191.562,183.562,183.562,17.500,201.062,1.000,0.911,1,1 +53,195.242,187.242,187.242,17.500,204.742,1.000,0.931,1,1 +54,198.921,190.921,190.921,17.500,208.421,1.000,0.950,1,1 +55,202.601,194.601,194.601,17.500,212.101,1.000,0.969,1,1 +56,206.281,198.281,198.281,17.500,215.781,1.000,0.988,1,1 +57,209.960,201.960,201.960,17.500,219.460,1.000,1.000,1,1 +58,213.640,205.640,205.640,17.500,223.140,1.000,1.000,1,1 +59,217.320,209.320,209.320,17.500,226.820,1.000,1.000,1,1 +60,221.000,213.000,213.000,17.500,230.500,1.000,1.000,1,1 diff --git a/chainring_generator/out/svg/filled/chainring_05_filled.svg b/chainring_generator/out/svg/filled/chainring_05_filled.svg new file mode 100644 index 0000000..90452db --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_05_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_06_filled.svg b/chainring_generator/out/svg/filled/chainring_06_filled.svg new file mode 100644 index 0000000..b66f2e8 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_06_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_07_filled.svg b/chainring_generator/out/svg/filled/chainring_07_filled.svg new file mode 100644 index 0000000..1d04a02 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_07_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_08_filled.svg b/chainring_generator/out/svg/filled/chainring_08_filled.svg new file mode 100644 index 0000000..d8d08e9 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_08_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_09_filled.svg b/chainring_generator/out/svg/filled/chainring_09_filled.svg new file mode 100644 index 0000000..73f04cc --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_09_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_10_filled.svg b/chainring_generator/out/svg/filled/chainring_10_filled.svg new file mode 100644 index 0000000..086bb82 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_10_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_11_filled.svg b/chainring_generator/out/svg/filled/chainring_11_filled.svg new file mode 100644 index 0000000..b963792 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_11_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_12_filled.svg b/chainring_generator/out/svg/filled/chainring_12_filled.svg new file mode 100644 index 0000000..ef22746 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_12_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_13_filled.svg b/chainring_generator/out/svg/filled/chainring_13_filled.svg new file mode 100644 index 0000000..fd9ef20 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_13_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_14_filled.svg b/chainring_generator/out/svg/filled/chainring_14_filled.svg new file mode 100644 index 0000000..4575b06 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_14_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_15_filled.svg b/chainring_generator/out/svg/filled/chainring_15_filled.svg new file mode 100644 index 0000000..4fb2343 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_15_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_16_filled.svg b/chainring_generator/out/svg/filled/chainring_16_filled.svg new file mode 100644 index 0000000..dd76c9e --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_16_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_17_filled.svg b/chainring_generator/out/svg/filled/chainring_17_filled.svg new file mode 100644 index 0000000..2280e88 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_17_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_18_filled.svg b/chainring_generator/out/svg/filled/chainring_18_filled.svg new file mode 100644 index 0000000..bac4bf5 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_18_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_19_filled.svg b/chainring_generator/out/svg/filled/chainring_19_filled.svg new file mode 100644 index 0000000..536e154 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_19_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_20_filled.svg b/chainring_generator/out/svg/filled/chainring_20_filled.svg new file mode 100644 index 0000000..51999c8 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_20_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_21_filled.svg b/chainring_generator/out/svg/filled/chainring_21_filled.svg new file mode 100644 index 0000000..5e2137c --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_21_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_22_filled.svg b/chainring_generator/out/svg/filled/chainring_22_filled.svg new file mode 100644 index 0000000..b742094 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_22_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_23_filled.svg b/chainring_generator/out/svg/filled/chainring_23_filled.svg new file mode 100644 index 0000000..8c9a510 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_23_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_24_filled.svg b/chainring_generator/out/svg/filled/chainring_24_filled.svg new file mode 100644 index 0000000..e8cb767 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_24_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_25_filled.svg b/chainring_generator/out/svg/filled/chainring_25_filled.svg new file mode 100644 index 0000000..9ec3162 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_25_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_26_filled.svg b/chainring_generator/out/svg/filled/chainring_26_filled.svg new file mode 100644 index 0000000..fbd6ab7 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_26_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_27_filled.svg b/chainring_generator/out/svg/filled/chainring_27_filled.svg new file mode 100644 index 0000000..c0ecd8c --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_27_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_28_filled.svg b/chainring_generator/out/svg/filled/chainring_28_filled.svg new file mode 100644 index 0000000..c54441c --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_28_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_29_filled.svg b/chainring_generator/out/svg/filled/chainring_29_filled.svg new file mode 100644 index 0000000..43bf601 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_29_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_30_filled.svg b/chainring_generator/out/svg/filled/chainring_30_filled.svg new file mode 100644 index 0000000..d93e3d3 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_30_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_31_filled.svg b/chainring_generator/out/svg/filled/chainring_31_filled.svg new file mode 100644 index 0000000..4c3282c --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_31_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_32_filled.svg b/chainring_generator/out/svg/filled/chainring_32_filled.svg new file mode 100644 index 0000000..f6040d7 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_32_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_33_filled.svg b/chainring_generator/out/svg/filled/chainring_33_filled.svg new file mode 100644 index 0000000..4f87c37 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_33_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_34_filled.svg b/chainring_generator/out/svg/filled/chainring_34_filled.svg new file mode 100644 index 0000000..8fb6c8b --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_34_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_35_filled.svg b/chainring_generator/out/svg/filled/chainring_35_filled.svg new file mode 100644 index 0000000..bc41428 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_35_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_36_filled.svg b/chainring_generator/out/svg/filled/chainring_36_filled.svg new file mode 100644 index 0000000..00dbd24 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_36_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_37_filled.svg b/chainring_generator/out/svg/filled/chainring_37_filled.svg new file mode 100644 index 0000000..0f95b0d --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_37_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_38_filled.svg b/chainring_generator/out/svg/filled/chainring_38_filled.svg new file mode 100644 index 0000000..5e0f894 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_38_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_39_filled.svg b/chainring_generator/out/svg/filled/chainring_39_filled.svg new file mode 100644 index 0000000..1a3d642 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_39_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_40_filled.svg b/chainring_generator/out/svg/filled/chainring_40_filled.svg new file mode 100644 index 0000000..e074cde --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_40_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_41_filled.svg b/chainring_generator/out/svg/filled/chainring_41_filled.svg new file mode 100644 index 0000000..5848c1d --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_41_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_42_filled.svg b/chainring_generator/out/svg/filled/chainring_42_filled.svg new file mode 100644 index 0000000..a1f1e3c --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_42_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_43_filled.svg b/chainring_generator/out/svg/filled/chainring_43_filled.svg new file mode 100644 index 0000000..6f57270 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_43_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_44_filled.svg b/chainring_generator/out/svg/filled/chainring_44_filled.svg new file mode 100644 index 0000000..8449395 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_44_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_45_filled.svg b/chainring_generator/out/svg/filled/chainring_45_filled.svg new file mode 100644 index 0000000..8812de3 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_45_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_46_filled.svg b/chainring_generator/out/svg/filled/chainring_46_filled.svg new file mode 100644 index 0000000..cd86439 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_46_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_47_filled.svg b/chainring_generator/out/svg/filled/chainring_47_filled.svg new file mode 100644 index 0000000..8c18c57 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_47_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_48_filled.svg b/chainring_generator/out/svg/filled/chainring_48_filled.svg new file mode 100644 index 0000000..4be68df --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_48_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_49_filled.svg b/chainring_generator/out/svg/filled/chainring_49_filled.svg new file mode 100644 index 0000000..24d66fa --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_49_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_50_filled.svg b/chainring_generator/out/svg/filled/chainring_50_filled.svg new file mode 100644 index 0000000..697759e --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_50_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_51_filled.svg b/chainring_generator/out/svg/filled/chainring_51_filled.svg new file mode 100644 index 0000000..2d3f1cb --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_51_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_52_filled.svg b/chainring_generator/out/svg/filled/chainring_52_filled.svg new file mode 100644 index 0000000..dfc4c46 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_52_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_53_filled.svg b/chainring_generator/out/svg/filled/chainring_53_filled.svg new file mode 100644 index 0000000..c6c92f7 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_53_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_54_filled.svg b/chainring_generator/out/svg/filled/chainring_54_filled.svg new file mode 100644 index 0000000..34b820a --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_54_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_55_filled.svg b/chainring_generator/out/svg/filled/chainring_55_filled.svg new file mode 100644 index 0000000..f92398b --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_55_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_56_filled.svg b/chainring_generator/out/svg/filled/chainring_56_filled.svg new file mode 100644 index 0000000..a789977 --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_56_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_57_filled.svg b/chainring_generator/out/svg/filled/chainring_57_filled.svg new file mode 100644 index 0000000..cfa63de --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_57_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_58_filled.svg b/chainring_generator/out/svg/filled/chainring_58_filled.svg new file mode 100644 index 0000000..f90bbeb --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_58_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_59_filled.svg b/chainring_generator/out/svg/filled/chainring_59_filled.svg new file mode 100644 index 0000000..fb852cc --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_59_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/filled/chainring_60_filled.svg b/chainring_generator/out/svg/filled/chainring_60_filled.svg new file mode 100644 index 0000000..d1ac6eb --- /dev/null +++ b/chainring_generator/out/svg/filled/chainring_60_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_05_outline.svg b/chainring_generator/out/svg/outline/chainring_05_outline.svg new file mode 100644 index 0000000..cc39651 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_05_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_06_outline.svg b/chainring_generator/out/svg/outline/chainring_06_outline.svg new file mode 100644 index 0000000..7783a60 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_06_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_07_outline.svg b/chainring_generator/out/svg/outline/chainring_07_outline.svg new file mode 100644 index 0000000..ae16d43 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_07_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_08_outline.svg b/chainring_generator/out/svg/outline/chainring_08_outline.svg new file mode 100644 index 0000000..5c4dfac --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_08_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_09_outline.svg b/chainring_generator/out/svg/outline/chainring_09_outline.svg new file mode 100644 index 0000000..5d3b947 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_09_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_10_outline.svg b/chainring_generator/out/svg/outline/chainring_10_outline.svg new file mode 100644 index 0000000..5a7433b --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_10_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_11_outline.svg b/chainring_generator/out/svg/outline/chainring_11_outline.svg new file mode 100644 index 0000000..d291e6a --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_11_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_12_outline.svg b/chainring_generator/out/svg/outline/chainring_12_outline.svg new file mode 100644 index 0000000..c53dde8 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_12_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_13_outline.svg b/chainring_generator/out/svg/outline/chainring_13_outline.svg new file mode 100644 index 0000000..9e1b0e0 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_13_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_14_outline.svg b/chainring_generator/out/svg/outline/chainring_14_outline.svg new file mode 100644 index 0000000..080c411 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_14_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_15_outline.svg b/chainring_generator/out/svg/outline/chainring_15_outline.svg new file mode 100644 index 0000000..32501bc --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_15_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_16_outline.svg b/chainring_generator/out/svg/outline/chainring_16_outline.svg new file mode 100644 index 0000000..b31020c --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_16_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_17_outline.svg b/chainring_generator/out/svg/outline/chainring_17_outline.svg new file mode 100644 index 0000000..5a813f8 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_17_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_18_outline.svg b/chainring_generator/out/svg/outline/chainring_18_outline.svg new file mode 100644 index 0000000..e898743 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_18_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_19_outline.svg b/chainring_generator/out/svg/outline/chainring_19_outline.svg new file mode 100644 index 0000000..28d05f0 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_19_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_20_outline.svg b/chainring_generator/out/svg/outline/chainring_20_outline.svg new file mode 100644 index 0000000..813417d --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_20_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_21_outline.svg b/chainring_generator/out/svg/outline/chainring_21_outline.svg new file mode 100644 index 0000000..4e5ac92 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_21_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_22_outline.svg b/chainring_generator/out/svg/outline/chainring_22_outline.svg new file mode 100644 index 0000000..3396a8c --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_22_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_23_outline.svg b/chainring_generator/out/svg/outline/chainring_23_outline.svg new file mode 100644 index 0000000..946ead6 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_23_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_24_outline.svg b/chainring_generator/out/svg/outline/chainring_24_outline.svg new file mode 100644 index 0000000..740a956 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_24_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/chainring_generator/out/svg/outline/chainring_25_outline.svg b/chainring_generator/out/svg/outline/chainring_25_outline.svg new file mode 100644 index 0000000..88c17b7 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_25_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_26_outline.svg b/chainring_generator/out/svg/outline/chainring_26_outline.svg new file mode 100644 index 0000000..2a60b14 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_26_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_27_outline.svg b/chainring_generator/out/svg/outline/chainring_27_outline.svg new file mode 100644 index 0000000..0a0f0a9 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_27_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_28_outline.svg b/chainring_generator/out/svg/outline/chainring_28_outline.svg new file mode 100644 index 0000000..b58d965 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_28_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_29_outline.svg b/chainring_generator/out/svg/outline/chainring_29_outline.svg new file mode 100644 index 0000000..b6d6da0 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_29_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_30_outline.svg b/chainring_generator/out/svg/outline/chainring_30_outline.svg new file mode 100644 index 0000000..6a257b5 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_30_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_31_outline.svg b/chainring_generator/out/svg/outline/chainring_31_outline.svg new file mode 100644 index 0000000..8c0f9e8 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_31_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_32_outline.svg b/chainring_generator/out/svg/outline/chainring_32_outline.svg new file mode 100644 index 0000000..91ff946 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_32_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_33_outline.svg b/chainring_generator/out/svg/outline/chainring_33_outline.svg new file mode 100644 index 0000000..d4bc309 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_33_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_34_outline.svg b/chainring_generator/out/svg/outline/chainring_34_outline.svg new file mode 100644 index 0000000..5e24fd8 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_34_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_35_outline.svg b/chainring_generator/out/svg/outline/chainring_35_outline.svg new file mode 100644 index 0000000..ff90c42 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_35_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_36_outline.svg b/chainring_generator/out/svg/outline/chainring_36_outline.svg new file mode 100644 index 0000000..d1f5e2a --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_36_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_37_outline.svg b/chainring_generator/out/svg/outline/chainring_37_outline.svg new file mode 100644 index 0000000..dcc331e --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_37_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_38_outline.svg b/chainring_generator/out/svg/outline/chainring_38_outline.svg new file mode 100644 index 0000000..9072e52 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_38_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_39_outline.svg b/chainring_generator/out/svg/outline/chainring_39_outline.svg new file mode 100644 index 0000000..594ee74 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_39_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_40_outline.svg b/chainring_generator/out/svg/outline/chainring_40_outline.svg new file mode 100644 index 0000000..e05d280 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_40_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_41_outline.svg b/chainring_generator/out/svg/outline/chainring_41_outline.svg new file mode 100644 index 0000000..43d974b --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_41_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_42_outline.svg b/chainring_generator/out/svg/outline/chainring_42_outline.svg new file mode 100644 index 0000000..961e903 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_42_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_43_outline.svg b/chainring_generator/out/svg/outline/chainring_43_outline.svg new file mode 100644 index 0000000..82997af --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_43_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_44_outline.svg b/chainring_generator/out/svg/outline/chainring_44_outline.svg new file mode 100644 index 0000000..7b46b00 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_44_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_45_outline.svg b/chainring_generator/out/svg/outline/chainring_45_outline.svg new file mode 100644 index 0000000..daccd42 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_45_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_46_outline.svg b/chainring_generator/out/svg/outline/chainring_46_outline.svg new file mode 100644 index 0000000..6265f4d --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_46_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_47_outline.svg b/chainring_generator/out/svg/outline/chainring_47_outline.svg new file mode 100644 index 0000000..59fc677 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_47_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_48_outline.svg b/chainring_generator/out/svg/outline/chainring_48_outline.svg new file mode 100644 index 0000000..3e2920b --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_48_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_49_outline.svg b/chainring_generator/out/svg/outline/chainring_49_outline.svg new file mode 100644 index 0000000..ab30034 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_49_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_50_outline.svg b/chainring_generator/out/svg/outline/chainring_50_outline.svg new file mode 100644 index 0000000..a36759c --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_50_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_51_outline.svg b/chainring_generator/out/svg/outline/chainring_51_outline.svg new file mode 100644 index 0000000..567e24c --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_51_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_52_outline.svg b/chainring_generator/out/svg/outline/chainring_52_outline.svg new file mode 100644 index 0000000..9a59037 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_52_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_53_outline.svg b/chainring_generator/out/svg/outline/chainring_53_outline.svg new file mode 100644 index 0000000..936a983 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_53_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_54_outline.svg b/chainring_generator/out/svg/outline/chainring_54_outline.svg new file mode 100644 index 0000000..243e299 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_54_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_55_outline.svg b/chainring_generator/out/svg/outline/chainring_55_outline.svg new file mode 100644 index 0000000..98a626b --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_55_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_56_outline.svg b/chainring_generator/out/svg/outline/chainring_56_outline.svg new file mode 100644 index 0000000..0af77ef --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_56_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_57_outline.svg b/chainring_generator/out/svg/outline/chainring_57_outline.svg new file mode 100644 index 0000000..8c77a82 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_57_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_58_outline.svg b/chainring_generator/out/svg/outline/chainring_58_outline.svg new file mode 100644 index 0000000..af1bf62 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_58_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_59_outline.svg b/chainring_generator/out/svg/outline/chainring_59_outline.svg new file mode 100644 index 0000000..246fdd5 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_59_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/out/svg/outline/chainring_60_outline.svg b/chainring_generator/out/svg/outline/chainring_60_outline.svg new file mode 100644 index 0000000..f5e43f9 --- /dev/null +++ b/chainring_generator/out/svg/outline/chainring_60_outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/chainring_generator/pyproject.toml b/chainring_generator/pyproject.toml new file mode 100644 index 0000000..97e4577 --- /dev/null +++ b/chainring_generator/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "chainring-generator" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [ + "numpy>=2.4.4", + "pillow>=12.2.0", +] diff --git a/chainring_generator/uv.lock b/chainring_generator/uv.lock new file mode 100644 index 0000000..2ab8f68 --- /dev/null +++ b/chainring_generator/uv.lock @@ -0,0 +1,126 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "chainring-generator" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.4.4" }, + { name = "pillow", specifier = ">=12.2.0" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] diff --git a/lib/controller/shifter_device_telemetry.dart b/lib/controller/shifter_device_telemetry.dart new file mode 100644 index 0000000..6492c6e --- /dev/null +++ b/lib/controller/shifter_device_telemetry.dart @@ -0,0 +1,20 @@ +import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final shifterDeviceTelemetryCacheProvider = StateNotifierProvider< + ShifterDeviceTelemetryCache, Map>( + (ref) => ShifterDeviceTelemetryCache(), +); + +class ShifterDeviceTelemetryCache + extends StateNotifier> { + ShifterDeviceTelemetryCache() : super(const {}); + + void upsert(String deviceId, ShifterDeviceTelemetry telemetry) { + final existing = state[deviceId]; + state = { + ...state, + deviceId: existing == null ? telemetry : existing.merge(telemetry), + }; + } +} diff --git a/lib/model/gear_configurator.dart b/lib/model/gear_configurator.dart new file mode 100644 index 0000000..69b12e1 --- /dev/null +++ b/lib/model/gear_configurator.dart @@ -0,0 +1,91 @@ +import 'package:abawo_bt_app/model/gear_ratio_codec.dart'; + +enum GearRetentionMode { keepHighest, keepAll } + +class GearConfiguratorCalculation { + const GearConfiguratorCalculation({ + required this.ratios, + required this.discardedBelowRange, + required this.discardedAboveRange, + required this.duplicateCount, + required this.truncatedCount, + }); + + final List ratios; + final int discardedBelowRange; + final int discardedAboveRange; + final int duplicateCount; + final int truncatedCount; +} + +GearConfiguratorCalculation calculateGearRatios({ + required List chainrings, + required List sprockets, + required GearRetentionMode mode, + int maxRatios = 32, +}) { + final sortedChainrings = List.from(chainrings)..sort(); + final sortedSprockets = List.from(sprockets)..sort((a, b) => b - a); + + var discardedBelowRange = 0; + var discardedAboveRange = 0; + var duplicateCount = 0; + final encoded = {}; + final ratios = []; + + void addRatio(double ratio) { + if (ratio < gearRatioMin) { + discardedBelowRange++; + return; + } + if (ratio > gearRatioMax) { + discardedAboveRange++; + return; + } + + final raw = encodeGearRatioByte(ratio); + if (!encoded.add(raw)) { + duplicateCount++; + return; + } + ratios.add(decodeGearRatioByte(raw)); + } + + switch (mode) { + case GearRetentionMode.keepAll: + for (final chainring in sortedChainrings) { + for (final sprocket in sortedSprockets) { + addRatio(chainring / sprocket); + } + } + break; + case GearRetentionMode.keepHighest: + var threshold = double.negativeInfinity; + for (final chainring in sortedChainrings) { + final beforeLength = ratios.length; + for (final sprocket in sortedSprockets) { + final ratio = chainring / sprocket; + if (ratio >= threshold) { + addRatio(ratio); + } + } + if (ratios.length > beforeLength) { + threshold = ratios.reduce((a, b) => a > b ? a : b); + } + } + break; + } + + ratios.sort(); + final truncatedCount = + ratios.length > maxRatios ? ratios.length - maxRatios : 0; + final limited = ratios.take(maxRatios).toList(growable: false); + + return GearConfiguratorCalculation( + ratios: limited, + discardedBelowRange: discardedBelowRange, + discardedAboveRange: discardedAboveRange, + duplicateCount: duplicateCount, + truncatedCount: truncatedCount, + ); +} diff --git a/lib/model/gear_ratio_codec.dart b/lib/model/gear_ratio_codec.dart new file mode 100644 index 0000000..633336a --- /dev/null +++ b/lib/model/gear_ratio_codec.dart @@ -0,0 +1,31 @@ +const double gearRatioMin = 0.25; +const double gearRatioMax = 5.75; +const int gearRatioEmptyRaw = 0; +const int gearRatioMinRaw = 1; +const int gearRatioMaxRaw = 255; + +const double gearRatioStep = + (gearRatioMax - gearRatioMin) / (gearRatioMaxRaw - gearRatioMinRaw); + +int encodeGearRatioByte(double value) { + if (value <= 0) { + return gearRatioEmptyRaw; + } + final clamped = value.clamp(gearRatioMin, gearRatioMax); + final scaled = ((clamped - gearRatioMin) / gearRatioStep).round(); + return (gearRatioMinRaw + scaled) + .clamp(gearRatioMinRaw, gearRatioMaxRaw) + .toInt(); +} + +double decodeGearRatioByte(int raw) { + if (raw <= gearRatioEmptyRaw) { + return 0; + } + final clamped = raw.clamp(gearRatioMinRaw, gearRatioMaxRaw).toInt(); + return gearRatioMin + (clamped - gearRatioMinRaw) * gearRatioStep; +} + +double quantizeGearRatio(double value) { + return decodeGearRatioByte(encodeGearRatioByte(value)); +} diff --git a/lib/model/shifter_types.dart b/lib/model/shifter_types.dart index c7138c2..de25140 100644 --- a/lib/model/shifter_types.dart +++ b/lib/model/shifter_types.dart @@ -1,6 +1,4 @@ -import 'dart:typed_data'; - -import 'package:cbor/simple.dart'; +import 'dart:convert'; const String universalShifterControlServiceUuid = '0993826f-0ee4-4b37-9614-d13ecba4ffc2'; @@ -19,6 +17,13 @@ const String universalShifterDfuDataCharacteristicUuid = const String universalShifterDfuAckCharacteristicUuid = '0993826f-0ee4-4b37-9614-d13ecba4000a'; const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb'; +const String batteryServiceUuid = '0000180f-0000-1000-8000-00805f9b34fb'; +const String batteryLevelCharacteristicUuid = + '00002a19-0000-1000-8000-00805f9b34fb'; +const String deviceInformationServiceUuid = + '0000180a-0000-1000-8000-00805f9b34fb'; +const String firmwareRevisionCharacteristicUuid = + '00002a26-0000-1000-8000-00805f9b34fb'; const int universalShifterDfuOpcodeStart = 0x01; const int universalShifterDfuOpcodeFinish = 0x02; @@ -239,6 +244,49 @@ enum UniversalShifterCommand { final int value; } +class ShifterDeviceTelemetry { + const ShifterDeviceTelemetry({ + this.batteryPercent, + this.firmwareRevision, + }); + + final int? batteryPercent; + final String? firmwareRevision; + + String get batteryLabel => batteryPercent == null ? '--' : '$batteryPercent%'; + + String get firmwareLabel => firmwareRevision ?? '--'; + + ShifterDeviceTelemetry merge(ShifterDeviceTelemetry next) { + return ShifterDeviceTelemetry( + batteryPercent: next.batteryPercent ?? batteryPercent, + firmwareRevision: next.firmwareRevision ?? firmwareRevision, + ); + } +} + +int parseBatteryLevelPercent(List payload) { + if (payload.isEmpty) { + throw const FormatException('Battery level payload is empty.'); + } + + final value = payload.first; + if (value < 0 || value > 100) { + throw FormatException('Battery level out of range: $value.'); + } + + return value; +} + +String? parseGattUtf8String(List payload) { + if (payload.isEmpty) { + return null; + } + + final value = utf8.decode(payload).replaceAll('\u0000', '').trim(); + return value.isEmpty ? null : value; +} + enum ControlConnectionState { disconnected, connected; @@ -412,58 +460,50 @@ class CentralStatus { ); } - static CentralStatus fromCborBytes(List bytes) { + static CentralStatus fromBytes(List bytes) { if (bytes.isEmpty) { throw const FormatException('Status payload is empty.'); } - - final decoded = cbor.decode(bytes); - if (decoded is! Map) { + if (bytes.length != 12) { throw FormatException( - 'Status payload must decode to a CBOR map, got ${decoded.runtimeType}.', + 'Status payload must be 12 bytes, got ${bytes.length}.', ); } - final controlRaw = _readMapValue(decoded, [0, 'control']); - final trainerRaw = _readMapValue(decoded, [1, 'trainer']); - final hasSavedBondRaw = _readMapValue(decoded, [2, 'has_saved_bond']); - final connectedTrainerAddrRaw = - _readMapValue(decoded, [3, 'connected_trainer_addr']); - final lastFailureRaw = _readMapValue(decoded, [4, 'last_failure']); + final protocolVersion = bytes[0]; + if (protocolVersion != 1) { + throw FormatException( + 'Unsupported status protocol version: $protocolVersion.', + ); + } + + final flags = bytes[4]; + final hasSavedBond = (flags & 0x01) != 0; + final hasConnectedTrainerAddr = (flags & 0x02) != 0; + final hasLastFailure = (flags & 0x04) != 0; + final trainerErrorCode = bytes[3] == 0 ? null : bytes[3]; + final trainer = bytes[2] == 6 + ? TrainerStatus( + state: TrainerConnectionState.error, + errorCode: trainerErrorCode, + ) + : TrainerStatus.fromRaw(bytes[2]); + final connectedTrainerAddr = hasConnectedTrainerAddr + ? bytes.sublist(5, 11).toList(growable: false) + : null; + final lastFailure = hasLastFailure && bytes[11] != 0 ? bytes[11] : null; return CentralStatus( - control: ControlConnectionState.fromRaw(controlRaw), - trainer: TrainerStatus.fromRaw(trainerRaw), - hasSavedBond: hasSavedBondRaw is bool ? hasSavedBondRaw : false, - connectedTrainerAddr: _toByteList(connectedTrainerAddrRaw), - lastFailure: lastFailureRaw is int ? lastFailureRaw : null, - raw: decoded, + control: ControlConnectionState.fromRaw(bytes[1]), + trainer: trainer, + hasSavedBond: hasSavedBond, + connectedTrainerAddr: connectedTrainerAddr, + lastFailure: lastFailure, + raw: bytes.toList(growable: false), ); } } -dynamic _readMapValue(Map map, List keys) { - for (final key in keys) { - if (map.containsKey(key)) { - return map[key]; - } - } - return null; -} - -List? _toByteList(dynamic value) { - if (value == null) { - return null; - } - if (value is Uint8List) { - return value.toList(growable: false); - } - if (value is List) { - return value.whereType().toList(growable: false); - } - return null; -} - List parseMacToLittleEndianBytes(String macAddress) { final compact = macAddress.replaceAll(':', '').replaceAll('-', ''); if (compact.length != 12) { diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index 2a3f056..1ad05ca 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart'; import 'package:abawo_bt_app/model/firmware_file_selection.dart'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/service/firmware_file_selection_service.dart'; @@ -69,6 +70,8 @@ class _DeviceDetailsPageState extends ConsumerState { String? _gearRatiosError; List _gearRatios = const []; int _defaultGearIndex = 0; + bool _isDeviceTelemetryLoading = false; + bool _hasLoadedDeviceTelemetry = false; late final FirmwareFileSelectionService _firmwareFileSelectionService; FirmwareUpdateService? _firmwareUpdateService; @@ -195,6 +198,9 @@ class _DeviceDetailsPageState extends ConsumerState { if (!_hasLoadedGearRatios && !_isGearRatiosLoading) { unawaited(_loadGearRatios()); } + if (!_hasLoadedDeviceTelemetry && !_isDeviceTelemetryLoading) { + unawaited(_loadDeviceTelemetry()); + } return; } final asyncBluetooth = ref.read(bluetoothProvider); @@ -238,6 +244,7 @@ class _DeviceDetailsPageState extends ConsumerState { _shifterService = service; }); unawaited(_loadGearRatios()); + unawaited(_loadDeviceTelemetry()); } Future _showPairingRecoveryDialog() async { @@ -276,6 +283,8 @@ class _DeviceDetailsPageState extends ConsumerState { await _disposeFirmwareUpdateService(); await _shifterService?.dispose(); _shifterService = null; + _isDeviceTelemetryLoading = false; + _hasLoadedDeviceTelemetry = false; } Future _disposeFirmwareUpdateService() async { @@ -285,6 +294,34 @@ class _DeviceDetailsPageState extends ConsumerState { _firmwareUpdateService = null; } + Future _loadDeviceTelemetry({bool force = false}) async { + final shifter = _shifterService; + if (shifter == null || _isDeviceTelemetryLoading) { + return; + } + if (_hasLoadedDeviceTelemetry && !force) { + return; + } + + _isDeviceTelemetryLoading = true; + final result = await shifter.readDeviceTelemetry(); + if (!mounted) { + return; + } + + _isDeviceTelemetryLoading = false; + if (result.isErr()) { + _hasLoadedDeviceTelemetry = false; + return; + } + + ref.read(shifterDeviceTelemetryCacheProvider.notifier).upsert( + widget.deviceAddress, + result.unwrap(), + ); + _hasLoadedDeviceTelemetry = true; + } + Future _loadGearRatios() async { final shifter = _shifterService; if (shifter == null || _isGearRatiosLoading || _isFirmwareUpdateBusy) { @@ -539,6 +576,11 @@ class _DeviceDetailsPageState extends ConsumerState { _firmwareUserMessage = result.unwrapErr().toString(); } }); + + if (result.isOk()) { + _hasLoadedDeviceTelemetry = false; + unawaited(_loadDeviceTelemetry(force: true)); + } } String _dfuPhaseText(DfuUpdateState state) { @@ -1208,6 +1250,11 @@ Widget _buildDeviceOverviewCard( required CentralStatus? status, }) { final asyncSavedDevices = ref.watch(nConnectedDevicesProvider); + final telemetry = ref.watch( + shifterDeviceTelemetryCacheProvider.select( + (cache) => cache[deviceAddress], + ), + ); return asyncSavedDevices.when( data: (devices) { @@ -1229,12 +1276,11 @@ Widget _buildDeviceOverviewCard( ); } - // TODO(yannik): Replace these overview placeholder metrics with actual - // battery, signal, and firmware values once the device exposes them. return _DeviceOverviewCard( device: currentDeviceData, connectionStatus: connectionStatus, status: status, + telemetry: telemetry, ); }, loading: () => const Card( @@ -1257,11 +1303,13 @@ class _DeviceOverviewCard extends StatelessWidget { required this.device, required this.connectionStatus, required this.status, + required this.telemetry, }); final ConnectedDevice device; final ConnectionStatus connectionStatus; final CentralStatus? status; + final ShifterDeviceTelemetry? telemetry; @override Widget build(BuildContext context) { @@ -1317,27 +1365,27 @@ class _DeviceOverviewCard extends StatelessWidget { ), const SizedBox(height: 18), Row( - children: const [ + children: [ Expanded( child: _OverviewMetricTile( label: 'Battery', - value: '--', + value: telemetry?.batteryLabel ?? '--', icon: Icons.battery_charging_full_rounded, ), ), - SizedBox(width: 10), - Expanded( + const SizedBox(width: 10), + const Expanded( child: _OverviewMetricTile( label: 'Signal', value: 'Ready', icon: Icons.signal_cellular_alt_rounded, ), ), - SizedBox(width: 10), + const SizedBox(width: 10), Expanded( child: _OverviewMetricTile( label: 'Firmware', - value: '--', + value: telemetry?.firmwareLabel ?? '--', icon: Icons.memory_rounded, ), ), diff --git a/lib/pages/devices_tab_page.dart b/lib/pages/devices_tab_page.dart index 65bfc86..5d73842 100644 --- a/lib/pages/devices_tab_page.dart +++ b/lib/pages/devices_tab_page.dart @@ -1,4 +1,5 @@ import 'package:abawo_bt_app/controller/bluetooth.dart'; +import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart'; import 'package:abawo_bt_app/database/database.dart'; import 'package:abawo_bt_app/model/bluetooth_device_model.dart'; import 'package:flutter/material.dart'; @@ -253,7 +254,7 @@ class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> { } } -class _ActiveDeviceCard extends StatelessWidget { +class _ActiveDeviceCard extends ConsumerWidget { const _ActiveDeviceCard({ required this.devices, required this.connectionData, @@ -263,7 +264,7 @@ class _ActiveDeviceCard extends StatelessWidget { final (ConnectionStatus, String?)? connectionData; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final shifterDevices = devices .where( (device) => @@ -291,11 +292,14 @@ class _ActiveDeviceCard extends StatelessWidget { final isConnected = connectedId == primaryDevice.deviceAddress && connectionData?.$1 == ConnectionStatus.connected; - // TODO(yannik): Populate battery, signal, and firmware from real device - // telemetry once these values are exposed in the saved-device overview. - const batteryLabel = '--'; + final telemetry = ref.watch( + shifterDeviceTelemetryCacheProvider.select( + (cache) => cache[primaryDevice.deviceAddress], + ), + ); + final batteryLabel = telemetry?.batteryLabel ?? '--'; const signalLabel = 'Ready'; - const firmwareLabel = '--'; + final firmwareLabel = telemetry?.firmwareLabel ?? '--'; return Card( child: Padding( @@ -350,7 +354,7 @@ class _ActiveDeviceCard extends StatelessWidget { ), const SizedBox(height: 18), Row( - children: const [ + children: [ Expanded( child: _MetricTile( label: 'Battery', @@ -358,15 +362,15 @@ class _ActiveDeviceCard extends StatelessWidget { icon: Icons.battery_charging_full_rounded, ), ), - SizedBox(width: 10), - Expanded( + const SizedBox(width: 10), + const Expanded( child: _MetricTile( label: 'Signal', value: signalLabel, icon: Icons.signal_cellular_alt, ), ), - SizedBox(width: 10), + const SizedBox(width: 10), Expanded( child: _MetricTile( label: 'Firmware', diff --git a/lib/service/firmware_update_service.dart b/lib/service/firmware_update_service.dart index 84a54c0..9dcaec2 100644 --- a/lib/service/firmware_update_service.dart +++ b/lib/service/firmware_update_service.dart @@ -544,8 +544,8 @@ abstract interface class FirmwareUpdateTransport { /// Verifies that the device is reachable after reconnect. /// - /// Current limitation: strict firmware version comparison is not possible - /// yet because no firmware version characteristic is exposed by the device. + /// Current limitation: strict firmware image comparison is not possible from + /// the selected binary metadata alone, even though DIS exposes a revision. Future> verifyDeviceReachable({ required Duration timeout, }); diff --git a/lib/service/shifter_service.dart b/lib/service/shifter_service.dart index f3c1eb1..e1f8dc8 100644 --- a/lib/service/shifter_service.dart +++ b/lib/service/shifter_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:abawo_bt_app/controller/bluetooth.dart'; +import 'package:abawo_bt_app/model/gear_ratio_codec.dart'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:anyhow/anyhow.dart'; import 'package:logging/logging.dart'; @@ -43,7 +44,6 @@ class ShifterService { static const int _gearRatioSlots = 32; static const int _defaultGearIndexOffset = _gearRatioSlots; static const int _gearRatioPayloadBytes = _gearRatioSlots + 1; - static const double _maxGearRatio = 255 / 64; static const int _gearRatioWriteMtu = 64; Future> writeConnectToAddress(String bikeDeviceId) async { @@ -166,12 +166,61 @@ class ShifterService { } try { - return Ok(CentralStatus.fromCborBytes(readRes.unwrap())); + return Ok(CentralStatus.fromBytes(readRes.unwrap())); } catch (e) { return bail('Failed to decode status payload: $e'); } } + Future> readDeviceTelemetry() async { + int? batteryPercent; + String? firmwareRevision; + final errors = []; + + final batteryResult = await _requireBluetooth.readCharacteristic( + buttonDeviceId, + batteryServiceUuid, + batteryLevelCharacteristicUuid, + ); + if (batteryResult.isOk()) { + try { + batteryPercent = parseBatteryLevelPercent(batteryResult.unwrap()); + } catch (error) { + errors.add('battery parse failed: $error'); + } + } else { + errors.add('battery read failed: ${batteryResult.unwrapErr()}'); + } + + final firmwareResult = await _requireBluetooth.readCharacteristic( + buttonDeviceId, + deviceInformationServiceUuid, + firmwareRevisionCharacteristicUuid, + ); + if (firmwareResult.isOk()) { + try { + firmwareRevision = parseGattUtf8String(firmwareResult.unwrap()); + } catch (error) { + errors.add('firmware parse failed: $error'); + } + } else { + errors.add('firmware read failed: ${firmwareResult.unwrapErr()}'); + } + + if (batteryPercent == null && + firmwareRevision == null && + errors.isNotEmpty) { + return bail('Could not read battery or firmware: ${errors.join('; ')}'); + } + + return Ok( + ShifterDeviceTelemetry( + batteryPercent: batteryPercent, + firmwareRevision: firmwareRevision, + ), + ); + } + Future> runDfuPreflight({ int requestedMtu = universalShifterDfuPreferredMtu, }) async { @@ -253,7 +302,7 @@ class ShifterService { .listen( (data) { try { - final status = CentralStatus.fromCborBytes(data); + final status = CentralStatus.fromBytes(data); _statusController.add(status); } catch (error, st) { _log.warning( @@ -284,19 +333,11 @@ class ShifterService { } int _encodeGearRatio(double value) { - if (value <= 0) { - return 0; - } - final clamped = value.clamp(0, _maxGearRatio); - final scaled = (clamped * 64).round(); - if (scaled <= 0) { - return 1; - } - return scaled.clamp(1, 255); + return encodeGearRatioByte(value); } double _decodeGearRatio(int raw) { - return raw / 64.0; + return decodeGearRatioByte(raw); } String _formatBytes(List bytes) { diff --git a/lib/widgets/gear_configurator_dialog.dart b/lib/widgets/gear_configurator_dialog.dart new file mode 100644 index 0000000..b4562ff --- /dev/null +++ b/lib/widgets/gear_configurator_dialog.dart @@ -0,0 +1,1004 @@ +import 'dart:math' as math; + +import 'package:abawo_bt_app/model/gear_configurator.dart'; +import 'package:abawo_bt_app/model/gear_ratio_codec.dart'; +import 'package:flutter/material.dart'; + +const _roadPreset = _DrivetrainPreset( + label: 'Road default', + chainrings: [34, 50], + sprockets: [11, 12, 13, 14, 15, 16, 17, 19, 21, 24, 27, 30], +); +const _gravelPreset = _DrivetrainPreset( + label: 'Gravel default', + chainrings: [40], + sprockets: [10, 11, 13, 15, 17, 19, 21, 24, 28, 32, 38, 44], +); +const _mtbPreset = _DrivetrainPreset( + label: 'MTB default', + chainrings: [32], + sprockets: [10, 12, 14, 16, 18, 21, 24, 28, 32, 38, 45, 51], +); + +const List<_DrivetrainPreset> _drivetrainPresets = [ + _roadPreset, + _gravelPreset, + _mtbPreset, +]; + +class _DrivetrainPreset { + const _DrivetrainPreset({ + required this.label, + required this.chainrings, + required this.sprockets, + }); + + final String label; + final List chainrings; + final List sprockets; +} + +Future showGearConfiguratorDialog( + BuildContext context, +) { + return showDialog( + context: context, + builder: (context) => const _GearConfiguratorDialog(), + ); +} + +class _GearConfiguratorDialog extends StatefulWidget { + const _GearConfiguratorDialog(); + + @override + State<_GearConfiguratorDialog> createState() => + _GearConfiguratorDialogState(); +} + +class _GearConfiguratorDialogState extends State<_GearConfiguratorDialog> { + static const int _minTeeth = 5; + static const int _maxTeeth = 60; + + List _chainrings = List.from(_roadPreset.chainrings); + List _sprockets = List.from(_roadPreset.sprockets); + GearRetentionMode _mode = GearRetentionMode.keepHighest; + + GearConfiguratorCalculation get _calculation => calculateGearRatios( + chainrings: _chainrings, + sprockets: _sprockets, + mode: _mode, + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final calculation = _calculation; + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 820, maxHeight: 780), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 12, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Gear Configurator', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '${calculation.ratios.length} ratios, ${gearRatioMin.toStringAsFixed(2)}-${gearRatioMax.toStringAsFixed(2)} supported range', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.64, + ), + ), + ), + ], + ), + ), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _GearPreview( + chainrings: _chainrings, + sprockets: _sprockets, + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final preset in _drivetrainPresets) + _PresetChip( + label: preset.label, + onPressed: () => _loadDrivetrainPreset(preset), + ), + ], + ), + const SizedBox(height: 14), + LayoutBuilder( + builder: (context, constraints) { + final editors = [ + _ToothListEditor( + title: 'Chainrings', + subtitle: 'Up to 3 rings', + values: _chainrings, + maxItems: 3, + minTeeth: _minTeeth, + maxTeeth: _maxTeeth, + onAdd: () => _addChainring(), + onChanged: (index, value) => + _changeChainring(index, value), + onRemove: (index) => _removeChainring(index), + ), + _ToothListEditor( + title: 'Sprockets', + subtitle: 'Up to 12 sprockets', + values: _sprockets, + maxItems: 12, + minTeeth: _minTeeth, + maxTeeth: _maxTeeth, + onAdd: () => _addSprocket(), + onChanged: (index, value) => + _changeSprocket(index, value), + onRemove: (index) => _removeSprocket(index), + ), + ]; + + if (constraints.maxWidth >= 620) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: editors[0]), + const SizedBox(width: 12), + Expanded(child: editors[1]), + ], + ); + } + + return Column( + children: [ + editors[0], + const SizedBox(height: 12), + editors[1], + ], + ); + }, + ), + const SizedBox(height: 12), + _CalculationSummary(calculation: calculation), + const SizedBox(height: 12), + _ModeSelector( + mode: _mode, + onChanged: (mode) { + setState(() { + _mode = mode; + }); + }, + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton.icon( + style: FilledButton.styleFrom( + minimumSize: const Size(0, 52), + ), + onPressed: calculation.ratios.isEmpty + ? null + : () => Navigator.of(context).pop(calculation), + icon: const Icon(Icons.calculate_outlined), + label: const Text('Calculate and Apply'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + void _loadDrivetrainPreset(_DrivetrainPreset preset) { + setState(() { + _chainrings = List.from(preset.chainrings); + _sprockets = List.from(preset.sprockets); + }); + } + + void _addChainring() { + if (_chainrings.length >= 3) { + return; + } + setState(() { + final next = (_chainrings.isEmpty ? 40 : _chainrings.last + 12) + .clamp(_minTeeth, _maxTeeth); + _chainrings = [..._chainrings, next]; + }); + } + + void _addSprocket() { + if (_sprockets.length >= 12) { + return; + } + setState(() { + final next = (_sprockets.isEmpty ? 16 : _sprockets.last + 2) + .clamp(_minTeeth, _maxTeeth); + _sprockets = [..._sprockets, next]; + }); + } + + void _changeChainring(int index, int value) { + setState(() { + _chainrings = List.from(_chainrings) + ..[index] = value.clamp(_minTeeth, _maxTeeth); + }); + } + + void _changeSprocket(int index, int value) { + setState(() { + _sprockets = List.from(_sprockets) + ..[index] = value.clamp(_minTeeth, _maxTeeth); + }); + } + + void _removeChainring(int index) { + if (_chainrings.length <= 1) { + return; + } + setState(() { + _chainrings = List.from(_chainrings)..removeAt(index); + }); + } + + void _removeSprocket(int index) { + if (_sprockets.length <= 1) { + return; + } + setState(() { + _sprockets = List.from(_sprockets)..removeAt(index); + }); + } +} + +class _PresetChip extends StatelessWidget { + const _PresetChip({required this.label, required this.onPressed}); + + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + ), + child: Text(label), + ); + } +} + +class _GearPreview extends StatelessWidget { + const _GearPreview({required this.chainrings, required this.sprockets}); + + final List chainrings; + final List sprockets; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AspectRatio( + aspectRatio: 2.25, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.88), + theme.colorScheme.surface.withValues(alpha: 0.96), + ], + ), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.68), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(22), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final height = constraints.maxHeight; + final previewBase = math.min(width, height); + + return Stack( + children: [ + Positioned.fill( + child: CustomPaint( + painter: _PreviewGridPainter( + color: + theme.colorScheme.primary.withValues(alpha: 0.08), + ), + ), + ), + Positioned( + left: 16, + top: 12, + child: Text( + 'chainrings', + style: theme.textTheme.labelSmall?.copyWith( + letterSpacing: 1.2, + color: + theme.colorScheme.onSurface.withValues(alpha: 0.52), + ), + ), + ), + Positioned( + right: 16, + top: 12, + child: Text( + 'cassette', + style: theme.textTheme.labelSmall?.copyWith( + letterSpacing: 1.2, + color: + theme.colorScheme.onSurface.withValues(alpha: 0.52), + ), + ), + ), + _GearStack( + gears: chainrings, + center: Offset(width * 0.25, height * 0.56), + maxSize: previewBase * 0.58, + depthStep: const Offset(6, -2), + rotation: -0.22, + verticalScale: 0.90, + minScale: 0.48, + backColor: + theme.colorScheme.onSurface.withValues(alpha: 0.92), + frontColor: theme.colorScheme.onSurface, + hubColor: theme.colorScheme.primaryContainer, + drawHub: false, + ), + _GearStack( + gears: sprockets, + center: Offset(width * 0.73, height * 0.58), + maxSize: previewBase * 0.72, + depthStep: const Offset(2.2, -0.7), + rotation: -0.20, + verticalScale: 0.92, + minScale: 0.34, + backColor: theme.colorScheme.onSurface.withValues( + alpha: 0.62, + ), + frontColor: theme.colorScheme.onSurface, + hubColor: + theme.colorScheme.onSurface.withValues(alpha: 0.72), + drawHub: false, + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _GearStack extends StatelessWidget { + const _GearStack({ + required this.gears, + required this.center, + required this.maxSize, + required this.depthStep, + required this.rotation, + required this.verticalScale, + required this.minScale, + required this.backColor, + required this.frontColor, + required this.hubColor, + required this.drawHub, + }); + + final List gears; + final Offset center; + final double maxSize; + final Offset depthStep; + final double rotation; + final double verticalScale; + final double minScale; + final Color backColor; + final Color frontColor; + final Color hubColor; + final bool drawHub; + + @override + Widget build(BuildContext context) { + final sorted = List.from(gears)..sort((a, b) => b - a); + if (sorted.isEmpty) { + return const SizedBox.shrink(); + } + + final largest = sorted.first.toDouble(); + final depthOrigin = center - depthStep * ((sorted.length - 1) / 2); + final frontCenter = + depthOrigin + depthStep * (sorted.length - 1).toDouble(); + final frontGearSize = + maxSize * (minScale + (1 - minScale) * sorted.last / largest); + + return Positioned.fill( + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < sorted.length; i++) + _ProjectedGearLayer( + teeth: sorted[i], + center: depthOrigin + depthStep * i.toDouble(), + size: maxSize * (minScale + (1 - minScale) * sorted[i] / largest), + tint: Color.lerp( + backColor, + frontColor, + i / math.max(1, sorted.length - 1), + )!, + rotation: rotation, + verticalScale: verticalScale, + depth: i, + ), + if (drawHub) + _ProjectedHub( + center: frontCenter, + size: frontGearSize * 0.11, + rotation: rotation, + verticalScale: verticalScale, + color: hubColor, + ), + ], + ), + ); + } +} + +class _ProjectedGearLayer extends StatelessWidget { + const _ProjectedGearLayer({ + required this.teeth, + required this.center, + required this.size, + required this.tint, + required this.rotation, + required this.verticalScale, + required this.depth, + }); + + final int teeth; + final Offset center; + final double size; + final Color tint; + final double rotation; + final double verticalScale; + final int depth; + + @override + Widget build(BuildContext context) { + return Positioned( + left: center.dx - size / 2, + top: center.dy - size / 2, + width: size, + height: size, + child: Transform.rotate( + angle: rotation, + child: Transform.scale( + scaleY: verticalScale, + child: Stack( + children: [ + Positioned.fill( + left: 3 + depth * 0.16, + top: 6 + depth * 0.08, + child: Opacity( + opacity: 0.32, + child: ColorFiltered( + colorFilter: const ColorFilter.mode( + Colors.black, + BlendMode.srcIn, + ), + child: Image.asset( + _gearAssetForTeeth(teeth), + fit: BoxFit.contain, + ), + ), + ), + ), + Positioned.fill( + child: ColorFiltered( + colorFilter: ColorFilter.mode(tint, BlendMode.srcIn), + child: Image.asset( + _gearAssetForTeeth(teeth), + fit: BoxFit.contain, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ProjectedHub extends StatelessWidget { + const _ProjectedHub({ + required this.center, + required this.size, + required this.rotation, + required this.verticalScale, + required this.color, + }); + + final Offset center; + final double size; + final double rotation; + final double verticalScale; + final Color color; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Positioned( + left: center.dx - size / 2, + top: center.dy - size / 2, + width: size, + height: size, + child: Transform.rotate( + angle: rotation, + child: Transform.scale( + scaleY: verticalScale, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + border: Border.all( + color: theme.colorScheme.surface.withValues(alpha: 0.72), + width: 1.1, + ), + boxShadow: [ + BoxShadow( + blurRadius: 4, + offset: const Offset(1, 2), + color: Colors.black.withValues(alpha: 0.18), + ), + ], + ), + child: FractionallySizedBox( + widthFactor: 0.46, + heightFactor: 0.46, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.surface.withValues(alpha: 0.86), + ), + ), + ), + ), + ), + ), + ); + } +} + +String _gearAssetForTeeth(int teeth) { + final padded = teeth.toString().padLeft(2, '0'); + return 'assets/images/gears/png/filled/chainring_${padded}_filled.png'; +} + +class _PreviewGridPainter extends CustomPainter { + const _PreviewGridPainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1; + for (var x = -size.height; x < size.width; x += 28) { + canvas.drawLine( + Offset(x, size.height), Offset(x + size.height, 0), paint); + } + } + + @override + bool shouldRepaint(covariant _PreviewGridPainter oldDelegate) { + return oldDelegate.color != color; + } +} + +class _ToothListEditor extends StatelessWidget { + const _ToothListEditor({ + required this.title, + required this.subtitle, + required this.values, + required this.maxItems, + required this.minTeeth, + required this.maxTeeth, + required this.onAdd, + required this.onChanged, + required this.onRemove, + }); + + final String title; + final String subtitle; + final List values; + final int maxItems; + final int minTeeth; + final int maxTeeth; + final VoidCallback onAdd; + final void Function(int index, int value) onChanged; + final void Function(int index) onRemove; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: + theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.42), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.62), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + ), + ), + ], + ), + ), + IconButton.filledTonal( + onPressed: values.length >= maxItems ? null : onAdd, + tooltip: 'Add $title', + icon: const Icon(Icons.add), + ), + ], + ), + const SizedBox(height: 8), + for (var i = 0; i < values.length; i++) ...[ + _ToothRow( + label: '${i + 1}', + value: values[i], + canRemove: values.length > 1, + minTeeth: minTeeth, + maxTeeth: maxTeeth, + onChanged: (value) => onChanged(i, value), + onRemove: () => onRemove(i), + ), + if (i != values.length - 1) const SizedBox(height: 6), + ], + ], + ), + ), + ); + } +} + +class _ToothRow extends StatelessWidget { + const _ToothRow({ + required this.label, + required this.value, + required this.canRemove, + required this.minTeeth, + required this.maxTeeth, + required this.onChanged, + required this.onRemove, + }); + + final String label; + final int value; + final bool canRemove; + final int minTeeth; + final int maxTeeth; + final ValueChanged onChanged; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.surface.withValues(alpha: 0.66), + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + SizedBox( + width: 28, + child: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w800, + color: theme.colorScheme.onSurface.withValues(alpha: 0.58), + ), + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: value <= minTeeth ? null : () => onChanged(value - 1), + icon: const Icon(Icons.remove), + ), + Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => _editValue(context), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: Text( + '$value teeth', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: value >= maxTeeth ? null : () => onChanged(value + 1), + icon: const Icon(Icons.add), + ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: canRemove ? onRemove : null, + tooltip: 'Remove', + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ), + ); + } + + Future _editValue(BuildContext context) async { + final controller = TextEditingController(text: value.toString()); + final selected = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Set teeth'), + content: TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: '$minTeeth-$maxTeeth', + suffixText: 'teeth', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final parsed = int.tryParse(controller.text.trim()); + Navigator.of(context).pop(parsed); + }, + child: const Text('Set'), + ), + ], + ); + }, + ); + + if (selected != null) { + onChanged(selected.clamp(minTeeth, maxTeeth)); + } + } +} + +class _CalculationSummary extends StatelessWidget { + const _CalculationSummary({required this.calculation}); + + final GearConfiguratorCalculation calculation; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final warnings = [ + if (calculation.discardedBelowRange > 0) + '${calculation.discardedBelowRange} below range skipped', + if (calculation.discardedAboveRange > 0) + '${calculation.discardedAboveRange} above range skipped', + if (calculation.duplicateCount > 0) + '${calculation.duplicateCount} duplicates removed', + if (calculation.truncatedCount > 0) + '${calculation.truncatedCount} high gears truncated', + ]; + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: theme.colorScheme.surface.withValues(alpha: 0.72), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + calculation.ratios.isEmpty + ? 'No valid ratios' + : 'Preview: ${calculation.ratios.first.toStringAsFixed(2)} -> ${calculation.ratios.last.toStringAsFixed(2)}', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + if (warnings.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + warnings.join(' ยท '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.tertiary, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _ModeSelector extends StatelessWidget { + const _ModeSelector({required this.mode, required this.onChanged}); + + final GearRetentionMode mode; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _ModeTile( + selected: mode == GearRetentionMode.keepHighest, + title: const Text('Keep Highest (recommended)'), + subtitle: + const Text('Drop overlapping lower ratios from larger rings.'), + onTap: () => onChanged(GearRetentionMode.keepHighest), + ), + _ModeTile( + selected: mode == GearRetentionMode.keepAll, + title: const Text('Keep All'), + subtitle: + const Text('Keep every unique combination until the gear limit.'), + onTap: () => onChanged(GearRetentionMode.keepAll), + ), + ], + ); + } +} + +class _ModeTile extends StatelessWidget { + const _ModeTile({ + required this.selected, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final bool selected; + final Widget title; + final Widget subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + selected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: selected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.62), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle.merge( + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + child: title, + ), + const SizedBox(height: 2), + DefaultTextStyle.merge( + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.62, + ), + ), + child: subtitle, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/gear_ratio_editor_card.dart b/lib/widgets/gear_ratio_editor_card.dart index a1d8ad2..c7a20a3 100644 --- a/lib/widgets/gear_ratio_editor_card.dart +++ b/lib/widgets/gear_ratio_editor_card.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; +import 'package:abawo_bt_app/model/gear_ratio_codec.dart'; +import 'package:abawo_bt_app/widgets/gear_configurator_dialog.dart'; import 'package:flutter/material.dart'; class GearRatioPreset { @@ -76,8 +78,8 @@ class GearRatioEditorCard extends StatefulWidget { } class _GearRatioEditorCardState extends State { - static const double _sliderMin = 0.10; - static const double _sliderMax = 3.90; + static const double _sliderMin = gearRatioMin; + static const double _sliderMax = gearRatioMax; static const double _sliderPivotT = 0.50; static const double _sliderPivotV = 1.00; static const Duration _animDuration = Duration(milliseconds: 280); @@ -165,10 +167,22 @@ class _GearRatioEditorCardState extends State { TextStyle(fontSize: 17, fontWeight: FontWeight.w700), ), ), + if (!_isEditing) + IconButton( + tooltip: 'Configure drivetrain', + onPressed: (widget.isLoading || + widget.errorText != null || + _isSaving) + ? null + : _openGearConfigurator, + icon: const Icon(Icons.settings_input_component_outlined), + ), if (!_isEditing) IconButton( tooltip: 'Edit ratios', - onPressed: (widget.isLoading || widget.errorText != null) + onPressed: (widget.isLoading || + widget.errorText != null || + _isSaving) ? null : _enterEditMode, icon: const Icon(Icons.edit_outlined), @@ -830,6 +844,62 @@ class _GearRatioEditorCardState extends State { _loadPreset(selected); } + Future _openGearConfigurator() async { + final calculation = await showGearConfiguratorDialog(context); + if (!mounted || calculation == null) { + return; + } + + setState(() { + _isSaving = true; + }); + + final ratios = List.from(calculation.ratios); + final message = + await widget.onSave(ratios, _normalizeDefaultIndex(0, ratios.length)); + if (!mounted) { + return; + } + + setState(() { + _isSaving = false; + if (message == null) { + _committed = ratios; + _draft = List.from(ratios); + _committedDefaultGearIndex = _normalizeDefaultIndex(0, ratios.length); + _draftDefaultGearIndex = _committedDefaultGearIndex; + _isEditing = false; + _isExpanded = true; + } + }); + + final messenger = ScaffoldMessenger.of(context); + if (message != null) { + messenger.showSnackBar(SnackBar(content: Text(message))); + return; + } + + final notices = [ + if (calculation.discardedBelowRange > 0) + '${calculation.discardedBelowRange} below range skipped', + if (calculation.discardedAboveRange > 0) + '${calculation.discardedAboveRange} above range skipped', + if (calculation.duplicateCount > 0) + '${calculation.duplicateCount} duplicates removed', + if (calculation.truncatedCount > 0) + '${calculation.truncatedCount} high gears truncated', + ]; + messenger.showSnackBar( + SnackBar( + content: Text( + notices.isEmpty + ? 'Applied ${ratios.length} calculated gear ratios.' + : 'Applied ${ratios.length} calculated gear ratios (${notices.join(', ')}).', + ), + ), + ); + } + void _applyNamedPreset(String name) { for (final preset in widget.presets) { if (preset.name.toLowerCase() == name.toLowerCase()) { @@ -995,8 +1065,7 @@ class _GearRatioEditorCardState extends State { } double _quantizeRatio(double raw) { - final clamped = raw.clamp(_sliderMin, _sliderMax); - return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax); + return quantizeGearRatio(raw).clamp(_sliderMin, _sliderMax); } (List, int) _sortedWithDefault( diff --git a/pubspec.lock b/pubspec.lock index bce48e0..89dce08 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -137,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.5" - cbor: - dependency: "direct main" - description: - name: cbor - sha256: "2c5c37650f0a2d25149f03e748ab7b2857787bde338f95fe947738b80d713da2" - url: "https://pub.dev" - source: hosted - version: "6.5.1" characters: dependency: transitive description: @@ -554,14 +546,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - hex: - dependency: transitive - description: - name: hex - sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" - url: "https://pub.dev" - source: hosted - version: "0.2.0" hotreloader: dependency: transitive description: @@ -675,10 +659,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1127,10 +1111,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7461c6c..b2c6efc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,6 @@ dependencies: flutter_rust_bridge: 2.11.1 flutter_reactive_ble: ^5.4.0 nb_utils: ^7.2.0 - cbor: ^6.3.3 file_picker: ^8.1.7 dev_dependencies: @@ -93,6 +92,7 @@ flutter: # - images/a_dot_ham.jpeg assets: - assets/images/shifter-wireframe.png + - assets/images/gears/png/filled/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/model/gear_configurator_test.dart b/test/model/gear_configurator_test.dart new file mode 100644 index 0000000..d6fdeae --- /dev/null +++ b/test/model/gear_configurator_test.dart @@ -0,0 +1,72 @@ +import 'package:abawo_bt_app/model/gear_configurator.dart'; +import 'package:abawo_bt_app/model/gear_ratio_codec.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('gear ratio codec', () { + test('maps one byte across the configured drivetrain range', () { + expect(decodeGearRatioByte(1), gearRatioMin); + expect(decodeGearRatioByte(255), gearRatioMax); + expect(encodeGearRatioByte(0), 0); + expect(encodeGearRatioByte(gearRatioMax + 1), 255); + }); + + test('quantizes ratios through the byte encoding', () { + final quantized = quantizeGearRatio(50 / 11); + + expect(quantized, closeTo(4.55, 0.02)); + expect(encodeGearRatioByte(quantized), encodeGearRatioByte(50 / 11)); + }); + }); + + group('calculateGearRatios', () { + test('keep all deduplicates and sorts ascending', () { + final result = calculateGearRatios( + chainrings: [40, 20], + sprockets: [20, 10], + mode: GearRetentionMode.keepAll, + ); + + expect(result.ratios, orderedEquals(result.ratios.toList()..sort())); + expect(result.ratios.length, 3); + expect(result.duplicateCount, 1); + }); + + test('keep highest drops lower overlapping larger-chainring ratios', () { + final result = calculateGearRatios( + chainrings: [30, 50], + sprockets: [10, 20, 30], + mode: GearRetentionMode.keepHighest, + ); + + expect(result.ratios.first, closeTo(1, 0.02)); + expect(result.ratios.last, closeTo(5, 0.02)); + expect(result.ratios.length, 4); + expect(result.ratios.where((ratio) => ratio < 1).length, 0); + }); + + test('discard ratios outside the one-byte range', () { + final result = calculateGearRatios( + chainrings: [20, 60], + sprockets: [5, 100], + mode: GearRetentionMode.keepAll, + ); + + expect(result.discardedBelowRange, 1); + expect(result.discardedAboveRange, 1); + expect(result.ratios.length, 2); + }); + + test('truncates to the configured maximum', () { + final result = calculateGearRatios( + chainrings: [30, 40, 50], + sprockets: [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21], + mode: GearRetentionMode.keepAll, + maxRatios: 8, + ); + + expect(result.ratios.length, 8); + expect(result.truncatedCount, greaterThan(0)); + }); + }); +} diff --git a/test/model/shifter_types_test.dart b/test/model/shifter_types_test.dart index 0e66294..323dd39 100644 --- a/test/model/shifter_types_test.dart +++ b/test/model/shifter_types_test.dart @@ -1,12 +1,11 @@ import 'package:abawo_bt_app/model/shifter_types.dart'; -import 'package:cbor/simple.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('CentralStatus.fromCborBytes', () { - test('decodes firmware packed status with FTMS ready', () { - final status = CentralStatus.fromCborBytes( - _packedStatusBytes( + group('CentralStatus.fromBytes', () { + test('decodes status with FTMS ready', () { + final status = CentralStatus.fromBytes( + _statusBytes( controlVariant: 1, trainerVariant: 5, hasSavedBond: true, @@ -22,7 +21,7 @@ void main() { expect(status.lastFailure, isNull); }); - test('decodes all firmware packed trainer unit variants', () { + test('decodes all trainer state variants', () { final expectedStates = { 0: TrainerConnectionState.idle, 1: TrainerConnectionState.connecting, @@ -34,8 +33,8 @@ void main() { }; for (final entry in expectedStates.entries) { - final status = CentralStatus.fromCborBytes( - _packedStatusBytes( + final status = CentralStatus.fromBytes( + _statusBytes( controlVariant: 1, trainerVariant: entry.key, ), @@ -49,11 +48,12 @@ void main() { } }); - test('decodes firmware packed trainer error newtype variant', () { - final status = CentralStatus.fromCborBytes( - _packedStatusBytes( + test('decodes trainer error code and last failure', () { + final status = CentralStatus.fromBytes( + _statusBytes( controlVariant: 1, - trainerRaw: [6, errorFtmsRequiredCharMissing], + trainerVariant: 6, + trainerErrorCode: errorFtmsRequiredCharMissing, lastFailure: errorFtmsRequiredCharMissing, ), ); @@ -63,48 +63,99 @@ void main() { expect(status.lastFailure, errorFtmsRequiredCharMissing); }); - test('decodes non-packed status maps with text keys', () { - final status = CentralStatus.fromCborBytes( - cbor.encode({ - 'control': 'Connected', - 'trainer': 'FtmsReady', - 'has_saved_bond': false, - 'connected_trainer_addr': [10, 11, 12, 13, 14, 15], - 'last_failure': null, - }), + test('omits optional values when flags are absent', () { + final status = CentralStatus.fromBytes( + _statusBytes( + controlVariant: 1, + trainerVariant: 5, + connectedTrainerAddr: [10, 11, 12, 13, 14, 15], + includeAddressFlag: false, + lastFailure: errorSequence, + includeLastFailureFlag: false, + ), ); expect(status.control, ControlConnectionState.connected); expect(status.trainer.state, TrainerConnectionState.ftmsReady); - expect(status.connectedTrainerAddr, [10, 11, 12, 13, 14, 15]); + expect(status.connectedTrainerAddr, isNull); + expect(status.lastFailure, isNull); }); test('throws for invalid status payloads instead of hiding them', () { expect( - () => CentralStatus.fromCborBytes(const []), + () => CentralStatus.fromBytes(const []), throwsFormatException, ); expect( - () => CentralStatus.fromCborBytes(cbor.encode([1, 2, 3])), + () => CentralStatus.fromBytes(const [1, 1, 5]), + throwsFormatException, + ); + expect( + () => CentralStatus.fromBytes( + _statusBytes(controlVariant: 1, trainerVariant: 5)..[0] = 2, + ), throwsFormatException, ); }); }); + + group('standard GATT telemetry parsing', () { + test('decodes battery level percentage', () { + expect(parseBatteryLevelPercent([0]), 0); + expect(parseBatteryLevelPercent([87]), 87); + expect(parseBatteryLevelPercent([100]), 100); + }); + + test('rejects invalid battery payloads', () { + expect( + () => parseBatteryLevelPercent(const []), + throwsFormatException, + ); + expect( + () => parseBatteryLevelPercent([101]), + throwsFormatException, + ); + }); + + test('decodes trimmed firmware revision strings', () { + expect(parseGattUtf8String(' 1.2.3 '.codeUnits), '1.2.3'); + expect(parseGattUtf8String([0x31, 0x2e, 0x32, 0x00]), '1.2'); + expect(parseGattUtf8String(const []), isNull); + }); + }); } -List _packedStatusBytes({ +List _statusBytes({ required int controlVariant, - int? trainerVariant, - Object? trainerRaw, + required int trainerVariant, + int trainerErrorCode = 0, bool hasSavedBond = false, List? connectedTrainerAddr, int? lastFailure, + bool includeAddressFlag = true, + bool includeLastFailureFlag = true, }) { - return cbor.encode({ - 0: controlVariant, - 1: trainerRaw ?? trainerVariant, - 2: hasSavedBond, - 3: connectedTrainerAddr, - 4: lastFailure, - }); + var flags = 0; + if (hasSavedBond) { + flags |= 0x01; + } + if (connectedTrainerAddr != null && includeAddressFlag) { + flags |= 0x02; + } + if (lastFailure != null && includeLastFailureFlag) { + flags |= 0x04; + } + + final payload = List.filled(12, 0, growable: false); + payload[0] = 1; + payload[1] = controlVariant; + payload[2] = trainerVariant; + payload[3] = trainerErrorCode; + payload[4] = flags; + final addr = connectedTrainerAddr; + if (addr != null) { + payload.setRange(5, 11, addr.take(6)); + } + payload[11] = lastFailure ?? 0; + return payload; }