From 141afa7a36ac47f961549c58564aa2e307e9a6a6 Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 1 Jun 2026 13:53:05 +0900 Subject: [PATCH] =?UTF-8?q?[2294]=20=EC=BC=80=EB=9D=BC=EB=B0=94=20?= =?UTF-8?q?=EC=B6=9C=ED=8F=AD=20surgical=20=E2=80=94=20skLine=20=EC=A0=88?= =?UTF-8?q?=EC=82=AD/=EB=B3=B5=EC=9B=90=20+=20=ED=8C=A8=ED=84=B4=20segment?= =?UTF-8?q?=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useEavesGableEdit.js | 18 ++- .../roofcover/useRoofAllocationSetting.js | 104 ++++++++++++++- src/hooks/usePolygon.js | 20 +++ src/util/kerab-offset-surgical.js | 124 ++++++++++++++++-- 4 files changed, 251 insertions(+), 15 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index c57cb5df..3faee18b 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -243,6 +243,20 @@ export function useEavesGableEdit(id) { // - radio 1 의 단순 변환에만 적용. JERKINHEAD/HIPANDGABLE 등 width 가 들어가는 // radio 2 변환은 파라미터 갱신 가능성이 있어 그대로 진행. if (radioTypeRef.current === '1' && target.attributes?.type === attributes?.type) { + // [KERAB-OFFSET-ONLY-RECLICK 2026-06-01] 동일 type 재클릭이지만 出幅(offset) 만 다른 경우, + // surgical 갱신(corner / 인접 inner-line 끝점 snap + attributes.offset 반영) 후 종료. + // 기존 collapse 흐름(applyKerab*Pattern, polygonPath BFS 등) 은 건너뛰어 케라바 패턴 보존. + const oldOffset = target.attributes?.offset ?? 0 + const newOffset = attributes?.offset ?? 0 + if (Math.abs(newOffset - oldOffset) > 1e-3) { + logger.log( + `[KERAB-OFFSET-ONLY-RECLICK] type=${attributes.type} 동일, offset ${oldOffset}→${newOffset} surgical 적용`, + ) + applyTargetOffsetSurgical(target, newOffset) + target.set({ attributes }) + canvas.renderAll() + return + } logger.log(`[KERAB-NOOP] 이미 ${attributes.type} → 재변환 무시`) return } @@ -1595,7 +1609,9 @@ export function useEavesGableEdit(id) { // roofBase 확장은 처마의 연장 → attributes.type=EAVES + parentLine + innerLines push (split 참여). // wallBase 확장은 wall layer (roof 폴리곤 밖) → 시각만, innerLines 비추가, type/parent 부여 안 함. const isRoofBase = !!(vr.source && vr.source.startsWith('roofBase')) - const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz } + // [KERAB-VALLEY-EXT 2026-05-29] wallLine ID 를 attributes 에 명시 저장. + // apply() 의 wallExt 재계산 RECALC 가 vExt.attributes.wallLine 으로 outerLine 매칭 — 보존 필수. + const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz, wallLine: target.id } if (isRoofBase) { // [KERAB-VALLEY-EXT 2026-05-27] splitPolygonWithLines 의 innerLineMapping 은 // EAVES polygonLine 을 skip 하므로(usePolygon.js:822) 처마확장은 자동 매핑이 안 된다. diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 9a8b3758..33fe7793 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -15,7 +15,7 @@ import { selectedRoofMaterialSelector, } from '@/store/settingAtom' import { usePopup } from '@/hooks/usePopup' -import { POLYGON_TYPE } from '@/common/common' +import { POLYGON_TYPE, LINE_TYPE } from '@/common/common' import { v4 as uuidv4 } from 'uuid' import ActualSizeSetting from '@/components/floor-plan/modal/roofAllocation/ActualSizeSetting' import { useMessage } from '@/hooks/useMessage' @@ -909,6 +909,14 @@ export function useRoofAllocationSetting(id) { const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial) const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL) logger.log(`[ALLOC] apply() 진입. roofBases=${roofBases.length}`) + // [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] 저장→로드 사이클을 거치면 fabric 객체 좌표/캐시가 정리되어 + // case A 가 잘 동작. 저장 X 시 그 효과가 없음. canvas.toJSON 한 번 호출하여 동일 효과 시뮬레이션. + try { + canvas.discardActiveObject() + canvas.toJSON() + } catch (e) { + logger.warn('[ALLOC] toJSON simulate failed', e) + } roofBases.forEach((roofBase) => { try { // 지붕 할당 로직에 extensionLine 추가 @@ -1046,6 +1054,100 @@ export function useRoofAllocationSetting(id) { ) } + // [KERAB-VALLEY-OVERLAP 2026-05-29] vExt 기반 wallExt 4변 in-memory 재계산. + // case A(저장 X→케라바→할당) 에서 canvas 의 kerabValleyOverlapLine 가 silent 사라지는 문제 회피. + // vExt(kerabPatternValleyExt) 는 roof.innerLines 에 안정적으로 보존 — 거기서 직접 재계산. + // 조건: vExt 가 polygon 내부에 들어온 케이스 한정 (innerLines 에 있으면 자동 충족). + const vExtsForOverlap = (roofBase.innerLines || []).filter( + (l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false, + ) + for (const vExt of vExtsForOverlap) { + // [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] wallLineId 보장 fallback 체인. + // 1) vExt.attributes.wallLine (정상 케이스) + // 2) vExt.__targetId (useEavesGableEdit.js drawValleyExtensions 에서 백업) + // 3) vExt.parentLine.attributes.wallLine + // 4) vStart 좌표 매칭하는 roof.lines 변의 wallLine + let wallLineId = vExt.attributes?.wallLine + if (!wallLineId) wallLineId = vExt.__targetId + if (!wallLineId && vExt.parentLine) wallLineId = vExt.parentLine.attributes?.wallLine + if (!wallLineId) { + // vStart 가 끝점인 roof.lines 변에서 wallLine 추출 + const vStartP = { x: vExt.x1, y: vExt.y1 } + for (const rl of roofBase.lines || []) { + if (!rl?.attributes?.wallLine) continue + const m1 = Math.hypot(rl.x1 - vStartP.x, rl.y1 - vStartP.y) < 1 + const m2 = Math.hypot(rl.x2 - vStartP.x, rl.y2 - vStartP.y) < 1 + if (m1 || m2) { + wallLineId = rl.attributes.wallLine + break + } + } + } + if (!wallLineId) { + logger.warn('[KERAB-VALLEY-OVERLAP-RECALC] vExt skip — wallLineId 매칭 실패 (attributes/parent/좌표 모두)') + continue + } + let target = canvas.getObjects().find((o) => o.id === wallLineId && o.name === 'outerLine') + if (!target) { + const candidates = canvas.getObjects().filter((o) => o.id === wallLineId) + target = candidates[0] + if (!target) { + logger.warn('[KERAB-VALLEY-OVERLAP-RECALC] outerLine 못찾음 wallLineId=' + wallLineId?.slice(0, 8)) + continue + } + } + const vStart = { x: vExt.x1, y: vExt.y1 } + const vEnd = { x: vExt.x2, y: vExt.y2 } + const dxT = target.x2 - target.x1 + const dyT = target.y2 - target.y1 + const lenTSq = dxT * dxT + dyT * dyT + const tFoot = ((vStart.x - target.x1) * dxT + (vStart.y - target.y1) * dyT) / Math.max(lenTSq, 1e-9) + const newWStart = { x: target.x1 + tFoot * dxT, y: target.y1 + tFoot * dyT } + const offX = newWStart.x - vStart.x + const offY = newWStart.y - vStart.y + const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY } + const make = (p1, p2) => { + const lpts = [p1.x, p1.y, p2.x, p2.y] + const dx = lpts[2] - lpts[0] + const dy = lpts[3] - lpts[1] + const lsz = Math.round(Math.hypot(dx, dy) * 10) + const ln = new QLine(lpts, { + parentId: roofBase.id, + fontSize: roofBase.fontSize, + stroke: 'rgba(0,0,0,0)', + strokeWidth: 0, + name: 'valley', + visible: true, + opacity: 0, + attributes: { + roofId: roofBase.id, + type: 'kerabValleyOverlapLine', + isStart: true, + planeSize: lsz, + actualSize: lsz, + }, + }) + ln.lineName = 'kerabValleyOverlapLine' + ln.roofId = roofBase.id + return ln + } + // [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] float drift 회피 — vStart 와 가장 가까운 polygon corner 로 snap. + // 저장→로드 후엔 좌표 정수 round 로 일치하지만 저장 X 시 drift 가능 → split 알고리즘 외곽 인식 실패. + const snapToCorner = (p) => { + for (const rp of roofBase.points || []) { + const d = Math.hypot(rp.x - p.x, rp.y - p.y) + if (d < 1) return { x: rp.x, y: rp.y } + } + return p + } + const vStartSnap = snapToCorner(vStart) + const wallLn = make(newWStart, wEndProj) + const bsLn = make(vStartSnap, newWStart) + const beLn = make(vEnd, wEndProj) + roofBase.lines.push(wallLn, bsLn, beLn) + roofBase.innerLines.push(wallLn, bsLn, beLn) + } + if (roofBase.separatePolygon.length > 0) { splitPolygonWithSeparate(roofBase.separatePolygon) } else { diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index 91596aab..5fefa914 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -9,6 +9,7 @@ import { fontSelector } from '@/store/fontAtom' import { QLine } from '@/components/fabric/QLine' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { useLine } from '@/hooks/useLine' +import { logger } from '@/util/logger' export const usePolygon = () => { const canvas = useRecoilValue(canvasState) @@ -744,6 +745,25 @@ export const usePolygon = () => { let innerLines = [...polygon.innerLines].filter((line) => line.visible) + // [SPLIT-ALLOC-DIAG 2026-06-01] 분할 graph 진입 전 innerLines 전수 dump. + // 케라바 패턴 라인(kerabPatternRidge/ExtRidge/Hip/ExtHip)과 원래 ridge(RG-2 등) 가 + // type='default' / isStart 미설정으로 graph 시드/유효점 후보에서 제외되는지 추적용. + logger.log(`[SPLIT-ALLOC-DIAG] polygon.id=${polygon.id?.slice(0, 8)} innerLines.visible=${innerLines.length} polygonLines=${polygon.lines?.length ?? 0}`) + innerLines.forEach((l, i) => { + logger.log( + ` [SPLIT-ALLOC-DIAG ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` + + `type=${l.attributes?.type ?? 'none'} isStart=${l.attributes?.isStart ?? false} ` + + `(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`, + ) + }) + ;(polygon.lines || []).forEach((l, i) => { + logger.log( + ` [SPLIT-ALLOC-DIAG outer ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` + + `type=${l.attributes?.type ?? 'none'} isStart=${l.attributes?.isStart ?? false} ` + + `(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`, + ) + }) + /*// innerLine이 세팅이 안되어있는경우 찾아서 세팅한다. if (!innerLines || innerLines.length === 0) { let innerLineTypes = Object.keys(LINE_TYPE.SUBLINE).map((key, value) => LINE_TYPE.SUBLINE[key]) diff --git a/src/util/kerab-offset-surgical.js b/src/util/kerab-offset-surgical.js index c6619ffa..ed437bea 100644 --- a/src/util/kerab-offset-surgical.js +++ b/src/util/kerab-offset-surgical.js @@ -105,21 +105,119 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { const nextSize = calcLinePlaneSize({ x1: nextRL.x1, y1: nextRL.y1, x2: nextRL.x2, y2: nextRL.y2 }) nextRL.attributes = { ...nextRL.attributes, planeSize: nextSize, actualSize: nextSize } - // 옛 corner 좌표에 닿아있던 inner line(hip/ridge/valley/케라바 ext) 끝점을 새 corner 로 snap. - const CORNER_SNAP_TOL = 0.5 - for (const il of roof.innerLines || []) { - if (!il) continue - if (Math.hypot(il.x1 - oldCorner1.x, il.y1 - oldCorner1.y) <= CORNER_SNAP_TOL) { - il.set({ x1: newCorner1.x, y1: newCorner1.y }) + // [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 } - if (Math.hypot(il.x2 - oldCorner1.x, il.y2 - oldCorner1.y) <= CORNER_SNAP_TOL) { - il.set({ x2: newCorner1.x, y2: newCorner1.y }) + // [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 } } - if (Math.hypot(il.x1 - oldCorner2.x, il.y1 - oldCorner2.y) <= CORNER_SNAP_TOL) { - il.set({ x1: newCorner2.x, y1: newCorner2.y }) - } - if (Math.hypot(il.x2 - oldCorner2.x, il.y2 - oldCorner2.y) <= CORNER_SNAP_TOL) { - il.set({ x2: newCorner2.x, y2: newCorner2.y }) + 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 } }), + ) } }