[최종수정 _N] 케라바 라인변경 보조 변경 — kerab-rule-checker 出幅(출폭) 겹침 밴드 검증 보강 + QPolygon 디버그 수치 라벨 + qpolygon-utils 보정

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
ysCha 2026-06-29 13:35:13 +09:00
parent 276fc8665f
commit 157828e29f
3 changed files with 715 additions and 22 deletions

View File

@ -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()
}

View File

@ -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
}

View File

@ -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)
}
}
}