diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index a6d2b86a..1da5d325 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -24,6 +24,10 @@ import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical' // false 로 두면 이번 세션 변경 전 동작 (apply/revert 시 출폭 입력값 무시) 으로 즉시 회귀. 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) { const canvas = useRecoilValue(canvasState) @@ -2543,6 +2547,9 @@ export function useEavesGableEdit(id) { // 증상: (1) b1 ray-cast 후 b4 출폭조정 → SHRINK-TRIM restore 가 b1 hip 을 snapshot 좌표로 리셋 // (2) CORNER-SHORTCUT 가 hip outer endpoint 를 새 roofLine 코너로 강제 스냅. // 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 }) const wA = { x: target.x1, y: target.y1 } const wB = { x: target.x2, y: target.y2 } @@ -2583,7 +2590,23 @@ export function useEavesGableEdit(id) { (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) { + // [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 outerOnVertex = vtxIdx >= 0 + if (outerShared && !outerOnVertex) { logger.log( '[KERAB-REVERT-EXTEND-45] skip (interior hip — outer shared) ' + JSON.stringify({ lineName: il.lineName, which, side, outerOld }), @@ -2592,35 +2615,43 @@ export function useEavesGableEdit(id) { delete il.__kerabRevertOuterSide continue } - // 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 + 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 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 - } - const hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT } 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 ratio = oldLen > 0 ? newLen / oldLen : 1 @@ -2670,15 +2701,78 @@ export function useEavesGableEdit(id) { ) if (kLineRidge) { 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 }) - if (ok) surgicalAfter() - logger.log('[KERAB-REVERT] applied (kLineOnly via targetId) ' + JSON.stringify({ ok })) + if (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 } // [2240 KERAB-MID-FROM-TARGET 2026-05-20] revert 도 target 중점 기준 ridge 검색 (forward 와 일관) const tEnd1 = { x: target.x1, y: target.y1 } const tEnd2 = { x: target.x2, y: target.y2 } 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) { target.set({ attributes }) const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid) @@ -3408,6 +3502,250 @@ export function useEavesGableEdit(id) { 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] 케라바 외곽선의 좌우/상하 절반 길이 라벨 // 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출. // 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체. diff --git a/src/util/kerab-offset-surgical.js b/src/util/kerab-offset-surgical.js index 2ccaa72e..019c7538 100644 --- a/src/util/kerab-offset-surgical.js +++ b/src/util/kerab-offset-surgical.js @@ -336,16 +336,17 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {} 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) - if (!ip) return - n1 = ip + n1 = ip || m1 } if (c2) { n2 = c2 } else if (m2) { const ip = lineLineIntersection(e1, e2, newCorner1, newCorner2) - if (!ip) return - n2 = ip + n2 = ip || m2 } branch = `c1=${!!c1} c2=${!!c2} m1=${!!m1} m2=${!!m2}` }