diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 9290e4f6..276a7287 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -8,6 +8,86 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' +// ======================================================================== +// [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local') +// ---------------------------------------------------------------------- +// 별도 util 파일을 두지 않고 QPolygon.js 안에 인라인. +// dev/prd 에선 가드가 즉시 false → 코드 미실행. 외부 의존성 없음. +// 라인 자체의 stroke/fill 은 절대 손대지 않고 fabric.Text 만 추가. +// ======================================================================== +const DEBUG_LABEL_NAME = '__debugLabel' + +function __isDebugLabelsEnabled() { + return process.env.NEXT_PUBLIC_RUN_MODE === 'local' +} + +function __classifyLineForLabel(obj) { + const name = obj.name + const lineName = obj.lineName + const type = obj.attributes?.type + + if (name === 'baseLine') return 'B' + if (name === 'outerLine' || name === 'eaves' || lineName === 'roofLine') return 'R' + if (name === LINE_TYPE.SUBLINE.HIP || lineName === LINE_TYPE.SUBLINE.HIP) return 'H' + if (name === LINE_TYPE.SUBLINE.RIDGE || lineName === LINE_TYPE.SUBLINE.RIDGE) return 'RG' + if (name === LINE_TYPE.SUBLINE.VALLEY || lineName === LINE_TYPE.SUBLINE.VALLEY) return 'V' + if (name === LINE_TYPE.SUBLINE.GABLE || lineName === LINE_TYPE.SUBLINE.GABLE) return 'G' + if (name === LINE_TYPE.SUBLINE.VERGE || lineName === LINE_TYPE.SUBLINE.VERGE) return 'VG' + if ( + type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || + lineName === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || + name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE + ) return 'E' + if ( + typeof obj.x1 === 'number' && + typeof obj.y1 === 'number' && + typeof obj.x2 === 'number' && + typeof obj.y2 === 'number' + ) return 'SK' + return null +} + +function __attachDebugLabels(canvas, parentId) { + if (!__isDebugLabelsEnabled()) return + if (!canvas || !parentId) return + + const counters = {} + const objects = canvas.getObjects().filter((o) => o.parentId === parentId && o.name !== DEBUG_LABEL_NAME) + + objects.forEach((obj) => { + const prefix = __classifyLineForLabel(obj) + if (!prefix) return + + counters[prefix] = (counters[prefix] || 0) + 1 + const label = `${prefix}-${counters[prefix]}` + const mx = (obj.x1 + obj.x2) / 2 + const my = (obj.y1 + obj.y2) / 2 + + const text = new fabric.Text(label, { + left: mx, + top: my, + originX: 'center', + originY: 'center', + fontSize: 12, + fill: '#000', + fontFamily: 'monospace', + fontWeight: 'bold', + backgroundColor: 'rgba(255,255,0,0.85)', + selectable: false, + evented: false, + hasControls: false, + hasBorders: false, + name: DEBUG_LABEL_NAME, + parentId: parentId, + excludeFromExport: true, + }) + canvas.add(text) + text.bringToFront() + }) + + canvas.renderAll() +} + export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', @@ -515,6 +595,9 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { // console.log('변별로 설정') drawRoofByAttribute(this.id, this.canvas, textMode) } + + // [DEBUG] 로컬(NEXT_PUBLIC_RUN_MODE==='local') 에서만 라벨 부착. 그 외 즉시 return. + __attachDebugLabels(this.canvas, this.id) }, /** diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 958eb53e..b0064ea0 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -940,7 +940,7 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // 스켈레톤 데이터를 기반으로 내부선 생성 roof.innerLines = roof.innerLines || [] - roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, isOverDetected) + roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, isOverDetected, changRoofLinePoints) //console.log("roofInnerLines:::", roof.innerLines); // 캔버스에 스켈레톤 상태 저장 if (!canvas.skeletonStates) { @@ -1067,7 +1067,7 @@ export const drawSkeletonRidgeRoofFromBaseLines = (roofId, canvas, textMode) => const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) roof.innerLines = roof.innerLines || [] - roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, /* isOverDetected */ true) + roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, /* isOverDetected */ true, rawPoints) if (!canvas.skeletonStates) { canvas.skeletonStates = {} @@ -1111,7 +1111,7 @@ export const drawSkeletonRidgeRoofFromBaseLines = (roofId, canvas, textMode) => * @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none') * @param {Array} baseLines - 원본 외벽선 QLine 객체 배열 */ -const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOverDetected = false) => { +const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOverDetected = false, skPolygonPoints = []) => { if (!skeleton?.Edges) return [] const roof = canvas?.getObjects().find((object) => object.id === roofId) const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes.roofId === roofId) @@ -1467,13 +1467,15 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver // [SHOULDER-ADJACENT] 인접 pair 가 SHOULDER_ABSORBED 된 경우 HIP 빗변은 orphan 이 됨. // 이유: processInBoth 는 roofLine(원본 roof.points 기반) 꼭짓점을 HIP 끝점으로 씀. // 인접 pair 흡수 = 해당 꼭짓점이 현재 wallBaseLine 토폴로지에 없음 → 공중에 뜬 대각선. - // SHOULDER_ABSORBED 가드(line 1482 인근)와 같은 원인·패턴의 후속 증상. + // SHOULDER_ABSORBED 가드(line 1608 인근)와 같은 원인·패턴의 후속 증상. + // 임계 = 10: SHOULDER_ABSORBED skip 임계와 동일하게 맞춤. (OVER_EPS=0.5 → planeSize=5) + // <1 로 두면 pair#3 흡수(planeSize=5) 인접인 pair#4 가 가드 통과 → phantom HIP 생성. { const n = sortWallBaseLines.length const prevBL = sortWallBaseLines[(index - 1 + n) % n] const nextBL = sortWallBaseLines[(index + 1) % n] - const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 1 - const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 1 + const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10 + const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10 if (prevAbsorbed || nextAbsorbed) { console.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`) return @@ -1512,25 +1514,45 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver innerLines.push(createdLine) } + // [SK-VERTEX-PROXIMITY] HIP target 은 새 SK 폴리곤(changRoofLinePoints) 의 vertex 근처여야 함. + // roofLine 은 OLD roof.points 기반 → 인접 흡수로 코너가 dissolve 되면 target 이 SK 어디에도 없는 phantom 좌표가 됨. + // pair#0 의 prev/next 인접이 아닌 2-hop 흡수 (ex. left_out 흡수 후 인접 pair#7 stretched, pair#0 end 가 옛 코너 참조) 도 차단. + const __isNearSkVertex = (pt, tol = 5) => { + if (!Array.isArray(skPolygonPoints) || skPolygonPoints.length === 0) return true + return skPolygonPoints.some((v) => Math.hypot(v.x - pt.x, v.y - pt.y) < tol) + } + if (checkDist1 > 0) { // findPoints 불필요: wallBaseLine 좌표가 ridge 라인 위에 없음 (스켈레톤이 이동된 폴리곤 기준으로 재계산되므로) // HIP extension 라인만 생성 + let target if (moveDistY1 > moveDistX1) { const dist = moveDistY1 - moveDistX1 - createHipLine(sPoint, { x: roofLine.x1, y: roofLine.y1 + ySignStart * dist }) + target = { x: roofLine.x1, y: roofLine.y1 + ySignStart * dist } } else { const dist = moveDistX1 - moveDistY1 - createHipLine(sPoint, { x: roofLine.x1 + xSignStart * dist, y: roofLine.y1 }) + target = { x: roofLine.x1 + xSignStart * dist, y: roofLine.y1 } + } + if (!__isNearSkVertex(target)) { + console.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP_VERTEX_MISS start target=(${target.x.toFixed(1)},${target.y.toFixed(1)})`) + } else { + createHipLine(sPoint, target) } } if (checkDist2 > 0) { + let target if (moveDistY2 > moveDistX2) { const dist = moveDistY2 - moveDistX2 - createHipLine(ePoint, { x: roofLine.x2, y: roofLine.y2 + ySignEnd * dist }) + target = { x: roofLine.x2, y: roofLine.y2 + ySignEnd * dist } } else { const dist = moveDistX2 - moveDistY2 - createHipLine(ePoint, { x: roofLine.x2 + xSignEnd * dist, y: roofLine.y2 }) + target = { x: roofLine.x2 + xSignEnd * dist, y: roofLine.y2 } + } + if (!__isNearSkVertex(target)) { + console.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP_VERTEX_MISS end target=(${target.x.toFixed(1)},${target.y.toFixed(1)})`) + } else { + createHipLine(ePoint, target) } } } @@ -1794,6 +1816,21 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver } } else if (condition === 'left_out') { + // [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨. + // roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현. + // processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계. + { + const n = sortWallBaseLines.length + const prevBL = sortWallBaseLines[(index - 1 + n) % n] + const nextBL = sortWallBaseLines[(index + 1) % n] + const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10 + const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10 + if (prevAbsorbed || nextAbsorbed) { + console.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`) + return + } + } + if (isStartEnd.start) { const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber() const aStartY = Big(roofLine.y1).minus(moveDist).toNumber() @@ -1903,6 +1940,21 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver } else if (condition === 'right_out') { + // [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨. + // roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현. + // processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계. + { + const n = sortWallBaseLines.length + const prevBL = sortWallBaseLines[(index - 1 + n) % n] + const nextBL = sortWallBaseLines[(index + 1) % n] + const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10 + const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10 + if (prevAbsorbed || nextAbsorbed) { + console.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`) + return + } + } + if (isStartEnd.start) { console.log('right_out::::isStartEnd:::::', isStartEnd) const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber() @@ -2049,6 +2101,21 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver } } else if (condition === 'top_out') { + // [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨. + // roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현. + // processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계. + { + const n = sortWallBaseLines.length + const prevBL = sortWallBaseLines[(index - 1 + n) % n] + const nextBL = sortWallBaseLines[(index + 1) % n] + const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10 + const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10 + if (prevAbsorbed || nextAbsorbed) { + console.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`) + return + } + } + if (isStartEnd.start) { console.log('top_out isStartEnd:::::::', isStartEnd) const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber() @@ -2156,6 +2223,22 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver } } else if (condition === 'bottom_out') { console.log('bottom_out isStartEnd:::::::', isStartEnd) + + // [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨. + // roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현. + // processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계. + { + const n = sortWallBaseLines.length + const prevBL = sortWallBaseLines[(index - 1 + n) % n] + const nextBL = sortWallBaseLines[(index + 1) % n] + const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10 + const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10 + if (prevAbsorbed || nextAbsorbed) { + console.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`) + return + } + } + if (isStartEnd.start) { console.log('isStartEnd:::::::', isStartEnd) const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber()