From 451ed9727931fc69ba6c52d8442570d5ff664d65 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 13:46:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=EB=8F=8C=EC=8C=93=EA=B8=B0=20?= =?UTF-8?q?=ED=95=B4=EC=B9=AD=20CAD=20=EB=B0=98=EC=98=81=20+=20=ED=91=9C?= =?UTF-8?q?=EC=A0=9C=EB=9E=80=20SVG=20=EC=84=9C=EB=AA=85=20=EB=B2=A1?= =?UTF-8?q?=ED=84=B0=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 해칭 — 수확기가 `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) --- .../B07_DesignDetail_Engine_Cad_Svg.py | 202 ++++++++++++++++++ .../B07_DesignDetail_Engine_Template.py | 75 ++++++- .../B07_DesignDetail_UI_Cad_Structures.ts | 124 ++++++++++- 3 files changed, 388 insertions(+), 13 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py new file mode 100644 index 00000000..b99c8c33 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py @@ -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 + ] diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index eb8dec65..ecec48f0 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -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]: diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index b4458007..89a249f0 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -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; 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));