[2294] fix: 누락된 kerab-rule-checker.js 추가 — dev 빌드 module not found 해결 #932

Merged
ysCha merged 1 commits from dev into dev-deploy 2026-06-23 18:06:30 +09:00

View File

@ -0,0 +1,301 @@
// 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'
const isHip = (ln) => !isBox(ln) && typeOf(ln) === 'hip'
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
}