v60
This commit is contained in:
@@ -0,0 +1,746 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user