[2294_1] 케라바 出幅(출폭) 토글 라인 재계산 핸들러 분리 + 규칙 검증 추가

골짜기(케라바 패턴)·일반 라인을 독립 핸들러로 분리해 상호 간섭(whack-a-mole) 차단.
일반 라인은 절대 재계산(apex 불변, roofLine-hit 끝점만 재교차) + apex junction degree≥1 임계.
KERAB-RULE-CHECK(R1~R4) forward/revert 검증, RIDGE-DIAG 자연 마루 길이 불변 감시 추가.
REVERT/VALLEY-EXT 끝점 공유 가드로 게이블 hip 확장·길이 0 붕괴 방지.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
ysCha 2026-06-10 18:05:36 +09:00
parent 54e6a31dcf
commit 1db206fe34
2 changed files with 327 additions and 106 deletions

View File

@ -152,6 +152,136 @@ export function useEavesGableEdit(id) {
canvas.renderAll() canvas.renderAll()
} }
// [KERAB-RULE-CHECK 2026-06-10] 케라바(처마↔게이블) 토글 종료 시 결과가 도메인 규칙에
// 맞는지 자동 판정하는 진단 단계. forward/revert 양쪽 끝에서 호출. 로컬 전용 — 위반은
// logger.warn 로 위반 라인만 덤프(production 은 DCE 로 제거).
// 규칙:
// R1 dangling: 모든 visible hip/ridge 끝점은 roofLine 코너에 닿거나 다른 inner line 과
// 공유돼야 한다(떠 있는 끝점=벽 교점/코너 이탈). 골짜기 내부 hip 은 양 끝이
// 다른 라인과 공유되므로 자동 통과(예외 불필요).
// R2 zero-length: 길이 0 으로 붕괴된 visible 라인(=라인 소실) 금지.
// R3 outside: 끝점이 roofLine 폴리곤 밖(경계 tol 초과)으로 이탈 금지.
// R4 anchor: 토글 전후로 움직이지 않은 roofLine 코너(stable corner)의 끝점 점유수 불변
// (코너에서 hip 이 떨어지거나 엉뚱한 코너로 횡단하면 점유수 변화).
const snapshotKerabState = (roof) => {
if (!roof || !Array.isArray(roof.innerLines)) return null
const lines = roof.innerLines
.filter((l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE) && l.visible !== false)
.map((l) => ({ x1: l.x1, y1: l.y1, x2: l.x2, y2: l.y2 }))
const points = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y }))
return { lines, points }
}
const runKerabRuleCheck = (roof, phase, before) => {
try {
if (!roof || !Array.isArray(roof.innerLines)) return
const TOL = 2.0
const OUT_TOL = 3.0
const ZERO = 1.0
const r1 = (n) => Math.round(n * 10) / 10
const fails = []
const rpts = Array.isArray(roof.points) ? roof.points : []
// 검사 대상 = visible 마루/힙. 끝점 공유(접합) 판정에는 골짜기확장(VALLEY)까지 포함 —
// RG-1 확장(kerabPatternExtRidge)은 vExt(VALLEY) 위에서 끝나므로 VALLEY 를 빼면 오탐.
const visLines = roof.innerLines.filter(
(l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE) && l.visible !== false,
)
const connLines = roof.innerLines.filter(
(l) =>
l &&
(l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY) &&
l.visible !== false,
)
const info = (l) => ({ n: l.name, ln: l.lineName || '-', x1: r1(l.x1), y1: r1(l.y1), x2: r1(l.x2), y2: r1(l.y2) })
const onCorner = (p) => rpts.some((c) => c && Math.hypot(c.x - p.x, c.y - p.y) < TOL)
const ends = []
for (const l of connLines) {
ends.push({ x: l.x1, y: l.y1, line: l })
ends.push({ x: l.x2, y: l.y2, line: l })
}
const sharedWithOther = (p, self) => ends.some((e) => e.line !== self && Math.hypot(e.x - p.x, e.y - p.y) < TOL)
// 폴리곤 내부/경계 판정 (ray-casting + edge 거리 tol)
const pip = (pt) => {
let inside = false
for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) {
const xi = rpts[i].x
const yi = rpts[i].y
const xj = rpts[j].x
const yj = rpts[j].y
const intersect = yi > pt.y !== yj > pt.y && pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi + 1e-12) + xi
if (intersect) inside = !inside
}
return inside
}
const distToSeg = (p, a, b) => {
const dx = b.x - a.x
const dy = b.y - a.y
const l2 = dx * dx + dy * dy || 1
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2
t = Math.max(0, Math.min(1, t))
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
}
const minEdgeDist = (pt) => {
let m = Infinity
for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) {
const d = distToSeg(pt, rpts[j], rpts[i])
if (d < m) m = d
}
return m
}
for (const l of visLines) {
const endpts = [
{ x: l.x1, y: l.y1 },
{ x: l.x2, y: l.y2 },
]
// R2 zero-length
if (Math.hypot(l.x2 - l.x1, l.y2 - l.y1) < ZERO) {
fails.push({ rule: 'R2-zero-length', line: info(l) })
}
for (const p of endpts) {
// R1 dangling: 끝점은 roofLine 경계(코너 + 변)에 닿거나 다른 내부선과 공유돼야 한다.
// kLine(중앙 마루)·게이블 hip 은 roofLine '코너'가 아닌 '변' 중간에 닿는 게 정상 →
// 코너만 보면 오탐. minEdgeDist 로 변까지 포함해 경계 도달을 판정한다.
const onBoundary = rpts.length >= 3 ? minEdgeDist(p) <= TOL : onCorner(p)
if (!onBoundary && !sharedWithOther(p, l)) {
fails.push({ rule: 'R1-dangling', line: info(l), at: { x: r1(p.x), y: r1(p.y) } })
}
// R3 outside roofLine
if (rpts.length >= 3 && !pip(p) && minEdgeDist(p) > OUT_TOL) {
fails.push({ rule: 'R3-outside', line: info(l), at: { x: r1(p.x), y: r1(p.y) } })
}
}
}
// R4 anchor: stable roofLine corner 점유수 불변
if (before && Array.isArray(before.points) && Array.isArray(before.lines)) {
const countOn = (lineArr, c) => {
let n = 0
for (const l of lineArr) {
if (Math.hypot(l.x1 - c.x, l.y1 - c.y) < TOL) n++
if (Math.hypot(l.x2 - c.x, l.y2 - c.y) < TOL) n++
}
return n
}
const afterPlain = visLines.map((l) => ({ x1: l.x1, y1: l.y1, x2: l.x2, y2: l.y2 }))
for (const c of rpts) {
const stable = before.points.some((b) => Math.hypot(b.x - c.x, b.y - c.y) < TOL)
if (!stable) continue
const bN = countOn(before.lines, c)
const aN = countOn(afterPlain, c)
if (bN !== aN) {
fails.push({ rule: 'R4-anchor', at: { x: r1(c.x), y: r1(c.y) }, before: bN, after: aN })
}
}
}
if (fails.length) {
logger.warn('[KERAB-RULE-CHECK] ' + phase + ' FAIL(' + fails.length + ') ' + JSON.stringify(fails))
} else {
logger.log('[KERAB-RULE-CHECK] ' + phase + ' PASS')
}
} catch (err) {
logger.warn('[KERAB-RULE-CHECK] error', err)
}
}
const mouseDownEvent = (e) => { const mouseDownEvent = (e) => {
// [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시 // [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시
// discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호. // discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호.
@ -461,6 +591,8 @@ export function useEavesGableEdit(id) {
} }
} }
dumpInnerLineSnapshot('BEFORE') dumpInnerLineSnapshot('BEFORE')
// [KERAB-RULE-CHECK 2026-06-10] surgical 전(원본 출폭) 상태를 R4 anchor 기준으로 캡처.
const kerabBeforeSnap = snapshotKerabState(roof)
// [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영. // [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영.
// SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존. // SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존.
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0) if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0)
@ -668,6 +800,7 @@ export function useEavesGableEdit(id) {
logger.log('[KERAB-SIMPLE] no polygon path → attr-only fallback') logger.log('[KERAB-SIMPLE] no polygon path → attr-only fallback')
target.set({ attributes }) target.set({ attributes })
applyKerabAttributeOnlyPattern() applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
return return
} }
const polygonLines = [h1Match.hip, h2Match.hip, ...polygonPath.map((p) => p.line)] const polygonLines = [h1Match.hip, h2Match.hip, ...polygonPath.map((p) => p.line)]
@ -742,6 +875,7 @@ export function useEavesGableEdit(id) {
logger.log('[KERAB-SIMPLE] no extenders — fallback attr-only') logger.log('[KERAB-SIMPLE] no extenders — fallback attr-only')
target.set({ attributes }) target.set({ attributes })
applyKerabAttributeOnlyPattern() applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
return return
} }
// 인쪽 방향 검증: extender 자연 방향(near→far)의 반대(near→inward) 와 일치해야 함. // 인쪽 방향 검증: extender 자연 방향(near→far)의 반대(near→inward) 와 일치해야 함.
@ -1965,6 +2099,16 @@ export function useEavesGableEdit(id) {
if (!ip) continue if (!ip) continue
if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
// [VALLEY-EXT-TRIM-ENDPOINT-GUARD 2026-06-10] ip 가 il 끝점과 일치하면
// 그 라인은 vExt 를 가로지르는(cross) 게 아니라 vExt 에서 끝나는(terminate)
// 라인(예: RG-1 연장선, 한 끝이 이미 vExt 위). 반대 끝을 ip 로 절삭하면 양 끝이
// 같은 점이 되어 길이 0 붕괴 → 라인 소실. 절삭은 막되, 끝점이 vExt 내부점이면
// split 은 여전히 필요(그래프 노드 공유 → 할당 dead-end 방지)하므로 split 전용
// 레코드만 push 한다(좌표 변경/cascade 없음). revert 는 splitOnly 를 건너뛴다.
if (Math.hypot(ip.x - il.x1, ip.y - il.y1) < 1.0 || Math.hypot(ip.x - il.x2, ip.y - il.y2) < 1.0) {
newTrimRecords.push({ line: il, splitOnly: true, newPt: { x: ip.x, y: ip.y } })
continue
}
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰. // [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰.
// V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점. // V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점.
// 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정. // 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정.
@ -2313,6 +2457,7 @@ export function useEavesGableEdit(id) {
if (trimRecords.length) target.__valleyExtTrims = trimRecords if (trimRecords.length) target.__valleyExtTrims = trimRecords
} }
dumpInnerLineSnapshot('AFTER') dumpInnerLineSnapshot('AFTER')
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')') logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')')
return return
} }
@ -2320,6 +2465,7 @@ export function useEavesGableEdit(id) {
logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only') logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only')
target.set({ attributes }) target.set({ attributes })
applyKerabAttributeOnlyPattern() applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
return return
} }
// condition 1: 자연 만남 — ext 없이 hip 제거 + kLine // condition 1: 자연 만남 — ext 없이 hip 제거 + kLine
@ -2335,12 +2481,14 @@ export function useEavesGableEdit(id) {
null, null,
) )
dumpInnerLineSnapshot('AFTER') dumpInnerLineSnapshot('AFTER')
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)') logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
return return
} }
} }
target.set({ attributes }) target.set({ attributes })
applyKerabAttributeOnlyPattern() applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
logger.log('[KERAB-SIMPLE] attr-only fallback') logger.log('[KERAB-SIMPLE] attr-only fallback')
return return
} }
@ -2354,6 +2502,8 @@ export function useEavesGableEdit(id) {
.getObjects() .getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) { if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) {
// [KERAB-RULE-CHECK 2026-06-10] revert 변환 전(게이블 출폭) 상태를 R4 anchor 기준으로 캡처.
const kerabRevertBeforeSnap = snapshotKerabState(roof)
const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 }) const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 })
const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 }) const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
if (c1 && c2) { if (c1 && c2) {
@ -2394,6 +2544,30 @@ export function useEavesGableEdit(id) {
const wCorner = side === 'A' ? wA : wB const wCorner = side === 'A' ? wA : wB
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
// [KERAB-REVERT-SHARED-ENDPOINT-GUARD 2026-06-10] outer(확장) 끝이 다른 내부선과
// 공유되면 확장하지 않는다. 골짜기(valley) 내부 hip 은 양 끝이 skeleton 교점이라 outer 도
// 공유 → 자동 제외. 게이블/코너 hip 의 outer 는 free(roofLine 변에 홀로 도달) → 확장.
// 판정이 출폭·snapshot 좌표와 무관한 구조 기준이라 다중 출폭 변경에도 안 깨진다.
// (구 c1/c2 가드는 c1/c2 를 현재 출폭으로, outerOld 를 forward 당시 출폭으로 잡아
// 출폭이 여러 번 바뀌면 게이블 hip 을 interior 로 오판 → SK 확장이 중간에 멈췄다.)
const SHARE_TOL = 2.0
const outerShared = (roof.innerLines || []).some(
(o) =>
o &&
o !== il &&
o.visible !== false &&
(Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL ||
Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL),
)
if (outerShared) {
logger.log(
'[KERAB-REVERT-EXTEND-45] skip (interior hip — outer shared) ' +
JSON.stringify({ lineName: il.lineName, which, side, outerOld }),
)
delete il.__kerabRevertOuterWhich
delete il.__kerabRevertOuterSide
continue
}
// hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음. // hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음.
const dx = outerOld.x - innerEnd.x const dx = outerOld.x - innerEnd.x
const dy = outerOld.y - innerEnd.y const dy = outerOld.y - innerEnd.y
@ -2456,6 +2630,7 @@ export function useEavesGableEdit(id) {
delete il.__kerabRevertOuterWhich delete il.__kerabRevertOuterWhich
delete il.__kerabRevertOuterSide delete il.__kerabRevertOuterSide
} }
runKerabRuleCheck(roof, 'revert', kerabRevertBeforeSnap)
} }
// [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원 // [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원
if (Array.isArray(target.__kerabParallelHipsSnapshot)) { if (Array.isArray(target.__kerabParallelHipsSnapshot)) {
@ -3012,6 +3187,9 @@ export function useEavesGableEdit(id) {
const rec = target.__valleyExtTrims[i] const rec = target.__valleyExtTrims[i]
const il = rec.line const il = rec.line
if (!il) continue if (!il) continue
// [VALLEY-EXT-TRIM-ENDPOINT-GUARD 2026-06-10] split 전용 레코드는 좌표/visible 변경이
// 없으므로 복원할 게 없다. split 세그먼트는 valleyExt(__targetId) 제거에서 정리됨.
if (rec.splitOnly) continue
if (rec.hide) { if (rec.hide) {
il.visible = rec.originalVisible !== false il.visible = rec.originalVisible !== false
} else { } else {

View File

@ -40,6 +40,18 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
const oldOffset = matchingRL.attributes?.offset ?? 0 const oldOffset = matchingRL.attributes?.offset ?? 0
if (Math.abs(newOffset - oldOffset) < 1e-3) return false if (Math.abs(newOffset - oldOffset) < 1e-3) return false
// [RIDGE-DIAG 2026-06-10] 자연 마루(ridge) 길이 간헐 변동 추적 — surgical 진입 전 before 스냅샷,
// 함수 종료 직전 길이 비교. 처마/케라바/revert(skipInnerLines 포함) 모든 경로 감시.
// 출폭 변경 시 자연 마루 끝점(apex)은 wallLine 안쪽이라 불변이 룰 → 길이 변동은 버그.
// kerabPatternRidge(kLine)/kerabPatternExtRidge 는 출폭에 따라 정상적으로 신축 → 감시 제외.
const isRidgeDiagLine = (il) => il && il.lineName === 'ridge'
const ridgeDiagBefore = new Map()
for (const il of roof.innerLines || []) {
if (isRidgeDiagLine(il)) {
ridgeDiagBefore.set(il, { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2, len: Math.hypot(il.x2 - il.x1, il.y2 - il.y1) })
}
}
const N = roof.lines.length const N = roof.lines.length
const prevRL = roof.lines[(idx - 1 + N) % N] const prevRL = roof.lines[(idx - 1 + N) % N]
const nextRL = roof.lines[(idx + 1) % N] const nextRL = roof.lines[(idx + 1) % N]
@ -118,15 +130,6 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
// 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제. // 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제.
// 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent. // 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent.
if (!skipInnerLines) { if (!skipInnerLines) {
const newAxisMid = { x: (newCorner1.x + newCorner2.x) / 2, y: (newCorner1.y + newCorner2.y) / 2 }
const OUTSIDE_TOL = 0.5
const segOk = (ip) => {
const minX = Math.min(newCorner1.x, newCorner2.x) - OUTSIDE_TOL
const maxX = Math.max(newCorner1.x, newCorner2.x) + OUTSIDE_TOL
const minY = Math.min(newCorner1.y, newCorner2.y) - OUTSIDE_TOL
const maxY = Math.max(newCorner1.y, newCorner2.y) + OUTSIDE_TOL
return ip.x >= minX && ip.x <= maxX && ip.y >= minY && ip.y <= maxY
}
// [KERAB-PATTERN-CORNER-SNAP 2026-06-01] 케라바 패턴 라인(kLine/ExtRidge/Hip/ExtHip)은 // [KERAB-PATTERN-CORNER-SNAP 2026-06-01] 케라바 패턴 라인(kLine/ExtRidge/Hip/ExtHip)은
// roofLine corner 변경에 따라 끝점도 같이 이동. corner 일치뿐 아니라 옛 segment 위의 // roofLine corner 변경에 따라 끝점도 같이 이동. corner 일치뿐 아니라 옛 segment 위의
// 중간 점(예: kLine 끝점이 옛 roofLine 변 위)도 새 segment 의 동일 t 비율 점으로 매핑. // 중간 점(예: kLine 끝점이 옛 roofLine 변 위)도 새 segment 의 동일 t 비율 점으로 매핑.
@ -154,31 +157,66 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
if (t < -0.05 || t > 1.05) return null if (t < -0.05 || t > 1.05) return null
return { x: newCorner1.x + t * newSegDx, y: newCorner1.y + t * newSegDy } return { x: newCorner1.x + t * newSegDx, y: newCorner1.y + t * newSegDy }
} }
for (const il of roof.innerLines || []) { // [KERAB-APEX-INVARIANT 2026-06-10] skeleton 교점(junction) 판별식(기하, lineName 아님).
if (!il) continue // 한 점이 다른 내부선 ≥1개의 끝점과 공유되면 그 점은 라인끼리 만나는 junction 이다.
// junction 은 wallLine 이 정하는 고정점이라 出幅 변경과 무관 → CORNER-SNAP/CASCADE 가 끌면 안 됨.
// 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점. // degree≥1 임계: Y 형 apex(ridge·2hip, degree 2)뿐 아니라 케라바 병합 후 콜리니어 ridge 하나만
// 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 → // 남은 junction(degree 1)도 잡아야 한다. (degree≥2 였을 때 병합 apex 를 놓쳐 kLine 의 apex 끝을
// 직각/형상 보존. 절삭/복원 흐름 skip. // 끌고 자연 ridge 까지 cascade → 마루 길이 증가 + revert R1-dangling 발생.) vExt 는 !isVExt 로 제외.
if (isKerabPatternLine(il)) { const APEX_SHARE_TOL = 2.0
const skeletonApexDegree = (pt, selfLine) => {
let deg = 0
for (const o of roof.innerLines || []) {
if (!o || o === selfLine || o.visible === false) continue
if (
Math.hypot(o.x1 - pt.x, o.y1 - pt.y) < APEX_SHARE_TOL ||
Math.hypot(o.x2 - pt.x, o.y2 - pt.y) < APEX_SHARE_TOL
)
deg++
}
return deg
}
// [KERAB-OFFSET-FUNCTIONIZE 2026-06-10] 라인 성격별 독립 핸들러.
// 두 핸들러는 공유 가변상태 없이 각자 il 만 갱신 → 골짜기↔일반 상호 간섭(whack-a-mole) 차단.
// 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점.
// 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 →
// 직각/형상 보존. 절삭/복원 흐름 skip.
const recomputeKerabPatternLine = (il) => {
const oldX1 = il.x1 const oldX1 = il.x1
const oldY1 = il.y1 const oldY1 = il.y1
const oldX2 = il.x2 const oldX2 = il.x2
const oldY2 = il.y2 const oldY2 = il.y2
const np1 = mapToNewSeg({ x: il.x1, y: il.y1 }) const np1 = mapToNewSeg({ x: il.x1, y: il.y1 })
const np2 = mapToNewSeg({ x: il.x2, y: il.y2 }) const np2 = mapToNewSeg({ x: il.x2, y: il.y2 })
// [KERAB-APEX-INVARIANT 2026-06-10] 한 끝만 roofLine 에 매핑될 때:
// 매핑 안 된 끝점이 junction(다른 내부선 ≥1 공유)이면 出幅 무관 고정점 →
// 이동 금지(junction 중심 pivot). vExt(수직 self-extension)는 우선순위상 평행이동 유지.
// 그 외(free 끝점, degree 0)만 같은 변위 평행이동(직각/형상 보존).
const isVExt = il.lineName === 'kerabPatternValleyExt'
let apexPivot = false
if (np1 && np2) { if (np1 && np2) {
il.set({ x1: np1.x, y1: np1.y, x2: np2.x, y2: np2.y }) il.set({ x1: np1.x, y1: np1.y, x2: np2.x, y2: np2.y })
} else if (np1 && !np2) { } else if (np1 && !np2) {
const dx = np1.x - il.x1 if (!isVExt && skeletonApexDegree({ x: il.x2, y: il.y2 }, il) >= 1) {
const dy = np1.y - il.y1 il.set({ x1: np1.x, y1: np1.y })
il.set({ x1: np1.x, y1: np1.y, x2: il.x2 + dx, y2: il.y2 + dy }) apexPivot = true
} else {
const dx = np1.x - il.x1
const dy = np1.y - il.y1
il.set({ x1: np1.x, y1: np1.y, x2: il.x2 + dx, y2: il.y2 + dy })
}
} else if (!np1 && np2) { } else if (!np1 && np2) {
const dx = np2.x - il.x2 if (!isVExt && skeletonApexDegree({ x: il.x1, y: il.y1 }, il) >= 1) {
const dy = np2.y - il.y2 il.set({ x2: np2.x, y2: np2.y })
il.set({ x1: il.x1 + dx, y1: il.y1 + dy, x2: np2.x, y2: np2.y }) apexPivot = true
} else {
const dx = np2.x - il.x2
const dy = np2.y - il.y2
il.set({ x1: il.x1 + dx, y1: il.y1 + dy, x2: np2.x, y2: np2.y })
}
} }
if (typeof il.setCoords === 'function') il.setCoords() if (typeof il.setCoords === 'function') il.setCoords()
if (isRidgeDiagLine(il)) il.__ridgeDiagBranch = `PATTERN-MAP np1=${!!np1} np2=${!!np2}`
if (np1 || np2) { if (np1 || np2) {
logger.log( logger.log(
'[KERAB-PATTERN-CORNER-SNAP] mapped ' + '[KERAB-PATTERN-CORNER-SNAP] mapped ' +
@ -203,7 +241,10 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
const projY = oldY1 + t * sdy const projY = oldY1 + t * sdy
return Math.hypot(px - projX, py - projY) < 1.0 return Math.hypot(px - projX, py - projY) < 1.0
} }
if (Math.hypot(dxVExt, dyVExt) > 0.01) { // [KERAB-APEX-INVARIANT 2026-06-10] apex pivot(매핑 안 된 끝점이 apex 라 고정)인 경우는
// 라인이 평행이동이 아니라 apex 중심 회전이므로 cascade 금지 — apex 공유 ridge/hip 끌림 방지.
// vExt 등 실제 평행이동(양 끝 같은 변위)일 때만 옛 segment 위 끝점 전파.
if (!apexPivot && Math.hypot(dxVExt, dyVExt) > 0.01) {
// cascade 대상: roof.innerLines + canvas 의 kerabValleyOverlapLine (innerLines 미포함). // cascade 대상: roof.innerLines + canvas 의 kerabValleyOverlapLine (innerLines 미포함).
// overlap 보조선들은 vExt 의 끝점과 90도로 만나므로 vExt 이동에 같이 따라가야 정합. // overlap 보조선들은 vExt 의 끝점과 90도로 만나므로 vExt 이동에 같이 따라가야 정합.
const overlapInCanvas = (canvas.getObjects() || []).filter( const overlapInCanvas = (canvas.getObjects() || []).filter(
@ -255,97 +296,75 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
} }
} }
} }
continue }
}
const orig = il.__shrinkOrig || { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } const recomputeNormalLine = (il) => {
const orig1 = { x: orig.x1, y: orig.y1 } // [KERAB-OFFSET-NORMAL-ABS 2026-06-10] 일반 라인(힙/마루/골짜기) 절대 재계산.
const orig2 = { x: orig.x2, y: orig.y2 } // 도메인: 모든 끝점은 둘 중 하나 —
// (a) anchor = apex(wallLine 이 정하는 skeleton 교점) 또는 변경 무관한 다른 roofLine 변 위 점 → 出幅 불변, 고정.
// [KERAB-OFFSET-CORNER-SHORTCUT 2026-06-01] orig 끝점이 옛 corner 와 일치하면 새 corner 로 직접 snap. // (b) hit = 변경된 roofLine 변(oldCorner1→oldCorner2) 위의 처마 끝 → 새 변으로 다시 그린다.
// 일반 절삭 흐름은 il segment 와 새 roofLine 변의 lineLineIntersection 으로 ip 계산하는데, // 변경 변과 무관한 라인(양 끝 모두 hit 아님)은 그대로. 증감·복원 분기 불필요(절대값이라 idempotent).
// 끝점이 옛 corner 위에 있으면 ip 가 새 corner segment 밖으로 떨어져 segOk 가 reject → 절삭 실패. // __shrinkOrig snapshot 제거 — apex 가 이미 불변(현재값=원본)이라 스냅샷 없이 매번 절대 재계산.
// 그 결과 c1·c2 비대칭으로 kLine 대각선 변형됨. // hit 재계산 규칙:
const CORNER_SNAP_TOL_TRIM = 0.5 // - 끝점이 옛 corner 와 일치 → 새 corner 로 snap. (코너는 인접 변끼리의 교점이라
let cornerSnapped = false // 라인 자기 방향 교점과 위치가 다르다 → 반드시 코너로 보정. 구 CORNER-SHORTCUT 의 기하 근거.)
const trySnap = (epx, epy, which) => { // - 끝점이 변 중간(mid-edge) → anchor→hit 라인 방향 보존하며 새 변과의 교점. 방향은 평행
if (Math.hypot(epx - oldCorner1.x, epy - oldCorner1.y) < CORNER_SNAP_TOL_TRIM) { // 出幅 변경에 불변(skeleton 각이등분선)이라 교점이 곧 새 처마 끝. 증가=확장/감소=절삭 동일식.
if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } const CORNER_TOL = 0.5
if (which === 1) il.set({ x1: newCorner1.x, y1: newCorner1.y }) const atCorner = (px, py) => {
else il.set({ x2: newCorner1.x, y2: newCorner1.y }) if (Math.hypot(px - oldCorner1.x, py - oldCorner1.y) < CORNER_TOL) return newCorner1
cornerSnapped = true if (Math.hypot(px - oldCorner2.x, py - oldCorner2.y) < CORNER_TOL) return newCorner2
return true return null
}
const e1 = { x: il.x1, y: il.y1 }
const e2 = { x: il.x2, y: il.y2 }
const m1 = mapToNewSeg(e1)
const m2 = mapToNewSeg(e2)
// 변경 변에 닿지 않는 라인(마루 등) → 무동작.
if (!m1 && !m2) return
const c1 = atCorner(e1.x, e1.y)
const c2 = atCorner(e2.x, e2.y)
let n1 = e1
let n2 = e2
let branch = ''
if (m1 && m2 && !c1 && !c2) {
// 양 끝 모두 변 중간 → 변 위에 누운 라인(드묾). t 비율로 매핑.
n1 = m1
n2 = m2
branch = 'BOTH-MID'
} else {
if (c1) {
n1 = c1
} else if (m1) {
const ip = lineLineIntersection(e2, e1, newCorner1, newCorner2)
if (!ip) return
n1 = ip
} }
if (Math.hypot(epx - oldCorner2.x, epy - oldCorner2.y) < CORNER_SNAP_TOL_TRIM) { if (c2) {
if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } n2 = c2
if (which === 1) il.set({ x1: newCorner2.x, y1: newCorner2.y }) } else if (m2) {
else il.set({ x2: newCorner2.x, y2: newCorner2.y }) const ip = lineLineIntersection(e1, e2, newCorner1, newCorner2)
cornerSnapped = true if (!ip) return
return true n2 = ip
} }
return false branch = `c1=${!!c1} c2=${!!c2} m1=${!!m1} m2=${!!m2}`
} }
trySnap(orig.x1, orig.y1, 1) il.set({ x1: n1.x, y1: n1.y, x2: n2.x, y2: n2.y })
trySnap(orig.x2, orig.y2, 2)
if (cornerSnapped) {
if (typeof il.setCoords === 'function') il.setCoords()
logger.log(
'[KERAB-OFFSET-CORNER-SHORTCUT] snapped ' +
JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }),
)
continue
}
const d1 = (orig1.x - newAxisMid.x) * nx + (orig1.y - newAxisMid.y) * ny
const d2 = (orig2.x - newAxisMid.x) * nx + (orig2.y - newAxisMid.y) * ny
// 양 끝 모두 새 polygon 안쪽 → 원본 복원 + 백업 삭제
if (d1 <= OUTSIDE_TOL && d2 <= OUTSIDE_TOL) {
if (il.__shrinkOrig) {
il.set({ x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 })
if (typeof il.setCoords === 'function') il.setCoords()
logger.log(
'[KERAB-OFFSET-SHRINK-TRIM] restored ' +
JSON.stringify({ lineName: il.lineName, x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }),
)
delete il.__shrinkOrig
}
continue
}
// 바깥 끝점 절삭 (원본 segment 와 새 roofLine 변의 교점)
let nx1 = orig.x1
let ny1 = orig.y1
let nx2 = orig.x2
let ny2 = orig.y2
let trimmed = false
if (d1 > OUTSIDE_TOL) {
const ip = lineLineIntersection(orig1, orig2, newCorner1, newCorner2)
if (ip && segOk(ip)) {
nx1 = ip.x
ny1 = ip.y
trimmed = true
}
}
if (d2 > OUTSIDE_TOL) {
const ip = lineLineIntersection(orig1, orig2, newCorner1, newCorner2)
if (ip && segOk(ip)) {
nx2 = ip.x
ny2 = ip.y
trimmed = true
}
}
if (!trimmed) continue
if (!il.__shrinkOrig) {
il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }
}
il.set({ x1: nx1, y1: ny1, x2: nx2, y2: ny2 })
if (typeof il.setCoords === 'function') il.setCoords() if (typeof il.setCoords === 'function') il.setCoords()
if (isRidgeDiagLine(il)) il.__ridgeDiagBranch = `NORMAL-ABS ${branch}`
logger.log( logger.log(
'[KERAB-OFFSET-SHRINK-TRIM] trimmed ' + '[KERAB-OFFSET-NORMAL-ABS] ' +
JSON.stringify({ lineName: il.lineName, orig, newPts: { x1: nx1, y1: ny1, x2: nx2, y2: ny2 } }), JSON.stringify({ lineName: il.lineName, branch, newPts: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } }),
) )
} }
// [KERAB-OFFSET-FUNCTIONIZE 2026-06-10] dispatch — 라인 성격으로만 분기.
// 골짜기(케라바 패턴) 라인과 일반 라인은 각자 독립 핸들러로 처리되어 서로 간섭하지 않는다.
for (const il of roof.innerLines || []) {
if (!il) continue
if (isKerabPatternLine(il)) recomputeKerabPatternLine(il)
else recomputeNormalLine(il)
}
} }
// points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작. // points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작.
@ -412,6 +431,30 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
} }
if (typeof roof.addLengthText === 'function') roof.addLengthText() if (typeof roof.addLengthText === 'function') roof.addLengthText()
// [RIDGE-DIAG 2026-06-10] 마루 길이 변동 검사 — 0.5mm 넘게 변하면 어느 분기(branch)에서
// 끌렸는지 + before/after 좌표를 warn 으로 남긴다. 우연 재발 시 콘솔에서 즉시 원인 특정.
for (const [il, before] of ridgeDiagBefore) {
const afterLen = Math.hypot(il.x2 - il.x1, il.y2 - il.y1)
const delta = afterLen - before.len
if (Math.abs(delta) > 0.5) {
logger.warn(
'[RIDGE-DIAG] 마루 길이 변동 ' +
JSON.stringify({
lineName: il.lineName,
branch: il.__ridgeDiagBranch || '(no-branch/skipInnerLines?)',
beforeLen: Math.round(before.len * 100) / 100,
afterLen: Math.round(afterLen * 100) / 100,
delta: Math.round(delta * 100) / 100,
oldOffset,
newOffset,
before: { x1: before.x1, y1: before.y1, x2: before.x2, y2: before.y2 },
after: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 },
}),
)
}
delete il.__ridgeDiagBranch
}
canvas.renderAll() canvas.renderAll()
logger.log( logger.log(
'[KERAB-OFFSET-SURGICAL] applied ' + '[KERAB-OFFSET-SURGICAL] applied ' +