From 8ef0810058e8a8fbe93f39b3344bcc65d148869a Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 2 Jun 2026 16:58:54 +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=20vExt=20cascade=20+?= =?UTF-8?q?=20corner=20snap=20+=20ExtRidge=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useEavesGableEdit.js | 11 ++- src/util/kerab-offset-surgical.js | 114 ++++++++++++++++++++++- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 3faee18b..f926a8ef 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -1839,7 +1839,9 @@ export function useEavesGableEdit(id) { if (typeof il.setCoords === 'function') il.setCoords() const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } - trimCascadePts.push(oldPt) + // [KERAB-EXTRIDGE-CONNECT 2026-06-01] cascade hide 시 ExtRidge 끝점을 vExt 끝점(=ip)로 연장. + // ExtRidge 와 vExt 끝점이 분리돼 split graph 의 sub-roof 외곽이 안 닫히는 문제 해소. + trimCascadePts.push({ pt: oldPt, newPt: { x: ip.x, y: ip.y } }) newTrimRecords.push({ line: il, end: trimEnd, @@ -1856,8 +1858,9 @@ export function useEavesGableEdit(id) { const cascadeHidden = new Set() let cascadeGuard = 0 while (trimCascadePts.length && cascadeGuard++ < 200) { - const pt = trimCascadePts.shift() - if (!pt) continue + const entry = trimCascadePts.shift() + if (!entry) continue + const pt = entry.pt || entry for (const il of roof.innerLines || []) { if (!il || il.visible === false) continue if (cascadeHidden.has(il)) continue @@ -1880,7 +1883,7 @@ export function useEavesGableEdit(id) { '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), ) - trimCascadePts.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) + trimCascadePts.push({ pt: m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } }) } } // [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적. diff --git a/src/util/kerab-offset-surgical.js b/src/util/kerab-offset-surgical.js index ed437bea..5d9e3f59 100644 --- a/src/util/kerab-offset-surgical.js +++ b/src/util/kerab-offset-surgical.js @@ -129,7 +129,9 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { (il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge' || il.lineName === 'kerabPatternHip' || - il.lineName === 'kerabPatternExtHip') + 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 @@ -150,18 +152,87 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { if (!il) continue // 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점. - // 절삭/복원 흐름 skip. + // 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 → + // 직각/형상 보존. 절삭/복원 흐름 skip. if (isKerabPatternLine(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 }) - 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 (np1 && np2) { + il.set({ x1: np1.x, y1: np1.y, x2: np2.x, y2: np2.y }) + } else if (np1 && !np2) { + 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) { + 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 (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 + } + if (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 + if (pointOnOldSeg(other.x1, other.y1)) { + other.set({ x1: other.x1 + dxVExt, y1: other.y1 + dyVExt }) + moved = true + } + if (pointOnOldSeg(other.x2, other.y2)) { + 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 }, + }), + ) + } + } + } } continue } @@ -169,6 +240,41 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { 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 } + + // [KERAB-OFFSET-CORNER-SHORTCUT 2026-06-01] orig 끝점이 옛 corner 와 일치하면 새 corner 로 직접 snap. + // 일반 절삭 흐름은 il segment 와 새 roofLine 변의 lineLineIntersection 으로 ip 계산하는데, + // 끝점이 옛 corner 위에 있으면 ip 가 새 corner segment 밖으로 떨어져 segOk 가 reject → 절삭 실패. + // 그 결과 c1·c2 비대칭으로 kLine 대각선 변형됨. + const CORNER_SNAP_TOL_TRIM = 0.5 + let cornerSnapped = false + const trySnap = (epx, epy, which) => { + if (Math.hypot(epx - oldCorner1.x, epy - oldCorner1.y) < CORNER_SNAP_TOL_TRIM) { + if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } + if (which === 1) il.set({ x1: newCorner1.x, y1: newCorner1.y }) + else il.set({ x2: newCorner1.x, y2: newCorner1.y }) + cornerSnapped = true + return true + } + if (Math.hypot(epx - oldCorner2.x, epy - oldCorner2.y) < CORNER_SNAP_TOL_TRIM) { + if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } + if (which === 1) il.set({ x1: newCorner2.x, y1: newCorner2.y }) + else il.set({ x2: newCorner2.x, y2: newCorner2.y }) + cornerSnapped = true + return true + } + return false + } + trySnap(orig.x1, orig.y1, 1) + 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