// [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 단위) * @returns true=적용됨 / false=조건 미달 또는 변경 없음 */ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { 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 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. { 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 비율 점으로 매핑. const isKerabPatternLine = (il) => il && (il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge' || il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip') 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 } } for (const il of roof.innerLines || []) { if (!il) continue // 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점. // 절삭/복원 흐름 skip. if (isKerabPatternLine(il)) { const np1 = mapToNewSeg({ x: il.x1, y: il.y1 }) if (np1) il.set({ x1: np1.x, y1: np1.y }) const np2 = mapToNewSeg({ x: il.x2, y: il.y2 }) if (np2) il.set({ x2: np2.x, y2: np2.y }) if (typeof il.setCoords === 'function') il.setCoords() 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 } }), ) } 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 } 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() logger.log( '[KERAB-OFFSET-SHRINK-TRIM] trimmed ' + JSON.stringify({ lineName: il.lineName, orig, newPts: { x1: nx1, y1: ny1, x2: nx2, y2: ny2 } }), ) } } // 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() canvas.renderAll() logger.log( '[KERAB-OFFSET-SURGICAL] applied ' + JSON.stringify({ idx, oldOffset, newOffset, oldCorner1, newCorner1, oldCorner2, newCorner2 }), ) return true }