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>
This commit is contained in:
2026-09-03 13:46:54 +09:00
co-authored by Claude Opus 5
parent ce4fcc1f13
commit 451ed97279
3 changed files with 388 additions and 13 deletions
@@ -0,0 +1,202 @@
"""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
]
@@ -9,14 +9,18 @@ A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표
내부 작도 영역 (42, 47) ~ (812, 567).
"""
import binascii
import json
import logging
import re
from base64 import b64decode
from contextvars import ContextVar
from functools import lru_cache
from pathlib import Path
from typing import Any
from uuid import uuid5
from uuid import NAMESPACE_URL, uuid5
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_ENTITY_NS,
@@ -28,6 +32,10 @@ _TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template
logger = logging.getLogger(__name__)
# 벡터로 바꾼 서명 폴리선 id 를 결정적으로 만드는 이름공간 — 같은 도면을 두 번 뽑아도
# 엔티티 id 가 같아야 CAD 가 같은 것으로 본다.
_SVG_NS_UUID = uuid5(NAMESPACE_URL, "aislo/b07/signature-vector")
A1_TEMPLATE = "00_template_A1"
COMPASS_TEMPLATE = "00_template_compass" # 유역도 등 평면 도면의 방위표
# A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다.
@@ -319,11 +327,74 @@ def _fill_placeholders(
if entity.get("type") == "Image" and isinstance(image, str) and "{{" in image:
shape["imageData"] = substitute(image)
# 그림을 못 구한 자리는 엔티티째 뺀다 — 빈 문자열을 남기면 CAD가 깨진 그림을 그린다.
return [
kept = [
entity
for entity in entities
if entity.get("type") != "Image" or (entity.get("shapeData") or {}).get("imageData")
]
return [replaced for entity in kept for replaced in _vectorize_svg_image(entity)]
def _vectorize_svg_image(entity: dict[str, Any]) -> list[dict[str, Any]]:
"""SVG 로 올라온 서명·로고를 CAD 폴리선으로 바꾼다(2026-09-03 사용자 결정).
래스터(PNG·JPG)는 그릴 선이 없으므로 종전대로 `ImageEntity` 로 둔다. SVG 만 벡터로
바꿔 DXF 로 나가도 선으로 남고 확대해도 깨지지 않게 한다. 읽지 못하면 원래 그림을
그대로 둔다 — 서명이 통째로 사라지는 것보다 낫다.
"""
if entity.get("type") != "Image":
return [entity]
shape = entity.get("shapeData") or {}
data_url = shape.get("imageData")
if not isinstance(data_url, str) or not data_url.startswith("data:image/svg+xml;base64,"):
return [entity]
points = shape.get("points") or []
if len(points) < 4:
return [entity]
try:
svg_text = b64decode(data_url.split(",", 1)[1]).decode("utf-8", errors="ignore")
except (ValueError, binascii.Error) as exc:
logger.warning("서명 SVG data URL 을 풀지 못했습니다 — %s", exc)
return [entity]
view_box, runs = svg_polylines(svg_text)
if not runs:
return [entity]
xs = [float(point["x"]) for point in points]
ys = [float(point["y"]) for point in points]
fitted = fit_polylines(runs, view_box, (min(xs), min(ys), max(xs), max(ys)))
layer_id = entity.get("layerId")
color = entity.get("lineColor", "#000000")
vector: list[dict[str, Any]] = []
for index, run in enumerate(fitted):
children = [
{
"id": str(uuid5(_SVG_NS_UUID, f"{entity.get('id')}:{index}:{step}")),
"type": "Line",
"lineColor": color,
"lineWidth": 1,
"layerId": layer_id,
"shapeData": {
"startPoint": {"x": run[step][0], "y": run[step][1]},
"endPoint": {"x": run[step + 1][0], "y": run[step + 1][1]},
},
}
for step in range(len(run) - 1)
]
if not children:
continue
vector.append(
{
"id": str(uuid5(_SVG_NS_UUID, f"{entity.get('id')}:{index}")),
"type": "PolyLine",
"lineColor": color,
"lineWidth": 1,
"layerId": layer_id,
"shapeData": None,
"children": children,
}
)
return vector or [entity]
def scale_fields(*denominators: tuple[str, int]) -> dict[str, str]:
@@ -75,8 +75,10 @@ const LABEL_FONT_MM = 2.0;
/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */
const CHAINAGE_TOLERANCE_M = 0.02;
/** 정의부(해칭 패턴·클립)는 도형이 아니다. 클립된 해칭은 1차 제외(잘라 낼 수단이 없다). */
const SKIP_SELECTOR = "defs, clipPath, pattern, g[clip-path]";
/** 정의부(해칭 패턴·클립)는 도형이 아니다 클립 경계 자체는 그림이 아니라 잘라 낼 자다. */
const SKIP_SELECTOR = "defs, clipPath, pattern";
/** 원을 폴리선으로 바꿀 때 쓰는 분할 수 — 클립 안에서만 쓴다(밖은 Circle 그대로). */
const CIRCLE_SEGMENTS = 36;
type Entity = Record<string, unknown>;
type XY = [number, number];
@@ -241,6 +243,86 @@ function parsePoints(element: SVGElement): XY[] {
return points;
}
/**
* 이 도형에 걸린 clipPath 폴리곤(도면 좌표). 없으면 null.
*
* 기슭막이 형태 해칭(`B06_Section_UI_Cross_Wall_Hatch`)은 벽 폴리곤 clip 안에서 그린다.
* 종전 수확기는 그 그룹을 통째로 건너뛰어 **돌쌓기·콘크리트 해칭이 CAD 도면에 하나도
* 실리지 않았다**. 클립을 쓰는 대신 경계로 **잘라서** 싣는다(2026-09-03 사용자 결정).
*/
function clipPolygonOf(element: SVGElement): XY[] | null {
const group = element.closest("g[clip-path]");
const reference = group?.getAttribute("clip-path") ?? "";
const id = reference.match(/url\(#([^)]+)\)/)?.[1];
if (!id) return null;
const shape = element.ownerDocument?.getElementById(id)?.querySelector("polygon");
if (!shape) return null;
const points = parsePoints(shape as unknown as SVGElement);
return points.length >= 3 ? points : null;
}
/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */
function pointInPolygon(point: XY, polygon: XY[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
const straddles = yi > point[1] !== yj > point[1];
if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */
function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] {
const [x0, y0] = start;
const [x1, y1] = end;
const dx = x1 - x0;
const dy = y1 - y0;
const cuts = [0, 1];
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [ex, ey] = polygon[j];
const [fx, fy] = polygon[i];
const denominator = dx * (fy - ey) - dy * (fx - ex);
if (Math.abs(denominator) < 1e-12) continue;
const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator;
const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator;
if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t);
}
cuts.sort((a, b) => a - b);
const runs: XY[][] = [];
for (let index = 0; index + 1 < cuts.length; index += 1) {
const from = cuts[index];
const to = cuts[index + 1];
if (to - from < 1e-9) continue;
const mid = (from + to) / 2;
if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue;
runs.push([
[x0 + dx * from, y0 + dy * from],
[x0 + dx * to, y0 + dy * to],
]);
}
return runs;
}
/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */
function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] {
const runs: XY[][] = [];
for (let index = 0; index + 1 < points.length; index += 1) {
runs.push(...clipSegment(points[index], points[index + 1], polygon));
}
return runs;
}
function circlePoints(center: XY, radius: number): XY[] {
const points: XY[] = [];
for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) {
const angle = (2 * Math.PI * index) / CIRCLE_SEGMENTS;
points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]);
}
return points;
}
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */
function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
const [fx0, , fx1] = frame;
@@ -251,26 +333,46 @@ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
for (const element of Array.from(nodes)) {
if (element.closest(SKIP_SELECTOR)) continue;
const tag = element.tagName.toLowerCase();
// 클립 그룹 안의 해칭은 경계로 **잘라서** 싣는다 — CAD 에는 클립이 없다.
const clip = clipPolygonOf(element);
if (tag === "polygon" || tag === "polyline") {
const points = parsePoints(element);
if (tag === "polygon" && points.length > 2) points.push(points[0]);
const poly = polyEntity(style, simplify(clipX(points, fx0, fx1)));
if (poly) entities.push(poly);
const runs = clip ? clipPointsToPolygon(points, clip) : [points];
for (const run of runs) {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
} else if (tag === "line") {
const start = flip(attr(element, "x1"), attr(element, "y1"));
const end = flip(attr(element, "x2"), attr(element, "y2"));
const cut = clipX([start, end], fx0, fx1);
if (cut.length === 2) segments.push(cut);
for (const run of clip ? clipSegment(start, end, clip) : [[start, end]]) {
const cut = clipX(run, fx0, fx1);
if (cut.length === 2) segments.push(cut);
}
} else if (tag === "circle") {
const [cx, cy] = flip(attr(element, "cx"), attr(element, "cy"));
if (!inside(cx)) continue;
entities.push(
baseEntity(style, "Circle", { center: { x: cx, y: cy }, radius: attr(element, "r") }),
);
const center = flip(attr(element, "cx"), attr(element, "cy"));
if (!inside(center[0])) continue;
const radius = attr(element, "r");
if (!clip) {
entities.push(
baseEntity(style, "Circle", {
center: { x: center[0], y: center[1] },
radius,
}),
);
continue;
}
// 클립 안 원(통나무 마구리 등)은 폴리선으로 바꿔 경계에서 자른다.
for (const run of clipPointsToPolygon(circlePoints(center, radius), clip)) {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
} else if (tag === "text") {
const label = (element.textContent ?? "").trim();
const at = flip(attr(element, "x"), attr(element, "y"));
if (!label || !inside(at[0])) continue;
if (clip && !pointInPolygon(at, clip)) continue;
const anchor = element.getAttribute("text-anchor");
const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center";
entities.push(textEntity(style, label, at, align));