#!/usr/bin/env python3 """Generate style-specific 1:1 A3 clay templates for SOFT FACET V2. The PDF contains: 1. A quick one-piece frustum blank with an 8 mm overlap. 2. Four style-specific gores for a lower-strain, more accurate assembly. 3. The exact raw-clay support plate outline. Both size presets are supported. Use --all-styles to generate one PDF/SVG/JSON set for every OpenSCAD surface style. """ from __future__ import annotations import argparse import copy import json import math from dataclasses import asdict, dataclass from pathlib import Path from typing import Iterable, Sequence from reportlab.lib.colors import HexColor from reportlab.lib.pagesizes import A3, landscape from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen import canvas ROOT = Path(__file__).resolve().parents[1] DEFAULT_SVG_DIR = ROOT / "output/svg" OUTPUT_ROOT = ROOT / "output" SURFACE_STYLES = ( "smooth_sculpted", "vertical_soft", "horizontal_rings", "cross_wave", "diagonal_flow", "woven_cells", "organic_asymmetric", "ribbed_petal", "pillow_facets", "dimpled_grid", "spiral_flow", ) PRESET_DEFAULTS = { "v60_01": { "target_inner_top_d": 114.0, "target_inner_outlet_d": 20.0, "target_inner_height": 87.0, "support_finished_span": 117.0, "support_finished_hole_d": 20.0, }, "v60_02": { "target_inner_top_d": 124.0, "target_inner_outlet_d": 21.0, "target_inner_height": 95.0, "support_finished_span": 127.0, "support_finished_hole_d": 21.0, }, } BLACK = HexColor("#1D252C") GRAY = HexColor("#69757D") LIGHT_GRAY = HexColor("#D7DDE1") ACCENT = HexColor("#B6412D") OCHRE = HexColor("#C7922B") @dataclass(frozen=True) class TemplateDimensions: model_version: str preset: str surface_style: str release_scale: float minimum_release_draft_angle_deg: float shrink_percent: float slab_thickness_mm: float master_top_radius_mm: float master_bottom_radius_mm: float master_height_mm: float average_meridian_length_mm: float single_piece_outer_radius_mm: float single_piece_inner_radius_mm: float single_piece_angle_deg: float seam_allowance_mm: float gore_count: int gore_top_widths_mm: list[float] gore_bottom_widths_mm: list[float] gore_heights_mm: list[float] support_max_span_mm: float support_min_span_mm: float support_hole_diameter_mm: float def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--preset", choices=("v60_01", "v60_02", "custom"), default="v60_02") parser.add_argument("--surface-style", choices=SURFACE_STYLES, default="smooth_sculpted") parser.add_argument("--all-styles", action="store_true") parser.add_argument("--target-inner-top-d", type=float, default=None) parser.add_argument("--target-inner-outlet-d", type=float, default=None) parser.add_argument("--target-inner-height", type=float, default=None) parser.add_argument("--support-finished-span", type=float, default=None) parser.add_argument("--support-finished-hole-d", type=float, default=None) parser.add_argument("--shrink-percent", type=float, default=12.0) parser.add_argument("--slab-thickness", type=float, default=5.0) parser.add_argument("--support-corner-ratio", type=float, default=0.18) parser.add_argument("--profile-power", type=float, default=0.97) parser.add_argument("--minimum-release-draft-angle", type=float, default=5.0) parser.add_argument("--seam-allowance", type=float, default=8.0) parser.add_argument("--gores", type=int, default=4) parser.add_argument("--output-root", type=Path, default=OUTPUT_ROOT) parser.add_argument("--pdf", type=Path, default=None) parser.add_argument("--data", type=Path, default=None) parser.add_argument("--svg-dir", type=Path, default=None) args = parser.parse_args() for field, value in PRESET_DEFAULTS.get(args.preset, {}).items(): if getattr(args, field) is None: setattr(args, field, value) if args.preset == "custom": required = ( "target_inner_top_d", "target_inner_outlet_d", "target_inner_height", "support_finished_span", "support_finished_hole_d", ) missing = [field for field in required if getattr(args, field) is None] if missing: raise ValueError("custom preset requires explicit values for: " + ", ".join(missing)) return args def validate(args: argparse.Namespace) -> None: if not 0 <= args.shrink_percent < 100: raise ValueError("shrink-percent must be in the range 0..99") positive = { "target-inner-top-d": args.target_inner_top_d, "target-inner-outlet-d": args.target_inner_outlet_d, "target-inner-height": args.target_inner_height, "support-finished-span": args.support_finished_span, "support-finished-hole-d": args.support_finished_hole_d, "slab-thickness": args.slab_thickness, "seam-allowance": args.seam_allowance, } for name, value in positive.items(): if value is None or value <= 0: raise ValueError(f"{name} must be greater than zero") if args.target_inner_top_d <= args.target_inner_outlet_d: raise ValueError("top diameter must be larger than outlet diameter") if not 0 < args.support_corner_ratio < 0.5: raise ValueError("support-corner-ratio must be between 0 and 0.5") if not 0 < args.minimum_release_draft_angle < 30: raise ValueError("minimum-release-draft-angle must be between 0 and 30") if args.gores != 4: raise ValueError("the verified A3 layout requires exactly 4 gores") if args.all_styles and any((args.pdf, args.data, args.svg_dir)): raise ValueError("--pdf, --data and --svg-dir are single-style options") def gauss(value: float, center: float, width: float) -> float: return math.exp(-((value - center) / width) ** 2) def edge_fade(t: float) -> float: return max(0.0, math.sin(math.radians(180.0 * t))) ** 0.80 def sculpted_offset(t: float) -> float: return -2.80 * gauss(t, 0.53, 0.22) + 1.60 * gauss(t, 0.88, 0.11) def facet_local_u(angle: float, count: int) -> float: segment = 360.0 / count return 2.0 * (angle / segment - math.floor(angle / segment + 0.5)) def dimple_bump(value_deg: float) -> float: return max(0.0, math.cos(math.radians(value_deg))) ** 3 def groove_profile(phase_deg: float, width_fraction: float) -> float: return max(0.0, math.cos(math.radians(phase_deg))) ** (1.0 / max(0.05, width_fraction)) def style_offset(style: str, t: float, angle: float) -> float: sculpted = sculpted_offset(t) if style == "smooth_sculpted": return sculpted if style == "vertical_soft": return sculpted + 2.20 * (0.25 + 0.75 * t) * math.cos(math.radians(10 * angle)) if style == "horizontal_rings": return sculpted + 1.80 * edge_fade(t) * math.sin(math.radians(1080 * t)) if style == "cross_wave": return sculpted + 0.72 * 2.20 * (0.25 + 0.75 * t) * math.cos( math.radians(8 * angle) ) + 0.72 * 1.80 * edge_fade(t) * math.sin(math.radians(1080 * t)) if style == "diagonal_flow": return sculpted + 1.80 * (0.20 + 0.80 * t) * math.cos( math.radians(7 * angle + 360 * 0.55 * t) ) if style == "woven_cells": return sculpted + 1.50 * edge_fade(t) * math.cos( math.radians(8 * angle) ) * math.sin(math.radians(1080 * t)) if style == "organic_asymmetric": return sculpted + 2.20 * edge_fade(t) * ( 0.66 * math.cos(math.radians(angle - 35 + 115 * t)) + 0.34 * math.cos(math.radians(3 * angle + 20 - 70 * t)) ) if style == "ribbed_petal": return sculpted + 1.60 * (0.30 + 0.70 * t) * ( 0.65 * math.cos(math.radians(14 * angle)) + 0.35 * math.cos(math.radians(28 * angle)) ) if style == "pillow_facets": return sculpted + 1.90 * edge_fade(t) * (1.0 - facet_local_u(angle, 9) ** 2) if style == "dimpled_grid": return sculpted - 1.15 * edge_fade(t) * dimple_bump(16 * angle) * dimple_bump(2160 * t) if style == "spiral_flow": return sculpted - 1.60 * edge_fade(t) * groove_profile(angle + 360 * 2.3 * t, 0.42) raise ValueError(f"unsupported surface style: {style}") def base_dimensions(args: argparse.Namespace) -> tuple[float, float, float]: shrink_scale = 1.0 - args.shrink_percent / 100.0 top_r = args.target_inner_top_d / shrink_scale / 2.0 + args.slab_thickness bottom_r = args.target_inner_outlet_d / shrink_scale / 2.0 + args.slab_thickness height = args.target_inner_height / shrink_scale + args.slab_thickness * 0.7 return top_r, bottom_r, height def base_radius(args: argparse.Namespace, t: float) -> float: top_r, bottom_r, _ = base_dimensions(args) return bottom_r + (top_r - bottom_r) * t ** args.profile_power def release_scale_for(args: argparse.Namespace, style: str, rings: int = 110, segments: int = 240) -> float: _, _, height = base_dimensions(args) required_step = math.tan(math.radians(args.minimum_release_draft_angle)) * height / rings limit = 1.0 for ring in range(rings): t0 = ring / rings t1 = (ring + 1) / rings base_step = base_radius(args, t1) - base_radius(args, t0) for segment in range(segments): angle = 360.0 * segment / segments relief_step = style_offset(style, t1, angle) - style_offset(style, t0, angle) if relief_step < 0: limit = min(limit, (base_step - required_step) / (-relief_step)) return max(0.0, min(1.0, limit)) def surface_radius(args: argparse.Namespace, style: str, scale: float, t: float, angle: float) -> float: return base_radius(args, t) + scale * style_offset(style, t, angle) def build_profile( args: argparse.Namespace, style: str, scale: float, angle: float, steps: int = 320, ) -> list[tuple[float, float, float]]: _, _, height = base_dimensions(args) raw = [ (height * index / steps, surface_radius(args, style, scale, index / steps, angle)) for index in range(steps + 1) ] profile: list[tuple[float, float, float]] = [(raw[0][0], raw[0][1], 0.0)] distance = 0.0 for previous, current in zip(raw, raw[1:]): distance += math.hypot(current[0] - previous[0], current[1] - previous[1]) profile.append((current[0], current[1], distance)) return profile def polar_arc_length( args: argparse.Namespace, style: str, scale: float, t: float, start_angle: float, end_angle: float, steps: int = 120, ) -> float: points = [] for index in range(steps + 1): angle = start_angle + (end_angle - start_angle) * index / steps radius = surface_radius(args, style, scale, t, angle) points.append((radius * math.cos(math.radians(angle)), radius * math.sin(math.radians(angle)))) return sum(math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(points, points[1:])) def build_gore_patterns( args: argparse.Namespace, style: str, scale: float, steps: int = 180, ) -> list[list[tuple[float, float]]]: patterns = [] sector_angle = 360.0 / args.gores for gore_index in range(args.gores): left_angle = gore_index * sector_angle center_angle = left_angle + sector_angle / 2.0 right_angle = left_angle + sector_angle meridian = build_profile(args, style, scale, center_angle, steps) left = [] right = [] for index, (_, _, distance) in enumerate(meridian): t = index / steps left.append((-polar_arc_length(args, style, scale, t, left_angle, center_angle, 48), distance)) right.append((polar_arc_length(args, style, scale, t, center_angle, right_angle, 48), distance)) patterns.append(left + list(reversed(right))) return patterns def compute_dimensions( args: argparse.Namespace, style: str, scale: float, patterns: Sequence[Sequence[tuple[float, float]]], ) -> TemplateDimensions: top_r, bottom_r, height = base_dimensions(args) top_circumference = polar_arc_length(args, style, scale, 1.0, 0.0, 360.0, 720) bottom_circumference = polar_arc_length(args, style, scale, 0.0, 0.0, 360.0, 720) effective_top_r = top_circumference / (2.0 * math.pi) effective_bottom_r = bottom_circumference / (2.0 * math.pi) meridian_lengths = [ build_profile(args, style, scale, angle, 640)[-1][2] for angle in range(0, 360, 15) ] meridian = sum(meridian_lengths) / len(meridian_lengths) outer = meridian * effective_top_r / (effective_top_r - effective_bottom_r) inner = outer - meridian angle = math.degrees(top_circumference / outer) shrink_scale = 1.0 - args.shrink_percent / 100.0 support_green_span = args.support_finished_span / shrink_scale envelope = support_green_span / ( math.sqrt(3.0) * (0.5 - args.support_corner_ratio) + 2.0 * args.support_corner_ratio ) corner_r = envelope * args.support_corner_ratio center_r = envelope / 2.0 - corner_r support_min_span = 1.5 * center_r + 2.0 * corner_r return TemplateDimensions( model_version="2.0", preset=args.preset, surface_style=style, release_scale=scale, minimum_release_draft_angle_deg=args.minimum_release_draft_angle, shrink_percent=args.shrink_percent, slab_thickness_mm=args.slab_thickness, master_top_radius_mm=top_r, master_bottom_radius_mm=bottom_r, master_height_mm=height, average_meridian_length_mm=meridian, single_piece_outer_radius_mm=outer, single_piece_inner_radius_mm=inner, single_piece_angle_deg=angle, seam_allowance_mm=args.seam_allowance, gore_count=args.gores, gore_top_widths_mm=[max(x for x, _ in pattern) - min(x for x, _ in pattern) for pattern in patterns], gore_bottom_widths_mm=[abs(pattern[0][0]) + abs(pattern[-1][0]) for pattern in patterns], gore_heights_mm=[max(y for _, y in pattern) - min(y for _, y in pattern) for pattern in patterns], support_max_span_mm=support_green_span, support_min_span_mm=support_min_span, support_hole_diameter_mm=args.support_finished_hole_d / shrink_scale, ) def register_fonts() -> None: regular = Path("/System/Library/Fonts/Supplemental/Arial.ttf") bold = Path("/System/Library/Fonts/Supplemental/Arial Bold.ttf") if regular.exists() and bold.exists(): pdfmetrics.registerFont(TTFont("TemplateSans", str(regular))) pdfmetrics.registerFont(TTFont("TemplateSans-Bold", str(bold))) else: pdfmetrics.registerFont(TTFont("TemplateSans", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")) pdfmetrics.registerFont(TTFont("TemplateSans-Bold", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf")) def pt(point_mm: tuple[float, float]) -> tuple[float, float]: return point_mm[0] * mm, point_mm[1] * mm def arc_points(radius: float, start_deg: float, end_deg: float, count: int = 180) -> list[tuple[float, float]]: return [ ( radius * math.cos(math.radians(start_deg + (end_deg - start_deg) * index / count)), radius * math.sin(math.radians(start_deg + (end_deg - start_deg) * index / count)), ) for index in range(count + 1) ] def draw_polyline(c: canvas.Canvas, points: Sequence[tuple[float, float]], close: bool = False) -> None: path = c.beginPath() path.moveTo(*pt(points[0])) for point in points[1:]: path.lineTo(*pt(point)) if close: path.close() c.drawPath(path, stroke=1, fill=0) def draw_header(c: canvas.Canvas, title: str, subtitle: str, page: int) -> None: c.setFillColor(BLACK) c.setFont("TemplateSans-Bold", 15) c.drawString(14 * mm, 283 * mm, title) c.setFillColor(GRAY) c.setFont("TemplateSans", 8.5) c.drawString(14 * mm, 276 * mm, subtitle) c.drawRightString(406 * mm, 283 * mm, f"A3, 100%, стр. {page}/3") c.setStrokeColor(LIGHT_GRAY) c.setLineWidth(0.35) c.line(14 * mm, 271 * mm, 406 * mm, 271 * mm) def draw_calibration(c: canvas.Canvas, x: float, y: float) -> None: c.setStrokeColor(BLACK) c.setFillColor(BLACK) c.setLineWidth(0.6) c.rect(x * mm, y * mm, 50 * mm, 50 * mm, stroke=1, fill=0) c.setFont("TemplateSans", 7) c.drawString(x * mm, (y - 4) * mm, "Контрольный квадрат 50 x 50 мм") c.line(x * mm, (y - 9) * mm, (x + 100) * mm, (y - 9) * mm) for mark in range(0, 101, 10): height = 3 if mark % 50 else 5 c.line((x + mark) * mm, (y - 9) * mm, (x + mark) * mm, (y - 9 + height) * mm) c.drawString((x + 102) * mm, (y - 10) * mm, "100 мм") def draw_compact_calibration(c: canvas.Canvas, x: float, y: float) -> None: c.setStrokeColor(BLACK) c.setFillColor(BLACK) c.setLineWidth(0.6) c.rect(x * mm, y * mm, 50 * mm, 50 * mm, stroke=1, fill=0) c.setFont("TemplateSans", 6.5) c.drawCentredString((x + 25) * mm, (y - 4) * mm, "50 x 50 мм") def label_for_preset(preset: str) -> str: return preset.upper().replace("_", "-") def draw_single_piece(c: canvas.Canvas, dims: TemplateDimensions) -> None: label = label_for_preset(dims.preset) draw_header( c, f"{label}: цельная заготовка стенки", f"{dims.surface_style}; release scale {dims.release_scale:.3f}. Красная линия - припуск {dims.seam_allowance_mm:g} мм.", 1, ) cx, cy = 210.0, 60.0 half = dims.single_piece_angle_deg / 2.0 start, end = 90.0 - half, 90.0 + half outer = [(cx + x, cy + y) for x, y in arc_points(dims.single_piece_outer_radius_mm, start, end)] inner = [(cx + x, cy + y) for x, y in arc_points(dims.single_piece_inner_radius_mm, end, start, 80)] outline = outer + inner c.setStrokeColor(BLACK) c.setLineWidth(0.65) draw_polyline(c, outline, close=True) nominal_inner = inner[-1] nominal_outer = outer[0] c.setDash(3 * mm, 2 * mm) c.setStrokeColor(GRAY) draw_polyline(c, [nominal_inner, nominal_outer]) c.setDash() angle = math.radians(start) outward = (math.sin(angle), -math.cos(angle)) allowance_inner = ( nominal_inner[0] + dims.seam_allowance_mm * outward[0], nominal_inner[1] + dims.seam_allowance_mm * outward[1], ) allowance_outer = ( nominal_outer[0] + dims.seam_allowance_mm * outward[0], nominal_outer[1] + dims.seam_allowance_mm * outward[1], ) c.setStrokeColor(ACCENT) c.setLineWidth(1.0) draw_polyline(c, [allowance_inner, allowance_outer]) draw_polyline(c, [nominal_inner, allowance_inner]) draw_polyline(c, [nominal_outer, allowance_outer]) c.setFillColor(ACCENT) c.setFont("TemplateSans-Bold", 8) c.drawString(318 * mm, 70 * mm, f"ПРИПУСК {dims.seam_allowance_mm:g} ММ") c.setFillColor(BLACK) c.setFont("TemplateSans", 8) c.drawString(128 * mm, 44 * mm, "Быстрый вариант: окончательная посадка формируется пластически в гипсе.") c.drawString(128 * mm, 37 * mm, "Не растягивать верхнюю кромку; избыток распределять по высоте.") c.drawString(128 * mm, 30 * mm, "Для минимальной деформации используйте 4 индивидуальных сектора.") draw_calibration(c, 14.0, 15.0) def draw_gore(c: canvas.Canvas, points: Sequence[tuple[float, float]], cx: float, cy: float, number: int) -> None: translated = [(cx + x, cy + y) for x, y in points] c.setStrokeColor(BLACK) c.setLineWidth(0.55) draw_polyline(c, translated, close=True) c.setFillColor(GRAY) c.setFont("TemplateSans-Bold", 7) c.drawCentredString(cx * mm, (cy + 5) * mm, f"СЕКТОР {number}") c.setDash(1.5 * mm, 1.5 * mm) c.setStrokeColor(OCHRE) c.line(cx * mm, (cy + 2) * mm, cx * mm, (cy + max(y for _, y in points) - 2) * mm) c.setDash() def draw_gores( c: canvas.Canvas, dims: TemplateDimensions, patterns: Sequence[Sequence[tuple[float, float]]], ) -> None: label = label_for_preset(dims.preset) draw_header( c, f"{label}: 4-секторный раскрой стенки", f"{dims.surface_style}: сектора различаются и пронумерованы по кругу. Соединять 1-2-3-4.", 2, ) if max(dims.gore_heights_mm) > 129.0 or max(dims.gore_top_widths_mm) > 180.0: raise ValueError(f"{dims.surface_style}: gore pattern does not fit verified A3 layout") positions = [(108.0, 8.0), (312.0, 8.0), (108.0, 139.0), (312.0, 139.0)] for index, (points, (cx, cy)) in enumerate(zip(patterns, positions), start=1): draw_gore(c, points, cx, cy, index) draw_compact_calibration(c, 185.0, 108.0) c.setFillColor(GRAY) c.setFont("TemplateSans", 7) c.drawString(14 * mm, 272.5 * mm, "Охра - ось. Кромки срезать под 45 градусов, насечь, нанести шликер и соединить встык.") def convex_hull(points: Iterable[tuple[float, float]]) -> list[tuple[float, float]]: unique = sorted(set(points)) if len(unique) <= 1: return unique def cross(origin: tuple[float, float], a: tuple[float, float], b: tuple[float, float]) -> float: return (a[0] - origin[0]) * (b[1] - origin[1]) - (a[1] - origin[1]) * (b[0] - origin[0]) lower: list[tuple[float, float]] = [] for point in unique: while len(lower) >= 2 and cross(lower[-2], lower[-1], point) <= 0: lower.pop() lower.append(point) upper: list[tuple[float, float]] = [] for point in reversed(unique): while len(upper) >= 2 and cross(upper[-2], upper[-1], point) <= 0: upper.pop() upper.append(point) return lower[:-1] + upper[:-1] def support_outline(args: argparse.Namespace) -> list[tuple[float, float]]: shrink_scale = 1.0 - args.shrink_percent / 100.0 green_span = args.support_finished_span / shrink_scale envelope = green_span / ( math.sqrt(3.0) * (0.5 - args.support_corner_ratio) + 2.0 * args.support_corner_ratio ) corner_r = envelope * args.support_corner_ratio center_r = envelope / 2.0 - corner_r samples: list[tuple[float, float]] = [] for center_angle in (90.0, 210.0, 330.0): cx = center_r * math.cos(math.radians(center_angle)) cy = center_r * math.sin(math.radians(center_angle)) for index in range(180): angle = 2.0 * math.pi * index / 180.0 samples.append((cx + corner_r * math.cos(angle), cy + corner_r * math.sin(angle))) return convex_hull(samples) def draw_support(c: canvas.Canvas, args: argparse.Namespace, dims: TemplateDimensions) -> None: label = label_for_preset(dims.preset) draw_header( c, f"{label}: опорная площадка из сырой глины", f"Общая для {dims.surface_style}; увеличена на усадку {dims.shrink_percent:g}%.", 3, ) cx, cy = 210.0, 145.0 outline = [(cx + x, cy + y) for x, y in support_outline(args)] c.setStrokeColor(BLACK) c.setLineWidth(0.75) draw_polyline(c, outline, close=True) c.circle(cx * mm, cy * mm, dims.support_hole_diameter_mm / 2.0 * mm, stroke=1, fill=0) c.setDash(2 * mm, 2 * mm) c.setStrokeColor(OCHRE) c.line((cx - dims.support_max_span_mm / 2) * mm, cy * mm, (cx + dims.support_max_span_mm / 2) * mm, cy * mm) c.setDash() c.setFillColor(BLACK) c.setFont("TemplateSans", 8) c.drawString(128 * mm, 55 * mm, f"Габарит сырой детали: {dims.support_max_span_mm:.2f} x {dims.support_min_span_mm:.2f} мм") c.drawString(128 * mm, 48 * mm, f"Отверстие: {dims.support_hole_diameter_mm:.2f} мм; толщина пласта по SCAD: 6 мм") c.drawString(128 * mm, 41 * mm, "Закруглить кромки, подсушить до мягкой кожи и соединить со стенкой.") draw_calibration(c, 14.0, 15.0) def svg_path(points: Sequence[tuple[float, float]], close: bool = True) -> str: commands = [f"M {points[0][0]:.4f} {points[0][1]:.4f}"] commands.extend(f"L {x:.4f} {y:.4f}" for x, y in points[1:]) if close: commands.append("Z") return " ".join(commands) def write_svg( path: Path, points: Sequence[tuple[float, float]], width: float, height: float, circles: Sequence[tuple[float, float, float]] = (), ) -> None: path.parent.mkdir(parents=True, exist_ok=True) min_x = min(x for x, _ in points) max_x = max(x for x, _ in points) min_y = min(y for _, y in points) max_y = max(y for _, y in points) offset_x = (width - (max_x - min_x)) / 2.0 - min_x offset_y = (height - (max_y - min_y)) / 2.0 + max_y translated = [(x + offset_x, offset_y - y) for x, y in points] circle_markup = "".join( f' \n' for cx, cy, radius in circles ) content = ( '\n' f'\n' f' \n' f'{circle_markup}' '\n' ) path.write_text(content, encoding="utf-8") def write_svgs( args: argparse.Namespace, dims: TemplateDimensions, patterns: Sequence[Sequence[tuple[float, float]]], svg_dir: Path, ) -> None: half = dims.single_piece_angle_deg / 2.0 single = arc_points(dims.single_piece_outer_radius_mm, 90.0 - half, 90.0 + half) single += arc_points(dims.single_piece_inner_radius_mm, 90.0 + half, 90.0 - half, 80) write_svg( svg_dir / f"{args.preset}_{dims.surface_style}_cone_single_piece.svg", single, 350.0, 190.0, ) for index, gore in enumerate(patterns, start=1): write_svg( svg_dir / f"{args.preset}_{dims.surface_style}_cone_gore_{index}_of_{dims.gore_count}.svg", gore, 180.0, 135.0, ) support = support_outline(args) hole_r = dims.support_hole_diameter_mm / 2.0 write_svg( svg_dir / f"{args.preset}_{dims.surface_style}_support_plate.svg", support, 180.0, 180.0, circles=[(0.0, 0.0, hole_r)], ) def generate_pdf( args: argparse.Namespace, dims: TemplateDimensions, patterns: Sequence[Sequence[tuple[float, float]]], pdf_path: Path, ) -> None: pdf_path.parent.mkdir(parents=True, exist_ok=True) c = canvas.Canvas(str(pdf_path), pagesize=landscape(A3), pageCompression=1) c.setTitle(f"{args.preset} {args.surface_style} clay templates A3") c.setAuthor("SOFT FACET V2 repository") c.setSubject("Style-specific full-scale clay cutting templates") draw_single_piece(c, dims) c.showPage() draw_gores(c, dims, patterns) c.showPage() draw_support(c, args, dims) c.showPage() c.save() def output_paths(args: argparse.Namespace, style: str) -> tuple[Path, Path, Path]: pdf = args.pdf or args.output_root / "pdf" / f"{args.preset}_{style}_clay_templates_a3.pdf" data = args.data or args.output_root / "data" / f"{args.preset}_{style}_clay_templates.json" svg_dir = args.svg_dir or args.output_root / "svg" / style return pdf, data, svg_dir def portable_path(path: Path) -> str: try: return str(path.relative_to(ROOT)) except ValueError: return str(path) def generate_style(args: argparse.Namespace, style: str) -> dict[str, object]: style_args = copy.copy(args) style_args.surface_style = style scale = release_scale_for(style_args, style) patterns = build_gore_patterns(style_args, style, scale) dims = compute_dimensions(style_args, style, scale, patterns) pdf_path, data_path, svg_dir = output_paths(style_args, style) generate_pdf(style_args, dims, patterns, pdf_path) write_svgs(style_args, dims, patterns, svg_dir) data_path.parent.mkdir(parents=True, exist_ok=True) data_path.write_text(json.dumps(asdict(dims), ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"{style}: {pdf_path}") return { "surface_style": style, "pdf": portable_path(pdf_path), "data": portable_path(data_path), "svg_dir": portable_path(svg_dir), "release_scale": scale, } def main() -> None: args = parse_args() validate(args) register_fonts() styles = SURFACE_STYLES if args.all_styles else (args.surface_style,) entries = [generate_style(args, style) for style in styles] if args.all_styles: index_path = args.output_root / "data" / f"{args.preset}_all_styles_index.json" index_path.write_text(json.dumps(entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"index: {index_path}") if __name__ == "__main__": main()