[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()
}
// [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) => {
// [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시
// discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호.
@ -461,6 +591,8 @@ export function useEavesGableEdit(id) {
}
}
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 반영.
// SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존.
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')
target.set({ attributes })
applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
return
}
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')
target.set({ attributes })
applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
return
}
// 인쪽 방향 검증: extender 자연 방향(near→far)의 반대(near→inward) 와 일치해야 함.
@ -1965,6 +2099,16 @@ export function useEavesGableEdit(id) {
if (!ip) 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
// [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 우선 룰.
// V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점.
// 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정.
@ -2313,6 +2457,7 @@ export function useEavesGableEdit(id) {
if (trimRecords.length) target.__valleyExtTrims = trimRecords
}
dumpInnerLineSnapshot('AFTER')
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')')
return
}
@ -2320,6 +2465,7 @@ export function useEavesGableEdit(id) {
logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only')
target.set({ attributes })
applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
return
}
// condition 1: 자연 만남 — ext 없이 hip 제거 + kLine
@ -2335,12 +2481,14 @@ export function useEavesGableEdit(id) {
null,
)
dumpInnerLineSnapshot('AFTER')
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
return
}
}
target.set({ attributes })
applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
logger.log('[KERAB-SIMPLE] attr-only fallback')
return
}
@ -2354,6 +2502,8 @@ export function useEavesGableEdit(id) {
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
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 c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
if (c1 && c2) {
@ -2394,6 +2544,30 @@ export function useEavesGableEdit(id) {
const wCorner = side === 'A' ? wA : wB
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 }
// [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 코너도 위에 있음.
const dx = outerOld.x - innerEnd.x
const dy = outerOld.y - innerEnd.y
@ -2456,6 +2630,7 @@ export function useEavesGableEdit(id) {
delete il.__kerabRevertOuterWhich
delete il.__kerabRevertOuterSide
}
runKerabRuleCheck(roof, 'revert', kerabRevertBeforeSnap)
}
// [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원
if (Array.isArray(target.__kerabParallelHipsSnapshot)) {
@ -3012,6 +3187,9 @@ export function useEavesGableEdit(id) {
const rec = target.__valleyExtTrims[i]
const il = rec.line
if (!il) continue
// [VALLEY-EXT-TRIM-ENDPOINT-GUARD 2026-06-10] split 전용 레코드는 좌표/visible 변경이
// 없으므로 복원할 게 없다. split 세그먼트는 valleyExt(__targetId) 제거에서 정리됨.
if (rec.splitOnly) continue
if (rec.hide) {
il.visible = rec.originalVisible !== false
} else {

View File

@ -40,6 +40,18 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
const oldOffset = matchingRL.attributes?.offset ?? 0
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 prevRL = roof.lines[(idx - 1 + N) % N]
const nextRL = roof.lines[(idx + 1) % N]
@ -118,15 +130,6 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
// 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제.
// 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent.
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)은
// roofLine corner 변경에 따라 끝점도 같이 이동. corner 일치뿐 아니라 옛 segment 위의
// 중간 점(예: kLine 끝점이 옛 roofLine 변 위)도 새 segment 의 동일 t 비율 점으로 매핑.
@ -154,31 +157,66 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
if (t < -0.05 || t > 1.05) return null
return { x: newCorner1.x + t * newSegDx, y: newCorner1.y + t * newSegDy }
}
for (const il of roof.innerLines || []) {
if (!il) continue
// 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점.
// 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 →
// 직각/형상 보존. 절삭/복원 흐름 skip.
if (isKerabPatternLine(il)) {
// [KERAB-APEX-INVARIANT 2026-06-10] skeleton 교점(junction) 판별식(기하, lineName 아님).
// 한 점이 다른 내부선 ≥1개의 끝점과 공유되면 그 점은 라인끼리 만나는 junction 이다.
// junction 은 wallLine 이 정하는 고정점이라 出幅 변경과 무관 → CORNER-SNAP/CASCADE 가 끌면 안 됨.
// degree≥1 임계: Y 형 apex(ridge·2hip, degree 2)뿐 아니라 케라바 병합 후 콜리니어 ridge 하나만
// 남은 junction(degree 1)도 잡아야 한다. (degree≥2 였을 때 병합 apex 를 놓쳐 kLine 의 apex 끝을
// 끌고 자연 ridge 까지 cascade → 마루 길이 증가 + revert R1-dangling 발생.) vExt 는 !isVExt 로 제외.
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 oldY1 = il.y1
const oldX2 = il.x2
const oldY2 = il.y2
const np1 = mapToNewSeg({ x: il.x1, y: il.y1 })
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) {
il.set({ x1: np1.x, y1: np1.y, x2: np2.x, y2: np2.y })
} else if (np1 && !np2) {
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 })
if (!isVExt && skeletonApexDegree({ x: il.x2, y: il.y2 }, il) >= 1) {
il.set({ x1: np1.x, y1: np1.y })
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) {
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 (!isVExt && skeletonApexDegree({ x: il.x1, y: il.y1 }, il) >= 1) {
il.set({ 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 (isRidgeDiagLine(il)) il.__ridgeDiagBranch = `PATTERN-MAP np1=${!!np1} np2=${!!np2}`
if (np1 || np2) {
logger.log(
'[KERAB-PATTERN-CORNER-SNAP] mapped ' +
@ -203,7 +241,10 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
const projY = oldY1 + t * sdy
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 미포함).
// overlap 보조선들은 vExt 의 끝점과 90도로 만나므로 vExt 이동에 같이 따라가야 정합.
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 orig1 = { x: orig.x1, y: orig.y1 }
const orig2 = { x: orig.x2, y: orig.y2 }
// [KERAB-OFFSET-CORNER-SHORTCUT 2026-06-01] orig 끝점이 옛 corner 와 일치하면 새 corner 로 직접 snap.
// 일반 절삭 흐름은 il segment 와 새 roofLine 변의 lineLineIntersection 으로 ip 계산하는데,
// 끝점이 옛 corner 위에 있으면 ip 가 새 corner segment 밖으로 떨어져 segOk 가 reject → 절삭 실패.
// 그 결과 c1·c2 비대칭으로 kLine 대각선 변형됨.
const CORNER_SNAP_TOL_TRIM = 0.5
let cornerSnapped = false
const trySnap = (epx, epy, which) => {
if (Math.hypot(epx - oldCorner1.x, epy - oldCorner1.y) < CORNER_SNAP_TOL_TRIM) {
if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }
if (which === 1) il.set({ x1: newCorner1.x, y1: newCorner1.y })
else il.set({ x2: newCorner1.x, y2: newCorner1.y })
cornerSnapped = true
return true
const recomputeNormalLine = (il) => {
// [KERAB-OFFSET-NORMAL-ABS 2026-06-10] 일반 라인(힙/마루/골짜기) 절대 재계산.
// 도메인: 모든 끝점은 둘 중 하나 —
// (a) anchor = apex(wallLine 이 정하는 skeleton 교점) 또는 변경 무관한 다른 roofLine 변 위 점 → 出幅 불변, 고정.
// (b) hit = 변경된 roofLine 변(oldCorner1→oldCorner2) 위의 처마 끝 → 새 변으로 다시 그린다.
// 변경 변과 무관한 라인(양 끝 모두 hit 아님)은 그대로. 증감·복원 분기 불필요(절대값이라 idempotent).
// __shrinkOrig snapshot 제거 — apex 가 이미 불변(현재값=원본)이라 스냅샷 없이 매번 절대 재계산.
// hit 재계산 규칙:
// - 끝점이 옛 corner 와 일치 → 새 corner 로 snap. (코너는 인접 변끼리의 교점이라
// 라인 자기 방향 교점과 위치가 다르다 → 반드시 코너로 보정. 구 CORNER-SHORTCUT 의 기하 근거.)
// - 끝점이 변 중간(mid-edge) → anchor→hit 라인 방향 보존하며 새 변과의 교점. 방향은 평행
// 出幅 변경에 불변(skeleton 각이등분선)이라 교점이 곧 새 처마 끝. 증가=확장/감소=절삭 동일식.
const CORNER_TOL = 0.5
const atCorner = (px, py) => {
if (Math.hypot(px - oldCorner1.x, py - oldCorner1.y) < CORNER_TOL) return newCorner1
if (Math.hypot(px - oldCorner2.x, py - oldCorner2.y) < CORNER_TOL) return newCorner2
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 (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }
if (which === 1) il.set({ x1: newCorner2.x, y1: newCorner2.y })
else il.set({ x2: newCorner2.x, y2: newCorner2.y })
cornerSnapped = true
return true
if (c2) {
n2 = c2
} else if (m2) {
const ip = lineLineIntersection(e1, e2, newCorner1, newCorner2)
if (!ip) return
n2 = ip
}
return false
branch = `c1=${!!c1} c2=${!!c2} m1=${!!m1} m2=${!!m2}`
}
trySnap(orig.x1, orig.y1, 1)
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 })
il.set({ x1: n1.x, y1: n1.y, x2: n2.x, y2: n2.y })
if (typeof il.setCoords === 'function') il.setCoords()
if (isRidgeDiagLine(il)) il.__ridgeDiagBranch = `NORMAL-ABS ${branch}`
logger.log(
'[KERAB-OFFSET-SHRINK-TRIM] trimmed ' +
JSON.stringify({ lineName: il.lineName, orig, newPts: { x1: nx1, y1: ny1, x2: nx2, y2: ny2 } }),
'[KERAB-OFFSET-NORMAL-ABS] ' +
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 감지가 동작.
@ -412,6 +431,30 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
}
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()
logger.log(
'[KERAB-OFFSET-SURGICAL] applied ' +