Files
v60/tools/generate_a3_templates.py

747 lines
29 KiB
Python

#!/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' <circle cx="{cx + offset_x:.4f}" cy="{offset_y - cy:.4f}" r="{radius:.4f}" '
'fill="none" stroke="#1D252C" stroke-width="0.35"/>\n'
for cx, cy, radius in circles
)
content = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}mm" height="{height}mm" viewBox="0 0 {width} {height}">\n'
f' <path d="{svg_path(translated)}" fill="none" stroke="#1D252C" stroke-width="0.35"/>\n'
f'{circle_markup}'
'</svg>\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()