해칭 — 수확기가 `g[clip-path]` 를 통째로 건너뛰어 기슭막이 형태 해칭(돌쌓기·콘크리트· 돌망태·통나무·바자)이 CAD 도면에 하나도 실리지 않았다. 클립을 쓰는 대신 **경계로 잘라서** 싣는다(2026-09-03 사용자 결정). 선분–폴리곤 교차로 안쪽 조각만 남기고, 클립 안 원(통나무 마구리)은 36각 폴리선으로 펴서 같은 방식으로 자른다. 문자는 중심점이 안일 때만 싣는다. 서명 — SVG 로 올라온 자산만 CAD 폴리선으로 바꾼다(2026-09-03 사용자 결정). DXF 로 나가도 선으로 남고 확대해도 깨지지 않는다. `B07_DesignDetail_Engine_Cad_Svg` 신설(path M·L·H·V· C·Q·S·T·Z 지원, 곡선은 16분할, viewBox 비율 유지·y 뒤집기). PNG·JPG 는 그릴 선이 없으므로 종전대로 ImageEntity, 읽기 실패도 원래 그림을 그대로 둔다. 검증 — `tmp/tests/test_b07_signature_vector.py` 5건 신설(파싱·비율·엔티티 교체·래스터 유지· 손상 SVG), 전체 383 passed·17 skipped. typecheck·prettier·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
203 lines
7.9 KiB
Python
203 lines
7.9 KiB
Python
"""SVG 서명·로고를 CAD 폴리선으로 옮긴다 (2026-09-03 사용자 결정: 벡터로 변환).
|
|
|
|
표제란 서명은 종전에 `ImageEntity`(data URL)로 들어갔다. 화면·PNG 는 문제없지만 DXF 로
|
|
나가면 래스터 그림이 되고 확대하면 깨진다. **SVG 로 올라온 자산만** 벡터로 바꾼다 —
|
|
PNG·JPG 는 그릴 선이 없으므로 종전대로 그림으로 둔다.
|
|
|
|
지원 도형: `path`(M·L·H·V·C·Q·S·T·A 제외·Z, 대소문자 = 절대·상대) · `polyline` ·
|
|
`polygon` · `line`. 곡선은 고정 분할로 펴서 폴리선 하나로 만든다 — CAD 쪽에 곡선
|
|
엔티티가 없고, 서명은 짧은 획이라 16분할이면 눈으로 구분되지 않는다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from xml.etree import ElementTree
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 베지에 한 구간을 몇 조각으로 펼지 — 서명 획 길이(수 mm)에서 충분한 값.
|
|
_CURVE_STEPS = 16
|
|
_NUMBER = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
|
|
_COMMAND = re.compile(r"([MmLlHhVvCcQqSsTtZz])")
|
|
_SVG_NS = "{http://www.w3.org/2000/svg}"
|
|
|
|
Point = tuple[float, float]
|
|
|
|
|
|
def _numbers(text: str) -> list[float]:
|
|
return [float(value) for value in _NUMBER.findall(text or "")]
|
|
|
|
|
|
def _bezier(points: list[Point], steps: int = _CURVE_STEPS) -> list[Point]:
|
|
"""드 카스텔조 분할 — 3차·2차 모두 같은 식으로 편다(제어점 개수만 다르다)."""
|
|
flattened: list[Point] = []
|
|
for index in range(1, steps + 1):
|
|
t = index / steps
|
|
current = points
|
|
while len(current) > 1:
|
|
current = [
|
|
(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
|
|
for a, b in zip(current, current[1:])
|
|
]
|
|
flattened.append(current[0])
|
|
return flattened
|
|
|
|
|
|
def _path_polylines(data: str) -> list[list[Point]]:
|
|
"""`d` 속성 → 폴리선 목록. 미지원 명령(A 등)을 만나면 그 자리에서 획을 끊는다."""
|
|
runs: list[list[Point]] = []
|
|
current: list[Point] = []
|
|
cursor: Point = (0.0, 0.0)
|
|
start: Point = (0.0, 0.0)
|
|
previous_control: Point | None = None
|
|
tokens = [token for token in _COMMAND.split(data or "") if token.strip()]
|
|
index = 0
|
|
while index < len(tokens):
|
|
command = tokens[index]
|
|
args = _numbers(tokens[index + 1]) if index + 1 < len(tokens) else []
|
|
index += 2 if index + 1 < len(tokens) else 1
|
|
relative = command.islower()
|
|
upper = command.upper()
|
|
|
|
def absolute(x: float, y: float) -> Point:
|
|
return (cursor[0] + x, cursor[1] + y) if relative else (x, y)
|
|
|
|
if upper == "M":
|
|
for pair in range(0, len(args) - 1, 2):
|
|
point = absolute(args[pair], args[pair + 1])
|
|
if pair == 0:
|
|
if len(current) >= 2:
|
|
runs.append(current)
|
|
current = [point]
|
|
start = point
|
|
else:
|
|
current.append(point)
|
|
cursor = point
|
|
previous_control = None
|
|
elif upper in {"L", "T"}:
|
|
for pair in range(0, len(args) - 1, 2):
|
|
point = absolute(args[pair], args[pair + 1])
|
|
current.append(point)
|
|
cursor = point
|
|
previous_control = None
|
|
elif upper == "H":
|
|
for value in args:
|
|
point = (cursor[0] + value, cursor[1]) if relative else (value, cursor[1])
|
|
current.append(point)
|
|
cursor = point
|
|
previous_control = None
|
|
elif upper == "V":
|
|
for value in args:
|
|
point = (cursor[0], cursor[1] + value) if relative else (cursor[0], value)
|
|
current.append(point)
|
|
cursor = point
|
|
previous_control = None
|
|
elif upper in {"C", "S", "Q"}:
|
|
stride = {"C": 6, "S": 4, "Q": 4}[upper]
|
|
for offset in range(0, len(args) - stride + 1, stride):
|
|
chunk = args[offset : offset + stride]
|
|
if upper == "C":
|
|
control1 = absolute(chunk[0], chunk[1])
|
|
control2 = absolute(chunk[2], chunk[3])
|
|
end = absolute(chunk[4], chunk[5])
|
|
elif upper == "S":
|
|
mirrored = previous_control or cursor
|
|
control1 = (2 * cursor[0] - mirrored[0], 2 * cursor[1] - mirrored[1])
|
|
control2 = absolute(chunk[0], chunk[1])
|
|
end = absolute(chunk[2], chunk[3])
|
|
else:
|
|
control1 = absolute(chunk[0], chunk[1])
|
|
control2 = control1
|
|
end = absolute(chunk[2], chunk[3])
|
|
current.extend(_bezier([cursor, control1, control2, end]))
|
|
previous_control = control2
|
|
cursor = end
|
|
elif upper == "Z":
|
|
if current:
|
|
current.append(start)
|
|
runs.append(current)
|
|
current = []
|
|
cursor = start
|
|
previous_control = None
|
|
else:
|
|
# 지원하지 않는 명령(원호 A 등) — 여기서 획을 끊고 다음 M 을 기다린다.
|
|
if len(current) >= 2:
|
|
runs.append(current)
|
|
current = []
|
|
previous_control = None
|
|
if len(current) >= 2:
|
|
runs.append(current)
|
|
return runs
|
|
|
|
|
|
def svg_polylines(svg_text: str) -> tuple[tuple[float, float, float, float], list[list[Point]]]:
|
|
"""SVG 원문 → (viewBox, 폴리선 목록). 읽지 못하면 빈 목록.
|
|
|
|
viewBox 가 없으면 도형 전체 bbox 를 대신 쓴다 — 자리 맞추기에만 쓰는 값이다.
|
|
"""
|
|
try:
|
|
root = ElementTree.fromstring(svg_text)
|
|
except ElementTree.ParseError as exc:
|
|
logger.warning("서명 SVG 를 읽지 못했습니다 — %s", exc)
|
|
return ((0.0, 0.0, 1.0, 1.0), [])
|
|
|
|
runs: list[list[Point]] = []
|
|
for element in root.iter():
|
|
tag = element.tag.replace(_SVG_NS, "")
|
|
if tag == "path":
|
|
runs.extend(_path_polylines(element.get("d", "")))
|
|
elif tag in {"polyline", "polygon"}:
|
|
values = _numbers(element.get("points", ""))
|
|
points = [(values[i], values[i + 1]) for i in range(0, len(values) - 1, 2)]
|
|
if tag == "polygon" and len(points) > 2:
|
|
points.append(points[0])
|
|
if len(points) >= 2:
|
|
runs.append(points)
|
|
elif tag == "line":
|
|
runs.append(
|
|
[
|
|
(float(element.get("x1", 0)), float(element.get("y1", 0))),
|
|
(float(element.get("x2", 0)), float(element.get("y2", 0))),
|
|
]
|
|
)
|
|
|
|
box = _numbers(root.get("viewBox", ""))
|
|
if len(box) == 4 and box[2] > 0 and box[3] > 0:
|
|
return ((box[0], box[1], box[2], box[3]), runs)
|
|
xs = [x for run in runs for x, _y in run]
|
|
ys = [y for run in runs for _x, y in run]
|
|
if not xs or not ys:
|
|
return ((0.0, 0.0, 1.0, 1.0), runs)
|
|
width = max(max(xs) - min(xs), 1e-6)
|
|
height = max(max(ys) - min(ys), 1e-6)
|
|
return ((min(xs), min(ys), width, height), runs)
|
|
|
|
|
|
def fit_polylines(
|
|
runs: list[list[Point]],
|
|
view_box: tuple[float, float, float, float],
|
|
rect: tuple[float, float, float, float],
|
|
) -> list[list[Point]]:
|
|
"""폴리선을 도면 사각형(x0, y0, x1, y1) 안에 **비율 유지**로 앉힌다.
|
|
|
|
SVG 는 y 가 아래로 자라고 도면은 위로 자라므로 세로를 뒤집는다.
|
|
"""
|
|
vx, vy, vw, vh = view_box
|
|
x0, y0, x1, y1 = rect
|
|
box_w = abs(x1 - x0)
|
|
box_h = abs(y1 - y0)
|
|
if vw <= 0 or vh <= 0 or box_w <= 0 or box_h <= 0:
|
|
return []
|
|
scale = min(box_w / vw, box_h / vh)
|
|
pad_x = (box_w - vw * scale) / 2.0
|
|
pad_y = (box_h - vh * scale) / 2.0
|
|
left = min(x0, x1)
|
|
bottom = min(y0, y1)
|
|
top = bottom + box_h
|
|
return [
|
|
[(left + pad_x + (x - vx) * scale, top - pad_y - (y - vy) * scale) for x, y in run]
|
|
for run in runs
|
|
]
|