diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index be2608cb..22b48f52 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -94,6 +94,28 @@ export function useMovementSetting(id) { } }) }) + + // ───────────────────────────────────────────────────────────────────────── + // [2차 마루이동 클릭불가 보강 2026-04-30] + // 증상: 1차 마루이동 성공 → 2차 팝업 오픈 시 ridge 클릭해도 선택 안됨. + // mouseMove 마다 [MV-TRACE] selectedObject.current=null → skip 무한 발생. + // 원인: 위 forEach 가 roof.innerLines 를 순회 → drawHelpLine 으로 ridge 재생성 시 + // innerLines 가 stale 이거나 새 ridge 가 빠져 selectable=true 누락 → + // 클릭 → fabric selection:created 미발화 → currentObjectState=null 유지 → + // useEffect[currentObject] 가 selectedObject.current 를 못 갱신. + // 방침: 기존 roof.innerLines 경로는 그대로 두고(정상 케이스 유지), + // canvas 레벨 ridge 직접 조회로 selectable=true 보강. 누락 시만 효과, + // 정상 시 동일 객체에 동일 set 재호출이라 부작용 없음. + // ───────────────────────────────────────────────────────────────────────── + if (type === TYPE.FLOW_LINE) { + canvas + .getObjects() + .filter((obj) => obj.name === LINE_TYPE.SUBLINE.RIDGE) + .forEach((ridge) => { + ridge.set({ selectable: true, strokeWidth: 5, stroke: '#1083E3' }) + ridge.bringToFront() + }) + } /** 외벽선 관련 속성 처리*/ const walls = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL) walls.forEach((wall) => { @@ -672,6 +694,41 @@ export function useMovementSetting(id) { return false }) + // ───────────────────────────────────────────────────────────────────────── + // [FLOW_LINE x-overlap 사전필터 2026-04-30 수정] + // 증상 1 (기존 ‘동률’ 케이스): 수평 마루 1회 이동 → wall.baseLines 양쪽 수직선이 다 길어짐. + // 증상 2 (오늘 발견 ‘반대측 wall 선택’): 1차 down 이동 후 2차 이동 시 + // 대상 wall(idx=1) 이 멀어져, ridge x-range 무관한 반대측 wall(idx=5) 이 + // y-distance 만으로 "가까움" 을 차지해 잘못 선택. 멀리 있는 vertical wall 이 + // 변형됨. + // 원인: 거리 최소 필터(아래 sort+filter)가 x-overlap 무관하게 동작. + // 기존 동률필터는 length>1 일 때만 동작하여 이미 거리최소필터로 1개만 + // 남은 케이스 보강 안 됨. + // 수정: 거리최소필터 **이전** 에 ridge 좌표범위와 겹치는 baseLine 만 우선 + // 추리고, 그 결과가 0개면 fallback 으로 전체 후보 유지(기존 동작). + // 이후 거리최소필터가 overlap 후보 중에서만 선택 → 반대측 wall 차단. + // UP_DOWN 은 항상 1개라 영향 없음. + // ───────────────────────────────────────────────────────────────────────── + if (typeRef.current === TYPE.FLOW_LINE && targetBaseLines.length > 0) { + const targetIsHorizontal = Math.abs(target.y1 - target.y2) < 0.5 + const tMin = targetIsHorizontal ? Math.min(target.x1, target.x2) : Math.min(target.y1, target.y2) + const tMax = targetIsHorizontal ? Math.max(target.x1, target.x2) : Math.max(target.y1, target.y2) + const overlapping = targetBaseLines.filter((item) => { + const ln = item.line + const lMin = targetIsHorizontal ? Math.min(ln.x1, ln.x2) : Math.min(ln.y1, ln.y2) + const lMax = targetIsHorizontal ? Math.max(ln.x1, ln.x2) : Math.max(ln.y1, ln.y2) + return !(lMax < tMin || lMin > tMax) + }) + if (overlapping.length > 0 && overlapping.length < targetBaseLines.length) { + if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') { + console.log( + `[FLOW_LINE-FILTER] 사전필터: ${targetBaseLines.length}개 중 ridge ${targetIsHorizontal ? 'x' : 'y'}=${tMin}~${tMax} 겹침 ${overlapping.length}개만 잔존` + ) + } + targetBaseLines = overlapping + } + } + // Sort by distance targetBaseLines.sort((a, b) => a.distance - b.distance) targetBaseLines = targetBaseLines.filter((line) => line.distance === targetBaseLines[0].distance) diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 09c6efa9..c5ac8d61 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -468,6 +468,10 @@ export function useRoofAllocationSetting(id) { const removedExt = [] const removedSk = [] const merged = [] + // [2026-04-30] sk 가 SK 빌드 시점에 이미 ext 끝점까지 연장된 경우(sk.__extended). + // merge 하면 mergedLine 이 sk 의 옛(축소된) 좌표로 되돌아가므로 스킵. + // ext 는 roofBase.lines 에서만 제거하고 canvas 에는 invisible 데이터로 그대로 둠. + const extLinesOnly = [] extLines.forEach((ext) => { const extP1 = { x: ext.x1, y: ext.y1 } @@ -491,6 +495,15 @@ export function useRoofAllocationSetting(id) { return } + // [2026-04-30] sk 가 이미 SK 빌드 단계에서 연장된 경우 → merge 스킵. + if (sk.__extended) { + console.log( + `[INTEGRATE] sk 이미 연장됨(id=${sk.id}) → merge 스킵, ext 는 lines 에서만 제거(canvas 유지)` + ) + extLinesOnly.push(ext) + return + } + const skP1 = { x: sk.x1, y: sk.y1 } const skP2 = { x: sk.x2, y: sk.y2 } const sharedPt = (isSamePt(extP1, skP1) || isSamePt(extP1, skP2)) ? extP1 : extP2 @@ -541,25 +554,27 @@ export function useRoofAllocationSetting(id) { merged.push(mergedLine) }) - if (merged.length === 0) { + if (merged.length === 0 && extLinesOnly.length === 0) { console.log(`[INTEGRATE] 통합 대상 없음 ext=${extLines.length}`) return } - roofBase.lines = roofBase.lines.filter((l) => !removedExt.includes(l)) + // roofBase.lines 에서: 기존 merge 로 제거할 ext + __extended 케이스의 ext 둘 다 제외 + // → split 단계에서 ext 가 외곽 처마처럼 처리되는 것 방지. + roofBase.lines = roofBase.lines.filter((l) => !removedExt.includes(l) && !extLinesOnly.includes(l)) roofBase.innerLines = roofBase.innerLines.filter((l) => !removedSk.includes(l)) roofBase.innerLines = [...roofBase.innerLines, ...merged] // canvas 정리: - // - 원본 ext 제거 - // - 원본 sk 도 canvas 에서 제거 (innerLines 배열에서 빠졌으므로 apply() 끝의 일괄 제거가 닿지 않음) - // - 둘 다 남기면 라벨/라인이 중복 표시됨 + // - 기존 merge 케이스: 원본 ext + 원본 sk 둘 다 canvas 제거 (mergedLine 이 대체) + // - __extended 케이스: ext 는 invisible 데이터로 canvas 에 그대로 유지 (디버그용), sk 는 이미 연장됨 removedExt.forEach((l) => canvas.remove(l)) removedSk.forEach((l) => canvas.remove(l)) console.log( `[INTEGRATE] 완료 roofBase.id=${roofBase.id} ` + `ext제거=${removedExt.length} sk제거=${removedSk.length} merged=${merged.length} ` + + `extKeptInCanvas=${extLinesOnly.length} ` + `→ lines=${roofBase.lines.length} innerLines=${roofBase.innerLines.length}` ) } diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 696de58f..b78a5618 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -845,6 +845,45 @@ const drawBaselineToRooflineHelpers = (roof, canvas, baseLinePoints, roofLinePoi `→ ext=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)})→(${hitX.toFixed(1)},${hitY.toFixed(1)})` ) + // ───────────────────────────────────────────────────────────────────────── + // [hip 좌표 연장 2026-04-30] + // ext 가 invisible 로 숨겨지면서 baseLine corner ↔ roof corner 시각 갭 발생. + // sk(hip) 의 outer 끝점을 ext 끝점(hitX/hitY) 까지 연장해서 한 줄의 대각선 라인으로. + // ext 객체는 그대로 만들어 canvas 에 남김 (invisible 데이터, 디버그/참조용). + // integrate 의 ext+sk merge 가 sk 좌표를 되돌리지 않게 __extended 플래그 부착. + // ───────────────────────────────────────────────────────────────────────── + const oldLen = Math.hypot(hip.x2 - hip.x1, hip.y2 - hip.y1) + const extLen = bestT // outerEnd → hitPoint 거리 + const ratio = oldLen > 0 ? (oldLen + extLen) / oldLen : 1 + + // outer 가 p1 (d1 { let { p1, p2, attributes, lineStyle } = sktLine; - // 중복방지 - 라인을 고유하게 식별할 수 있는 키 생성 (정규화된 좌표로 정렬하여 비교) - const lineKey = [ - [p1.x, p1.y].sort().join(','), - [p2.x, p2.y].sort().join(',') - ].sort().join('|'); + // [dedup fix 2026-04-30] 중복방지 키 — drift 흡수 + endpoint 순서 무관. + // 기존: [p1.x,p1.y].sort() — 한 점의 x/y 를 정렬하던 의도불명 코드 + + // exact string 매치 → SK 빌더 face A/B 가 같은 inner edge 를 1e-7 + // drift 로 emit 하면 dedup 실패 → 가짜 RG-N (dead-end) 생성. + // 수정: 1696라인 _keyOfEdge 와 동일 패턴 — 0.1 단위 round 후 점 쌍 정렬. + const _ptKey = (p) => `${Math.round(p.x * 10) / 10},${Math.round(p.y * 10) / 10}` + const _a = _ptKey(p1) + const _b = _ptKey(p2) + const lineKey = _a < _b ? `${_a}|${_b}` : `${_b}|${_a}` // 이미 추가된 라인인지 확인 if (existingLines.has(lineKey)) { @@ -2733,6 +2778,90 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver } + // ───────────────────────────────────────────────────────────────────────── + // [B안 cull 2026-04-30] dead-end ridge 제거 + // + // 배경: + // 정확한 수치 입력 + 마루이동 누적시 SK builder (StraightSkeleton) 가 + // 1e-4~1e-7 좌표 drift 로 phantom skeleton edge 1개를 emit 하는 케이스. + // dedup(line 1583) 으로는 단일 entry 라 못 잡음 → 화면에 RG-N 으로 등장. + // + // 정의: + // - lineName === 'ridge' 인데 한쪽 endpoint 가 다른 어떤 innerLine 과도 + // 연결되지 않고(degree=1, 0.1mm round 기준) 지붕 경계 corner 도 아님. + // - 이 조건을 만족하는 ridge 만 phantom 으로 간주하고 제거. + // + // 안전장치: + // - hip / eaves / roofLine 은 절대 건드리지 않음 (lineName 가드). + // - roof.points / roof.lines 의 corner 와 1mm 이내면 정상 끝점으로 인정. + // - 정상 마루이동/gable 처리 결과는 양 끝이 다른 라인 또는 corner 와 연결됨. + // ───────────────────────────────────────────────────────────────────────── + ;(() => { + const TOL = 1.0 + const isNear = (a, b) => Math.abs(a.x - b.x) < TOL && Math.abs(a.y - b.y) < TOL + const roofVertices = [] + ;(roof.points || []).forEach((p) => roofVertices.push(p)) + ;(roof.lines || []).forEach((l) => { + roofVertices.push({ x: l.x1, y: l.y1 }) + roofVertices.push({ x: l.x2, y: l.y2 }) + }) + const isRoofVertex = (p) => roofVertices.some((v) => isNear(v, p)) + + const ptKey = (p) => `${Math.round(p.x * 10) / 10},${Math.round(p.y * 10) / 10}` + + const toRemove = [] + + // 1단계: zero-length 퇴화 ridge 먼저 제거. + // SK builder drift 로 한 점에서 시작/끝나는 ridge 가 emit 되는 케이스. + // 이 라인이 살아있으면 그 점의 degree 가 +2 부풀려져 진짜 dead-end ridge 가 + // degree=3 으로 위장되어 2단계 가드를 우회함. + const ZERO_LEN = 0.5 + innerLines.forEach((line) => { + if (line.lineName !== 'ridge') return + const dx = Math.abs(line.x2 - line.x1) + const dy = Math.abs(line.y2 - line.y1) + if (dx < ZERO_LEN && dy < ZERO_LEN) toRemove.push(line) + }) + + // 2단계: zero-length 제외하고 degree 재계산 → dead-end ridge 식별. + const degree = new Map() + innerLines.forEach((l) => { + if (toRemove.includes(l)) return + const k1 = ptKey({ x: l.x1, y: l.y1 }) + const k2 = ptKey({ x: l.x2, y: l.y2 }) + degree.set(k1, (degree.get(k1) || 0) + 1) + degree.set(k2, (degree.get(k2) || 0) + 1) + }) + + innerLines.forEach((line) => { + if (toRemove.includes(line)) return + if (line.lineName !== 'ridge') return + const p1 = { x: line.x1, y: line.y1 } + const p2 = { x: line.x2, y: line.y2 } + const p1Dead = degree.get(ptKey(p1)) === 1 && !isRoofVertex(p1) + const p2Dead = degree.get(ptKey(p2)) === 1 && !isRoofVertex(p2) + if (p1Dead || p2Dead) toRemove.push(line) + }) + + if (toRemove.length > 0) { + if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') { + console.log( + `[B안 cull] dead-end ridge 제거 ${toRemove.length}개`, + toRemove.map( + (l) => `(${Math.round(l.x1)},${Math.round(l.y1)})→(${Math.round(l.x2)},${Math.round(l.y2)})` + ) + ) + } + toRemove.forEach((line) => { + canvas.remove(line) + const idx = innerLines.indexOf(line) + if (idx >= 0) innerLines.splice(idx, 1) + }) + canvas.renderAll() + } + })() + + if (findPoints.length > 0) { // 모든 점에 대해 라인 업데이트를 누적 return findPoints.reduce((innerLines, point) => { @@ -3243,7 +3372,12 @@ function addRawLine(id, skeletonLines, p1, p2, lineType, color, width, pitch, is const currentDegree = getDegreeByChon(pitch) const dx = Math.abs(p2.x - p1.x); const dy = Math.abs(p2.y - p1.y); - const isDiagonal = dx > 0.1 && dy > 0.1; + // [HIP/RIDGE 임계 2026-04-30] 0.1 → 5.0 상향. + // 배경: 벽라인 이동 누적 drift 로 수직/수평 ridge 의 한쪽 축에 0.1mm 초과 + // drift 가 생기면 즉시 HIP 로 재분류되던 케이스 수정. + // 진짜 hip 은 dx, dy 모두 수십 mm 이상 → 5mm 임계로 영향 없음. + // drift ridge 는 작은 축이 ~수 mm 이내 → ridge 유지. + const isDiagonal = dx > 5.0 && dy > 5.0; const normalizedType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : lineType; // Count existing HIP lines