feat(B07): CAD 수확 — pattern 채움 무늬를 다각형 안 선 · 원으로 펴서 도면에(88-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-08 12:21:32 +09:00
co-authored by Claude Opus 5.5
parent e066ee1c3e
commit de2c03cfc7
2 changed files with 227 additions and 0 deletions
@@ -317,6 +317,108 @@ function rectPoints(element: SVGElement): XY[] {
});
}
/** pattern 한 칸의 도형(SVG 좌표) — 선(path M/L) · 원/타원. 바탕 rect 는 그림이 아니라 뺌. */
export interface PatternTile {
width: number;
height: number;
paths: XY[][];
rings: Array<{ cx: number; cy: number; rx: number; ry: number }>;
}
/** 무늬 칸 수 상한 — 넘으면 무늬를 버리고 외곽선만(ponytail: 큰 다각형이면 칸 크기를 키울 것). */
const PATTERN_TILE_LIMIT = 20000;
/**
* pattern 채움을 다각형(도면 좌표) 안으로 펴서 자른다(88-4) — CAD 에는 무늬 채움이 없다.
* 칸은 SVG 사용자 좌표 원점 기준(`patternUnits="userSpaceOnUse"`). 선은 이어 붙여 한 줄로,
* 다각형 안에 온전히 든 원은 원 그대로 · 걸친 원/타원은 잘린 폴리선으로.
*/
export function patternMarks(
polygon: XY[],
tile: PatternTile,
): { polylines: XY[][]; circles: Array<{ center: XY; radius: number }> } {
const polylines: XY[][] = [];
const circles: Array<{ center: XY; radius: number }> = [];
const { width, height } = tile;
if (polygon.length < 3 || !(width > 0) || !(height > 0)) return { polylines, circles };
const xs = polygon.map((point) => point[0]);
const ys = polygon.map((point) => -point[1]);
const [i0, i1] = [Math.floor(Math.min(...xs) / width), Math.floor(Math.max(...xs) / width)];
const [j0, j1] = [Math.floor(Math.min(...ys) / height), Math.floor(Math.max(...ys) / height)];
if ((i1 - i0 + 1) * (j1 - j0 + 1) > PATTERN_TILE_LIMIT) return { polylines, circles };
const lines: XY[][] = [];
const arcs: XY[][] = [];
for (let i = i0; i <= i1; i += 1) {
for (let j = j0; j <= j1; j += 1) {
const [dx, dy] = [i * width, j * height];
for (const path of tile.paths) {
lines.push(
...clipPointsToPolygon(
path.map(([x, y]) => flip(x + dx, y + dy)),
polygon,
),
);
}
for (const { cx, cy, rx, ry } of tile.rings) {
const center = flip(cx + dx, cy + dy);
const ring: XY[] = circlePoints([0, 0], 1).map(([x, y]) => [
center[0] + x * rx,
center[1] + y * ry,
]);
if (rx === ry && ring.every((point) => pointInPolygon(point, polygon))) {
circles.push({ center, radius: rx });
} else {
arcs.push(...clipPointsToPolygon(ring, polygon));
}
}
}
}
polylines.push(...chain(lines).map((points) => simplify(points)), ...chain(arcs));
return { polylines, circles };
}
/** 다각형 채움이 가리키는 pattern 을 칸 도형으로 읽는다(채움 = fill 속성 또는 style). 없으면 null. */
function patternTileOf(element: SVGElement, root: SVGElement): PatternTile | null {
const fill = element.getAttribute("fill") ?? String(element.style?.fill ?? "");
const id = /url\(["']?#([^"')]+)/.exec(fill)?.[1];
if (!id) return null;
const pattern = Array.from(root.querySelectorAll<SVGElement>("pattern")).find(
(node) => node.getAttribute("id") === id,
);
if (!pattern) return null;
const tile: PatternTile = {
width: attr(pattern, "width"),
height: attr(pattern, "height"),
paths: [],
rings: [],
};
for (const child of Array.from(pattern.children) as SVGElement[]) {
const tag = child.tagName.toLowerCase();
if (tag === "path") {
// M/L 절대 좌표만(무늬 빗금 꼴) — M 마다 새 줄
for (const run of (child.getAttribute("d") ?? "").split(/(?=M)/i)) {
const numbers = run.match(/-?[\d.]+/g)?.map(Number) ?? [];
const points: XY[] = [];
for (let index = 0; index + 1 < numbers.length; index += 2) {
points.push([numbers[index], numbers[index + 1]]);
}
if (points.length > 1) tile.paths.push(points);
}
} else if (tag === "circle") {
const r = attr(child, "r");
tile.rings.push({ cx: attr(child, "cx"), cy: attr(child, "cy"), rx: r, ry: r });
} else if (tag === "ellipse") {
tile.rings.push({
cx: attr(child, "cx"),
cy: attr(child, "cy"),
rx: attr(child, "rx"),
ry: attr(child, "ry"),
});
}
}
return tile;
}
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */
function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
const [fx0, , fx1] = frame;
@@ -337,6 +439,21 @@ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
// 무늬 채움(노면공 · 물넘이포장) — 무늬 선 · 원을 다각형 안으로 펴서 같은 층 · 색으로(88-4)
const tile = tag === "polygon" && !clip ? patternTileOf(element, root) : null;
if (tile) {
const marks = patternMarks(points, tile);
for (const run of marks.polylines) {
const poly = polyEntity(style, clipX(run, fx0, fx1));
if (poly) entities.push(poly);
}
for (const { center, radius } of marks.circles) {
if (!inside(center[0])) continue;
entities.push(
baseEntity(style, "Circle", { center: { x: center[0], y: center[1] }, radius }),
);
}
}
} else if (tag === "line") {
const start = flip(attr(element, "x1"), attr(element, "y1"));
const end = flip(attr(element, "x2"), attr(element, "y2"));