diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 337d90e3..cc5011fc 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -6,7 +6,89 @@ import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, toGeo import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' -import { drawSkeletonRidgeRoof, 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() { + // 디버그 라벨(H-1, B-4, RG-1 등) 표시 비활성화 + return false +} + +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', @@ -385,21 +467,23 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { /** * 보조선 그리기 * @param settingModalFirstOptions + * @param forceRedraw - true 면 verifyMoveBoundary 'crossed' 가드를 우회하고 강제로 재빌드. + * (예: 오버 이동 상태에서 wall.baseLines/roof.lines 는 그대로 두고 SK 만 재생성하고 싶을 때) */ - drawHelpLine(settingModalFirstOptions) { - // [보호 가드] 이번 이동이 골짜기 경계를 넘어가는 과한 이동이면 - // 아예 초기화/재빌드 자체를 수행하지 않고 현재 상태 그대로 유지한다. - // ※ 기존 drawHelpLine 로직은 건드리지 않고, 진입부에 가드 한 블록만 추가. - // ※ 최초 그리기·offset 변경 등 moveUpDown=0 / moveFlowLine=0 케이스는 - // verifyMoveBoundary 가 'ok' 를 돌려주므로 영향 없음. + drawHelpLine(settingModalFirstOptions, forceRedraw = false) { + // [정책] 오버 이동(verifyMoveBoundary='crossed') 처리: + // - roofLine 은 그대로, wall.baseLines 는 이미 오버된 좌표로 mutate 된 상태. + // - 보조선 싹 지우고 '오버된 wall.baseLines 좌표 그대로' SK 재빌드. + // - 일반 경로(drawSkeletonRidgeRoof) 는 45도 대각 확장/roof 경계 클램핑이 들어가 + // 오버 polygon 이 왜곡 → 용마루 지붕 케이스는 전용 함수(drawSkeletonRidgeRoofFromBaseLines)로 분기. + // - forceRedraw 인자는 명시적 수동 호출 의도 표현 용도로만 유지. + let __verdict = 'unknown' try { - const __verdict = verifyMoveBoundary(this.id, this.canvas) + __verdict = verifyMoveBoundary(this.id, this.canvas) if (__verdict === 'crossed') { - console.warn('[drawHelpLine] 경계 넘음(crossed) → 초기화/재빌드 스킵 (기존 innerLines/SK 유지)') - return + console.warn(`[drawHelpLine] 경계 넘음(crossed) → 오버 전용 경로(drawSkeletonRidgeRoofFromBaseLines) 사용 (forceRedraw=${forceRedraw})`) } } catch (e) { - // 판정기 자체 실패는 무시하고 기존 흐름 진행 (안전한 no-op) console.warn('[drawHelpLine] verifyMoveBoundary 예외 → 기존 흐름 진행:', e) } @@ -500,8 +584,12 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { if (types.every((type) => type === LINE_TYPE.WALLLINE.EAVES)) { // 용마루 -- straight-skeleton - // console.log('용마루 지붕') - drawSkeletonRidgeRoof(this.id, this.canvas, textMode) + // 오버(crossed) 이동 시 wall.baseLines 좌표 그대로 사용하는 전용 경로로 분기. + if (__verdict === 'crossed') { + drawSkeletonRidgeRoofFromBaseLines(this.id, this.canvas, textMode) + } else { + drawSkeletonRidgeRoof(this.id, this.canvas, textMode) + } } else if (isGableRoof(types)) { // A형, B형 박공 지붕 // console.log('패턴 지붕') @@ -513,6 +601,68 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { // console.log('변별로 설정') drawRoofByAttribute(this.id, this.canvas, textMode) } + + // 대칭 hip line 쌍의 planeSize/actualSize 를 평균값으로 통일 (예: 1637 / 1638 → 1637.5) + // hip 라인은 좌우 대칭이지만 부동소수점 round-off 로 1mm 차이가 발생함 + if (this.innerLines && this.innerLines.length > 0) { + const hips = this.innerLines.filter((l) => l && l.name === LINE_TYPE.SUBLINE.HIP) + const _segLen = (l) => Math.round(Math.sqrt((l.x2 - l.x1) ** 2 + (l.y2 - l.y1) ** 2) * 10) + const _equalizeText = (line, plane, actual) => { + if (line.attributes) { + line.attributes.planeSize = plane + line.attributes.actualSize = actual + } + // canvas 위 lengthText 도 즉시 갱신 + const text = this.canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id) + if (text) { + text.set({ planeSize: plane, actualSize: actual }) + // 현재 표시 모드에 맞춰 text 갱신 + const isPlane = !text.actualSize || text.text === String(text.planeSize) + text.set({ text: isPlane ? String(plane) : String(actual) }) + } + } + // 길이 그룹화: ±5mm 이내 길이끼리 묶고, 그 그룹 안에서 평균값으로 통일 + const used = new Set() + hips.forEach((h, i) => { + if (used.has(i)) return + const group = [{ idx: i, line: h }] + const baseLen = _segLen(h) + hips.forEach((h2, j) => { + if (j <= i || used.has(j)) return + if (Math.abs(_segLen(h2) - baseLen) <= 5) group.push({ idx: j, line: h2 }) + }) + if (group.length >= 2) { + const avgPlane = group.reduce((s, g) => s + (g.line.attributes?.planeSize || 0), 0) / group.length + const avgActual = group.reduce((s, g) => s + (g.line.attributes?.actualSize || 0), 0) / group.length + // 0.5 단위 정밀도 유지 + const plane = Math.round(avgPlane * 2) / 2 + const actual = Math.round(avgActual * 2) / 2 + group.forEach(({ idx, line }) => { + used.add(idx) + _equalizeText(line, plane, actual) + }) + } else { + used.add(i) + } + }) + this.canvas?.renderAll() + } + + // [DEBUG] 로컬(NEXT_PUBLIC_RUN_MODE==='local') 에서만 라벨 부착. 그 외 즉시 return. + __attachDebugLabels(this.canvas, this.id) + }, + + /** + * 오버 이동(crossed) 상태여도 wall.baseLines / roof.lines / outerLine / lengthText 는 그대로 두고 + * 보조선(innerLines: eaveHelpLine, HIP, extensionLine, SK ridge 등) 만 제거 후 SK 재빌드. + * + * drawHelpLine 의 진입부 verifyMoveBoundary 가드만 우회하는 wrapper. 이후 흐름은 동일. + * - 초기화 필터(line 405-411) 가 이미 wall/roof/baseLine/outerLine/lengthText 를 제외하고 있어 + * 해당 객체들은 보존됨. + * - 사용자가 오버된 상태에서 "SK 만 다시 그려보기" 의도로 명시적으로 호출할 때 사용. + */ + redrawHelpLineForced(settingModalFirstOptions) { + return this.drawHelpLine(settingModalFirstOptions, true) }, addLengthText() { diff --git a/src/components/floor-plan/modal/outerlinesetting/WallLineSetting.jsx b/src/components/floor-plan/modal/outerlinesetting/WallLineSetting.jsx index 99fefe9f..0919bf60 100644 --- a/src/components/floor-plan/modal/outerlinesetting/WallLineSetting.jsx +++ b/src/components/floor-plan/modal/outerlinesetting/WallLineSetting.jsx @@ -10,6 +10,7 @@ import Angle from '@/components/floor-plan/modal/lineTypes/Angle' import DoublePitch from '@/components/floor-plan/modal/lineTypes/DoublePitch' import Diagonal from '@/components/floor-plan/modal/lineTypes/Diagonal' import { usePopup } from '@/hooks/usePopup' +import { useSwal } from '@/hooks/useSwal' import { useState } from 'react' import Image from 'next/image' import { v4 as uuidv4 } from 'uuid' @@ -18,6 +19,7 @@ export default function WallLineSetting(props) { const { id } = props const { addPopup, closePopup } = usePopup() const { getMessage } = useMessage() + const { swalFire } = useSwal() const [propertiesId, setPropertiesId] = useState(uuidv4()) const [useCalcPad, setUseCalcPad] = useState(false) const { @@ -182,10 +184,11 @@ export default function WallLineSetting(props) { - diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index c5085c6c..01b43d01 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -12,6 +12,14 @@ import { getSelectLinePosition } from '@/util/skeleton-utils' import { useMouse } from '@/hooks/useMouse' import { useUndoRedo } from '@/hooks/useUndoRedo' +// [정책 flag] 오버 이동(인접 라인 반전 = polygon self-cross) 입력 처리 방법. +// 'clamp' : (기본/2026-04-24 최종) 평행 위치-OVER_EPS 까지 클램핑. wall.baseLines 는 항상 정상 polygon, SK 확장 정상. +// 'reject' : (2026-04-22 정책) silent skip — 오버면 해당 target 의 mutation 자체를 건너뜀. 토스트 없음. +// 'passthrough' : 가드 전면 스킵 — 사용자가 입력한 raw 값을 그대로 적용. (과거 crossed 허용 동작; polygon 왜곡 위험) +// 교체 방법: 이 상수만 변경. OVER_GUARD 분기가 자동 반응. +const OVER_MOVE_POLICY = 'clamp' +const OVER_EPS = 0.5 + //동선이동 형 올림 내림 export function useMovementSetting(id) { const TYPE = { @@ -749,6 +757,57 @@ export function useMovementSetting(id) { deltaX = value.toNumber() } + // [OVER_GUARD] 오버 이동(인접 라인 반전) 처리. 정책은 파일 상단 OVER_MOVE_POLICY 로 전환. + // 한계 계산 (공통): + // nLimit = (nextLine 의 free 끝점 좌표) - (currentLine 의 해당 끝점 좌표) + // pLimit = (prevLine 의 free 끝점 좌표) - (currentLine 의 해당 시작점 좌표) + // delta 와 같은 부호인 한계만 후보(반대 부호 한계는 인접 라인이 길어지는 방향). + // 정책별 동작: + // 'passthrough' → 가드 전체 스킵 + // 'reject' → |delta| > |한계-EPS| 이면 해당 target 은 mutation 없이 건너뜀 (silent skip) + // 'clamp' → |delta| 를 |한계-EPS| 로 잘라 평행 직전까지 이동. roof.moveUpDown 도 동일 비율 동기화. + if (OVER_MOVE_POLICY !== 'passthrough') { + const __isHoriz = currentLine.y1 === currentLine.y2 + const __raw = __isHoriz ? deltaY : deltaX + const __sgn = Math.sign(__raw) + if (__sgn !== 0) { + const __nLimit = __isHoriz ? (nextLine.y2 - currentLine.y2) : (nextLine.x2 - currentLine.x2) + const __pLimit = __isHoriz ? (prevLine.y1 - currentLine.y1) : (prevLine.x1 - currentLine.x1) + let __absLimit = Infinity + if (Math.sign(__nLimit) === __sgn) __absLimit = Math.min(__absLimit, Math.abs(__nLimit)) + if (Math.sign(__pLimit) === __sgn) __absLimit = Math.min(__absLimit, Math.abs(__pLimit)) + // [EPS] 평행 정확히 도달 시 인접 baseLine zero-length → polygon 중복 꼭짓점 → SkeletonBuilder 헛선 발생. + // OVER_EPS 여유 두어 인접 baseLine 길이 OVER_EPS(=0.5) 로 남기고 SHOULDER_ABSORBED(planeSize<1) 가 자연 흡수. + const __safeAbsLimit = Math.max(0, __absLimit - OVER_EPS) + const __isOver = Math.abs(__raw) > __safeAbsLimit + 0.001 + + if (__isOver && OVER_MOVE_POLICY === 'reject') { + // silent skip: mutation 자체 건너뜀. movingLineFromSkeleton 의 roof.points 확장도 막으려면 moveUpDown=0. + console.warn( + `[handleSave] OVER 거부(reject): ${__isHoriz ? 'deltaY' : 'deltaX'}=${__raw.toFixed(1)} > limit=${__safeAbsLimit.toFixed(1)} → target skip` + ) + roof.moveUpDown = 0 + return + } + + if (OVER_MOVE_POLICY === 'clamp') { + const __limited = __sgn * Math.min(Math.abs(__raw), __safeAbsLimit) + if (Math.abs(__limited - __raw) > 0.001) { + console.warn( + `[handleSave] OVER 클램핑: ${__isHoriz ? 'deltaY' : 'deltaX'} ${__raw.toFixed(1)} → ${__limited.toFixed(1)} (nLimit=${__nLimit.toFixed(1)} pLimit=${__pLimit.toFixed(1)}, EPS=${OVER_EPS})` + ) + if (__isHoriz) deltaY = __limited + else deltaX = __limited + // [SYNC roof.moveUpDown] movingLineFromSkeleton 은 roof.moveUpDown(mm) 로 roof.points 확장. + // delta 만 줄이고 moveUpDown 방치 시 wall 은 평행까지, roof.points 는 원본 양만큼 이동 → + // polygon 자기교차 → removeNonOrthogonalPoints 가 꼭짓점 축소 → "SK 가 wall 안에서 멈춤". + // __limited(cm) × 10 = mm. + roof.moveUpDown = Math.round(Math.abs(__limited) * 10) + } + } + } + } + currentLine.set({ x1: currentLine.x1 + deltaX, y1: currentLine.y1 + deltaY, diff --git a/src/hooks/roofcover/useOuterLineWall.js b/src/hooks/roofcover/useOuterLineWall.js index 82472227..44f53475 100644 --- a/src/hooks/roofcover/useOuterLineWall.js +++ b/src/hooks/roofcover/useOuterLineWall.js @@ -32,6 +32,8 @@ import { calculateAngle } from '@/util/qpolygon-utils' import { fabric } from 'fabric' import { outlineDisplaySelector } from '@/store/settingAtom' import { usePopup } from '@/hooks/usePopup' +import { useSwal } from '@/hooks/useSwal' +import { useMessage } from '@/hooks/useMessage' import Big from 'big.js' import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting' import { useObject } from '@/hooks/useObject' @@ -88,6 +90,8 @@ export function useOuterLineWall(id, propertiesId) { const arrow1Ref = useRef(arrow1) const arrow2Ref = useRef(arrow2) const { addPopup, closePopup } = usePopup() + const { swalFire } = useSwal() + const { getMessage } = useMessage() const setOuterLineFix = useSetRecoilState(outerLineFixState) const setCurrentMenu = useSetRecoilState(currentMenuState) @@ -937,7 +941,11 @@ export function useOuterLineWall(id, propertiesId) { const enterCheck = (e) => { if (e.key === 'Enter') { - handleFix() + swalFire({ + type: 'confirm', + text: getMessage('modal.cover.outline.fix.confirm'), + confirmFn: handleFix, + }) } } diff --git a/src/hooks/surface/usePlacementShapeDrawing.js b/src/hooks/surface/usePlacementShapeDrawing.js index 2f08713e..67ff35f9 100644 --- a/src/hooks/surface/usePlacementShapeDrawing.js +++ b/src/hooks/surface/usePlacementShapeDrawing.js @@ -22,6 +22,8 @@ import { import { usePolygon } from '@/hooks/usePolygon' import { POLYGON_TYPE } from '@/common/common' import { usePopup } from '@/hooks/usePopup' +import { useSwal } from '@/hooks/useSwal' +import { useMessage } from '@/hooks/useMessage' import { useSurfaceShapeBatch } from './useSurfaceShapeBatch' import { roofDisplaySelector } from '@/store/settingAtom' @@ -50,6 +52,8 @@ export function usePlacementShapeDrawing(id) { const { changeSurfaceLineType } = useSurfaceShapeBatch({}) const { handleSelectableObjects } = useObject() const { saveSnapshot, popLastSnapshot } = useUndoRedo() + const { swalFire } = useSwal() + const { getMessage } = useMessage() const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState) const adsorptionRange = useRecoilValue(adsorptionRangeState) @@ -1097,7 +1101,11 @@ export function usePlacementShapeDrawing(id) { const enterCheck = (e) => { if (e.key === 'Enter') { - handleFix() + swalFire({ + type: 'confirm', + text: getMessage('modal.cover.outline.fix.confirm'), + confirmFn: handleFix, + }) } } diff --git a/src/hooks/useLine.js b/src/hooks/useLine.js index df876fb1..351760d6 100644 --- a/src/hooks/useLine.js +++ b/src/hooks/useLine.js @@ -215,7 +215,8 @@ export const useLine = () => { line.attributes = { ...line.attributes, - actualSize: Number(line.attributes.actualSize.toFixed(0)), + // 0.5 단위 정밀도 유지 (대칭 분할 시 1637.5 같은 값 보존). 그 외에는 정수. + actualSize: Number((Math.round(line.attributes.actualSize * 2) / 2).toFixed(1)), isCalculated: true, } } diff --git a/src/hooks/useMode.js b/src/hooks/useMode.js index 46219a65..c1b5524c 100644 --- a/src/hooks/useMode.js +++ b/src/hooks/useMode.js @@ -1777,7 +1777,8 @@ export function useMode() { const offsetEdges = [] polygon.edges.forEach((edge, i) => { - const offset = lines[i % lines.length].attributes.offset === 0 ? 1 : lines[i % lines.length].attributes.offset + // offset 이 0(처마/케라바 출폭 0) 인 경우 그대로 0 유지 → wall == roof + const offset = lines[i % lines.length].attributes.offset const dx = edge.outwardNormal.x * offset const dy = edge.outwardNormal.y * offset offsetEdges.push(createOffsetEdge(edge, dx, dy)) @@ -1830,7 +1831,8 @@ export function useMode() { const offsetEdges = [] polygon.edges.forEach((edge, i) => { - const offset = lines[i % lines.length].attributes.offset === 0 ? 1 : lines[i % lines.length].attributes.offset + // offset 이 0(처마/케라바 출폭 0) 인 경우 그대로 0 유지 → wall == roof + const offset = lines[i % lines.length].attributes.offset const dx = edge.inwardNormal.x * offset const dy = edge.inwardNormal.y * offset offsetEdges.push(createOffsetEdge(edge, dx, dy)) @@ -1977,10 +1979,20 @@ export function useMode() { const y1 = Big(line.y1) const y2 = Big(line.y2) const lineLength = x1.minus(x2).abs().pow(2).plus(y1.minus(y2).abs().pow(2)).sqrt().times(10).round().toNumber() + const isDivLine = divWallLines.some((divLine) => divLine.index === index) + + // 옵션 3: 외벽선의 입력 치수(attributes.planeSize/actualSize)를 오프셋 폴리곤 라인에도 propagate + // → polygon-offset 알고리즘의 부동소수점 누적 오차로 인한 5~10mm drift 제거 + // divLine(외벽 분기로 인해 새로 삽입된 코너 라인)은 wall.lines 와 1:1 매칭이 아니므로 + // geometric lineLength 그대로 사용 + const sourceWallLine = !isDivLine ? wall.lines[roofWallIndex] : null + const propagatedPlaneSize = sourceWallLine?.attributes?.planeSize ?? lineLength + const propagatedActualSize = sourceWallLine?.attributes?.actualSize ?? lineLength + line.attributes = { roofId: roof.id, - planeSize: lineLength, - actualSize: lineLength, + planeSize: propagatedPlaneSize, + actualSize: propagatedActualSize, wallLine: wall.lines[roofWallIndex].id, type: wall.lines[roofWallIndex].attributes.type, offset: wall.lines[roofWallIndex].attributes.offset, @@ -1989,7 +2001,6 @@ export function useMode() { sleeve: wall.lines[roofWallIndex].attributes.sleeve || false, } - const isDivLine = divWallLines.some((divLine) => divLine.index === index) if (!isDivLine) { roofWallIndex++ } diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index 9b17419e..8b2f7c37 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -1053,26 +1053,53 @@ export const usePolygon = () => { const length1 = Math.round(Math.hypot(newLine1.x1 - newLine1.x2, newLine1.y1 - newLine1.y2)) * 10 const length2 = Math.round(Math.hypot(newLine2.x1 - newLine2.x2, newLine2.y1 - newLine2.y2)) * 10 - // 원본에 planeSize/actualSize가 있으면 비율로 분배, 없으면 기하학적 길이 사용 - let planeSize1, planeSize2, actualSize1, actualSize2 - if (line.attributes.planeSize && originalGeomLength > 0) { - const ratio1 = length1 / originalGeomLength - const ratio2 = length2 / originalGeomLength - planeSize1 = Math.round(line.attributes.planeSize * ratio1) - planeSize2 = Math.round(line.attributes.planeSize * ratio2) - } else { - planeSize1 = length1 - planeSize2 = length2 + // 분할 시 새 sub-segment 의 planeSize/actualSize 는 새 기하학으로 직접 계산. + // 부모 비율을 쓰면 부모 좌표 drift 가 그대로 전파되어 사용자 기대 round 값과 어긋남. + // sum 이 부모 입력 planeSize 와 ±20mm 차이일 때 잔차 보정: + // - sum < parent (drift +): geomLen 이 underestimated → 잔차를 SHORTER 에 더해 + // LONGER 의 round 값(예: 910)을 보존 + // - sum > parent (drift -): geomLen 이 overestimated → 잔차를 LONGER 에서 빼서 + // SHORTER 의 round 값(예: 460)을 보존 + let planeSize1 = length1 + let planeSize2 = length2 + let actualSize1 = length1 + let actualSize2 = length2 + + const _redistribute = (parentVal, p1, p2) => { + if (!parentVal) return [p1, p2] + const sum = p1 + p2 + const drift = parentVal - sum + if (Math.abs(drift) > 20) return [p1, p2] // 큰 차이는 보정하지 않음 + if (drift === 0) return [p1, p2] + // drift > 0: SHORTER 에 잔차 추가 → LONGER 보존 + // drift < 0: LONGER 에 잔차 제거 → SHORTER 보존 + if (drift > 0) { + if (length1 <= length2) return [parentVal - p2, p2] + return [p1, parentVal - p1] + } else { + if (length1 >= length2) return [parentVal - p2, p2] + return [p1, parentVal - p1] + } } - if (line.attributes.actualSize && originalGeomLength > 0) { - const ratio1 = length1 / originalGeomLength - const ratio2 = length2 / originalGeomLength - actualSize1 = Math.round(line.attributes.actualSize * ratio1) - actualSize2 = Math.round(line.attributes.actualSize * ratio2) - } else { - actualSize1 = length1 - actualSize2 = length2 + + const parentPlane = line.attributes?.planeSize + const parentActual = line.attributes?.actualSize + ;[planeSize1, planeSize2] = _redistribute(parentPlane, planeSize1, planeSize2) + ;[actualSize1, actualSize2] = _redistribute(parentActual, actualSize1, actualSize2) + + // 2분할에서 양 끝이 거의 같을 때(차이 ≤ 5mm) 대칭성 강제: 두 값을 평균으로 통일 + // 예: ridge 가 wall 을 거의 정확히 양분 → 1637 / 1638 → 둘 다 1637.5 + // 합이 홀수이면 0.5 소수점을 유지하여 양쪽이 정확히 같은 값으로 표시되도록 함 + const _equalizeIfNearSymmetric = (p1, p2) => { + if (!p1 || !p2) return [p1, p2] + if (Math.abs(p1 - p2) > 5) return [p1, p2] + const avg = (p1 + p2) / 2 // 0.5 소수점 허용 (예: 3275 / 2 = 1637.5) + // 0.5 단위로 정밀도 유지 (Big number/이상한 부동소수점 방지) + const halfPrecision = Math.round(avg * 2) / 2 + return [halfPrecision, halfPrecision] } + ;[planeSize1, planeSize2] = _equalizeIfNearSymmetric(planeSize1, planeSize2) + ;[actualSize1, actualSize2] = _equalizeIfNearSymmetric(actualSize1, actualSize2) newLine1.attributes = { ...line.attributes, @@ -1159,6 +1186,23 @@ export const usePolygon = () => { newLine.length = lastCalcLength newLines.push(newLine) + + // 홀수 분할(3, 5, ...) 시 양 끝(첫·마지막) sub-segment 의 길이를 평균값으로 동일하게 보정 + // 패턴 A/B 등 대칭 입력 wall 이 hip lines 로 분할될 때 좌우 대칭성을 강제로 유지 + const newSubsForThisLine = newLines.slice(-(intersections.length + 1)) // 이 부모 line 이 만든 sub-segment 들 + const segCount = newSubsForThisLine.length + if (segCount >= 3 && segCount % 2 === 1) { + const first = newSubsForThisLine[0] + const last = newSubsForThisLine[segCount - 1] + const avgPlane = Math.round((first.attributes.planeSize + last.attributes.planeSize) / 2) + const avgActual = Math.round((first.attributes.actualSize + last.attributes.actualSize) / 2) + // 두 끝값의 차이가 작을 때만 평균화 (대칭성 가정 케이스) + if (Math.abs(first.attributes.planeSize - last.attributes.planeSize) <= 20) { + first.attributes = { ...first.attributes, planeSize: avgPlane, actualSize: avgActual } + last.attributes = { ...last.attributes, planeSize: avgPlane, actualSize: avgActual } + } + } + divideLines.splice(i, 1) // 기존 line 제거 } } @@ -1638,18 +1682,60 @@ export const usePolygon = () => { return startFlag && endFlag }) + // 1차 매칭: 양 끝점이 정확히 일치하는 부모 line 의 attributes 복사 + const matchedRoofLines = new Set() roofLines.forEach((line) => { - //console.log("::::::::::",line); roof.lines.forEach((roofLine) => { if ( (isSamePoint(line.startPoint, roofLine.startPoint) && isSamePoint(line.endPoint, roofLine.endPoint)) || (isSamePoint(line.startPoint, roofLine.endPoint) && isSamePoint(line.endPoint, roofLine.startPoint)) ) { roofLine.attributes = { ...line.attributes } + matchedRoofLines.add(roofLine) } }) }) + // 2차 매칭 (옵션 3 + α): 대각선이 외벽 중간을 분할한 sub-segment 처리 + const _isCollinearWithin = (parent, p) => { + const ax = parent.startPoint.x, ay = parent.startPoint.y + const bx = parent.endPoint.x, by = parent.endPoint.y + const dx = bx - ax, dy = by - ay + const segLenSq = dx * dx + dy * dy + if (segLenSq < 1) return false + const cross = (p.x - ax) * dy - (p.y - ay) * dx + if (Math.abs(cross) / Math.sqrt(segLenSq) > 2) return false + const t = ((p.x - ax) * dx + (p.y - ay) * dy) / segLenSq + return t >= -0.001 && t <= 1.001 + } + const _segLen = (s, e) => Math.sqrt((s.x - e.x) ** 2 + (s.y - e.y) ** 2) + + const parentCandidates = [...polygonLines, ...polygon.innerLines] + roof.lines.forEach((roofLine) => { + if (matchedRoofLines.has(roofLine)) return + const parent = parentCandidates.find( + (p) => p?.startPoint && p?.endPoint && _isCollinearWithin(p, roofLine.startPoint) && _isCollinearWithin(p, roofLine.endPoint), + ) + if (!parent) return + const parentLen = _segLen(parent.startPoint, parent.endPoint) + if (parentLen < 1) return + const subLen = _segLen(roofLine.startPoint, roofLine.endPoint) + const ratio = subLen / parentLen + + const parentAttrs = parent.attributes || {} + const parentPlane = Number(parentAttrs.planeSize) + const parentActual = Number(parentAttrs.actualSize) + const propagatedPlane = Number.isFinite(parentPlane) ? Math.round(parentPlane * ratio) : null + const propagatedActual = Number.isFinite(parentActual) ? Math.round(parentActual * ratio) : null + + roofLine.attributes = { + ...parentAttrs, + ...(propagatedPlane != null ? { planeSize: propagatedPlane } : {}), + ...(propagatedActual != null ? { actualSize: propagatedActual } : {}), + } + matchedRoofLines.add(roofLine) + }) + // canvas.add(roof) createdRoofs.push(roof) canvas.remove(polygon) diff --git a/src/locales/ja.json b/src/locales/ja.json index 13435c2a..96f34735 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -70,6 +70,7 @@ "modal.cover.outline.length": "長さ(mm)", "modal.cover.outline.arrow": "方向(矢印)", "modal.cover.outline.fix": "外壁線確定", + "modal.cover.outline.fix.confirm": "本当に確定しますか?確定後は修正できません。", "modal.cover.outline.rollback": "前に戻る", "modal.cover.outline.finish": "設定完了", "common.setting.finish": "設定完了", diff --git a/src/locales/ko.json b/src/locales/ko.json index e3403f02..f9b48bb4 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -70,6 +70,7 @@ "modal.cover.outline.length": "길이(mm)", "modal.cover.outline.arrow": "방향(화살표)", "modal.cover.outline.fix": "외벽선 확정", + "modal.cover.outline.fix.confirm": "정말로 확정하시겠습니까? 확정 후에는 수정할 수 없습니다.", "modal.cover.outline.rollback": "일변전으로 돌아가기", "modal.cover.outline.finish": "설정완료", "common.setting.finish": "설정완료", diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index c01feb2e..dccd3414 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -726,8 +726,11 @@ export const drawGableRoof = (roofId, canvas, textMode) => { let currentRoof if (line.attributes.offset === 0) { checkRoofLines.forEach((roofLine) => { - const isVerticalSame = line.y1 === roofLine.y1 && line.y2 === roofLine.y2 - const isHorizontalSame = line.x1 === roofLine.x1 && line.x2 === roofLine.x2 + // 정방향/역방향 양쪽 모두 매칭 허용 (좌표 정렬에 따라 끝점이 swap 될 수 있음) + const isVerticalSame = + (line.y1 === roofLine.y1 && line.y2 === roofLine.y2) || (line.y1 === roofLine.y2 && line.y2 === roofLine.y1) + const isHorizontalSame = + (line.x1 === roofLine.x1 && line.x2 === roofLine.x2) || (line.x1 === roofLine.x2 && line.x2 === roofLine.x1) if ( (isVerticalSame || isHorizontalSame) && (isPointOnLineNew(roofLine, { x: line.x1, y: line.y1 }) || isPointOnLineNew(roofLine, { x: line.x2, y: line.y2 })) @@ -735,6 +738,11 @@ export const drawGableRoof = (roofId, canvas, textMode) => { currentRoof = { roofLine } } }) + // offset=0(처마/케라바 출폭 0) 케이스에서 매칭 실패하면 wall.line 자체를 roofLine 으로 사용 + // (wall == roof 이므로 자기 자신이 맞음). currentRoof 가 undefined 일 때 NPE 방지. + if (!currentRoof) { + currentRoof = { roofLine: line } + } } else { const findEdge = { vertex1: { x: midX, y: midY }, vertex2: { x: midX + roofVector.x * offset, y: midY + roofVector.y * offset } } const edgeDx = @@ -2041,8 +2049,11 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { let currentRoof if (line.attributes.offset === 0) { checkRoofLines.forEach((roofLine) => { - const isVerticalSame = line.y1 === roofLine.y1 && line.y2 === roofLine.y2 - const isHorizontalSame = line.x1 === roofLine.x1 && line.x2 === roofLine.x2 + // 정방향/역방향 양쪽 모두 매칭 허용 (좌표 정렬에 따라 끝점이 swap 될 수 있음) + const isVerticalSame = + (line.y1 === roofLine.y1 && line.y2 === roofLine.y2) || (line.y1 === roofLine.y2 && line.y2 === roofLine.y1) + const isHorizontalSame = + (line.x1 === roofLine.x1 && line.x2 === roofLine.x2) || (line.x1 === roofLine.x2 && line.x2 === roofLine.x1) if ( (isVerticalSame || isHorizontalSame) && (isPointOnLineNew(roofLine, { x: line.x1, y: line.y1 }) || isPointOnLineNew(roofLine, { x: line.x2, y: line.y2 })) @@ -2050,6 +2061,11 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { currentRoof = { roofLine } } }) + // offset=0(처마/케라바 출폭 0) 케이스에서 매칭 실패하면 wall.line 자체를 roofLine 으로 사용 + // (wall == roof 이므로 자기 자신이 맞음). currentRoof 가 undefined 일 때 NPE 방지. + if (!currentRoof) { + currentRoof = { roofLine: line } + } } else { const findEdge = { vertex1: { x: midX, y: midY }, vertex2: { x: midX + roofVector.x * offset, y: midY + roofVector.y * offset } } const edgeDx = diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 88096345..b0064ea0 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -783,14 +783,17 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // 6. 마루이동(용마루 위치 수동 조정)이 설정된 경우 movingLineFromSkeleton 결과로 대체 if (moveFlowLine !== 0 || moveUpDown !== 0) { - // [보호 가드] 이동이 골짜기 경계를 넘어가는지 사전 판정 - // 'crossed' → 외곽선을 뒤집을 만큼 과한 이동 → 기존 SK/innerLines/skeletonLines 전부 보존 후 조용히 종료 - // 'ok' | 'on-boundary' | 'unknown' → 기존 흐름 그대로 진행 - // ※ 기존 로직은 전혀 수정하지 않고, 진입부에 한 블록만 추가 - const __moveVerdict = verifyMoveBoundary(roofId, canvas) - if (__moveVerdict === 'crossed') { - console.warn('[skeleton] 경계 넘음(crossed) → 재빌드 스킵 (기존 SK/innerLines/skeletonLines 유지)') - return + // [정책] 오버 이동(verifyMoveBoundary='crossed') 처리: + // - 과거: 'crossed' 시 재빌드 스킵하여 기존 SK 유지 (silent skip). + // - 현재: 사용자가 재작도 회피용 단축 수단으로 오버를 의도 사용 → 오버된 wall.baseLines 좌표 기반 재빌드 수행. + // - 경고 로그만 남기고 기존 흐름 그대로 진행. + try { + const __moveVerdict = verifyMoveBoundary(roofId, canvas) + if (__moveVerdict === 'crossed') { + console.warn('[skeleton] 경계 넘음(crossed) → 오버된 wall.baseLine 좌표 기반 재빌드 진행') + } + } catch (e) { + console.warn('[skeleton] verifyMoveBoundary 예외 → 기존 흐름 진행:', e) } const movedPoints = safeMovedPointsWithFallback(roofId, canvas) @@ -901,18 +904,16 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // ────────────────────────────────────────────────────────────────── // [분기] 스켈레톤 입력 포인트 결정 - // 정상: changRoofLinePoints 그대로 사용 (기존 로직 변경 없음) - // 오버: calcOverCorrectedPoints() 별도 함수로 보정 포인트 생성 - // ※ changRoofLinePoints 자체는 절대 수정하지 않음 - // ※ 오버 보정 실패 시 changRoofLinePoints 그대로 fallback + // 정상: changRoofLinePoints 그대로 사용 + // 오버(일반 경로로 들어온 에지 케이스): calcOverCorrectedPoints() 로 보정 포인트 생성 + // ※ verifyMoveBoundary === 'crossed' 케이스는 drawHelpLine 에서 drawSkeletonRidgeRoofFromBaseLines + // 로 사전 분기하므로, 여기 isOverDetected 분기는 일반 경로의 안전망 용도로만 작동. + // ※ changRoofLinePoints 자체는 절대 수정하지 않음. 실패 시 그대로 fallback. // ────────────────────────────────────────────────────────────────── - let skeletonInputPoints = changRoofLinePoints // 정상 경로 + let skeletonInputPoints = changRoofLinePoints if (isOverDetected) { - // [예외] 오버 감지 시 → 별도 함수에서 스켈레톤 전용 보정 포인트 계산 try { - // 누적 이동 보호: 이전 SK 확정 상태(lastPoints)를 전달하여 - // 1차 이동에서 이미 확정된 점은 보정 대상에서 제외한다. const corrected = calcOverCorrectedPointsSafe( changRoofLinePoints, origPoints, @@ -923,7 +924,6 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { console.log('[SK_OVER] 보정 포인트 적용:', skeletonInputPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) } } catch (e) { - // 보정 실패 시 기존 changRoofLinePoints로 fallback → 기존 동작 보장 console.error('[SK_OVER] 보정 실패 → fallback:', e) } } @@ -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) { @@ -1006,6 +1006,102 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { } } +/** + * 오버 이동(verifyMoveBoundary='crossed') 전용 경로. + * + * 일반 경로(drawSkeletonRidgeRoof) 는 wall.baseLines → 45도 대각 확장 → roof 경계까지 늘린 polygon 을 + * SkeletonBuilder 에 입력한다. 오버 이동 상태에선 이 확장/클램핑이 폴리곤 자가교차나 roof 경계로의 + * 되돌림을 야기해 엉뚱한 SK 를 만든다. + * + * 본 함수는 **wall.baseLines 의 끝점을 확장/offset 없이 그대로** polygon 으로 넘겨 SK 를 빌드한다. + * - roof.points / roof.lines / outerLine / lengthText 는 건드리지 않음. + * - SHOULDER_ABSORBED (planeSize < 1) baseLines 는 스킵. + * - innerLines 는 기존 createInnerLinesFromSkeleton 으로 생성 (isOverDetected=true 마킹). + * - canvas.skeleton.lastPoints 는 기존 값을 보존 (오버 상태에서 새로 계산하지 않음). + */ +export const drawSkeletonRidgeRoofFromBaseLines = (roofId, canvas, textMode) => { + const roof = canvas?.getObjects().find((o) => o.id === roofId) + if (!roof) { + console.warn('[SK_OVER_FN] roof 없음 → 중단') + return + } + const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes?.roofId === roofId) + if (!wall || !wall.baseLines?.length) { + console.warn('[SK_OVER_FN] wall/baseLines 없음 → 중단') + return + } + + // wall.baseLines 순차 끝점(x1,y1) 수집. zero-length(SHOULDER_ABSORBED) 항목 스킵. + const rawPoints = [] + for (let i = 0; i < wall.baseLines.length; i++) { + const bl = wall.baseLines[i] + const planeSize = bl?.attributes?.planeSize ?? Infinity + if (planeSize < 1) { + console.log(`[SK_OVER_FN] baseLines[${i}] SHOULDER_ABSORBED skip (planeSize=${planeSize})`) + continue + } + if (bl == null || !isFinite(bl.x1) || !isFinite(bl.y1)) { + console.warn(`[SK_OVER_FN] baseLines[${i}] invalid coord → skip`) + continue + } + rawPoints.push({ x: bl.x1, y: bl.y1 }) + } + + if (rawPoints.length < 3) { + console.warn(`[SK_OVER_FN] 유효 baseLines 끝점 ${rawPoints.length} < 3 → 중단`) + return + } + + console.log('[SK_OVER_FN] wall.baseLines polygon:', rawPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) + + // skeletonPoints 마킹 (오버 좌표 그대로) + roof.skeletonPoints = rawPoints.map((p) => ({ x: p.x, y: p.y })) + + const perturbed = perturbPolygonPoints(rawPoints) + const geoJSONPolygon = toGeoJSON(perturbed) + + try { + geoJSONPolygon.pop() + console.log('[SK_OVER_FN] geoJSONPolygon:', JSON.stringify(geoJSONPolygon, null, 2)) + console.log('[SK_OVER_FN] 꼭짓점 수:', geoJSONPolygon.length) + const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) + + roof.innerLines = roof.innerLines || [] + roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, /* isOverDetected */ true, rawPoints) + + if (!canvas.skeletonStates) { + canvas.skeletonStates = {} + canvas.skeletonLines = [] + } + canvas.skeletonStates[roofId] = true + canvas.skeletonLines = [] + canvas.skeletonLines.push(...roof.innerLines) + roof.skeletonLines = canvas.skeletonLines + + const cleanSkeleton = { + Edges: skeleton.Edges.map((edge) => ({ + X1: edge.Edge.Begin.X, + Y1: edge.Edge.Begin.Y, + X2: edge.Edge.End.X, + Y2: edge.Edge.End.Y, + Polygon: edge.Polygon, + })), + roofId: roofId, + } + // lastPoints 는 기존 값 유지 (오버 상태에서 새 누적 기준점을 만들지 않음) + const __preservedLastPoints = canvas.skeleton?.lastPoints ?? null + canvas.skeleton = cleanSkeleton + if (__preservedLastPoints) canvas.skeleton.lastPoints = __preservedLastPoints + + canvas.set('skeleton', cleanSkeleton) + canvas.renderAll() + } catch (e) { + console.error('[SK_OVER_FN] 스켈레톤 생성 오류:', e) + console.error('[SK_OVER_FN] 입력 좌표:', rawPoints.map((p, i) => `[${i}] (${p.x.toFixed(2)},${p.y.toFixed(2)})`)) + if (canvas.skeletonStates) canvas.skeletonStates[roofId] = false + } +} + /** * 스켈레톤 결과와 외벽선 정보를 바탕으로 내부선(용마루, 추녀)을 생성합니다. * @param {object} skeleton - SkeletonBuilder로부터 반환된 스켈레톤 객체 @@ -1015,7 +1111,7 @@ export const skeletonBuilder = (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) @@ -1243,24 +1339,29 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver }) } - // 각 라인 집합 정렬 - const sortWallLines = ensureCounterClockwiseLines(wallLines) - // wall.baseLines를 독립적으로 CCW 정렬하면 ㅗ→붕괴 등으로 꼭짓점 집합이 달라질 때 - // 시작점(min-Y-then-min-X)이 wallLines와 어긋나 배열이 shift된다. - // wallLines[i]와 wall.baseLines[i]는 초기 생성 시부터 raw-index 1:1 페어링이 유지되므로, - // sortWallLines의 CCW 순서에 대응하는 원 인덱스를 복원하여 같은 순서로 wall.baseLines를 매핑한다. + // [정렬 정책 — 인덱스가 생명] (2026-04-27) + // 1) master = roofLines. roofLine 은 외곽(처마) 경계라 사용자 mutation 영향 없음 → 시작점 안정. + // 2) ensureCounterClockwiseLines 가 master 의 leftTop(min-Y → min-X) 꼭짓점에서 시작해 CCW 정렬. + // 3) wall.lines, wall.baseLines 는 raw 인덱스 1:1 페어링 유지된다는 전제로 + // master 의 sorted 순서 → 원 인덱스 → wall.lines[idx] / wall.baseLines[idx] 매핑. + // wall 쪽을 master 로 쓰던 구현은 OVER 흡수/usePropertiesSetting 등에서 wall.lines 가 + // 재할당되며 시작점이 어긋나 페어 인덱스가 회전되던 문제(2026-04-27 ㅗ 좌측오버 케이스) 가 있어 변경. const _keyOfEdge = (l) => { const a = `${Math.round(l.x1 * 10) / 10},${Math.round(l.y1 * 10) / 10}` const b = `${Math.round(l.x2 * 10) / 10},${Math.round(l.y2 * 10) / 10}` return a < b ? `${a}|${b}` : `${b}|${a}` } const _rawIdxByKey = new Map() - wallLines.forEach((l, i) => _rawIdxByKey.set(_keyOfEdge(l), i)) - const sortWallBaseLines = sortWallLines.map((sl) => { + roofLines.forEach((l, i) => _rawIdxByKey.set(_keyOfEdge(l), i)) + const sortRoofLines = ensureCounterClockwiseLines(roofLines) + const sortWallLines = sortRoofLines.map((sl) => { + const origIdx = _rawIdxByKey.get(_keyOfEdge(sl)) + return origIdx != null && wallLines[origIdx] ? wallLines[origIdx] : sl + }) + const sortWallBaseLines = sortRoofLines.map((sl) => { const origIdx = _rawIdxByKey.get(_keyOfEdge(sl)) return origIdx != null && wall.baseLines[origIdx] ? wall.baseLines[origIdx] : sl }) - const sortRoofLines = ensureCounterClockwiseLines(roofLines) // [MOVE-TRACE] 진입 스냅샷 (필요 시 주석 해제) // console.log('[MOVE-TRACE] === getMoveUpDownLine entry ===', @@ -1366,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 @@ -1411,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) } } } @@ -1500,10 +1623,11 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver const roofLine = sortRoofLines[index] const wallBaseLine = sortWallBaseLines[index] - // [SHOULDER-ABSORBED] wall.baseLines[k] 가 이동 흡수로 zero-length(planeSize≈0)면 - // 해당 edge 는 흡수되어 사라진 상태. pair 생성 자체가 불필요(DIR_MISMATCH + eaveHelpLine 노이즈 유발). - // 참고: 같은 파일 line 666 의 filter(planeSize > 0) 선례와 동일 패턴. - if (!wallBaseLine || (wallBaseLine.attributes?.planeSize ?? Infinity) < 1) { + // [SHOULDER-ABSORBED] wall.baseLines[k] 가 이동 흡수/OVER_EPS 클램핑으로 거의 zero-length 이면 + // 해당 edge 는 흡수된 상태. pair 생성 자체가 불필요(DIR_MISMATCH + eaveHelpLine 노이즈 유발). + // 임계 = 10: useMovementSetting OVER_EPS=0.5 (canvas unit) 시 잔여 길이=0.5 → planeSize=5 (calcLinePlaneSize × 10). + // 안전마진 두고 < 10 으로 차단. 정상 baseLine 은 보통 50+ 이므로 false positive 없음. + if (!wallBaseLine || (wallBaseLine.attributes?.planeSize ?? Infinity) < 10) { console.log(`⏭️ [pair#${index}] SHOULDER_ABSORBED skip: wb=(${Math.round(wallBaseLine?.x1)},${Math.round(wallBaseLine?.y1)})→(${Math.round(wallBaseLine?.x2)},${Math.round(wallBaseLine?.y2)}) planeSize=${wallBaseLine?.attributes?.planeSize}`) return } @@ -1692,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() @@ -1801,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() @@ -1947,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() @@ -2054,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()