// kerab-rule-checker.js // [KERAB-RULE-CHECKER 2026-06-23] 라인변경 상호작용 규칙 체커 — 비침습 / 로그 전용 / local 게이트. // // 목적: 규칙(R1~R7 + 불변식)을 코드가 "들고 있게" 만든다. 힙·마루 출력을 받아 // 규칙 위반만 찾아 logger 로 보고한다. 동작은 절대 바꾸지 않는다(읽기만). // // 설계 원칙: // 1) 각 규칙은 격리된 순수 판정 함수(RULES 배열의 한 항목). 추가/변경/끄기가 서로 영향 없음. // 2) 박스(kerabValleyOverlapLine = 지붕 겹침)는 상호작용 규칙 적용 제외 구역 → 통째로 마스킹. // 3) NEXT_PUBLIC_ENABLE_LOGGING !== 'true' 이면 계산 자체를 건너뛴다(운영 비용 0). // // 사용: // import { checkKerabRules } from '@/util/kerab-rule-checker' // checkKerabRules(roof.innerLines, { roofId: roof.id, label: 'after-apply' }) // (dev 콘솔에서 수동: window.__checkKerabRules(roof.innerLines)) import { logger } from '@/util/logger' const ENABLED = process.env.NEXT_PUBLIC_ENABLE_LOGGING === 'true' // ── 기본 임계값(옵션으로 덮어쓸 수 있음) ────────────────────────────── const DEFAULTS = { angleDegEps: 1.0, // 45°/축정렬 허용 오차(도). 불변식상 정확해야 하므로 작게 — drift 를 잡는 게 목적. pointEps: 0.5, // 같은 점 판정(메모리: UI/Big.js drift 고려 넉넉히). zeroLenEps: 0.5, // 길이 0(소멸) 판정. boxPadding: 0.5, // 박스 경계 여유. } // ── 좌표/기하 헬퍼 ──────────────────────────────────────────────── const coords = (ln) => { if (!ln) return null if (Number.isFinite(ln.x1) && Number.isFinite(ln.y1) && Number.isFinite(ln.x2) && Number.isFinite(ln.y2)) { return { x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 } } if (ln.startPoint && ln.endPoint) { return { x1: ln.startPoint.x, y1: ln.startPoint.y, x2: ln.endPoint.x, y2: ln.endPoint.y } } return null } const lineId = (ln) => ln?.id ?? ln?.__id ?? ln?.lineName ?? '?' const len = (c) => Math.hypot(c.x2 - c.x1, c.y2 - c.y1) const dir = (c) => { const dx = c.x2 - c.x1 const dy = c.y2 - c.y1 const m = Math.hypot(dx, dy) || 1 return { x: dx / m, y: dy / m } } const cross = (a, b) => a.x * b.y - a.y * b.x const pointEq = (p, q, eps) => Math.abs(p.x - q.x) <= eps && Math.abs(p.y - q.y) <= eps // 각도(도, 0~180) — 방향 무관(라인은 양방향). const angle180 = (c) => { let a = (Math.atan2(c.y2 - c.y1, c.x2 - c.x1) * 180) / Math.PI a = ((a % 180) + 180) % 180 return a } const devFrom = (a, targets) => Math.min(...targets.map((t) => Math.abs(a - t))) // 선분 교차점(둘 다 *내부*에서 만날 때만 반환 — 끝점 접합은 null). const segCross = (a, b, eps) => { const r = { x: a.x2 - a.x1, y: a.y2 - a.y1 } const s = { x: b.x2 - b.x1, y: b.y2 - b.y1 } const denom = cross(r, s) if (Math.abs(denom) < 1e-9) return null // 평행/공선 const qp = { x: b.x1 - a.x1, y: b.y1 - a.y1 } const t = cross(qp, s) / denom const u = cross(qp, r) / denom const margin = eps / (Math.hypot(r.x, r.y) || 1) // 양쪽 모두 *내부*(끝점 제외)에서 교차 → 관통(크로스) if (t > margin && t < 1 - margin && u > margin && u < 1 - margin) { return { x: a.x1 + t * r.x, y: a.y1 + t * r.y } } return null } // ── 라인 분류 ───────────────────────────────────────────────────── const typeOf = (ln) => ln?.lineName || ln?.name || ln?.attributes?.type || '' const isBox = (ln) => ln?.lineName === 'kerabValleyOverlapLine' || ln?.attributes?.type === 'kerabValleyOverlapLine' // 힙의 도메인 정의 = 정사각형 대각선(45°). 스켈레톤 생성 단계에서 축정렬(0°/90°) 라인에도 name='hip' // 이 붙는 경우가 있는데(실제론 ridge/eaves), 이들을 hip 으로 분류하면 R-45HIP 가 자기모순적으로 위반을 // 낸다(2026-06-24 사용자 확정). 이름이 hip 이어도 축(0/90/180)에 더 가까우면 hip 으로 보지 않는다. // 45° 쪽에 더 가까운 경우만 hip → 실제 힙의 drift(예: 50°)는 여전히 R-45HIP 가 잡는다. const isDiagonalHip = (ln) => { const c = coords(ln) if (!c) return false 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' // 박스(겹침) 영역 = 같은 __targetId 의 kerabValleyOverlapLine 세그먼트들의 bbox. const buildBoxes = (lines, pad) => { const groups = new Map() for (const ln of lines) { if (!isBox(ln)) continue const c = coords(ln) if (!c) continue const key = ln.__targetId ?? '__box' const g = groups.get(key) || { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } g.minX = Math.min(g.minX, c.x1, c.x2) g.minY = Math.min(g.minY, c.y1, c.y2) g.maxX = Math.max(g.maxX, c.x1, c.x2) 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 })) } const pointInBoxes = (p, boxes) => boxes.some((b) => p.x >= b.minX && p.x <= b.maxX && p.y >= b.minY && p.y <= b.maxY) // ── 규칙 정의 (격리된 순수 판정. 여기에 한 줄씩 추가/수정/주석처리) ───────── // 종류: // 'line' — 라인 하나 검사. check(c, ctx) → falsy(정상) | {detail} // 'pair' — 라인 두 개(hip/ridge) 검사. check(ca, cb, la, lb, ctx) → falsy | {point, detail} // 'junction' — 한 교점에 모인 라인들 검사. check(junction, ctx) → falsy | {point, detail} const RULES = [ { id: 'R-45HIP', desc: '힙은 무조건 45° (정사각형 대각선). 아니면 drift/버그.', kind: 'line', enabled: true, applies: (ln) => isHip(ln), check: (c, ctx) => { const a = angle180(c) const d = devFrom(a, [45, 135]) return d > ctx.angleDegEps ? { detail: `hip 각도 ${a.toFixed(2)}° (45/135 에서 ${d.toFixed(2)}° 벗어남)` } : null }, }, { id: 'R-AXISRIDGE', desc: '마루는 수직 또는 수평. 대각선 마루는 존재 불가.', kind: 'line', enabled: true, applies: (ln) => isRidge(ln), check: (c, ctx) => { const a = angle180(c) const d = devFrom(a, [0, 90, 180]) return d > ctx.angleDegEps ? { detail: `ridge 각도 ${a.toFixed(2)}° (0/90 에서 ${d.toFixed(2)}° 벗어남)` } : null }, }, { id: 'R-ZEROLEN', desc: '길이 0(소멸) 라인은 절삭 후처리에서 삭제됐어야 함.', kind: 'line', enabled: true, applies: (ln) => isHip(ln) || isRidge(ln), check: (c, ctx) => (len(c) <= ctx.zeroLenEps ? { detail: `길이 ${len(c).toFixed(3)} ≈ 0` } : null), }, { id: 'R-CROSS', desc: '크로스 금지 — 두 내부선은 교점에서 멈춰야 하며 관통 불가. (박스 안 제외)', kind: 'pair', enabled: true, applies: (a, b) => (isHip(a) || isRidge(a)) && (isHip(b) || isRidge(b)), check: (ca, cb, la, lb, ctx) => { const ip = segCross(ca, cb, ctx.pointEps) if (!ip) return null if (pointInBoxes(ip, ctx.boxes)) return null // 겹침 박스 = 규칙 적용 제외 return { point: ip, detail: `${typeOf(la)}↔${typeOf(lb)} 가 (${ip.x.toFixed(1)}, ${ip.y.toFixed(1)}) 에서 관통` } }, }, { id: 'R-WEDGE', desc: '\\|/ 금지 — 마루가 두 힙 사이에 끼인 형상 절대 불가(R5 Case A).', 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 (hips.length < 2 || ridges.length < 1) return null for (const r of ridges) { for (let i = 0; i < hips.length; i++) { for (let k = i + 1; k < hips.length; k++) { if (insideCone(hips[i].dir, hips[k].dir, r.dir)) { return { point: j.point, detail: `ridge 가 hip 두 개(${j.point.x.toFixed(1)}, ${j.point.y.toFixed(1)}) 사이에 끼임` } } } } } return null }, }, // ── 후속 추가 자리 ────────────────────────────────────────────── // { 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)에 있나. const insideCone = (a, b, v) => { const ab = cross(a, b) if (Math.abs(ab) < 1e-6) return false return ab > 0 ? cross(a, v) > 0 && cross(v, b) > 0 : cross(a, v) < 0 && cross(v, b) < 0 } // 교점(junction) 수집: 라인 끝점들을 pointEps 로 클러스터링 → 각 점에서 뻗는 spoke(line+바깥방향). const buildJunctions = (lines, eps) => { const nodes = [] // { point, spokes: [{ line, dir }] } const addSpoke = (p, outDir, line) => { let node = nodes.find((n) => pointEq(n.point, p, eps)) if (!node) { node = { point: { x: p.x, y: p.y }, spokes: [] } nodes.push(node) } node.spokes.push({ line, dir: outDir }) } for (const ln of lines) { if (!(isHip(ln) || isRidge(ln))) continue const c = coords(ln) if (!c || len(c) <= eps) 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 addSpoke(e, { x: -dir(c).x, y: -dir(c).y }, ln) // e 에서 바깥 = e→s } return nodes.filter((n) => n.spokes.length >= 2) } /** * 라인변경 상호작용 규칙 체커. * @param {Array} lines - 검사 대상 라인들(보통 roof.innerLines). 힙/마루/박스 혼재 OK. * @param {Object} [opts] * @param {string} [opts.roofId] * @param {string} [opts.label] - 어느 시점 호출인지 식별용. * @param {boolean} [opts.silent] - true 면 로그 생략(반환값만). * @param {number} [opts.angleDegEps], [opts.pointEps], [opts.zeroLenEps], [opts.boxPadding] * @returns {{ violations: Array, boxes: Array, stats: Object }} */ export function checkKerabRules(lines, opts = {}) { const empty = { violations: [], boxes: [], stats: { hips: 0, ridges: 0, boxes: 0 } } if (!ENABLED) return empty // 운영 비용 0 if (!Array.isArray(lines) || lines.length === 0) return empty const ctx = { angleDegEps: opts.angleDegEps ?? DEFAULTS.angleDegEps, pointEps: opts.pointEps ?? DEFAULTS.pointEps, zeroLenEps: opts.zeroLenEps ?? DEFAULTS.zeroLenEps, } ctx.boxes = buildBoxes(lines, opts.boxPadding ?? DEFAULTS.boxPadding) const violations = [] const push = (rule, extra) => violations.push({ rule: rule.id, severity: 'error', ...extra }) const checked = lines.filter((ln) => isHip(ln) || isRidge(ln)) // line 규칙 for (const rule of RULES) { if (!rule.enabled || rule.kind !== 'line') continue for (const ln of lines) { 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 }) } } // pair 규칙 for (const rule of RULES) { if (!rule.enabled || rule.kind !== 'pair') continue for (let i = 0; i < checked.length; i++) { for (let k = i + 1; k < checked.length; k++) { const la = checked[i] const lb = checked[k] if (rule.applies && !rule.applies(la, lb)) continue const ca = coords(la) 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 }) } } } // junction 규칙 const needJunction = RULES.some((r) => r.enabled && r.kind === 'junction') if (needJunction) { const junctions = buildJunctions(lines, ctx.pointEps) for (const rule of RULES) { if (!rule.enabled || rule.kind !== 'junction') continue for (const j of junctions) { const r = rule.check(j, ctx) if (r) push(rule, { lineIds: j.spokes.map((s) => lineId(s.line)), ...r }) } } } const stats = { hips: lines.filter(isHip).length, ridges: lines.filter(isRidge).length, boxes: ctx.boxes.length, } const result = { violations, boxes: ctx.boxes, stats } if (!opts.silent) { const tag = `[KERAB-RULE-CHECK]${opts.label ? ` ${opts.label}` : ''}${opts.roofId ? ` roof=${opts.roofId}` : ''}` if (violations.length === 0) { logger.info(`${tag} ✓ 위반 없음 (hip ${stats.hips} / ridge ${stats.ridges} / box ${stats.boxes})`) } else { 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) } } return result } // dev 콘솔에서 수동 호출용. if (typeof window !== 'undefined' && ENABLED) { window.__checkKerabRules = checkKerabRules }