qcast-front/src/util/kerab-offset-surgical.js
ysCha 1e0124d9f2 [2294_5] 케라바 사각형 결정론적 솔버 + 처마전면 지붕면 할당 수정
- solveRectangleKerab: 4변 타입만으로 힙/마루 재생성(forward·revert 단일 공식)
- nGable=0 우진각·1 Y apex·1 CASE B 케라바·2 맞배 지원, 그 외 기존 maze 폴백
- hip attributes.extended=true 부여 → integrateExtensionLines merge 스킵 → split 4면 정상 할당
- outerLine bbox 기준 sideOf 수정(出幅 offset 좌표 불일치 해소)
- apex depth clamp min(L/2, oppDist) → R3-outside 방지
- detectKerabHipMarks·extendKerabHipsTo45 함수 추출(reclick·revert 45° 통합)
- KERAB-EXTHIP-COORD-CLEAN: orphan ext-hip 좌표 기반 재제거
- useRefFiles: 변환 API FileData 누락 시 에러 메시지 swalFire 안내
- kerab-offset-surgical: skipKerabHips 옵션(케라바 hip t-ratio 재계산 방지)

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-15 16:01:11 +09:00

470 lines
24 KiB
JavaScript

// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 forward/revert 시 出幅(offset) 변경분만
// target 의 matchingRoofLine + 인접 2 corner roof.points + corner 에 닿은 inner line 끝점
// + lengthText 라벨을 surgical 갱신. SK 재실행 없이 다른 벽의 케라바 패턴 보존.
//
// 고객 회귀 검증을 위해 본 로직 전체를 useEavesGableEdit.js 에서 분리.
// 호출 ON/OFF 는 useEavesGableEdit.js 상단 `ENABLE_KERAB_OFFSET_SURGICAL` 상수로 토글한다.
import { fabric } from 'fabric'
import { POLYGON_TYPE } from '@/common/common'
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { logger } from '@/util/logger'
const lineLineIntersection = (p1, p2, p3, p4) => {
const d = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x)
if (Math.abs(d) < 1e-6) return null
const t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / d
return { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) }
}
/**
* @param canvas fabric canvas
* @param target outerLine fabric 객체 (target.attributes 는 OLD 상태로 호출되어야 함)
* @param newOffset 새 출폭 (canvas 단위)
* @param options { skipInnerLines?: boolean }
* skipInnerLines=true 면 innerLines 루프(CORNER-SHORTCUT / SHRINK-TRIM / KERAB-PATTERN-CORNER-SNAP) 전체 skip.
* [KERAB-REVERT-EXTEND-45 2026-06-05] 룰: 출폭 변경 시 wallbaseLine 안의 내부라인 끝점은 절대 이동 X.
* roofLine 코너로 스냅(CORNER-SHORTCUT)·__shrinkOrig 로 복원(SHRINK-TRIM) 둘 다 룰 위반.
* 호출자(revert)가 별도 ray-cast 로 hip 의 outer endpoint 만 새 rL 변까지 45° 확장한다.
* @returns true=적용됨 / false=조건 미달 또는 변경 없음
*/
export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}) => {
const { skipInnerLines = false, skipKerabHips = false } = options
const roof = canvas
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
if (!roof || !Array.isArray(roof.lines) || !Array.isArray(roof.points)) return false
const idx = roof.lines.findIndex((rl) => rl && rl.attributes?.wallLine === target.id)
if (idx < 0) return false
const matchingRL = roof.lines[idx]
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]
// target wall edge 의 outward unit normal — matchingRL 현재 위치로 sign 추정 (oldOffset=0 시 centroid 폴백).
const tdx = target.x2 - target.x1
const tdy = target.y2 - target.y1
const tL = Math.hypot(tdx, tdy) || 1
let nx = -tdy / tL
let ny = tdx / tL
const tMid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 }
if (Math.abs(oldOffset) > 1e-6) {
const mMid = { x: (matchingRL.x1 + matchingRL.x2) / 2, y: (matchingRL.y1 + matchingRL.y2) / 2 }
if ((mMid.x - tMid.x) * nx + (mMid.y - tMid.y) * ny < 0) {
nx = -nx
ny = -ny
}
} else {
let cx = 0
let cy = 0
for (const p of roof.points) {
cx += p.x
cy += p.y
}
cx /= roof.points.length
cy /= roof.points.length
if ((tMid.x - cx) * nx + (tMid.y - cy) * ny < 0) {
nx = -nx
ny = -ny
}
}
// 새 matchingRL axis 와 prev/nextRL 무한확장 교점.
const newAxisP1 = { x: target.x1 + nx * newOffset, y: target.y1 + ny * newOffset }
const newAxisP2 = { x: target.x2 + nx * newOffset, y: target.y2 + ny * newOffset }
const newCorner1 = lineLineIntersection(
{ x: prevRL.x1, y: prevRL.y1 },
{ x: prevRL.x2, y: prevRL.y2 },
newAxisP1,
newAxisP2,
)
const newCorner2 = lineLineIntersection(
newAxisP1,
newAxisP2,
{ x: nextRL.x1, y: nextRL.y1 },
{ x: nextRL.x2, y: nextRL.y2 },
)
if (!newCorner1 || !newCorner2) {
logger.log('[KERAB-OFFSET-SURGICAL] intersect failed — skip surgical update')
return false
}
const oldCorner1 = { x: roof.points[idx].x, y: roof.points[idx].y }
const oldCorner2 = { x: roof.points[(idx + 1) % N].x, y: roof.points[(idx + 1) % N].y }
matchingRL.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y })
prevRL.set({ x2: newCorner1.x, y2: newCorner1.y })
nextRL.set({ x1: newCorner2.x, y1: newCorner2.y })
const newSize = calcLinePlaneSize({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y })
matchingRL.attributes = {
...matchingRL.attributes,
offset: newOffset,
planeSize: newSize,
actualSize: newSize,
}
const prevSize = calcLinePlaneSize({ x1: prevRL.x1, y1: prevRL.y1, x2: prevRL.x2, y2: prevRL.y2 })
prevRL.attributes = { ...prevRL.attributes, planeSize: prevSize, actualSize: prevSize }
const nextSize = calcLinePlaneSize({ x1: nextRL.x1, y1: nextRL.y1, x2: nextRL.x2, y2: nextRL.y2 })
nextRL.attributes = { ...nextRL.attributes, planeSize: nextSize, actualSize: nextSize }
// [KERAB-OFFSET-SHRINK-TRIM 2026-06-01] 도메인 룰:
// - 기본: skLine + ext 원본 좌표 보존.
// - 원본 끝점이 새 roofLine 변 바깥(wall 쪽)에 있으면 새 roofLine 변과의 교점으로 절삭.
// - 원본 끝점이 새 roofLine 변 안쪽으로 복귀하면 원본 좌표로 복원 (출폭 다시 늘린 케이스).
// 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제.
// 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent.
if (!skipInnerLines) {
// [KERAB-PATTERN-CORNER-SNAP 2026-06-01] 케라바 패턴 라인(kLine/ExtRidge/Hip/ExtHip)은
// roofLine corner 변경에 따라 끝점도 같이 이동. corner 일치뿐 아니라 옛 segment 위의
// 중간 점(예: kLine 끝점이 옛 roofLine 변 위)도 새 segment 의 동일 t 비율 점으로 매핑.
const isKerabPatternLine = (il) =>
il &&
(il.lineName === 'kerabPatternRidge' ||
il.lineName === 'kerabPatternExtRidge' ||
il.lineName === 'kerabPatternHip' ||
il.lineName === 'kerabPatternExtHip' ||
il.lineName === 'kerabPatternValleyExt' ||
il.lineName === 'kerabValleyOverlapLine')
const oldSegDx = oldCorner2.x - oldCorner1.x
const oldSegDy = oldCorner2.y - oldCorner1.y
const oldSegLen2 = oldSegDx * oldSegDx + oldSegDy * oldSegDy || 1
const newSegDx = newCorner2.x - newCorner1.x
const newSegDy = newCorner2.y - newCorner1.y
const mapToNewSeg = (pt) => {
const dx = pt.x - oldCorner1.x
const dy = pt.y - oldCorner1.y
const t = (dx * oldSegDx + dy * oldSegDy) / oldSegLen2
const projX = oldCorner1.x + t * oldSegDx
const projY = oldCorner1.y + t * oldSegDy
const perpD = Math.hypot(pt.x - projX, pt.y - projY)
if (perpD > 0.5) return null
if (t < -0.05 || t > 1.05) return null
return { x: newCorner1.x + t * newSegDx, y: newCorner1.y + t * newSegDy }
}
// [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) {
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) {
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 ' +
JSON.stringify({ lineName: il.lineName, np1, np2, newPts: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } }),
)
// [KERAB-PATTERN-CASCADE 2026-06-01] vExt 등 평행 이동 시 옛 끝점에 닿아있던
// 다른 innerLine 끝점도 같은 변위로 평행 이동. RG-1 의 valley-trim 결과 끝점이
// vExt 끝점과 분리되어 split graph 의 closed path 안 만들어지는 문제 해소.
// cascade: vExt 옛 segment 위에 끝점이 있는 다른 innerLine 도 같은 변위로 평행 이동.
// 끝점-끝점 일치 외에 segment 위 중간 점(예: kLine 끝점이 vExt segment 위)도 매칭.
// 케라바 패턴 라인 가드 제거 — 자체 매핑된 라인은 이미 새 좌표라 옛 segment 위 X → 자동 skip.
const dxVExt = il.x1 - oldX1
const dyVExt = il.y1 - oldY1
const pointOnOldSeg = (px, py) => {
const sdx = oldX2 - oldX1
const sdy = oldY2 - oldY1
const slen2 = sdx * sdx + sdy * sdy
if (slen2 < 1e-6) return Math.hypot(px - oldX1, py - oldY1) < 1.0
const t = ((px - oldX1) * sdx + (py - oldY1) * sdy) / slen2
if (t < -0.02 || t > 1.02) return false
const projX = oldX1 + t * sdx
const projY = oldY1 + t * sdy
return Math.hypot(px - projX, py - projY) < 1.0
}
// [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(
(o) =>
o &&
o.lineName === 'kerabValleyOverlapLine' &&
(o.roofId === roof.id || o.attributes?.roofId === roof.id),
)
const cascadeTargets = [...(roof.innerLines || []), ...overlapInCanvas]
for (const other of cascadeTargets) {
if (!other || other === il) continue
let moved = false
const hit1 = pointOnOldSeg(other.x1, other.y1)
const hit2 = pointOnOldSeg(other.x2, other.y2)
// [KERAB-VALLEY-EXT-PARALLEL 2026-06-05] vExt(골짜기 확장라인)는 self-extension
// 수직/수평 라인이라 한 끝만 cascade 로 끌면 대각선이 된다 (사용자 룰: 대각선은 hip뿐).
// split 된 vExt 세그먼트는 한 끝만 옛 segment 에 닿아도 양 끝을 같은 변위로 평행이동.
if (other.lineName === 'kerabPatternValleyExt' && (hit1 || hit2)) {
other.set({
x1: other.x1 + dxVExt,
y1: other.y1 + dyVExt,
x2: other.x2 + dxVExt,
y2: other.y2 + dyVExt,
})
moved = true
} else {
if (hit1) {
other.set({ x1: other.x1 + dxVExt, y1: other.y1 + dyVExt })
moved = true
}
if (hit2) {
other.set({ x2: other.x2 + dxVExt, y2: other.y2 + dyVExt })
moved = true
}
}
if (moved) {
if (typeof other.setCoords === 'function') other.setCoords()
logger.log(
'[KERAB-PATTERN-CASCADE] moved ' +
JSON.stringify({
lineName: other.lineName,
name: other.name,
dx: dxVExt,
dy: dyVExt,
newPts: { x1: other.x1, y1: other.y1, x2: other.x2, y2: other.y2 },
}),
)
}
}
}
}
}
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) {
// [KERAB-OFFSET-COLLINEAR 2026-06-11] 라인 방향이 변과 평행(변 위에 누운 면경계 세그먼트)이면
// 교점이 없어 ip=null → 옛날엔 return 으로 라인 통째 포기(=옛 위치 ghost). 이때는 변이
// 평행이동했으므로 mapToNewSeg(m1) 의 t-비율 투영이 곧 새 변 위 대응점 → 그걸로 폴백.
const ip = lineLineIntersection(e2, e1, newCorner1, newCorner2)
n1 = ip || m1
}
if (c2) {
n2 = c2
} else if (m2) {
const ip = lineLineIntersection(e1, e2, newCorner1, newCorner2)
n2 = ip || m2
}
branch = `c1=${!!c1} c2=${!!c2} m1=${!!m1} m2=${!!m2}`
}
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-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
// [KERAB-HIP-45-PROTECT 2026-06-15] 케라바 hip(확장라인)은 CORNER-SNAP 금지 —
// 호출자(reclick 45° 블록)가 wall 코너에서 45° ray-cast 로 roofLine 까지 뻗는다.
// 여기서 recomputeKerabPatternLine 의 t-ratio 매핑을 타면 roofLine 코너로 스냅돼 룰 위반.
if (skipKerabHips && il.lineName === 'kerabPatternHip') continue
if (isKerabPatternLine(il)) recomputeKerabPatternLine(il)
else recomputeNormalLine(il)
}
}
// points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작.
// [KERAB-OFFSET-SURGICAL 2026-05-29] 출폭 증가 시 새 corner 가 polygon bbox 밖에 있으면
// 외곽선이 안 그려짐. _setPositionDimensions 로 width/height/pathOffset 재계산 + 앵커
// 절대좌표 보존(setPositionByOrigin) 으로 polygon path 가 새 영역까지 다시 그려지게 강제.
const newPoints = roof.points.map((p, i) => {
if (i === idx) return { x: newCorner1.x, y: newCorner1.y }
if (i === (idx + 1) % N) return { x: newCorner2.x, y: newCorner2.y }
return { x: p.x, y: p.y }
})
let absolutePoint = null
let anchorIdx = 0
// 변경 대상이 아닌 첫 인덱스를 앵커로 — pathOffset 갱신 후 그 점 절대 좌표 보존.
for (let i = 0; i < roof.points.length; i++) {
if (i !== idx && i !== (idx + 1) % N) {
anchorIdx = i
break
}
}
try {
if (typeof roof.calcTransformMatrix === 'function' && roof.pathOffset) {
const oldLocal = {
x: roof.points[anchorIdx].x - roof.pathOffset.x,
y: roof.points[anchorIdx].y - roof.pathOffset.y,
}
absolutePoint = fabric.util.transformPoint(oldLocal, roof.calcTransformMatrix())
}
} catch (e) {
absolutePoint = null
}
roof.points = newPoints
roof.set({ points: newPoints, dirty: true })
if (typeof roof._setPositionDimensions === 'function') roof._setPositionDimensions({})
if (absolutePoint && typeof roof.setPositionByOrigin === 'function') {
const strokeW = roof.strokeUniform ? roof.strokeWidth / Math.max(roof.scaleX || 1, 1e-9) : roof.strokeWidth
const baseW = (roof.width || 0) + strokeW
const baseH = (roof.height || 0) + (roof.strokeUniform ? roof.strokeWidth / Math.max(roof.scaleY || 1, 1e-9) : roof.strokeWidth)
const newX = (roof.points[anchorIdx].x - roof.pathOffset.x) / Math.max(baseW, 1e-9)
const newY = (roof.points[anchorIdx].y - roof.pathOffset.y) / Math.max(baseH, 1e-9)
roof.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5)
}
if (typeof roof.setCoords === 'function') roof.setCoords()
// canvas 에 add 된 동일 wallLine 매칭의 eaves(외곽 roofLine) fabric 객체도 같이 갱신.
// [KERAB-OFFSET-SURGICAL 2026-05-29] outerLine 은 wall 좌표(출폭 0 기준) 유지가 원칙.
// corner 좌표(=wall + offset*normal) 로 set 하면 출폭 증가 시 외곽 처마라인이 통째로 이동해
// 화면상 roofLine 이 안 그려진 듯 보임. outerLine 은 attributes 만 갱신, 좌표는 보존.
const canvasEdgeObjs = canvas
.getObjects()
.filter(
(o) =>
o.parentId === roof.id &&
(o.name === 'eaves' || o.lineName === 'roofLine' || o.name === 'outerLine') &&
o.attributes?.wallLine === target.id,
)
for (const eo of canvasEdgeObjs) {
if (eo.name !== 'outerLine') {
eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true })
}
eo.attributes = { ...eo.attributes, offset: newOffset, planeSize: newSize, actualSize: newSize }
if (typeof eo.setCoords === 'function') eo.setCoords()
}
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 ' +
JSON.stringify({ idx, oldOffset, newOffset, oldCorner1, newCorner1, oldCorner2, newCorner2 }),
)
return true
}