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"));
@@ -0,0 +1,110 @@
// B07 CAD 수확 — pattern 채움(노면공 무늬 셋 · 물넘이 빗금)을 다각형 안 선 · 원으로 펴기(88-4).
// 실제 코드(`B07_DesignDetail_Cross_Overlay.patternMarks`)를 vite ssr 로 묶어 돌린다.
// 실행: node resources/tester/test_cad_pattern_harvest.mjs
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = resolve(import.meta.dirname, "../..");
const out = mkdtempSync(join(tmpdir(), "cad-pattern-"));
execFileSync(
"node",
[
"./config/node_modules/vite/bin/vite.js",
"build",
"--configLoader",
"runner",
"--ssr",
"../B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts",
"--outDir",
out,
"--logLevel",
"error",
],
{ cwd: ROOT, stdio: "pipe" },
);
const { patternMarks } = await import(
pathToFileURL(join(out, "B07_DesignDetail_Cross_Overlay.js")).href
);
// 도면 좌표(y 위로) 다각형 — SVG y 100~103(두께 3) · x 10~70. 닫힌 점열.
const polygon = [
[10, -100],
[70, -100],
[70, -103],
[10, -103],
[10, -100],
];
const inPoly = ([x, y]) => x >= 10 - 1e-6 && x <= 70 + 1e-6 && y <= -100 + 1e-6 && y >= -103 - 1e-6;
const allPoints = (marks) => [
...marks.polylines.flat(),
...marks.circles.flatMap(({ center: [cx, cy], radius }) => [
[cx - radius, cy],
[cx + radius, cy],
[cx, cy - radius],
[cx, cy + radius],
]),
];
// ① 콘크리트 · 물넘이 빗금(M0,6 L6,0 · 칸 6) — 대각선이 칸을 넘어 이어져 한 줄씩 · 모두 다각형 안
{
const marks = patternMarks(polygon, {
width: 6,
height: 6,
paths: [
[
[0, 6],
[6, 0],
],
],
rings: [],
});
assert.equal(marks.circles.length, 0);
// x+y = 6k 인 대각선 중 띠(y 100~103)를 지나는 것: x+y ∈ (110, 173) → k = 19..28 = 10 줄
assert.equal(marks.polylines.length, 10, `빗금 줄 수 ${marks.polylines.length}`);
for (const run of marks.polylines) assert.equal(run.length, 2, "한 줄 = 두 점(이어 붙임)");
assert.ok(allPoints(marks).every(inPoly), "빗금이 다각형 밖으로 나감");
}
// ② 아스콘 점(원 r 0.7 · 칸 5) — 온전히 든 원은 원 그대로
{
const marks = patternMarks(polygon, {
width: 5,
height: 5,
paths: [],
rings: [{ cx: 1.5, cy: 1.5, rx: 0.7, ry: 0.7 }],
});
// 중심 y = 101.5 (칸 j=20) · x = 11.5, 16.5 … 66.5 = 12 개 — 모두 띠 안
assert.equal(marks.circles.length, 12, `점 수 ${marks.circles.length}`);
assert.equal(marks.polylines.length, 0);
assert.ok(allPoints(marks).every(inPoly), "점이 다각형 밖으로 나감");
}
// ③ 쇄석 돌(원 r 1.6 + 타원 · 칸 9) — 걸친 것은 잘린 폴리선 · 밖으로 안 나감
{
const marks = patternMarks(polygon, {
width: 9,
height: 9,
paths: [],
rings: [
{ cx: 2.5, cy: 2.5, rx: 1.6, ry: 1.6 },
{ cx: 6.8, cy: 6.5, rx: 1.8, ry: 1.2 },
],
});
assert.ok(marks.polylines.length + marks.circles.length > 0, "돌 무늬 없음");
assert.ok(allPoints(marks).every(inPoly), "돌이 다각형 밖으로 나감");
}
// ④ 빈 칸 · 다각형 없음 = 무늬 없음
{
assert.deepEqual(patternMarks(polygon, { width: 0, height: 6, paths: [], rings: [] }), {
polylines: [],
circles: [],
});
assert.equal(patternMarks([], { width: 6, height: 6, paths: [], rings: [] }).polylines.length, 0);
}
console.log("OK test_cad_pattern_harvest.mjs — 4건");