Merge branch 'dev' of https://git.hanasys.jp/qcast3/qcast-front into feature/redo-undo

# Conflicts:
#	src/hooks/surface/usePlacementShapeDrawing.js
This commit is contained in:
hyojun.choi 2026-04-29 18:07:53 +09:00
commit ca8778ba31
13 changed files with 628 additions and 88 deletions

View File

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

View File

@ -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) {
<button
className="btn-frame modal act"
onClick={() => {
handleFix()
// closePopup(id)
// setShowPropertiesSettingModal(true)
swalFire({
type: 'confirm',
text: getMessage('modal.cover.outline.fix.confirm'),
confirmFn: handleFix,
})
}}
>
{getMessage('modal.cover.outline.fix')}

View File

@ -9,11 +9,13 @@ import { OUTER_LINE_TYPE } from '@/store/outerLineAtom'
import OuterLineWall from '@/components/floor-plan/modal/lineTypes/OuterLineWall'
import { usePlacementShapeDrawing } from '@/hooks/surface/usePlacementShapeDrawing'
import { usePopup } from '@/hooks/usePopup'
import { useSwal } from '@/hooks/useSwal'
import Image from 'next/image'
export default function PlacementShapeDrawing({ id, pos = { x: 50, y: 230 } }) {
const { getMessage } = useMessage()
const { closePopup } = usePopup()
const { swalFire } = useSwal()
const [buttonAct, setButtonAct] = useState(1)
const [useCalcPad, setUseCalcPad] = useState(false)
const types = [
@ -155,7 +157,16 @@ export default function PlacementShapeDrawing({ id, pos = { x: 50, y: 230 } }) {
<button className="btn-frame modal mr5" onClick={handleRollback}>
{getMessage('modal.cover.outline.rollback')}
</button>
<button className="btn-frame modal act" onClick={handleFix}>
<button
className="btn-frame modal act"
onClick={() => {
swalFire({
type: 'confirm',
text: getMessage('modal.cover.outline.fix.confirm'),
confirmFn: handleFix,
})
}}
>
{getMessage('modal.placement.surface.drawing.fix')}
</button>
</div>

View File

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

View File

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

View File

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

View File

@ -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,
}
}

View File

@ -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++
}

View File

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

View File

@ -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": "設定完了",

View File

@ -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": "설정완료",

View File

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

View File

@ -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<QLine>} 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()