dev #911

Merged
ysCha merged 4 commits from dev into prd-deploy 2026-06-12 10:51:27 +09:00
4 changed files with 755 additions and 239 deletions

View File

@ -1141,55 +1141,6 @@ export function useModule() {
canvas.renderAll() canvas.renderAll()
} }
// [MODULE-MULTI-SELECT 2026-06-10] 드래그 완료 후 지붕면 이탈/겹침 검증 → 위반 시 startPos 복원, 정상이면 확정
const commitModuleDragMove = (target, startPos) => {
if (!canvas || !target) return
const isGroup = target.type === 'activeSelection'
const modules = isGroup
? target.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE)
: target.name === POLYGON_TYPE.MODULE
? [target]
: []
if (modules.length === 0) return
const movingIds = new Set(modules.map((module) => module.id))
const objects = getObjects()
const invalid = modules.some((module) => {
const surface = canvas.getObjects().find((obj) => obj.id === module.surfaceId && obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
if (surface && isOutsideSurface(module, surface)) return true
const others = canvas
.getObjects()
.filter((obj) => obj.name === POLYGON_TYPE.MODULE && obj.surfaceId === module.surfaceId && !movingIds.has(obj.id))
if (isOverlapOtherModules(module, others)) return true
if (isOverlapObjects(module, objects)) return true
return false
})
// 위반 시 드래그 시작 위치로 복원 (그룹/단일 공통 — 둘 다 left/top 평행이동)
if (invalid && startPos) {
target.set({ left: startPos.left, top: startPos.top })
target.setCoords()
}
// 그룹은 자식 좌표가 상대값이라 discard 로 절대좌표 베이킹. 단일은 이미 절대좌표라 선택 유지.
if (isGroup) {
canvas.discardActiveObject()
}
const surfaceIds = [...new Set(modules.map((module) => module.surfaceId))]
surfaceIds.forEach((surfaceId) => recalculateAllModulesCoords(surfaceId))
canvas.requestRenderAll()
if (invalid) {
swalFire({
title: getMessage('can.not.move.module'),
icon: 'error',
type: 'alert',
})
}
}
/** /**
* [PLAN-CORRUPT-RESTORE 2026-05-18] 손상 module 좌표 복원. * [PLAN-CORRUPT-RESTORE 2026-05-18] 손상 module 좌표 복원.
* *
@ -1329,7 +1280,6 @@ export function useModule() {
moduleRoofRemove, moduleRoofRemove,
alignModule, alignModule,
recalculateAllModulesCoords, recalculateAllModulesCoords,
commitModuleDragMove,
restoreCorruptedModules, restoreCorruptedModules,
} }
} }

View File

@ -24,6 +24,10 @@ import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical'
// false 로 두면 이번 세션 변경 전 동작 (apply/revert 시 출폭 입력값 무시) 으로 즉시 회귀. // false 로 두면 이번 세션 변경 전 동작 (apply/revert 시 출폭 입력값 무시) 으로 즉시 회귀.
const ENABLE_KERAB_OFFSET_SURGICAL = true const ENABLE_KERAB_OFFSET_SURGICAL = true
// [KERAB-TYPE-EAVES 2026-06-11] TYPE(A/B 박공) 출신 케라바→처마 전용 변환 토글.
// false 면 기존 applyKerabRevertPattern 폴백(토글 이력 기반) 으로 회귀.
const ENABLE_TYPE_GABLE_TO_EAVES = true
// 처마.케라바 변경 // 처마.케라바 변경
export function useEavesGableEdit(id) { export function useEavesGableEdit(id) {
const canvas = useRecoilValue(canvasState) const canvas = useRecoilValue(canvasState)
@ -152,6 +156,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 흐름 보호.
@ -238,6 +372,53 @@ export function useEavesGableEdit(id) {
break break
} }
// [KERAB-STATE-DUMP 2026-06-11] A/B타입(가로/세로 케라바) 토글 진입 시점 지붕 상태 진단.
// 벽 type 지정은 있는데 내부 skLine 이 0인 "무에서 유" 케이스 파악용 — 사용자 설명 전 사실 수집.
// 읽기 전용(좌표/visible/type 만 덤프), 동작 변경 없음. logger 게이트(local 만 출력).
{
const _dr = canvas
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
const _r1 = (v) => (typeof v === 'number' ? Math.round(v * 10) / 10 : v)
logger.log(
'[KERAB-STATE-DUMP] ' +
JSON.stringify({
uiType: typeRef.current,
radio: radioTypeRef.current,
target: {
type: target.attributes?.type,
offset: target.attributes?.offset,
x1: _r1(target.x1),
y1: _r1(target.y1),
x2: _r1(target.x2),
y2: _r1(target.y2),
},
willBecome: attributes?.type,
roofId: target.attributes?.roofId,
roofFound: !!_dr,
points: _dr?.points?.map((p) => ({ x: _r1(p.x), y: _r1(p.y) })),
lines: _dr?.lines?.map((l) => ({
type: l.attributes?.type,
offset: l.attributes?.offset,
x1: _r1(l.x1),
y1: _r1(l.y1),
x2: _r1(l.x2),
y2: _r1(l.y2),
})),
innerCount: (_dr?.innerLines || []).length,
innerLines: (_dr?.innerLines || []).map((il) => ({
lineName: il.lineName,
type: il.attributes?.type,
visible: il.visible !== false,
x1: _r1(il.x1),
y1: _r1(il.y1),
x2: _r1(il.x2),
y2: _r1(il.y2),
})),
}),
)
}
// [2240 KERAB-NOOP-REKLICK 2026-05-19] 같은 type 으로의 재클릭은 무동작. // [2240 KERAB-NOOP-REKLICK 2026-05-19] 같은 type 으로의 재클릭은 무동작.
// - 케라바→케라바, 처마→처마 등. 기존 rebuild 흐름이 다시 돌면 패턴 상태 // - 케라바→케라바, 처마→처마 등. 기존 rebuild 흐름이 다시 돌면 패턴 상태
// (ridge/half-label/orphan ext 정리)를 망가뜨림. // (ridge/half-label/orphan ext 정리)를 망가뜨림.
@ -461,6 +642,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 +851,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 +926,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) 와 일치해야 함.
@ -1720,36 +1905,17 @@ export function useEavesGableEdit(id) {
wallHit = ip wallHit = ip
} }
} }
let ridgeStop = null // [KERAB-VALLEY-HALF 2026-06-11] A/B 타입 표준 규칙(방향 무관):
let ridgeT = Infinity // 내부확장라인은 맞은편 roofLine 까지 거리의 "절반"에서 멈춘다.
// [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (roof 측 post 경로). // 기존 ridge-stop / wall-끝까지 휴리스틱은 ridge 가 우연히 중간에 있으면 절반처럼,
for (const il of roof.innerLines || []) { // 없으면 끝까지 가버려 방향(세로/가로)에 따라 결과가 들쭉날쭉했다 → 항상 절반으로 통일.
if (!il || il.visible === false) continue // wallHit = 이 ray 가 만나는 맞은편 roof 외곽선(roofLine). 그 중점이 stop.
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue if (wallHit) {
if (il.lineName !== 'ridge') continue bestStop = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 }
const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (!ip) continue
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
if (t < 0.5) continue
if (wallT !== Infinity && t > wallT + 0.5) continue
if (t < ridgeT) {
ridgeT = t
ridgeStop = ip
}
}
if (ridgeStop) {
bestStop = ridgeStop
logger.log( logger.log(
'[KERAB-VALLEY-EXT-RIDGE-STOP] post label=' + ep.label + '[KERAB-VALLEY-HALF] post label=' + ep.label +
' ridgeT=' + Math.round(ridgeT * 100) / 100 + ' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' +
' stop={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}', ' halfStop={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
)
} else if (wallHit) {
bestStop = wallHit
logger.log(
'[KERAB-VALLEY-EXT-WALL] post label=' + ep.label +
' end={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
) )
} }
if (bestStop) { if (bestStop) {
@ -1915,15 +2081,11 @@ export function useEavesGableEdit(id) {
const offX = newWStart.x - vStart.x const offX = newWStart.x - vStart.x
const offY = newWStart.y - vStart.y const offY = newWStart.y - vStart.y
const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY } const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY }
// wEnd(원 ray hit) 가 wEndProj 보다 ray 방향으로 더 멀리 있는지 — newWStart 기준 투영 비교.
const tProj = (wEndProj.x - newWStart.x) * wUx + (wEndProj.y - newWStart.y) * wUy
const tEndR = (wEnd.x - newWStart.x) * wUx + (wEnd.y - newWStart.y) * wUy
buildOverlapLine(newWStart, wEndProj, '-wall') buildOverlapLine(newWStart, wEndProj, '-wall')
buildOverlapLine(vStart, newWStart, '-bridge-start') buildOverlapLine(vStart, newWStart, '-bridge-start')
buildOverlapLine(vEnd, wEndProj, '-bridge-end') buildOverlapLine(vEnd, wEndProj, '-bridge-end')
if (tEndR > tProj + 0.5) { // [KERAB-VALLEY-HALF 2026-06-11] 절반 규칙: wLine(wallBase) 도 vEnd(절반) 까지만.
buildOverlapLine(wEndProj, wEnd, '-extend') // wEndProj 는 절반인 vEnd 에서 파생되므로, wEnd(원 ray full hit) 너머 확장은 금지.
}
logger.log( logger.log(
'[KERAB-VALLEY-OVERLAP] drawn ' + '[KERAB-VALLEY-OVERLAP] drawn ' +
JSON.stringify({ JSON.stringify({
@ -1934,7 +2096,7 @@ export function useEavesGableEdit(id) {
wEnd: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 }, wEnd: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 },
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 }, vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 }, vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
extended: tEndR > tProj + 0.5, extended: false,
}), }),
) )
@ -1965,6 +2127,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 +2485,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 +2493,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 +2509,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 +2530,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) {
@ -2369,6 +2547,9 @@ export function useEavesGableEdit(id) {
// 증상: (1) b1 ray-cast 후 b4 출폭조정 → SHRINK-TRIM restore 가 b1 hip 을 snapshot 좌표로 리셋 // 증상: (1) b1 ray-cast 후 b4 출폭조정 → SHRINK-TRIM restore 가 b1 hip 을 snapshot 좌표로 리셋
// (2) CORNER-SHORTCUT 가 hip outer endpoint 를 새 roofLine 코너로 강제 스냅. // (2) CORNER-SHORTCUT 가 hip outer endpoint 를 새 roofLine 코너로 강제 스냅.
// hip 의 outer endpoint 만 아래 ray-cast 로 새 rL 변까지 45° 확장한다. // hip 의 outer endpoint 만 아래 ray-cast 로 새 rL 변까지 45° 확장한다.
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] surgical 은 roof.points 를 새 출폭으로 갱신하므로,
// 게이블 코너 hip 판정용으로 이동 전 옛 폴리곤 꼭짓점을 미리 스냅샷한다.
const oldPolyPoints = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y }))
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true }) if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true })
const wA = { x: target.x1, y: target.y1 } const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 } const wB = { x: target.x2, y: target.y2 }
@ -2394,35 +2575,83 @@ 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 }
// hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음. // [KERAB-REVERT-SHARED-ENDPOINT-GUARD 2026-06-10] outer(확장) 끝이 다른 내부선과
const dx = outerOld.x - innerEnd.x // 공유되면 확장하지 않는다. 골짜기(valley) 내부 hip 은 양 끝이 skeleton 교점이라 outer 도
const dy = outerOld.y - innerEnd.y // 공유 → 자동 제외. 게이블/코너 hip 의 outer 는 free(roofLine 변에 홀로 도달) → 확장.
const dlen = Math.hypot(dx, dy) // 판정이 출폭·snapshot 좌표와 무관한 구조 기준이라 다중 출폭 변경에도 안 깨진다.
if (dlen < 1e-6) { // (구 c1/c2 가드는 c1/c2 를 현재 출폭으로, outerOld 를 forward 당시 출폭으로 잡아
delete il.__kerabRevertOuterWhich // 출폭이 여러 번 바뀌면 게이블 hip 을 interior 로 오판 → SK 확장이 중간에 멈췄다.)
delete il.__kerabRevertOuterSide const SHARE_TOL = 2.0
continue 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),
)
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] TYPE 출신 게이블 hip 의 outer 끝은 폴리곤 코너(roofLine 꼭짓점)
// 라 인접 면경계선과 공유 → outerShared 가 true 가 돼 확장이 스킵됐다(처마변경 실패: hip 이 옛 코너에 멈춤).
// 골짜기 내부 hip 의 outer 는 폴리곤 경계에 없는 skeleton 교점이다. 따라서 outerOld 가 (이동 전) 폴리곤
// 꼭짓점이면 게이블 코너 hip 으로 보고 확장을 강제, 공유여도 스킵하지 않는다.
const VERT_TOL = 2.0
// outerOld 가 (이동 전) 폴리곤 꼭짓점이면 그 인덱스를 잡는다. surgical 은 점 순서·개수를 보존하므로
// 같은 인덱스의 새 roof.points 가 곧 이동 후 코너 = 게이블 hip 의 새 outer 끝점.
let vtxIdx = -1
for (let vi = 0; vi < oldPolyPoints.length; vi++) {
const p = oldPolyPoints[vi]
if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) {
vtxIdx = vi
break
}
} }
const ux = dx / dlen const outerOnVertex = vtxIdx >= 0
const uy = dy / dlen if (outerShared && !outerOnVertex) {
// wL 코너에서 45° 방향으로 ray → 새 rL 변 (roof.points 는 surgical 후 새 출폭) 의 첫 hit.
let bestT = Infinity
for (let k = 0; k < M; k++) {
const A = rps[k]
const B = rps[(k + 1) % M]
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
if (t < bestT) bestT = t
}
if (!isFinite(bestT) || bestT < 0.5) {
logger.log( logger.log(
'[KERAB-REVERT-EXTEND-45] no-hit ' + '[KERAB-REVERT-EXTEND-45] skip (interior hip — outer shared) ' +
JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }), JSON.stringify({ lineName: il.lineName, which, side, outerOld }),
) )
delete il.__kerabRevertOuterWhich delete il.__kerabRevertOuterWhich
delete il.__kerabRevertOuterSide delete il.__kerabRevertOuterSide
continue continue
} }
const hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT } let hit
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] 게이블 코너 hip: 45° ray-cast 는 출폭이 클 때 인접
// 변에 먼저 닿아(예: 출폭 80 → -803 에서 멈춤) 코너(-839)에 못 미친다 → rLine 겹침/틈.
// surgical 후 동일 인덱스 코너로 직접 스냅해 항상 새 roofLine 꼭짓점까지 도달시킨다.
if (outerOnVertex && rps.length === oldPolyPoints.length && rps[vtxIdx]) {
hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y }
} else {
// hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음.
const dx = outerOld.x - innerEnd.x
const dy = outerOld.y - innerEnd.y
const dlen = Math.hypot(dx, dy)
if (dlen < 1e-6) {
delete il.__kerabRevertOuterWhich
delete il.__kerabRevertOuterSide
continue
}
const ux = dx / dlen
const uy = dy / dlen
// wL 코너에서 45° 방향으로 ray → 새 rL 변 (roof.points 는 surgical 후 새 출폭) 의 첫 hit.
let bestT = Infinity
for (let k = 0; k < M; k++) {
const A = rps[k]
const B = rps[(k + 1) % M]
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
if (t < bestT) bestT = t
}
if (!isFinite(bestT) || bestT < 0.5) {
logger.log(
'[KERAB-REVERT-EXTEND-45] no-hit ' +
JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }),
)
delete il.__kerabRevertOuterWhich
delete il.__kerabRevertOuterSide
continue
}
hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
}
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y) const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y) const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
const ratio = oldLen > 0 ? newLen / oldLen : 1 const ratio = oldLen > 0 ? newLen / oldLen : 1
@ -2456,6 +2685,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)) {
@ -2471,15 +2701,78 @@ export function useEavesGableEdit(id) {
) )
if (kLineRidge) { if (kLineRidge) {
target.set({ attributes }) target.set({ attributes })
// [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] TYPE 출신 revert(케라바→처마)도 출폭이 바뀌므로 옛 변 위
// 면경계선이 따라와야 한다(안 그러면 rLine 겹침). apex(= kLine 의 cMid 반대 끝) + edgeIdx 미리 캡처.
const _kMid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
const _kApex =
Math.hypot(kLineRidge.x1 - _kMid.x, kLineRidge.y1 - _kMid.y) >= Math.hypot(kLineRidge.x2 - _kMid.x, kLineRidge.y2 - _kMid.y)
? { x: kLineRidge.x1, y: kLineRidge.y1 }
: { x: kLineRidge.x2, y: kLineRidge.y2 }
const _kTol = 0.5
const _kNP = roof.points.length
let _kEdgeIdx = -1
for (let i = 0; i < _kNP; i++) {
const p = roof.points[i]
const q = roof.points[(i + 1) % _kNP]
if (
(Math.hypot(p.x - c1.x, p.y - c1.y) < _kTol && Math.hypot(q.x - c2.x, q.y - c2.y) < _kTol) ||
(Math.hypot(p.x - c2.x, p.y - c2.y) < _kTol && Math.hypot(q.x - c1.x, q.y - c1.y) < _kTol)
) {
_kEdgeIdx = i
break
}
}
const ok = applyKerabRevertPattern(roof, target, c1, c2, { ridge: kLineRidge, apex: null }) const ok = applyKerabRevertPattern(roof, target, c1, c2, { ridge: kLineRidge, apex: null })
if (ok) surgicalAfter() if (ok) {
logger.log('[KERAB-REVERT] applied (kLineOnly via targetId) ' + JSON.stringify({ ok })) surgicalAfter()
moveStaleEdgeInnerLines(roof, c1, c2, _kEdgeIdx, _kApex)
extendTypeGableHipsStraightToRoofLine(roof, target, _kApex)
}
logger.log('[KERAB-REVERT] applied (kLineOnly via targetId) ' + JSON.stringify({ ok, edgeIdx: _kEdgeIdx, apex: _kApex }))
if (ok) return if (ok) return
} }
// [2240 KERAB-MID-FROM-TARGET 2026-05-20] revert 도 target 중점 기준 ridge 검색 (forward 와 일관) // [2240 KERAB-MID-FROM-TARGET 2026-05-20] revert 도 target 중점 기준 ridge 검색 (forward 와 일관)
const tEnd1 = { x: target.x1, y: target.y1 } const tEnd1 = { x: target.x1, y: target.y1 }
const tEnd2 = { x: target.x2, y: target.y2 } const tEnd2 = { x: target.x2, y: target.y2 }
const ridgeAtMid = findRidgeAtMidpoint(roof, tEnd1, tEnd2) const ridgeAtMid = findRidgeAtMidpoint(roof, tEnd1, tEnd2)
// [KERAB-TYPE-EAVES 2026-06-11] TYPE 출신(native 마루·토글 이력 없음) 은 전용 함수로 분기.
// 기존 폴백(applyKerabRevertPattern single-ridge)은 apex=마루 far-end + 마루 전체 삭제라
// L/2 setback·마루 단축 규칙과 어긋남. native 마루일 때만 가로채고 나머지는 그대로.
// 불변식 "마루는 roofLine 까지" → native 마루 끝점은 wLine 중점이 아니라 roofLine 코너(c1/c2)
// 중점에 있다. 그래서 파인더는 c1/c2 기준 (tEnd 기준 ridgeAtMid 는 出幅>0 이면 null).
const typeRidge = ridgeAtMid || findRidgeAtMidpoint(roof, c1, c2)
if (ENABLE_TYPE_GABLE_TO_EAVES && typeRidge && isNativeTypeRidge(typeRidge.ridge)) {
target.set({ attributes })
// 변환 전 옛 roofLine 변(c1↔c2) 의 polygon 인덱스 캡처 — surgical 후 새 코너 좌표 매핑용.
const _EDGE_TOL = 0.5
const _NP = roof.points.length
let _edgeIdx = -1
for (let i = 0; i < _NP; i++) {
const p = roof.points[i]
const q = roof.points[(i + 1) % _NP]
if (
(Math.hypot(p.x - c1.x, p.y - c1.y) < _EDGE_TOL && Math.hypot(q.x - c2.x, q.y - c2.y) < _EDGE_TOL) ||
(Math.hypot(p.x - c2.x, p.y - c2.y) < _EDGE_TOL && Math.hypot(q.x - c1.x, q.y - c1.y) < _EDGE_TOL)
) {
_edgeIdx = i
break
}
}
const apexRes = applyTypeGableToEavesPattern(roof, target, typeRidge.ridge)
if (apexRes) {
surgicalAfter()
// [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] surgicalAfter 는 skipInnerLines:true 라 옛 roofLine
// 변 위에 놓인 normal inner line(L자 면경계)이 안 따라온다 → 옛 위치에 ghost("한개 더").
// 변은 出幅만큼 평행이동(delta)했으므로, 옛 변 위 끝점만 동일 delta 로 평행이동(= NORMAL-ABS 동치).
// 단, cMid(변 중점)에 걸린 비-collinear 선의 끝점은 apex 로 수렴해야 한다(힙 사이 마루 금지).
// 케라바패턴 라인(내 hip)은 surgicalAfter 의 ray-cast 가 이미 처리 → 제외.
moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes)
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장.
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
}
logger.log('[KERAB-TYPE-EAVES] branch ' + JSON.stringify({ ok: !!apexRes, c1, c2, edgeIdx: _edgeIdx }))
if (apexRes) return
}
if (ridgeAtMid) { if (ridgeAtMid) {
target.set({ attributes }) target.set({ attributes })
const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid) const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid)
@ -3012,6 +3305,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 {
@ -3206,6 +3502,250 @@ export function useEavesGableEdit(id) {
return true return true
} }
// [KERAB-TYPE-EAVES 2026-06-11] TYPE(A/B 박공) 출신 케라바→처마 전용 변환.
// forward 토글 스냅샷이 없는 native 마루(RIDGE) 케이스. 마루확장라인을 hip 2개로 "펼친다".
// 규칙(사용자 확정, 방향 무관):
// 1) apex = 선택라인(wLine) 중점에서 마루 방향(inward) 으로 L/2. (45° 등거리 = 반으로 쪼갬)
// 2) 마루는 케라바변에 닿은 끝점을 apex 로 단축.
// 3) hip 2개(wA→apex, wB→apex) 생성 + outer 마크 → surgicalAfter 가 새 출폭 rLine 까지 45° ray-cast.
// markRevertOuter 는 applyKerabRevertPattern 내부 클로저라 여기선 which/side 를 직접 마크.
const isNativeTypeRidge = (ridge) =>
!!ridge &&
ridge.name === LINE_TYPE.SUBLINE.RIDGE &&
!ridge.__patternKind &&
!ridge.__originalHips &&
!ridge.__removedHipsSnapshot &&
ridge.lineName !== 'kerabPatternRidge' &&
ridge.lineName !== 'kerabPatternExtRidge'
const applyTypeGableToEavesPattern = (roof, target, ridge) => {
const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 }
const L = Math.hypot(wB.x - wA.x, wB.y - wA.y)
if (L < 1) return null
const mid = { x: (wA.x + wB.x) / 2, y: (wA.y + wB.y) / 2 }
// 마루의 케라바변쪽 끝점 식별 + 내부(inward) 방향 도출.
const rA = { x: ridge.x1, y: ridge.y1 }
const rB = { x: ridge.x2, y: ridge.y2 }
const midIsA = Math.hypot(rA.x - mid.x, rA.y - mid.y) <= Math.hypot(rB.x - mid.x, rB.y - mid.y)
const ridgeInner = midIsA ? rB : rA
let ix = ridgeInner.x - mid.x
let iy = ridgeInner.y - mid.y
const ilen = Math.hypot(ix, iy) || 1
ix /= ilen
iy /= ilen
const apex = { x: mid.x + ix * (L / 2), y: mid.y + iy * (L / 2) }
// 마루 단축: 케라바변쪽 끝점 → apex.
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
else ridge.set({ x2: apex.x, y2: apex.y })
const rsz = calcLinePlaneSize({ x1: ridge.x1, y1: ridge.y1, x2: ridge.x2, y2: ridge.y2 })
if (ridge.attributes) {
ridge.attributes.planeSize = rsz
ridge.attributes.actualSize = rsz
}
if (typeof ridge.setCoords === 'function') ridge.setCoords()
if (typeof ridge.setLength === 'function') ridge.setLength()
if (typeof ridge.addLengthText === 'function') ridge.addLengthText()
// hip 2개: wA→apex, wB→apex. pts=[corner, apex] 라 corner 끝(x1,y1)이 outer(=which 1).
const mkHip = (corner, side) => {
const pts = [corner.x, corner.y, apex.x, apex.y]
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
const hip = new QLine(pts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.HIP,
textMode: roof.textMode,
// [KERAB-TYPE-EAVES-ROUNDTRIP 2026-06-11] forward(처마→케라바) findHipAtEndpoint 는
// attributes.type === HIP 로 매칭한다. type 누락 시 재변환에서 hip 미인식 → attr-only 폴백.
attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.HIP, planeSize: sz, actualSize: sz },
})
hip.lineName = 'kerabPatternHip'
hip.__kerabRevertOuterWhich = 1
hip.__kerabRevertOuterSide = side
return hip
}
const hip1 = mkHip(wA, 'A')
const hip2 = mkHip(wB, 'B')
canvas.add(hip1)
canvas.add(hip2)
hip1.bringToFront()
hip2.bringToFront()
roof.innerLines.push(hip1, hip2)
removeKerabHalfLabels(target.id)
hideOriginalLengthText(target.id, false)
canvas.renderAll()
logger.log(
'[KERAB-TYPE-EAVES] applied ' +
JSON.stringify({ L: Math.round(L), mid, apex, inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) } }),
)
return apex
}
// [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] TYPE 변환 후 옛 roofLine 변 위에 남은 normal inner line 이동.
// surgicalAfter(skipInnerLines:true) 는 폴리곤 변·내 hip 만 갱신하고 L자 면경계선은 안 옮긴다 →
// 옛 出幅 위치(ghost) 잔존. 변은 出幅만큼 평행이동했으므로 옛 변 위 끝점만 동일 delta 로 평행이동한다.
// (出幅 평행이동이라 변 위 모든 점의 변위가 동일 = recomputeNormalLine 의 NORMAL-ABS 와 동치.)
// 케라바패턴 라인은 ray-cast 가 이미 처리하므로 제외. oldC1/oldC2 = 변환 전 roofLine 코너.
// [KERAB-TYPE-EAVES-WEDGE 2026-06-11] apex 인자 추가. 처마 위상에서는 두 힙이 apex 에서 만나고
// 힙 사이 쐐기에는 라인이 없어야 한다. 옛 변 중점(cMid)에 끝점이 닿아 있던 非평행 라인(=힙)은
// 평행이동 시 쐐기를 가로지르므로, 그 끝점만 apex 로 수렴시킨다. 변과 평행한 라인(roofLine 절반)과
// 코너 끝점은 종전처럼 出幅 delta 로 평행이동.
const moveStaleEdgeInnerLines = (roof, oldC1, oldC2, edgeIdx, apex) => {
if (edgeIdx < 0 || !Array.isArray(roof.points)) return null
const N = roof.points.length
const nP = roof.points[edgeIdx]
const nQ = roof.points[(edgeIdx + 1) % N]
// c1↔c2 ↔ nP↔nQ 방향 정합 (idx 끝과 가까운 쪽으로 매핑).
const c1ToP = Math.hypot(oldC1.x - nP.x, oldC1.y - nP.y) <= Math.hypot(oldC1.x - nQ.x, oldC1.y - nQ.y)
const newA = c1ToP ? nP : nQ
const newB = c1ToP ? nQ : nP
// 出幅 평행이동 delta (양 코너 동일, 안전하게 평균).
const dxE = ((newA.x - oldC1.x) + (newB.x - oldC2.x)) / 2
const dyE = ((newA.y - oldC1.y) + (newB.y - oldC2.y)) / 2
if (Math.hypot(dxE, dyE) < 0.01) return null
const segDx = oldC2.x - oldC1.x
const segDy = oldC2.y - oldC1.y
const segLen2 = segDx * segDx + segDy * segDy || 1
const segLen = Math.sqrt(segLen2)
const onOldEdge = (px, py) => {
const t = ((px - oldC1.x) * segDx + (py - oldC1.y) * segDy) / segLen2
if (t < -0.02 || t > 1.02) return false
const prx = oldC1.x + t * segDx
const pry = oldC1.y + t * segDy
return Math.hypot(px - prx, py - pry) < 0.5
}
const oldMid = { x: (oldC1.x + oldC2.x) / 2, y: (oldC1.y + oldC2.y) / 2 }
const nearMid = (px, py) => Math.hypot(px - oldMid.x, py - oldMid.y) < 2.0
// 라인 방향이 변과 평행이면 cross 가 0 → roofLine 절반(평행이동 대상).
const isCollinearWithEdge = (x1, y1, x2, y2) => {
const lx = x2 - x1
const ly = y2 - y1
const llen = Math.hypot(lx, ly)
if (llen < 0.01) return true
const cross = Math.abs(lx * segDy - ly * segDx) / (llen * segLen)
return cross < 0.05
}
const isKerabPattern = (ln) =>
ln === 'kerabPatternHip' ||
ln === 'kerabPatternRidge' ||
ln === 'kerabPatternExtHip' ||
ln === 'kerabPatternExtRidge' ||
ln === 'kerabPatternValleyExt'
let movedCnt = 0
for (const il of roof.innerLines || []) {
if (!il || isKerabPattern(il.lineName)) continue
const collinear = isCollinearWithEdge(il.x1, il.y1, il.x2, il.y2)
// 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동.
const relocate = (px, py) => {
if (apex && !collinear && nearMid(px, py)) return { x: apex.x, y: apex.y }
return { x: px + dxE, y: py + dyE }
}
let moved = false
if (onOldEdge(il.x1, il.y1)) {
const np1 = relocate(il.x1, il.y1)
il.set({ x1: np1.x, y1: np1.y })
moved = true
}
if (onOldEdge(il.x2, il.y2)) {
const np2 = relocate(il.x2, il.y2)
il.set({ x2: np2.x, y2: np2.y })
moved = true
}
if (moved) {
const ns = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
if (il.attributes) {
il.attributes.planeSize = ns
il.attributes.actualSize = ns
}
if (typeof il.setCoords === 'function') il.setCoords()
if (typeof il.setLength === 'function') il.setLength()
if (typeof il.addLengthText === 'function') il.addLengthText()
movedCnt++
}
}
canvas.renderAll()
logger.log('[KERAB-TYPE-EAVES-EDGEMOVE] ' + JSON.stringify({ edgeIdx, delta: { dxE, dyE }, movedCnt }))
return { dxE, dyE }
}
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] 힙은 단일 직선 45°. apex 에서 벽교점(wLine 코너) 방향(=45°)
// 그대로 roofLine 까지 직선 연장한다. 꺾지 않고, roofLine '코너/교점'을 찾아가지도 않는다 — ray 가
// roofLine 변에 처음 닿는 지점이 끝점(出幅60 → 코너 10mm 못미쳐 변 위에서 끝남, 사용자 모델 그대로).
// apex 는 벽기준 고정값이라 出幅 무관(half-rule 폐기) — "出幅에 의해 힙·마루 변질" 차단.
// surgicalAfter/EXTEND-45 가 코너 스냅한 힙을 ray-cast 직선으로 덮어쓴다.
const extendTypeGableHipsStraightToRoofLine = (roof, target, apex) => {
if (!apex || !roof || !Array.isArray(roof.innerLines)) return
const pts = Array.isArray(roof.points) ? roof.points : []
if (pts.length < 2) return
const wcs = [
{ x: target.x1, y: target.y1 },
{ x: target.x2, y: target.y2 },
]
// ray P+t*dir 가 선분 A→B 와 만나는 t(>0) 반환, 없으면 Infinity.
const rayHit = (P, dir, A, B) => {
const ex = B.x - A.x
const ey = B.y - A.y
const den = dir.x * ey - dir.y * ex
if (Math.abs(den) < 1e-9) return Infinity
const t = ((A.x - P.x) * ey - (A.y - P.y) * ex) / den
const u = ((A.x - P.x) * dir.y - (A.y - P.y) * dir.x) / den
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) return t
return Infinity
}
const APEX_TOL = 2.0
let extended = 0
for (const il of [...roof.innerLines]) {
if (!il || il.lineName !== 'kerabPatternHip') continue
const d1 = Math.hypot(il.x1 - apex.x, il.y1 - apex.y)
const d2 = Math.hypot(il.x2 - apex.x, il.y2 - apex.y)
if (Math.min(d1, d2) > APEX_TOL) continue
const apexIsE1 = d1 <= d2
const outerEnd = apexIsE1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
// outerEnd 에 가까운 벽교점(wLine 끝점) 선택.
const wc =
Math.hypot(outerEnd.x - wcs[0].x, outerEnd.y - wcs[0].y) <= Math.hypot(outerEnd.x - wcs[1].x, outerEnd.y - wcs[1].y)
? wcs[0]
: wcs[1]
// 방향 = apex→wc (45°). 이 방향 그대로 roofLine 까지 ray.
const dx = wc.x - apex.x
const dy = wc.y - apex.y
const dlen = Math.hypot(dx, dy) || 1
const dir = { x: dx / dlen, y: dy / dlen }
// wc 직전(살짝 안쪽)에서 ray 발사 → wc 통과 후 첫 roofLine 변 교차.
const P = { x: wc.x - dir.x * 0.05, y: wc.y - dir.y * 0.05 }
let best = Infinity
let hit = null
for (let i = 0; i < pts.length; i++) {
const A = pts[i]
const B = pts[(i + 1) % pts.length]
const t = rayHit(P, dir, A, B)
if (t < best) {
best = t
hit = { x: P.x + dir.x * t, y: P.y + dir.y * t }
}
}
if (!hit) continue
// 힙 = apex → hit (단일 직선 45°).
if (apexIsE1) il.set({ x1: apex.x, y1: apex.y, x2: hit.x, y2: hit.y })
else il.set({ x1: hit.x, y1: hit.y, x2: apex.x, y2: apex.y })
const sz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
if (il.attributes) {
il.attributes.planeSize = sz
il.attributes.actualSize = sz
il.attributes.extended = true
}
il.__extended = true
if (typeof il.setCoords === 'function') il.setCoords()
if (typeof il.setLength === 'function') il.setLength()
if (typeof il.addLengthText === 'function') il.addLengthText()
extended++
}
canvas.renderAll()
logger.log('[KERAB-TYPE-EAVES-STRAIGHT] ' + JSON.stringify({ extended, apex }))
}
// [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨 // [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨
// 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출. // 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출.
// 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체. // 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.

View File

@ -8,7 +8,6 @@ import { fontSelector } from '@/store/fontAtom'
import { MENU, POLYGON_TYPE } from '@/common/common' import { MENU, POLYGON_TYPE } from '@/common/common'
import { useText } from '@/hooks/useText' import { useText } from '@/hooks/useText'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
import { useModule } from '@/hooks/module/useModule'
// 캔버스에 필요한 이벤트 // 캔버스에 필요한 이벤트
export function useCanvasEvent() { export function useCanvasEvent() {
@ -23,7 +22,6 @@ export function useCanvasEvent() {
const isManualModuleSetup = useRecoilValue(isManualModuleSetupState) const isManualModuleSetup = useRecoilValue(isManualModuleSetupState)
const { changeCorridorDimensionText } = useText() const { changeCorridorDimensionText } = useText()
const { setPolygonLinesActualSize } = usePolygon() const { setPolygonLinesActualSize } = usePolygon()
const { commitModuleDragMove } = useModule()
// [MODULE-MULTI-SELECT 2026-06-10] 모듈 복수선택 refine 재진입 가드 (rebuild 중첩 방지) // [MODULE-MULTI-SELECT 2026-06-10] 모듈 복수선택 refine 재진입 가드 (rebuild 중첩 방지)
const refiningRef = useRef(false) const refiningRef = useRef(false)
@ -61,7 +59,6 @@ export function useCanvasEvent() {
canvas?.on('object:added', objectEvent.onChange) canvas?.on('object:added', objectEvent.onChange)
canvas?.on('object:added', objectEvent.addEvent) canvas?.on('object:added', objectEvent.addEvent)
canvas?.on('object:modified', objectEvent.onChange) canvas?.on('object:modified', objectEvent.onChange)
canvas?.on('object:modified', handleModuleSelectionMoved)
canvas?.on('object:removed', objectEvent.onChange) canvas?.on('object:removed', objectEvent.onChange)
canvas?.on('selection:cleared', selectionEvent.cleared) canvas?.on('selection:cleared', selectionEvent.cleared)
canvas?.on('selection:created', selectionEvent.created) canvas?.on('selection:created', selectionEvent.created)
@ -294,10 +291,10 @@ export function useCanvasEvent() {
const active = canvas.getActiveObject() const active = canvas.getActiveObject()
if (!active) return if (!active) return
// 단일 모듈 선택: 마우스 이동 가능하도록 이동잠금 해제 (회전·스케일은 잠금 유지). // [MODULE-DRAG-DISABLE 2026-06-12] 단일 모듈 선택: 마우스 이동 잠금 유지(드래그 박스 복수선택·Del 삭제만 허용). 회전·스케일도 잠금.
if (active.type !== 'activeSelection') { if (active.type !== 'activeSelection') {
if (active.name === POLYGON_TYPE.MODULE) { if (active.name === POLYGON_TYPE.MODULE) {
active.set({ lockMovementX: false, lockMovementY: false, lockRotation: true, lockScalingX: true, lockScalingY: true, hasControls: false }) active.set({ lockMovementX: true, lockMovementY: true, lockRotation: true, lockScalingX: true, lockScalingY: true, hasControls: false })
canvas.requestRenderAll() canvas.requestRenderAll()
} }
return return
@ -317,11 +314,12 @@ export function useCanvasEvent() {
refiningRef.current = false refiningRef.current = false
} }
// [MODULE-DRAG-DISABLE 2026-06-12] 복수선택 그룹도 이동 잠금 유지(드래그 박스 선택·Del 삭제만 허용).
const finalActive = canvas.getActiveObject() const finalActive = canvas.getActiveObject()
if (finalActive?.type === 'activeSelection') { if (finalActive?.type === 'activeSelection') {
finalActive.set({ finalActive.set({
lockMovementX: false, lockMovementX: true,
lockMovementY: false, lockMovementY: true,
lockScalingX: true, lockScalingX: true,
lockScalingY: true, lockScalingY: true,
lockRotation: true, lockRotation: true,
@ -331,22 +329,6 @@ export function useCanvasEvent() {
canvas.requestRenderAll() canvas.requestRenderAll()
} }
// [MODULE-MULTI-SELECT 2026-06-10] object:modified 시 지붕면 경계 검증 → 위반이면 드래그 시작 좌표로 복원
const handleModuleSelectionMoved = (e) => {
if (currentMenu !== MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING) return
const target = e?.target
if (!target) return
const isGroup = target.type === 'activeSelection'
const isSingleModule = target.name === POLYGON_TYPE.MODULE
if (!isGroup && !isSingleModule) return
if (isGroup && !target.getObjects().some((obj) => obj.name === POLYGON_TYPE.MODULE)) return
const original = e?.transform?.original
const startPos = original ? { left: original.left, top: original.top } : null
commitModuleDragMove(target, startPos)
}
const removeEventOnCanvas = () => { const removeEventOnCanvas = () => {
canvas?.off('object:added') canvas?.off('object:added')
canvas?.off('object:modified') canvas?.off('object:modified')

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,76 @@ 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) {
// [KERAB-OFFSET-COLLINEAR 2026-06-11] 라인 방향이 변과 평행(변 위에 누운 면경계 세그먼트)이면
// 교점이 없어 ip=null → 옛날엔 return 으로 라인 통째 포기(=옛 위치 ghost). 이때는 변이
// 평행이동했으므로 mapToNewSeg(m1) 의 t-비율 투영이 곧 새 변 위 대응점 → 그걸로 폴백.
const ip = lineLineIntersection(e2, e1, newCorner1, newCorner2)
n1 = ip || m1
} }
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 n2 = ip || m2
return true
} }
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 +432,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 ' +