Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py
T
eomsangdonandClaude Opus 5 451ed97279 feat(B07): 돌쌓기 해칭 CAD 반영 + 표제란 SVG 서명 벡터 변환
해칭 — 수확기가 `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>
2026-09-03 13:46:54 +09:00

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
]