Merge pull request '잔여선 삭제 및 라벨표시(local)' (#796) from dev into dev-deploy

Reviewed-on: #796
This commit is contained in:
ysCha 2026-04-27 15:12:26 +09:00
commit 41a2e1648e
2 changed files with 176 additions and 10 deletions

View File

@ -8,6 +8,86 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import Big from 'big.js' import Big from 'big.js'
import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' 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, { export const QPolygon = fabric.util.createClass(fabric.Polygon, {
type: 'QPolygon', type: 'QPolygon',
@ -515,6 +595,9 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
// console.log('변별로 설정') // console.log('변별로 설정')
drawRoofByAttribute(this.id, this.canvas, textMode) drawRoofByAttribute(this.id, this.canvas, textMode)
} }
// [DEBUG] 로컬(NEXT_PUBLIC_RUN_MODE==='local') 에서만 라벨 부착. 그 외 즉시 return.
__attachDebugLabels(this.canvas, this.id)
}, },
/** /**

View File

@ -940,7 +940,7 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
// 스켈레톤 데이터를 기반으로 내부선 생성 // 스켈레톤 데이터를 기반으로 내부선 생성
roof.innerLines = roof.innerLines || [] 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); //console.log("roofInnerLines:::", roof.innerLines);
// 캔버스에 스켈레톤 상태 저장 // 캔버스에 스켈레톤 상태 저장
if (!canvas.skeletonStates) { if (!canvas.skeletonStates) {
@ -1067,7 +1067,7 @@ export const drawSkeletonRidgeRoofFromBaseLines = (roofId, canvas, textMode) =>
const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]])
roof.innerLines = roof.innerLines || [] 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) { if (!canvas.skeletonStates) {
canvas.skeletonStates = {} canvas.skeletonStates = {}
@ -1111,7 +1111,7 @@ export const drawSkeletonRidgeRoofFromBaseLines = (roofId, canvas, textMode) =>
* @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none') * @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none')
* @param {Array<QLine>} baseLines - 원본 외벽선 QLine 객체 배열 * @param {Array<QLine>} baseLines - 원본 외벽선 QLine 객체 배열
*/ */
const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOverDetected = false) => { const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOverDetected = false, skPolygonPoints = []) => {
if (!skeleton?.Edges) return [] if (!skeleton?.Edges) return []
const roof = canvas?.getObjects().find((object) => object.id === roofId) const roof = canvas?.getObjects().find((object) => object.id === roofId)
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes.roofId === 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 이 됨. // [SHOULDER-ADJACENT] 인접 pair 가 SHOULDER_ABSORBED 된 경우 HIP 빗변은 orphan 이 됨.
// 이유: processInBoth 는 roofLine(원본 roof.points 기반) 꼭짓점을 HIP 끝점으로 씀. // 이유: processInBoth 는 roofLine(원본 roof.points 기반) 꼭짓점을 HIP 끝점으로 씀.
// 인접 pair 흡수 = 해당 꼭짓점이 현재 wallBaseLine 토폴로지에 없음 → 공중에 뜬 대각선. // 인접 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 n = sortWallBaseLines.length
const prevBL = sortWallBaseLines[(index - 1 + n) % n] const prevBL = sortWallBaseLines[(index - 1 + n) % n]
const nextBL = sortWallBaseLines[(index + 1) % n] const nextBL = sortWallBaseLines[(index + 1) % n]
const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 1 const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 1 const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
if (prevAbsorbed || nextAbsorbed) { if (prevAbsorbed || nextAbsorbed) {
console.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`) console.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`)
return return
@ -1512,25 +1514,45 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
innerLines.push(createdLine) 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) { if (checkDist1 > 0) {
// findPoints 불필요: wallBaseLine 좌표가 ridge 라인 위에 없음 (스켈레톤이 이동된 폴리곤 기준으로 재계산되므로) // findPoints 불필요: wallBaseLine 좌표가 ridge 라인 위에 없음 (스켈레톤이 이동된 폴리곤 기준으로 재계산되므로)
// HIP extension 라인만 생성 // HIP extension 라인만 생성
let target
if (moveDistY1 > moveDistX1) { if (moveDistY1 > moveDistX1) {
const dist = 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 { } else {
const dist = moveDistX1 - moveDistY1 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) { if (checkDist2 > 0) {
let target
if (moveDistY2 > moveDistX2) { if (moveDistY2 > moveDistX2) {
const dist = 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 { } else {
const dist = moveDistX2 - moveDistY2 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') { } 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) { if (isStartEnd.start) {
const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber() const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber()
const aStartY = Big(roofLine.y1).minus(moveDist).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') { } 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) { if (isStartEnd.start) {
console.log('right_out::::isStartEnd:::::', isStartEnd) console.log('right_out::::isStartEnd:::::', isStartEnd)
const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber() 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') { } 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) { if (isStartEnd.start) {
console.log('top_out isStartEnd:::::::', isStartEnd) console.log('top_out isStartEnd:::::::', isStartEnd)
const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber() 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') { } else if (condition === 'bottom_out') {
console.log('bottom_out isStartEnd:::::::', isStartEnd) 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) { if (isStartEnd.start) {
console.log('isStartEnd:::::::', isStartEnd) console.log('isStartEnd:::::::', isStartEnd)
const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber() const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber()