innerLine #808

Merged
ysCha merged 1 commits from dev into dev-deploy 2026-04-30 17:02:53 +09:00
3 changed files with 218 additions and 12 deletions
Showing only changes of commit c2eba138a6 - Show all commits

View File

@ -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)

View File

@ -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}`
)
}

View File

@ -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<d2) 인지 p2 (d2<=d1) 인지에 따라 해당 끝만 갱신.
if (d1 < d2) {
hip.set({ x1: hitX, y1: hitY })
} else {
hip.set({ x2: hitX, y2: hitY })
}
// attributes (planeSize/actualSize) 0.01 정밀도 유지 (integrateExtensionLines 와 동일 규칙).
if (!hip.attributes) hip.attributes = {}
const oldPlane = hip.attributes.planeSize ?? 0
const oldActual = hip.attributes.actualSize ?? 0
hip.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
hip.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
// integrateExtensionLines 가 이 hip 의 좌표를 되돌리지 않게 가드.
hip.__extended = true
// Fabric bounding/length/text 갱신.
if (typeof hip.setCoords === 'function') hip.setCoords()
if (typeof hip.setLength === 'function') hip.setLength()
if (typeof hip.addLengthText === 'function') hip.addLengthText()
console.log(
`[v1 ext] hip 연장 oldLen=${oldLen.toFixed(1)} +extLen=${extLen.toFixed(1)} ratio=${ratio.toFixed(3)} ` +
`(plane ${oldPlane}${hip.attributes.planeSize}, actual ${oldActual}${hip.attributes.actualSize})`
)
// ─────────────────────────────────────────────────────────────────────────
// [확장선 스타일] local: 빨강 점선(디버그 가시화), dev/prd: innerLine 동일색·실선(연결된 것처럼).
const __isLocalExt = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
const __extStroke = __isLocalExt ? '#FF0000' : '#1083E3'
@ -858,7 +897,9 @@ const drawBaselineToRooflineHelpers = (roof, canvas, baseLinePoints, roofLinePoi
// 자동 수집 후 roofBase.lines 에 추가.
name: BASELINE_TO_ROOFLINE_HELPER_TYPE,
lineName: 'extensionLine',
visible: true,
// [2026-04-30] 시각적으로 숨김. canvas 데이터(좌표/lineName/roofId)는 유지하여
// integrate 픽업 및 디버그 조회(canvas.getObjects().filter)에 영향 없음.
visible: false,
roofId: roof.id,
selectable: false,
hoverCursor: 'default',
@ -1539,11 +1580,15 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
skeletonLines.forEach((sktLine, skIndex) => {
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