[2294] fix: 누락된 kerab-rule-checker.js 추가 — dev 빌드 module not found 해결
[작업내용] : - useEavesGableEdit.js 의 import 대상인 kerab-rule-checker.js 가 커밋되지 않아 dev 빌드가 '@/util/kerab-rule-checker' 모듈 해석에 실패하던 문제 수정 - 케라바 기하 불변식 체커(checkKerabRules) 파일을 저장소에 추가 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
parent
c3c90925cd
commit
eb18d0e1ac
301
src/util/kerab-rule-checker.js
Normal file
301
src/util/kerab-rule-checker.js
Normal 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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user