From 157828e29fdc411d1c00570165840074835ced3d Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 29 Jun 2026 13:35:13 +0900 Subject: [PATCH] =?UTF-8?q?[=EC=B5=9C=EC=A2=85=EC=88=98=EC=A0=95=20=5FN]?= =?UTF-8?q?=20=EC=BC=80=EB=9D=BC=EB=B0=94=20=EB=9D=BC=EC=9D=B8=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=B4=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20=E2=80=94?= =?UTF-8?q?=20kerab-rule-checker=20=E5=87=BA=E5=B9=85(=EC=B6=9C=ED=8F=AD)?= =?UTF-8?q?=20=EA=B2=B9=EC=B9=A8=20=EB=B0=B4=EB=93=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EB=B3=B4=EA=B0=95=20+=20QPolygon=20=EB=94=94=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=B9=98=20=EB=9D=BC=EB=B2=A8=20+=20qpoly?= =?UTF-8?q?gon-utils=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4 --- src/components/fabric/QPolygon.js | 21 + src/util/kerab-rule-checker.js | 708 +++++++++++++++++++++++++++++- src/util/qpolygon-utils.js | 8 +- 3 files changed, 715 insertions(+), 22 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index aadc2aeb..96ca4b72 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -142,6 +142,10 @@ function __attachDebugLabels(canvas, parentId) { const boundaryEdges = __roofBoundaryEdges(canvas, parentId) const objects = canvas.getObjects().filter((o) => o.parentId === parentId && o.name !== DEBUG_LABEL_NAME) + // [LABEL-DUMP 2026-06-26] 캔버스 라벨↔lineId↔태그↔좌표↔각도 매핑을 debug.log 로 덤프. + // 체커 로그는 lineId/좌표만 남겨 사용자(라벨 기준)와 대화가 안 맞음 → 라벨 부여 시점에 같이 기록. + const __labelDump = [] + objects.forEach((obj) => { const prefix = __classifyLineForLabel(obj, boundaryEdges) if (!prefix) return @@ -151,6 +155,19 @@ function __attachDebugLabels(canvas, parentId) { const mx = (obj.x1 + obj.x2) / 2 const my = (obj.y1 + obj.y2) / 2 + if (typeof obj.x1 === 'number' && typeof obj.x2 === 'number') { + const ang = Math.round((Math.atan2(obj.y2 - obj.y1, obj.x2 - obj.x1) * 180) / Math.PI) + __labelDump.push({ + label, + id: obj.id || obj.lineId || '', + name: obj.name || '', + lineName: obj.lineName || '', + type: obj.attributes?.type || '', + coords: `(${Math.round(obj.x1)},${Math.round(obj.y1)})-(${Math.round(obj.x2)},${Math.round(obj.y2)})`, + angle: ang, + }) + } + const text = new fabric.Text(label, { left: mx, top: my, @@ -173,6 +190,10 @@ function __attachDebugLabels(canvas, parentId) { text.bringToFront() }) + if (__labelDump.length) { + debugCapture.log(`LABEL-DUMP roof=${parentId}`, { count: __labelDump.length, lines: __labelDump }) + } + canvas.renderAll() } diff --git a/src/util/kerab-rule-checker.js b/src/util/kerab-rule-checker.js index d1db6224..0b015cdb 100644 --- a/src/util/kerab-rule-checker.js +++ b/src/util/kerab-rule-checker.js @@ -15,6 +15,7 @@ // (dev 콘솔에서 수동: window.__checkKerabRules(roof.innerLines)) import { logger } from '@/util/logger' +import { debugCapture } from '@/util/debugCapture' const ENABLED = process.env.NEXT_PUBLIC_ENABLE_LOGGING === 'true' @@ -23,7 +24,7 @@ const DEFAULTS = { angleDegEps: 1.0, // 45°/축정렬 허용 오차(도). 불변식상 정확해야 하므로 작게 — drift 를 잡는 게 목적. pointEps: 0.5, // 같은 점 판정(메모리: UI/Big.js drift 고려 넉넉히). zeroLenEps: 0.5, // 길이 0(소멸) 판정. - boxPadding: 0.5, // 박스 경계 여유. + boxPadding: 1.0, // 박스 경계 여유 — 드로잉(clipCrossedHipsAtIntersection)의 PAD=1.0 과 통일. } // ── 좌표/기하 헬퍼 ──────────────────────────────────────────────── @@ -87,10 +88,33 @@ const isDiagonalHip = (ln) => { const a = angle180(c) return devFrom(a, [45, 135]) < devFrom(a, [0, 90, 180]) } -const isHip = (ln) => !isBox(ln) && typeOf(ln) === 'hip' && isDiagonalHip(ln) -const isRidge = (ln) => !isBox(ln) && typeOf(ln) === 'ridge' +// [KERAB-CLASSIFY-BY-GEOMETRY 2026-06-26] 이름(type) 의존 폐기 — 실측 로그에서 힙이 'default' 로 타입돼 +// isHip 가 0개를 반환, 모든 힙 규칙(R-BOXINNER/R-45HIP/R-ANCHOR/R-WEDGE)이 통째로 안 돌고 PASS 였다. +// 임의 다각형·임의 라인변경에서 일관되려면 분류는 도형(방향)으로만 한다: innerLine 중 45°≈힙, +// 축정렬≈마루. (eaves/roofLine 은 roof.points 라 innerLines 에 없음 → 오분류 없음.) +// drift(50°/10° 등)는 가까운 쪽으로 분류된 뒤 R-45HIP/R-AXISRIDGE 가 각도 위반으로 잡는다. +// [KERAB-CLASSIFY-EXCLUDE-ROOFLINE 2026-06-26] roofLine(외곽 경계)이 innerLines 에 섞여 들어오는 케이스가 +// 실측 로그에 있다(type:'roofLine' 끝점이 박스 코너에 닿아 R-RIDGE-VANISH 오탐). roofLine 은 inner +// 힙/마루가 아니라 경계이므로 분류에서 제외한다(도형 불변 — 이름이 아닌 "경계 vs 내부선" 의 의미 구분). +const isRoofLine = (ln) => ln?.lineName === 'roofLine' || ln?.name === 'roofLine' || ln?.attributes?.type === 'roofLine' +// [KERAB-BOUNDARY-GEOMETRIC 2026-06-26] 경계(roofLine/wallLine) 판정은 lineName 태그가 아니라 *위치(기하)* 로 한다. +// 배경(사용자 확정): roofLine = wallLine + 出幅. 出幅=0 이면 둘이 일치 → 별개가 아니라 하나의 "경계" 가족. +// lineName='roofLine' 태그는 골짜기/박공 *내부선*(name='hip')에도 과적재돼 있어(QPolygon.js:98, qpolygon-utils.js:6042 등) +// 태그만으론 경계↔내부를 못 가른다. 체커는 wallLine *내부 전용*(마루/힙/박스)이므로, "선분 양 끝점이 경계 폴리곤 +// (roofPoints/wallEdges)의 한 변 위에 놓였나" 로 경계를 판정해 그 선을 검사·교정에서 제외한다(read-only 참조). +// __boundarySet 은 매 checkKerabRules 호출 시작에서 기하로 다시 채운다(기하 정보 없으면 태그로 폴백). +let __boundarySet = new WeakSet() +const isBoundary = (ln) => __boundarySet.has(ln) +const isHip = (ln) => !isBox(ln) && !isBoundary(ln) && isDiagonalHip(ln) +const isRidge = (ln) => !isBox(ln) && !isBoundary(ln) && !!coords(ln) && !isDiagonalHip(ln) -// 박스(겹침) 영역 = 같은 __targetId 의 kerabValleyOverlapLine 세그먼트들의 bbox. +// [KERAB-BOX-CAP-BBOX 2026-06-26] 박스(겹침=出幅) 영역 검출 — 드로잉 경로와 통일. +// 이전엔 캡(kerabValleyOverlapLine)을 양옆 레일까지 growBandRect 로 키웠으나, 레일(긴 수평 힙)이 +// 멀리 뻗는 노치 형상에서 박스가 과확장돼(예: x≈412~747) 정상 apex 마루(R-2)를 "박스 안"으로 오탐하고 +// roofLine 관통 같은 진짜 위반을 가렸다. 이미 잘 동작하는 드로잉(clipCrossedHipsAtIntersection)은 +// 캡 세그먼트 bbox + PAD 를 그대로 박스로 쓴다 → 체커도 동일하게 캡 bbox 만 쓴다(사용자 확정 2026-06-26). +// 퇴화축(폭≈0)은 PAD 로 얇은 띠가 돼 boundary-inclusive 제외(R-CROSS 등)엔 충분하고, strict-interior 규칙 +// (R-RIDGE-VANISH/R-BOXINNER)은 얇은 박스에 내부가 없어 자연히 발화 안 함(= 과발화 방지, 의도된 동작). const buildBoxes = (lines, pad) => { const groups = new Map() for (const ln of lines) { @@ -105,9 +129,254 @@ const buildBoxes = (lines, pad) => { g.maxY = Math.max(g.maxY, c.y1, c.y2) groups.set(key, g) } - return [...groups.values()].map((b) => ({ minX: b.minX - pad, minY: b.minY - pad, maxX: b.maxX + pad, maxY: b.maxY + pad })) + if (groups.size === 0) return [] + return [...groups.values()].map((seed) => ({ minX: seed.minX - pad, minY: seed.minY - pad, maxX: seed.maxX + pad, maxY: seed.maxY + pad })) +} +// [KERAB-OVERLAP-BAND 2026-06-27] 出幅 겹침 박스의 *진짜 사각형* (사용자 확정: 박스 = H-1+H-2+H-3+B-1 4변, 단일선 아님). +// 캡(kerabValleyOverlapLine = B-1)은 박스의 한 변일 뿐 — B-1 의 두 끝점은 출폭만큼 떨어진 두 평행 힙(상·하 레일) +// 위에 놓인다. 박스 = 그 두 레일이 *겹치는 구간* × B-1 두께(出幅). buildBoxes(캡 bbox)는 다른 규칙 호환 위해 +// 그대로 두고, 이 사각형은 출幅 겹침 제외 전용으로 체크 시점 실제 기하에서 따로 만든다(전역 박스 안 흔듦). +// 일반 규칙 — 좌표 하드코딩 없이 캡 방향(수직/수평) + 끝점에 닿은 평행 힙들의 합집합 구간으로 박스를 복원한다. +const buildOverlapBands = (boxLines, allLines, pad) => { + const EPS = 1.5 + const isHorz = (lc) => Math.abs(lc.y1 - lc.y2) < EPS + const isVert = (lc) => Math.abs(lc.x1 - lc.x2) < EPS + // 레일 = 캡 끝점을 지나는 평행 경계선(상·하 처마힙 H-1/H-2/H-3). 주의: 체커 isHip 은 *대각선*만 hip 으로 보므로 + // 축정렬(angle 0/90/180)인 H-1/H-2/H-3 은 ridge 로 분류된다 → 분류 무관하게 *축정렬 collinear 세그먼트*를 모은다. + // 캡(box)·경계(roofLine)는 제외(레일은 내부 힙/마루). collinear 세그먼트(H-2+H-3)는 합집합으로 레일 전체구간 복원. + // axis='h': value=공유 y 인 수평선들의 x 합집합. axis='v': value=공유 x 인 수직선들의 y 합집합. + const railExtent = (value, axis) => { + let lo = Infinity + let hi = -Infinity + let found = false + for (const ln of allLines) { + if (isBox(ln) || isBoundary(ln)) continue + const lc = coords(ln) + if (!lc) continue + if (axis === 'h') { + if (!isHorz(lc) || Math.abs(lc.y1 - value) > EPS) continue + lo = Math.min(lo, lc.x1, lc.x2) + hi = Math.max(hi, lc.x1, lc.x2) + } else { + if (!isVert(lc) || Math.abs(lc.x1 - value) > EPS) continue + lo = Math.min(lo, lc.y1, lc.y2) + hi = Math.max(hi, lc.y1, lc.y2) + } + found = true + } + return found ? { lo, hi } : null + } + const bands = [] + for (const bx of boxLines) { + const c = coords(bx) + if (!c) continue + if (isVert(c)) { + // 캡 수직 → 레일 수평(상·하 처마힙), 박스 장축 = x. + const r1 = railExtent(c.y1, 'h') + const r2 = railExtent(c.y2, 'h') + if (!r1 || !r2) continue + const lo = Math.max(r1.lo, r2.lo) + const hi = Math.min(r1.hi, r2.hi) + if (hi - lo <= EPS) continue + bands.push({ minX: lo - pad, maxX: hi + pad, minY: Math.min(c.y1, c.y2) - pad, maxY: Math.max(c.y1, c.y2) + pad }) + } else if (isHorz(c)) { + // 캡 수평 → 레일 수직, 박스 장축 = y. + const r1 = railExtent(c.x1, 'v') + const r2 = railExtent(c.x2, 'v') + if (!r1 || !r2) continue + const lo = Math.max(r1.lo, r2.lo) + const hi = Math.min(r1.hi, r2.hi) + if (hi - lo <= EPS) continue + bands.push({ minX: Math.min(c.x1, c.x2) - pad, maxX: Math.max(c.x1, c.x2) + pad, minY: lo - pad, maxY: hi + pad }) + } + } + return bands } const pointInBoxes = (p, boxes) => boxes.some((b) => p.x >= b.minX && p.x <= b.maxX && p.y >= b.minY && p.y <= b.maxY) +// 박스 *내부*(경계 m 안쪽)에 엄격히 들어왔나 — 박스 변 위(=상대 roofLine, 정상 종단점)는 제외. +// 납작 박스(한 축 폭<2m)는 내부가 없으므로 항상 false → 무영향. +const pointStrictlyInBoxes = (p, boxes, m) => + boxes.some((b) => b.maxX - b.minX > 2 * m && b.maxY - b.minY > 2 * m && p.x > b.minX + m && p.x < b.maxX - m && p.y > b.minY + m && p.y < b.maxY - m) +// 선분이 박스의 *내부 사각형*(경계 m 안쪽)을 관통(가로지름)하는 첫 진입점. 끝점이 안이 아니어도 +// 박스를 통과하면(들어가고 나감) "내부선에서 안 멈췄다"는 위반. 납작 박스는 내부가 없어 항상 null. +const segCrossesBoxInterior = (c, boxes, m) => { + for (const b of boxes) { + const ix0 = b.minX + m + const iy0 = b.minY + m + const ix1 = b.maxX - m + const iy1 = b.maxY - m + if (ix1 - ix0 <= 0 || iy1 - iy0 <= 0) continue // 납작 박스 = 내부 없음. + const rectSegs = [ + { x1: ix0, y1: iy0, x2: ix1, y2: iy0 }, + { x1: ix1, y1: iy0, x2: ix1, y2: iy1 }, + { x1: ix1, y1: iy1, x2: ix0, y2: iy1 }, + { x1: ix0, y1: iy1, x2: ix0, y2: iy0 }, + ] + for (const rs of rectSegs) { + const ip = segCross(c, rs, 0.01) + if (ip) return ip + } + } + return null +} + +// [KERAB-BOX-CROSS 2026-06-26] from→to 선분이 박스(실제 경계, m=0)와 만나는 경계 교차점. +// farthest=true → from 에서 가장 *먼* 교차(= 박스를 빠져나가는 변). 박스는 出幅 겹침이라 힙은 박스안까지 +// 그려져야 하고, 박스를 빠져나가 반대편으로 넘어가면 안 됨 → 빠져나가는 경계(far)에 클램프해 박스안까지만 남긴다. +const boxBoundaryCross = (from, to, boxes, farthest) => { + let best = null + let bestT = farthest ? -Infinity : Infinity + const seg = { x1: from.x, y1: from.y, x2: to.x, y2: to.y } + for (const b of boxes) { + const rectSegs = [ + { x1: b.minX, y1: b.minY, x2: b.maxX, y2: b.minY }, + { x1: b.maxX, y1: b.minY, x2: b.maxX, y2: b.maxY }, + { x1: b.maxX, y1: b.maxY, x2: b.minX, y2: b.maxY }, + { x1: b.minX, y1: b.maxY, x2: b.minX, y2: b.minY }, + ] + for (const rs of rectSegs) { + const ip = segCross(seg, rs, 0.01) + if (!ip) continue + const t = Math.hypot(ip.x - from.x, ip.y - from.y) + if (farthest ? t > bestT : t < bestT) { + bestT = t + best = ip + } + } + } + return best +} + +// [KERAB-RAY-ROOFLINE 2026-06-26] 반직선(origin + t·u, t>0)이 roofLine(다각형 변)과 만나는 가장 가까운 forward 교차. +// "힙 생성"의 완성 = 허공에 뜬 힙 끝을 roofLine 까지 연장(불변식: 라인은 roofLine 까지)에 쓴다. +const rayHitPolygon = (ox, oy, ux, uy, pts) => { + let best = null + let bestT = Infinity + for (let i = 0; i < pts.length; i++) { + const a = pts[i] + const b = pts[(i + 1) % pts.length] + if (!a || !b) continue + const ex = b.x - a.x + const ey = b.y - a.y + const denom = ux * ey - uy * ex + if (Math.abs(denom) < 1e-9) continue // 평행. + const wx = a.x - ox + const wy = a.y - oy + const t = (wx * ey - wy * ex) / denom // origin 으로부터의 거리(방향계수). + const s = (wx * uy - wy * ux) / denom // 변 위 매개변수(0~1). + if (t > 0.5 && s >= -0.01 && s <= 1.01 && t < bestT) { + bestT = t + best = { x: ox + ux * t, y: oy + uy * t } + } + } + return best +} + +// [KERAB-RAY-SEG 2026-06-26] origin 에서 (ux,uy) 단위방향으로 쏜 ray 가 *다른 힙/마루 선분* 과 만나는 최근접점. +// "힙·마루는 박스·힙·마루를 만나면 멈춘다" 를 구현하려고 R-ANCHOR 연장이 roofLine 보다 먼저 만나는 선을 찾는다. +// self 제외, 박스 내부 교점 제외(he2·he3 박스 시각크로스는 만남이 아님). 반환 t = origin 으로부터의 거리. +const rayHitSegments = (ox, oy, ux, uy, segs, selfRef, boxes, accept) => { + const ok = accept || ((ref) => isHip(ref) || isRidge(ref)) + let best = null + let bestT = Infinity + for (const sg of segs) { + if (sg.ref === selfRef) continue + if (!ok(sg.ref)) continue + const ex = sg.x2 - sg.x1 + const ey = sg.y2 - sg.y1 + const denom = ux * ey - uy * ex + if (Math.abs(denom) < 1e-9) continue + const wx = sg.x1 - ox + const wy = sg.y1 - oy + const t = (wx * ey - wy * ex) / denom // ray 위 거리. + const s = (wx * uy - wy * ux) / denom // 선분 위 매개변수(0~1). + if (t > 0.5 && s >= -0.01 && s <= 1.01 && t < bestT) { + const hit = { x: ox + ux * t, y: oy + uy * t } + if (boxes && pointInBoxes(hit, boxes)) continue // 박스 안 만남은 제외. + bestT = t + best = { x: hit.x, y: hit.y, t } + } + } + return best +} + +// 점 → 선분 최단거리. +const distPointSeg = (px, py, x1, y1, x2, y2) => { + const dx = x2 - x1 + const dy = y2 - y1 + const L2 = dx * dx + dy * dy + if (L2 < 1e-9) return Math.hypot(px - x1, py - y1) + let t = ((px - x1) * dx + (py - y1) * dy) / L2 + t = Math.max(0, Math.min(1, t)) + return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) +} + +// 방향(spoke) 기반 분류 — 이름(typeOf)이 'default' 여도 기하학적으로 힙/마루를 판정한다. +// 45°(±SPOKE_TOL) = 힙 방향, 축정렬(0/90/180/270) = 마루 방향. R-WEDGE 가 \|/ 를 이름과 무관하게 잡게 함. +const SPOKE_TOL = 5 +const dirAngle360 = (d) => ((Math.atan2(d.y, d.x) * 180) / Math.PI + 360) % 360 +const isHipSpoke = (d) => Math.min(...[45, 135, 225, 315].map((t) => Math.abs(dirAngle360(d) - t))) <= SPOKE_TOL +const isRidgeSpoke = (d) => Math.min(...[0, 90, 180, 270, 360].map((t) => Math.abs(dirAngle360(d) - t))) <= SPOKE_TOL +const angDiff360 = (a, b) => { + const d = Math.abs(a - b) % 360 + return d > 180 ? 360 - d : d +} +// [KERAB-JUNCTION-PEAK 2026-06-26] 한 교점의 "봉우리(peak)" 방향. 정확히 두 힙이 모여 축정렬 봉우리를 이룰 때만 +// { ang, ux, uy }, 아니면 null. 두 힙 바깥(eaves)방향 합의 *반대* = 마루가 서야 할 봉우리 방향. +// R-RIDGE-GEN 검출(부재 판정)과 fix(생성) 가 같은 정의를 공유 — "두 힙이 만나면 마루 생성". +const junctionPeak = (j) => { + const hips = j.spokes.filter((s) => isHipSpoke(s.dir)) + if (hips.length !== 2) return null + const bx = hips[0].dir.x + hips[1].dir.x + const by = hips[0].dir.y + hips[1].dir.y + const bm = Math.hypot(bx, by) + if (bm < 0.3) return null // 두 힙이 정반대(일직선) = 봉우리 아님. + const ux = -bx / bm + const uy = -by / bm + const ang = dirAngle360({ x: ux, y: uy }) + const axisDev = Math.min(...[0, 90, 180, 270, 360].map((t) => Math.abs(ang - t))) + if (axisDev > 8) return null // 축정렬 봉우리만(대각=apex/박스변 → 마루 대상 아님). + return { ang, ux, uy } +} + +// 끝점 P 가 roofLine·다른 라인(자기 제외)·박스 중 하나에 닿아 앵커돼 있나. +const isAnchored = (p, selfRef, ctx) => { + const eps = ctx.anchorEps + const pts = ctx.roofPoints + if (pts && pts.length >= 2) { + for (let i = 0; i < pts.length; i++) { + const a = pts[i] + const b = pts[(i + 1) % pts.length] + if (a && b && distPointSeg(p.x, p.y, a.x, a.y, b.x, b.y) <= eps) return true + } + } + for (const s of ctx.anchorSegs) { + if (s.ref === selfRef) continue + if (distPointSeg(p.x, p.y, s.x1, s.y1, s.x2, s.y2) <= eps) return true + } + return false +} + +// [KERAB-EDGE-HIP 2026-06-26] roofLine 변 e 위에 끝점이 놓인 힙의 개수. includeCorners=false 면 변의 양 끝(코너)에 +// 놓인 힙은 제외 — 케라바는 인접 처마와 코너를 공유하므로, 처마쪽 힙이 공유코너에 닿은 것을 케라바 위반으로 오탐하지 +// 않으려는 것. 처마(eaves) 판정엔 코너 포함(코너에서 뻗는 힙도 그 처마의 힙). +const countHipEndpointsOnEdge = (e, ctx, includeCorners) => { + const eps = ctx.edgeEps + let n = 0 + for (const h of ctx.hipLines || []) { + const c = coords(h) + if (!c) continue + for (const p of [ + { x: c.x1, y: c.y1 }, + { x: c.x2, y: c.y2 }, + ]) { + if (distPointSeg(p.x, p.y, e.x1, e.y1, e.x2, e.y2) > eps) continue + if (!includeCorners && (pointEq(p, { x: e.x1, y: e.y1 }, eps) || pointEq(p, { x: e.x2, y: e.y2 }, eps))) continue + n++ + } + } + return n +} // ── 규칙 정의 (격리된 순수 판정. 여기에 한 줄씩 추가/수정/주석처리) ───────── // 종류: @@ -156,9 +425,37 @@ const RULES = [ check: (ca, cb, la, lb, ctx) => { const ip = segCross(ca, cb, ctx.pointEps) if (!ip) return null - if (pointInBoxes(ip, ctx.boxes)) return null // 겹침 박스 = 규칙 적용 제외 + if (pointInBoxes(ip, ctx.exclBoxes)) return null // 겹침 박스 내부 교점 = 절삭 열외(出幅 사각형 포함) return { point: ip, detail: `${typeOf(la)}↔${typeOf(lb)} 가 (${ip.x.toFixed(1)}, ${ip.y.toFixed(1)}) 에서 관통` } }, + // [KERAB-RULE-FIX 2026-06-26] "만나면 멈추거나 절삭" — 관통한 두 선을 교점 ip 에서 종단(절삭). 절삭 *우선순위*: + // 힙↔마루 = **힙 보존, 마루만 절삭**(사용자 확정). 힙↔힙 = 둘 다 ip 에서 종단(→ R-RIDGE-GEN 이 마루 생성). + // 각 선은 앵커된 끝(roofLine 코너 등)을 남기고 허공쪽 끝을 ip 로 당긴다. 양끝 앵커/허공이면 어느 쪽 자를지 + // 모호하므로 보류(null) — 추측 절삭 금지. ip 는 박스 밖(check 에서 제외)이라 he2·he3 겹침은 안 건드림. + fix: (ca, cb, la, lb, ctx, r) => { + if (!r || !r.point) return null + const ip = r.point + const pickMove = (c, ln) => { + const a0 = isAnchored({ x: c.x1, y: c.y1 }, ln, ctx) + const a1 = isAnchored({ x: c.x2, y: c.y2 }, ln, ctx) + if (a0 && !a1) return { x2: ip.x, y2: ip.y } + if (a1 && !a0) return { x1: ip.x, y1: ip.y } + return null // 모호 — 절삭 보류. + } + const multiSet = [] + const push = (c, ln) => { + const m = pickMove(c, ln) + if (m) multiSet.push({ line: ln, set: m }) + } + // 힙↔마루: 마루만 절삭(힙 보존). 그 외(힙↔힙·마루↔마루): 둘 다 종단. + if (isHip(la) && isRidge(lb)) push(cb, lb) + else if (isHip(lb) && isRidge(la)) push(ca, la) + else { + push(ca, la) + push(cb, lb) + } + return multiSet.length ? { multiSet } : null + }, }, { id: 'R-WEDGE', @@ -166,9 +463,10 @@ const RULES = [ kind: 'junction', enabled: true, check: (j, ctx) => { - if (pointInBoxes(j.point, ctx.boxes)) return null - const hips = j.spokes.filter((s) => isHip(s.line)) - const ridges = j.spokes.filter((s) => isRidge(s.line)) + if (pointInBoxes(j.point, ctx.exclBoxes)) return null // 박스 내부 교점 = 멈춤(쐐기) 열외(出幅 사각형 포함) + // 이름이 아니라 방향으로 분류 — 'default' 타입 힙/마루도 \|/ 로 잡는다. + const hips = j.spokes.filter((s) => isHipSpoke(s.dir)) + const ridges = j.spokes.filter((s) => isRidgeSpoke(s.dir)) if (hips.length < 2 || ridges.length < 1) return null for (const r of ridges) { for (let i = 0; i < hips.length; i++) { @@ -182,9 +480,228 @@ const RULES = [ return null }, }, + { + id: 'R-ANCHOR', + desc: '힙·마루의 모든 끝점은 roofLine·다른 힙/마루/박스에 닿아야 한다(허공 끝점 금지). 막을 게 없으면 roofLine 까지 그려져야 함.', + kind: 'line', + enabled: true, + applies: (ln) => isHip(ln) || isRidge(ln), + // [KERAB-RULE-FIX 2026-06-26] 케라바→처마의 기본 = "마루 삭제 + 힙 생성". 그 "힙 생성"의 완성을 여기서 교정한다: + // · 허공에 뜬 *힙* → roofLine 까지 자기 방향대로 연장(불변식: 라인은 roofLine 까지). 각도(45°) 보존. + // · 허공에 뜬 *마루* → 케라바→처마에서 마루는 소멸 대상이므로 제거(잔존 중앙마루 = R-2 류). + fix: (ln, c, ctx, r) => { + if (!r || !r.point) return null + if (isRidge(ln)) return { remove: true } // 마루는 소멸 — 허공 마루는 잔존물. + if (!isHip(ln) || !ctx.roofPoints || ctx.roofPoints.length < 3) return null + const ends = [ + { x: c.x1, y: c.y1 }, + { x: c.x2, y: c.y2 }, + ] + const di = pointEq(ends[0], r.point, ctx.pointEps) ? 0 : 1 // 허공 끝 인덱스. + const anchorEnd = ends[1 - di] // 고정(앵커)된 끝 = 연장 기준. + const ux0 = r.point.x - anchorEnd.x + const uy0 = r.point.y - anchorEnd.y + const m = Math.hypot(ux0, uy0) + if (m < 1e-6) return null + const ux = ux0 / m + const uy = uy0 / m + // [KERAB-RULE-FIX 2026-06-26] "힙은 만나면 멈춘다" — roofLine 까지 가기 전에 다른 *힙* 을 만나면 그 만남점에서 + // 멈춘다(절삭). he1·he4 처럼 두 힙이 서로 만나는 경우: 짧으면 연장돼 만남점에 닿고, 길면 만남점까지 당겨진다. + // 둘 다 "만남점에서 종단" → 그 자리에 R-RIDGE-GEN 이 마루 생성. 박스 내부 만남은 rayHitSegments 가 제외. + // *마루* 는 만남 대상에서 뺀다 — 절삭 우선순위상 힙이 보존되고 마루가 잘리므로(R-CROSS), 힙은 마루에 안 멈춤. + const polyHit = rayHitPolygon(anchorEnd.x, anchorEnd.y, ux, uy, ctx.roofPoints) + const segHit = rayHitSegments(anchorEnd.x, anchorEnd.y, ux, uy, ctx.anchorSegs, ln, ctx.exclBoxes, isHip) + const polyT = polyHit ? Math.hypot(polyHit.x - anchorEnd.x, polyHit.y - anchorEnd.y) : Infinity + const hit = segHit && segHit.t < polyT - ctx.pointEps ? segHit : polyHit + if (!hit) return null + return di === 0 ? { set: { x1: hit.x, y1: hit.y } } : { set: { x2: hit.x, y2: hit.y } } + }, + check: (c, ctx, ln) => { + const ends = [ + { x: c.x1, y: c.y1 }, + { x: c.x2, y: c.y2 }, + ] + for (const p of ends) { + if (isAnchored(p, ln, ctx)) continue + return { point: p, detail: `끝점 (${p.x.toFixed(1)}, ${p.y.toFixed(1)}) 가 어떤 선(roofLine/힙/마루/박스)에도 안 닿음(허공)` } + } + return null + }, + }, + { + id: 'R-BOXINNER', + desc: '박스(골짜기=두 지붕면 出幅 겹침구역)는 선이 그 안까지 상대적으로 그려져야 하는 영역이다 — 힙이 박스 안에서 종단하는 것은 정상(겹침 표현). 위반은 박스를 *관통해 반대편으로 빠져나가는* 경우뿐(들어갔다 다시 나감). 이때 박스 반대편 경계(出幅 끝)에서 멈춰야 한다.', + kind: 'line', + enabled: true, + applies: (ln) => isHip(ln), + // [KERAB-RULE-FIX 2026-06-26] 교정: 박스는 出幅 겹침 표현이라 선이 박스 안까지 그려지는 게 정상. + // 잘못된 건 박스를 관통해 *반대편으로 빠져나가는* 힙뿐 → 빠져나간 쪽 끝을 박스의 *반대편(먼) 경계*로 당겨 + // 박스 안에서 종단시킨다(박스 밖으로 못 나가게). 앵커(코너)쪽 보존. + // · 양끝 모두 박스 밖 + 박스 내부 관통일 때만 위반. 박스 안에서 끝나는 종단형(he2·he3 겹침)은 정상 → 보류. + // · 양끝 앵커/양끝 허공이면 어느 면의 힙인지 모호 → 보류(추측 절삭 금지, 위반은 로그에 남김). + fix: (ln, c, ctx) => { + const e0 = { x: c.x1, y: c.y1 } + const e1 = { x: c.x2, y: c.y2 } + // 박스 안에서 종단(끝점이 박스 내부)이면 정상 — 절삭 안 함. + if (pointStrictlyInBoxes(e0, ctx.exclBoxes, ctx.boxInnerEps) || pointStrictlyInBoxes(e1, ctx.exclBoxes, ctx.boxInnerEps)) return null + if (!segCrossesBoxInterior(c, ctx.exclBoxes, ctx.boxInnerEps)) return null + const a0 = isAnchored(e0, ln, ctx) + const a1 = isAnchored(e1, ln, ctx) + if (a0 === a1) return null + const keep = a0 ? e0 : e1 + const move = a0 ? e1 : e0 + // 박스를 빠져나간 끝을 *먼* 경계(出幅 끝=관통 출구변)로 당겨 박스 안에서 멈추게 한다. + const exit = boxBoundaryCross(keep, move, ctx.exclBoxes, true) + if (!exit) return null + return a0 ? { set: { x2: exit.x, y2: exit.y } } : { set: { x1: exit.x, y1: exit.y } } + }, + check: (c, ctx, ln) => { + const e0 = { x: c.x1, y: c.y1 } + const e1 = { x: c.x2, y: c.y2 } + // 박스 안에서 종단(끝점이 박스 내부)이면 정상(出幅 겹침 표현) — 위반 아님. + if (pointStrictlyInBoxes(e0, ctx.exclBoxes, ctx.boxInnerEps) || pointStrictlyInBoxes(e1, ctx.exclBoxes, ctx.boxInnerEps)) return null + // 박스를 관통해 반대편으로 빠져나감(양끝 다 박스 밖 + 내부 가로지름) — 위반. + const ip = segCrossesBoxInterior(c, ctx.exclBoxes, ctx.boxInnerEps) + if (ip) { + return { point: ip, detail: `(${ip.x.toFixed(1)}, ${ip.y.toFixed(1)}) 에서 박스를 관통해 반대편으로 빠져나감 — 박스 반대편 경계(出幅 끝)에서 멈춰야` } + } + return null + }, + }, + { + id: 'R-RIDGE-VANISH', + desc: '케라바→처마 변환에서 native 마루는 양 코너의 두 힙(HE)으로 펼쳐지며 소멸한다(R5 Case A 의 역: 처마→케라바 = "두 힙 삭제 + 마루 생성" → 역 = "마루 삭제 + 두 힙 생성"). 마루의 존재 근거 = 두 힙의 수렴인데, 박스(겹침)에 막혀 두 힙이 만나지 못하면 그 근거가 없다 → 마루는 남으면 안 된다. 마루 끝점이 박스에 닿거나 들어가 있으면 = 삭제됐어야 할 마루가 잔존 = 위반(박스 진입변에 단축해 남기는 것도 금지 — 힙은 진입변에서 멈춰도 되지만 마루는 박스에 관여 불가).', + kind: 'line', + enabled: true, + applies: (ln) => isRidge(ln), + // [KERAB-RULE-FIX 2026-06-26] 교정: 박스 내부에 잔존한 마루(예: A타입을 사방처마로 오인해 재생성된 R-2)는 + // 소멸했어야 하므로 그 마루 객체를 제거한다(라인을 따라가며 체크되면 바로 수정 — 원래 설계 목표). + fix: () => ({ remove: true }), + check: (c, ctx, ln) => { + if (!ctx.boxes.length) return null + const ends = [ + { x: c.x1, y: c.y1 }, + { x: c.x2, y: c.y2 }, + ] + // (1) [KERAB-RIDGE-VANISH-STRICT 2026-06-26] 박스 *내부*(strict) 에 마루 끝점이 있을 때 위반. 경계(코너) + // 접촉은 정상 — 박스 코너는 roofLine·축정렬 라인과 좌표를 공유하므로 경계포함 판정만으론 오탐을 낳는다. + for (const p of ends) { + if (pointStrictlyInBoxes(p, ctx.boxes, ctx.boxInnerEps)) { + return { + point: p, + detail: `마루 끝점 (${p.x.toFixed(1)}, ${p.y.toFixed(1)}) 이 박스(겹침) 내부에 있음 — 케라바→처마에서 마루는 두 힙으로 펼쳐져 소멸했어야(잔존 금지)`, + } + } + } + // (2) [KERAB-RIDGE-VANISH-DANGLE 2026-06-26] 한 끝이 박스에 닿고(경계/내부) 다른 끝이 허공(미앵커) = 소멸했어야 + // 할 중앙마루 잔존(R-2). 박스(겹침)에 막혀 두 힙이 못 만난 자리로 떨어진 마루는 존재근거가 없다. 끝점이 + // 박스 *경계*에 걸쳐 strict 내부를 빠져나가는 R-2 를 잡는다. 정상 마루는 박스에 끝나도 반대끝이 앵커됨 → 오탐 X. + const nearBox = (p) => + ctx.boxes.some( + (b) => p.x >= b.minX - ctx.boxInnerEps && p.x <= b.maxX + ctx.boxInnerEps && p.y >= b.minY - ctx.boxInnerEps && p.y <= b.maxY + ctx.boxInnerEps, + ) + const touch = [nearBox(ends[0]), nearBox(ends[1])] + const anch = [isAnchored(ends[0], ln, ctx), isAnchored(ends[1], ln, ctx)] + for (let i = 0; i < 2; i++) { + const j = 1 - i + if (touch[i] && !anch[j]) { + return { + point: ends[j], + detail: `마루 한끝이 박스(겹침)에 닿고 다른끝 (${ends[j].x.toFixed(1)}, ${ends[j].y.toFixed(1)}) 은 허공 — 두 힙이 박스에 막혀 못 만난 자리의 잔존 중앙마루(소멸 대상)`, + } + } + } + return null + }, + }, + { + id: 'R-RIDGE-GEN', + desc: '힙이 만나면(수렴) 그 교점에 마루가 *생성*돼야 한다. 처마→케라바 = "두 힙 삭제 + 마루 생성"(R5 Case A), 그 역(케라바→처마)도 새로 생긴 두 힙(예: he1·he4)이 한 점에서 만나면 그 자리에 마루가 새로 선다. 정확히 두 힙이 한 점에 모여 축정렬 봉우리(peak)를 이루는데 그 봉우리축 방향으로 마루 spoke 가 하나도 없으면 = 마루 미생성 = 위반. (R-WEDGE 는 \\|/ 끼임을, R-RIDGE-VANISH 는 박스 내부 잔존을 잡고, 이 규칙은 정반대 — 생겼어야 할 마루의 부재를 잡는다.)', + kind: 'junction', + enabled: true, + check: (j, ctx) => { + // [KERAB-OVERLAP-BAND 2026-06-27] 出幅 겹침은 "겹침의 표현"이라 두 힙이 서로 다른 면 위에 스택돼 *크로스/만난 것처럼 + // 보일 뿐* 실제 수렴이 아니다(사용자 반복 강조). 캡 bbox(ctx.boxes)는 폭0 으로 he2·he3 시각크로스를 놓치므로 + // exclBoxes(캡 + 出幅 사각형 overlapBands)로 박스 내부 "보이는 크로스"를 정상 겹침으로 제외 → 마루 생성 안 함. + if (pointInBoxes(j.point, ctx.exclBoxes)) return null + // [KERAB-INTO-BOX 2026-06-27] 봉우리에서 뻗은 어떤 라인의 *먼 끝*이 出幅 박스 안이면 = 그 라인이 겹침으로 + // "그냥 지나가는" 중(사용자: 박스 안에서 잘 만남 → 지나가면 됨). 마루가 박스로 들어가 보이는 것이라 별도 + // 마루 생성 의무 없음 → 정상. (대각이라 힙으로 재분류돼 ridges 필터에서 빠지는 ridge 를 구제 — 오발 방지.) + for (const s of j.spokes) { + const sc = coords(s.line) + if (!sc) continue + const d0 = (sc.x1 - j.point.x) ** 2 + (sc.y1 - j.point.y) ** 2 + const d1 = (sc.x2 - j.point.x) ** 2 + (sc.y2 - j.point.y) ** 2 + const far = d0 >= d1 ? { x: sc.x1, y: sc.y1 } : { x: sc.x2, y: sc.y2 } + if (pointStrictlyInBoxes(far, ctx.exclBoxes, ctx.boxInnerEps)) return null + } + const peak = junctionPeak(j) + if (!peak) return null // 정확히 두 힙이 축정렬 봉우리를 이룰 때만. + const ridges = j.spokes.filter((s) => isRidgeSpoke(s.dir)) + for (const r of ridges) { + if (angDiff360(dirAngle360(r.dir), peak.ang) <= 25) return null // 봉우리축 방향 마루 존재 → 정상. + } + return { + point: j.point, + peak, + detail: `두 힙이 (${j.point.x.toFixed(1)}, ${j.point.y.toFixed(1)}) 에서 수렴했으나 봉우리축(${peak.ang.toFixed(0)}°) 방향 마루가 없음 — 힙이 만나면 마루가 생성돼야 함(미생성)`, + } + }, + // [KERAB-RULE-FIX 2026-06-26] 교정: 봉우리축 방향으로 *마주보는* 다른 봉우리(두 힙 수렴) 교점을 찾아 그 사이에 마루를 + // 새로 생성한다(create). 마주보는 짝이 없거나, 생성선이 박스(겹침)를 가로지르면(=R-2 재생성) 생성 안 함(null). + // "두 힙이 만나면 마루 생성, 단 박스 안 제외" 를 그대로 구현. 끝점 단축/제거가 아니라 신규 라인이라 action=create. + fix: (j, ctx, r) => { + if (!r || !r.peak || !Array.isArray(ctx.junctions)) return null + const { ux, uy, ang } = r.peak + const o = j.point + let best = null + let bestT = Infinity + for (const k of ctx.junctions) { + if (k === j) continue + const vx = k.point.x - o.x + const vy = k.point.y - o.y + const t = vx * ux + vy * uy // 봉우리 ray 전방 거리. + if (t <= 1) continue + const perp = Math.abs(vx * -uy + vy * ux) // ray 로부터 수직 offset. + if (perp > ctx.pointEps + 1) continue + const kp = junctionPeak(k) + if (!kp) continue + if (angDiff360(kp.ang, ang + 180) > 20) continue // 상대 봉우리가 *마주봐야*(반대방향) 마루 양끝. + if (t < bestT) { + bestT = t + best = k + } + } + if (!best) return null + const seg = { x1: o.x, y1: o.y, x2: best.point.x, y2: best.point.y } + if (segCrossesBoxInterior(seg, ctx.exclBoxes, ctx.boxInnerEps)) return null // 박스 관통 마루 = R-2 → 생성 금지(出幅 사각형 포함). + return { create: { x1: o.x, y1: o.y, x2: best.point.x, y2: best.point.y, name: 'ridge' } } + }, + }, + { + id: 'R-EAVES-HIP', + desc: '처마(eaves) 변에는 힙이 최소 1개 있어야 한다(코너 포함). 케라바→처마 = "마루 삭제 + 힙 생성" 이므로 처마인데 힙 0 = 힙 미생성 위반. (opts.edges 로 변 종류가 주입될 때만 동작.)', + kind: 'edge', + enabled: true, + applies: (e) => e.kind === 'eaves', + check: (e, ctx) => { + if (countHipEndpointsOnEdge(e, ctx, true) >= 1) return null + return { detail: `처마변 (${e.x1.toFixed(1)},${e.y1.toFixed(1)})-(${e.x2.toFixed(1)},${e.y2.toFixed(1)}) 에 힙 없음 — 처마는 힙 ≥1(미생성)` } + }, + }, + { + id: 'R-KERAB-HIP', + desc: '케라바(gable) 변에는 힙이 존재하면 안 된다(공유 코너 제외). 케라바 = 마루가 향하는 변, 힙 금지. 케라바변 중간에 힙 끝점이 놓이면 위반.', + kind: 'edge', + enabled: true, + applies: (e) => e.kind === 'kerab', + check: (e, ctx) => { + const n = countHipEndpointsOnEdge(e, ctx, false) + if (n === 0) return null + return { detail: `케라바변 (${e.x1.toFixed(1)},${e.y1.toFixed(1)})-(${e.x2.toFixed(1)},${e.y2.toFixed(1)}) 에 힙 ${n}개 — 케라바는 힙 금지` } + }, + }, // ── 후속 추가 자리 ────────────────────────────────────────────── - // { id: 'R-FLOATING', desc: '양끝 내부(roofLine 미접촉) 마루 = underdetermined 후보', kind: 'line', enabled: false, ... }, - // { id: 'R-HIP-ANCHOR', desc: '힙 끝 하나는 코너(roofLine)에 앵커', kind: 'line', enabled: false, ... }, ] // v(=r) 가 a→b 의 *작은* 각 사이(cone)에 있나. @@ -206,9 +723,12 @@ const buildJunctions = (lines, eps) => { node.spokes.push({ line, dir: outDir }) } for (const ln of lines) { - if (!(isHip(ln) || isRidge(ln))) continue + if (isBox(ln)) continue const c = coords(ln) if (!c || len(c) <= eps) continue + const sdir = dir(c) + // 이름(hip/ridge) 또는 방향(45°/축정렬)으로 힙·마루 후보면 junction 에 포함. R-WEDGE 가 \|/ 를 놓치지 않게. + if (!(isHip(ln) || isRidge(ln) || isHipSpoke(sdir) || isRidgeSpoke(sdir))) continue const s = { x: c.x1, y: c.y1 } const e = { x: c.x2, y: c.y2 } addSpoke(s, dir(c), ln) // s 에서 바깥 = s→e @@ -237,10 +757,70 @@ export function checkKerabRules(lines, opts = {}) { pointEps: opts.pointEps ?? DEFAULTS.pointEps, zeroLenEps: opts.zeroLenEps ?? DEFAULTS.zeroLenEps, } - ctx.boxes = buildBoxes(lines, opts.boxPadding ?? DEFAULTS.boxPadding) + ctx.roofPoints = Array.isArray(opts.roofPoints) ? opts.roofPoints : null + // [KERAB-BOUNDARY-GEOMETRIC 2026-06-26] 경계 선(roofLine/wallLine)을 *위치*로 식별 → __boundarySet 에 등록 → + // isHip/isRidge 가 자동 제외. roofPoints(외곽) 변 + opts.wallEdges(wall.baseLines, 出幅 안쪽) 양쪽을 경계로 본다. + // 出幅=0 이면 두 폴리곤이 겹쳐 한 번에 잡힌다. 기하 정보가 전혀 없으면(수동 콘솔 호출 등) 태그(isRoofLine)로 폴백. + __boundarySet = new WeakSet() + { + const bEps = opts.boundaryEps ?? 1.5 + const bEdges = [] + if (ctx.roofPoints && ctx.roofPoints.length >= 2) { + for (let i = 0; i < ctx.roofPoints.length; i++) { + const a = ctx.roofPoints[i] + const b = ctx.roofPoints[(i + 1) % ctx.roofPoints.length] + if (a && b) bEdges.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y }) + } + } + if (Array.isArray(opts.wallEdges)) for (const e of opts.wallEdges) if (e) bEdges.push({ x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2 }) + const onEdge = (c) => + bEdges.some((e) => distPointSeg(c.x1, c.y1, e.x1, e.y1, e.x2, e.y2) <= bEps && distPointSeg(c.x2, c.y2, e.x1, e.y1, e.x2, e.y2) <= bEps) + for (const ln of lines) { + if (isBox(ln)) continue + const c = coords(ln) + if (!c) continue + if (bEdges.length ? onEdge(c) : isRoofLine(ln)) __boundarySet.add(ln) // 기하 있으면 위치로, 없으면 태그 폴백. + } + } + // [KERAB-BOX-EXTERNAL 2026-06-26] 박스(kerabValleyOverlapLine)는 outer(polygon.lines)에 살아 innerLines 에 없다 → + // 호출측이 opts.boxLines 로 따로 넘긴다. 박스는 (1) buildBoxes(겹침구역 검출) (2) anchorSegs(끝점 앵커 대상) + // 에만 합쳐 쓰고, 규칙 iteration 의 `lines`(=roof.innerLines)에는 섞지 않는다 — checkAndFixKerabRules 의 + // 반복 교정이 roof.innerLines 만 splice/push 하므로 검사 배열과 분리해야 mutation 의미가 어긋나지 않는다. + const boxLines = Array.isArray(opts.boxLines) ? opts.boxLines : [] + const linesWithBox = boxLines.length ? lines.concat(boxLines) : lines + ctx.boxes = buildBoxes(linesWithBox, opts.boxPadding ?? DEFAULTS.boxPadding) + // [KERAB-OVERLAP-BAND 2026-06-27] 出幅 겹침 제외 전용 사각형(캡 + 상·하 평행힙 겹침구간). 전역 ctx.boxes 와 분리. + ctx.overlapBands = buildOverlapBands(boxLines, linesWithBox, opts.boxPadding ?? DEFAULTS.boxPadding) + // [KERAB-BOX-INTERIOR-EXCL 2026-06-27] 사용자 원칙: 出幅 겹침 박스 *내부 교점*은 절삭·멈춤·생성 등 모든 상호작용이 + // 열외다. 박스는 "겹침의 표현"이라 내부 교점이 실제 만남이 아니기 때문. 폭0 캡 bbox(ctx.boxes)만으론 박스 내부를 + // 못 잡으므로, 캡 + 진짜 出幅 사각형(overlapBands)을 합친 단일 제외존을 절삭/멈춤/생성 전 규칙에 일관 적용한다. + ctx.exclBoxes = ctx.boxes.concat(ctx.overlapBands || []) + ctx.boxInnerEps = opts.boxInnerEps ?? 2.0 + ctx.anchorEps = opts.anchorEps ?? 1.0 + ctx.edgeEps = opts.edgeEps ?? 2.0 // 변 위 힙 끝점 판정(roofLine drift 고려). + ctx.edges = Array.isArray(opts.edges) ? opts.edges : null // 처마/케라바 변 분류. line 규칙(R-RIDGE-VANISH)도 참조. + ctx.hipLines = lines.filter(isHip) // R-EAVES-HIP/R-KERAB-HIP 가 변별 힙 끝점 카운트에 사용. + ctx.anchorSegs = [] + for (const ln of linesWithBox) { + const c = coords(ln) + if (c) ctx.anchorSegs.push({ x1: c.x1, y1: c.y1, x2: c.x2, y2: c.y2, ref: ln }) + } const violations = [] const push = (rule, extra) => violations.push({ rule: rule.id, severity: 'error', ...extra }) + // [KERAB-RULE-FIX 2026-06-26] 위반 발견 시 규칙이 정의한 교정 액션을 모은다. 실제 canvas/innerLines 변형은 + // 체크함수가 직접 하지 않고(이식성·순수성 유지) 호출측(canvas 접근 가능)이 fixes 를 받아 적용한다. + // action 예: { remove:true } (라인 제거), { set:{x1,y1,x2,y2} } (끝점 단축). opts.apply 일 때만 수집. + const fixes = [] + // [KERAB-FIXRULES-ALLOWLIST 2026-06-26] opts.fixRules 가 배열이면 그 id 의 규칙만 교정(fix) 적용 대상. + // 메뉴별 라우팅용: 처마변경은 생성/절삭(R-CROSS·R-RIDGE-GEN)만 타고 삭제 규칙(R-ANCHOR·R-RIDGE-VANISH remove)은 + // 배제한다(박스 파괴 방지). 검출·로그(violations)는 전 규칙 그대로 — 화이트리스트는 *fix* 에만 작용. + const fixAllowed = (id) => !Array.isArray(opts.fixRules) || opts.fixRules.includes(id) + const collectFix = (rule, ln, c, r) => { + if (!opts.apply || typeof rule.fix !== 'function' || !fixAllowed(rule.id)) return + const action = rule.fix(ln, c, ctx, r) + if (action) fixes.push({ rule: rule.id, line: ln, action }) + } const checked = lines.filter((ln) => isHip(ln) || isRidge(ln)) @@ -251,8 +831,11 @@ export function checkKerabRules(lines, opts = {}) { if (rule.applies && !rule.applies(ln)) continue const c = coords(ln) if (!c) continue - const r = rule.check(c, ctx) - if (r) push(rule, { lineIds: [lineId(ln)], type: typeOf(ln), ...r }) + const r = rule.check(c, ctx, ln) + if (r) { + push(rule, { lineIds: [lineId(ln)], type: typeOf(ln), ...r }) + collectFix(rule, ln, c, r) + } } } @@ -268,7 +851,13 @@ export function checkKerabRules(lines, opts = {}) { const cb = coords(lb) if (!ca || !cb) continue const r = rule.check(ca, cb, la, lb, ctx) - if (r) push(rule, { lineIds: [lineId(la), lineId(lb)], ...r }) + if (r) { + push(rule, { lineIds: [lineId(la), lineId(lb)], ...r }) + if (opts.apply && typeof rule.fix === 'function' && fixAllowed(rule.id)) { + const action = rule.fix(ca, cb, la, lb, ctx, r) + if (action) fixes.push({ rule: rule.id, line: null, action }) + } + } } } } @@ -276,12 +865,32 @@ export function checkKerabRules(lines, opts = {}) { // junction 규칙 const needJunction = RULES.some((r) => r.enabled && r.kind === 'junction') if (needJunction) { - const junctions = buildJunctions(lines, ctx.pointEps) + ctx.junctions = buildJunctions(lines, ctx.pointEps) // fix(R-RIDGE-GEN 생성)가 마주보는 봉우리 탐색에 쓴다. for (const rule of RULES) { if (!rule.enabled || rule.kind !== 'junction') continue - for (const j of junctions) { + for (const j of ctx.junctions) { const r = rule.check(j, ctx) - if (r) push(rule, { lineIds: j.spokes.map((s) => lineId(s.line)), ...r }) + if (r) { + push(rule, { lineIds: j.spokes.map((s) => lineId(s.line)), ...r }) + if (opts.apply && typeof rule.fix === 'function' && fixAllowed(rule.id)) { + const action = rule.fix(j, ctx, r) + if (action) fixes.push({ rule: rule.id, line: null, action }) + } + } + } + } + } + + // edge 규칙 (처마/케라바 변 ↔ 힙 존재). opts.edges = [{x1,y1,x2,y2, kind:'eaves'|'kerab'}] 가 주입될 때만. + // 검출 전용(fix 없음) — 힙 자동생성/삭제는 별개 작업. + const edges = ctx.edges + if (edges && edges.length) { + for (const rule of RULES) { + if (!rule.enabled || rule.kind !== 'edge') continue + for (const e of edges) { + if (rule.applies && !rule.applies(e)) continue + const r = rule.check(e, ctx) + if (r) push(rule, { edge: { x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2 }, edgeKind: e.kind, ...r }) } } } @@ -291,7 +900,7 @@ export function checkKerabRules(lines, opts = {}) { ridges: lines.filter(isRidge).length, boxes: ctx.boxes.length, } - const result = { violations, boxes: ctx.boxes, stats } + const result = { violations, boxes: ctx.boxes, stats, fixes } if (!opts.silent) { const tag = `[KERAB-RULE-CHECK]${opts.label ? ` ${opts.label}` : ''}${opts.roofId ? ` roof=${opts.roofId}` : ''}` @@ -301,11 +910,70 @@ export function checkKerabRules(lines, opts = {}) { logger.warn(`${tag} ✗ 위반 ${violations.length}건 (hip ${stats.hips} / ridge ${stats.ridges} / box ${stats.boxes})`) for (const v of violations) logger.warn(` └ ${v.rule}: ${v.detail || ''}`, v) } + // 브라우저 콘솔과 별개로 debug/debug.log 에도 남긴다 — 크롬 없이 파일로 검증 가능하게. + debugCapture.log(`KERAB-RULE-CHECK ${opts.label || ''} roof=${opts.roofId || ''}`, { + pass: violations.length === 0, + stats, + // [VALLEY-BOX-RECT-DIAG 2026-06-27] 체커가 쓴 캡 bbox(ctx.boxes)와 出幅 겹침 사각형(overlapBands) 식별용. + boxRects: ctx.boxes.map((b) => ({ minX: b.minX, minY: b.minY, maxX: b.maxX, maxY: b.maxY })), + overlapBands: (ctx.overlapBands || []).map((b) => ({ minX: b.minX, minY: b.minY, maxX: b.maxX, maxY: b.maxY })), + violations, + fixes: fixes.map((f) => ({ rule: f.rule, action: f.action })), + }) } return result } +/** + * [KERAB-RULE-ITERATIVE 2026-06-26] 라인을 따라가며 한 번에 한 위반씩 고치고 *처음부터 다시* 체크하는 반복 교정기. + * + * 사용자 모델(확정): "a-b-c 라인이 있으면 a→b 가다 b 문제 발생 → b 수정 → a 부터 다시 체크 → 다시 진행." + * 단일 패스(collect-then-apply)로는 안 된다 — 한 위반을 고치면 기하가 바뀌어(예: 박스 내부 마루 R-2 제거 → + * 그 자리에 he1·he4 마루가 생성돼야 함이 드러나거나, 인접 힙 절삭이 풀림) 새 위반이 보이거나 기존 위반이 사라진다. + * 그래서 *하나만* 고치고 전체를 다시 돌린다(수렴할 때까지). 무한루프는 maxIter + 진행없음 감지로 차단. + * + * @param {Array} lines - roof.innerLines (참조로 전달 — applyFix 가 이 배열을 직접 splice 해야 다음 패스에 반영됨). + * @param {Object} opts + * @param {(fix:{rule:string,line:Object,action:Object}) => boolean} opts.applyFix + * canvas/innerLines 실제 변형 담당. 성공 시 true. checker 는 canvas 를 모르므로 호출측이 주입. + * @param {number} [opts.maxIter=16] 안전 상한. + * @returns {{ iterations:number, applied:Array, final:Object }} + */ +export function checkAndFixKerabRules(lines, opts = {}) { + if (!ENABLED) return { iterations: 0, applied: [], final: { violations: [], boxes: [], stats: { hips: 0, ridges: 0, boxes: 0 } } } + const maxIter = opts.maxIter ?? 16 + const applied = [] + let iter = 0 + for (; iter < maxIter; iter++) { + // 매 패스: 전체 재검사(처음부터). 중간 패스는 silent — 마지막에 한 번만 로그를 남긴다. + const res = checkKerabRules(lines, { ...opts, apply: true, silent: true }) + const fix = res.fixes && res.fixes.length ? res.fixes[0] : null + if (!fix) break // 더 고칠 게 없음 = 수렴. + // [KERAB-FIX-AUDIT 2026-06-26] 적용 직전에 대상 선의 정체를 박제(적용 후엔 splice 돼 사라짐). roofLine 이 + // 잘못 지워진다는 보고 추적용 — 어떤 규칙이 어떤 lineName/type/좌표 선을 건드렸는지 남긴다. + const tline = fix.line || (fix.action && fix.action.multiSet && fix.action.multiSet[0] && fix.action.multiSet[0].line) || null + const tinfo = tline + ? { lineName: tline.lineName ?? null, type: typeOf(tline), id: lineId(tline), x1: tline.x1, y1: tline.y1, x2: tline.x2, y2: tline.y2 } + : null + const ok = typeof opts.applyFix === 'function' ? opts.applyFix(fix) === true : false + if (!ok) break // 적용 실패(또는 applyFix 미주입) → 같은 위반 무한반복 차단. + applied.push({ rule: fix.rule, action: fix.action, target: tinfo }) + } + // 수렴 후 최종 상태를 한 번 로그(비교정·非silent) — 남은 위반(예: R-RIDGE-GEN 미생성)도 여기서 보인다. + if (!opts.silent && applied.length) { + debugCapture.log(`KERAB-FIX-APPLIED ${opts.label || ''} roof=${opts.roofId || ''}`, { count: applied.length, applied }) + } + const final = checkKerabRules(lines, { + ...opts, + apply: false, + silent: false, + label: `${opts.label || ''} (after-fix×${applied.length})`, + }) + return { iterations: iter, applied, final } +} + // dev 콘솔에서 수동 호출용. if (typeof window !== 'undefined' && ENABLED) { window.__checkKerabRules = checkKerabRules + window.__checkAndFixKerabRules = checkAndFixKerabRules } diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 2b8cf683..842c8eba 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -1692,8 +1692,12 @@ export const drawGableRoof = (roofId, canvas, textMode) => { } } if (pts) { - // logger.log(`[VALLEY-BOX-FILL] box`) - roof.innerLines.push(drawValleyBoxLine(pts, canvas, roof, roof.textMode)) + // [VALLEY-BOX-FILL 2026-06-26] 박스는 겹침구간(두 마루 범위의 교집합) 위, 더 짧은 마루의 x 에 놓인다. + // 교집합은 항상 짧은 마루의 부분구간이라 박스는 그 마루와 collinear 중복선이 된다. + // 이를 innerLines 에 넣으면 할당 그래프(splitPolygonWithLines)에 중복 변이 생겨 해당 쪽 면이 + // degenerate 한 덩어리로 뭉개진다(우측 면 미분할). 박스는 시각용 캡(B 라벨)이므로 canvas 에만 + // 추가하고 innerLines 에는 넣지 않는다. (B 라벨러·kerab 머지·surgical 은 모두 canvas 스캔이라 무관.) + drawValleyBoxLine(pts, canvas, roof, roof.textMode) } } }