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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user