From eb18d0e1ac1284bf79c0a89b9cdeebc09c1babf8 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 23 Jun 2026 18:05:18 +0900 Subject: [PATCH] =?UTF-8?q?[2294]=20fix:=20=EB=88=84=EB=9D=BD=EB=90=9C=20k?= =?UTF-8?q?erab-rule-checker.js=20=EC=B6=94=EA=B0=80=20=E2=80=94=20dev=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20module=20not=20found=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - useEavesGableEdit.js 의 import 대상인 kerab-rule-checker.js 가 커밋되지 않아 dev 빌드가 '@/util/kerab-rule-checker' 모듈 해석에 실패하던 문제 수정 - 케라바 기하 불변식 체커(checkKerabRules) 파일을 저장소에 추가 Co-Authored-By: Claude Opus 4 --- src/util/kerab-rule-checker.js | 301 +++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 src/util/kerab-rule-checker.js diff --git a/src/util/kerab-rule-checker.js b/src/util/kerab-rule-checker.js new file mode 100644 index 00000000..f59faaca --- /dev/null +++ b/src/util/kerab-rule-checker.js @@ -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 +}