From de2c03cfc7af1da694b1cd1eeca21fa7b3e2534b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:21:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20CAD=20=EC=88=98=ED=99=95=20?= =?UTF-8?q?=E2=80=94=20pattern=20=EC=B1=84=EC=9B=80=20=EB=AC=B4=EB=8A=AC?= =?UTF-8?q?=EB=A5=BC=20=EB=8B=A4=EA=B0=81=ED=98=95=20=EC=95=88=20=EC=84=A0?= =?UTF-8?q?=20=C2=B7=20=EC=9B=90=EC=9C=BC=EB=A1=9C=20=ED=8E=B4=EC=84=9C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=B4=EC=97=90(88-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../B07_DesignDetail_Cross_Overlay.ts | 117 ++++++++++++++++++ resources/tester/test_cad_pattern_harvest.mjs | 110 ++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 resources/tester/test_cad_pattern_harvest.mjs diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index ff43a5cff..10fba7455 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -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("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")); diff --git a/resources/tester/test_cad_pattern_harvest.mjs b/resources/tester/test_cad_pattern_harvest.mjs new file mode 100644 index 000000000..3e3506ea2 --- /dev/null +++ b/resources/tester/test_cad_pattern_harvest.mjs @@ -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건");