@ -15,7 +15,6 @@
// (dev 콘솔에서 수동: window.__checkKerabRules(roof.innerLines))
import { logger } from '@/util/logger'
import { debugCapture } from '@/util/debugCapture'
const ENABLED = process . env . NEXT _PUBLIC _ENABLE _LOGGING === 'true'
@ -24,7 +23,7 @@ const DEFAULTS = {
angleDegEps : 1.0 , // 45°/축정렬 허용 오차(도). 불변식상 정확해야 하므로 작게 — drift 를 잡는 게 목적.
pointEps : 0.5 , // 같은 점 판정(메모리: UI/Big.js drift 고려 넉넉히).
zeroLenEps : 0.5 , // 길이 0(소멸) 판정.
boxPadding : 1. 0, // 박스 경계 여유 — 드로잉(clipCrossedHipsAtIntersection)의 PAD=1.0 과 통일 .
boxPadding : 0.5 , // 박스 경계 여유 .
}
// ── 좌표/기하 헬퍼 ────────────────────────────────────────────────
@ -88,33 +87,10 @@ const isDiagonalHip = (ln) => {
const a = angle180 ( c )
return devFrom ( a , [ 45 , 135 ] ) < devFrom ( a , [ 0 , 90 , 180 ] )
}
// [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 )
const isHip = ( ln ) => ! isBox ( ln ) && typeOf ( ln ) === 'hip' && isDiagonalHip ( ln )
const isRidge = ( ln ) => ! isBox ( ln ) && typeOf ( ln ) === 'ridge'
// [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)은 얇은 박스에 내부가 없어 자연히 발화 안 함(= 과발화 방지, 의도된 동작).
// 박스(겹침) 영역 = 같은 __targetId 의 kerabValleyOverlapLine 세그먼트들의 bbox.
const buildBoxes = ( lines , pad ) => {
const groups = new Map ( )
for ( const ln of lines ) {
@ -129,254 +105,9 @@ const buildBoxes = (lines, pad) => {
g . maxY = Math . max ( g . maxY , c . y1 , c . y2 )
groups . set ( key , g )
}
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
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 )
// 박스 *내부*(경계 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
}
// ── 규칙 정의 (격리된 순수 판정. 여기에 한 줄씩 추가/수정/주석처리) ─────────
// 종류:
@ -425,37 +156,9 @@ const RULES = [
check : ( ca , cb , la , lb , ctx ) => {
const ip = segCross ( ca , cb , ctx . pointEps )
if ( ! ip ) return null
if ( pointInBoxes ( ip , ctx . exclBoxes) ) 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 ) } ) 에서 관통 ` }
} ,
// [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' ,
@ -463,10 +166,9 @@ const RULES = [
kind : 'junction' ,
enabled : true ,
check : ( j , ctx ) => {
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 ( 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 ++ ) {
@ -480,228 +182,9 @@ 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)에 있나.
@ -723,12 +206,9 @@ const buildJunctions = (lines, eps) => {
node . spokes . push ( { line , dir : outDir } )
}
for ( const ln of lines ) {
if ( isBox ( ln ) ) continue
if ( ! ( isHip ( ln ) || isRidge ( 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
@ -757,70 +237,10 @@ export function checkKerabRules(lines, opts = {}) {
pointEps : opts . pointEps ? ? DEFAULTS . pointEps ,
zeroLenEps : opts . zeroLenEps ? ? DEFAULTS . zeroLenEps ,
}
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 } )
}
ctx . boxes = buildBoxes ( lines , opts . boxPadding ? ? DEFAULTS . boxPadding )
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 ) )
@ -831,11 +251,8 @@ 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 , ln )
if ( r ) {
push ( rule , { lineIds : [ lineId ( ln ) ] , type : typeOf ( ln ) , ... r } )
collectFix ( rule , ln , c , r )
}
const r = rule . check ( c , ctx )
if ( r ) push ( rule , { lineIds : [ lineId ( ln ) ] , type : typeOf ( ln ) , ... r } )
}
}
@ -851,13 +268,7 @@ 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 ( 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 } )
}
}
if ( r ) push ( rule , { lineIds : [ lineId ( la ) , lineId ( lb ) ] , ... r } )
}
}
}
@ -865,32 +276,12 @@ export function checkKerabRules(lines, opts = {}) {
// junction 규칙
const needJunction = RULES . some ( ( r ) => r . enabled && r . kind === 'junction' )
if ( needJunction ) {
ctx . junctions = buildJunctions ( lines , ctx . pointEps ) // fix(R-RIDGE-GEN 생성)가 마주보는 봉우리 탐색에 쓴다.
const junctions = buildJunctions ( lines , ctx . pointEps )
for ( const rule of RULES ) {
if ( ! rule . enabled || rule . kind !== 'junction' ) continue
for ( const j of ctx. junctions) {
for ( const j of junctions) {
const r = rule . check ( j , ctx )
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 } )
if ( r ) push ( rule , { lineIds : j . spokes . map ( ( s ) => lineId ( s . line ) ) , ... r } )
}
}
}
@ -900,7 +291,7 @@ export function checkKerabRules(lines, opts = {}) {
ridges : lines . filter ( isRidge ) . length ,
boxes : ctx . boxes . length ,
}
const result = { violations , boxes : ctx . boxes , stats , fixes }
const result = { violations , boxes : ctx . boxes , stats }
if ( ! opts . silent ) {
const tag = ` [KERAB-RULE-CHECK] ${ opts . label ? ` ${ opts . label } ` : '' } ${ opts . roofId ? ` roof= ${ opts . roofId } ` : '' } `
@ -910,70 +301,11 @@ 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
}