[2294] 케라바 골짜기 할당 — 밴드 dedup + fold 대각선 제거 (변 연장 흡수) #880

Merged
ysCha merged 1 commits from dev into prd-deploy 2026-06-04 15:51:02 +09:00
4 changed files with 487 additions and 162 deletions

View File

@ -8,6 +8,7 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import Big from 'big.js'
import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils'
import { logger } from '@/util/logger'
import { debugCapture } from '@/util/debugCapture'
// ========================================================================
// [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local')
@ -100,6 +101,137 @@ function __attachDebugLabels(canvas, parentId) {
canvas.renderAll()
}
// ========================================================================
// [ROOF-FACE-DIAG 2026-06-04] 지붕면 할당 디버그 — 로컬 전용.
// 할당된 각 지붕면(name === POLYGON_TYPE.ROOF)에
// - 캔버스: 면 식별자 + 평면면적 라벨(F-n) (사람이 "F-3" 지목용)
// - 로거: 면적(평면/실제)·구배·방향·꼭짓점 절대좌표 전체 (대화/디버그 공유용 — 본체)
// 캔버스 라벨만으론 Claude 가 못 읽으므로 logger 덤프가 핵심.
// ========================================================================
const ROOF_FACE_LABEL_NAME = '__roofFaceDebugLabel'
// fabric Polygon 의 points 는 pathOffset 기준 상대좌표 → 변환행렬로 절대(canvas)좌표 복원.
function __roofFaceAbsPoints(roof) {
const m = roof.calcTransformMatrix()
const ox = roof.pathOffset?.x ?? 0
const oy = roof.pathOffset?.y ?? 0
return (roof.points || []).map((p) => fabric.util.transformPoint({ x: p.x - ox, y: p.y - oy }, m))
}
function __shoelaceAreaPx(pts) {
let a = 0
for (let i = 0; i < pts.length; i++) {
const cur = pts[i]
const nxt = pts[(i + 1) % pts.length]
a += cur.x * nxt.y - nxt.x * cur.y
}
return Math.abs(a) / 2
}
export function reattachRoofFaceDebugLabels(canvas) {
if (!__isDebugLabelsEnabled()) return
if (!canvas) return
// 재할당/재진입 시 기존 면 라벨 제거 → 카운트 reset 후 새로 부여.
canvas
.getObjects()
.filter((o) => o.name === ROOF_FACE_LABEL_NAME)
.forEach((o) => canvas.remove(o))
const roofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF)
logger.log(`[ROOF-FACE-DIAG] 할당 지붕면 ${roofs.length}`)
// [ROOF-FACE-DIAG 2026-06-04] logger.log 는 브라우저 콘솔뿐이라 Claude 가 못 읽음.
// 좌표/면적을 debugCapture 로 debug/debug.log 에 영속화 → log-check 로 읽는 본체.
const records = []
roofs.forEach((roof, i) => {
const label = `F-${i + 1}`
const pts = __roofFaceAbsPoints(roof)
if (pts.length < 3) {
logger.log(`[ROOF-FACE-DIAG] ${label} 좌표 부족(pts=${pts.length}) skip`)
records.push({ face: label, skip: `pts=${pts.length}` })
return
}
// 좌표 단위 1px = 10mm (planeSize = hypot(px)*10) → 평면면적 ㎡ = pxArea * 100 / 1e6 = pxArea / 1e4
const planeM2 = __shoelaceAreaPx(pts) / 10000
// 구배(寸) → 경사각. 실제 지붕면적 = 평면 / cos(angle). 구배 없으면 실제 = 평면.
const pitch = Number(roof.pitch ?? roof.roofMaterial?.pitch ?? 0) || 0
const angle = Math.atan(pitch / 10)
const actualM2 = angle ? planeM2 / Math.cos(angle) : planeM2
// 캔버스 라벨: 면 중심에 식별자 + 평면면적만 (좌표는 로그에만).
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length
const text = new fabric.Text(`${label} ${planeM2.toFixed(2)}`, {
left: cx,
top: cy,
originX: 'center',
originY: 'center',
fontSize: 12,
fill: '#000',
fontFamily: 'monospace',
fontWeight: 'bold',
backgroundColor: 'rgba(0,255,255,0.85)',
selectable: false,
evented: false,
hasControls: false,
hasBorders: false,
name: ROOF_FACE_LABEL_NAME,
parentId: roof.id,
excludeFromExport: true,
})
canvas.add(text)
text.bringToFront()
// 꼭짓점별 좌표 라벨 (화면 표시 — local 전용). 같은 name 으로 cleanup 에 함께 제거됨.
pts.forEach((p) => {
const coord = new fabric.Text(`${Math.round(p.x)},${Math.round(p.y)}`, {
left: p.x,
top: p.y,
originX: 'center',
originY: 'bottom',
fontSize: 9,
fill: '#0a0',
fontFamily: 'monospace',
backgroundColor: 'rgba(255,255,255,0.7)',
selectable: false,
evented: false,
hasControls: false,
hasBorders: false,
name: ROOF_FACE_LABEL_NAME,
parentId: roof.id,
excludeFromExport: true,
})
canvas.add(coord)
coord.bringToFront()
})
// 로거 덤프(본체): 평면/실제 면적 + 구배 + 방향 + 변 개수 + 꼭짓점 절대좌표 전체.
const ptsStr = pts.map((p) => `(${p.x.toFixed(1)},${p.y.toFixed(1)})`).join(' ')
logger.log(
`[ROOF-FACE-DIAG] ${label} id=${roof.id?.slice?.(0, 8) ?? 'none'} ` +
`plane=${planeM2.toFixed(2)}㎡ actual=${actualM2.toFixed(2)}㎡ pitch=${pitch} ` +
`dir=${roof.direction ?? 'none'} lines=${roof.lines?.length ?? 0} pts=${pts.length} ${ptsStr}`,
)
records.push({
face: label,
id: roof.id?.slice?.(0, 8) ?? null,
planeM2: Number(planeM2.toFixed(2)),
actualM2: Number(actualM2.toFixed(2)),
pitch,
dir: roof.direction ?? null,
lines: roof.lines?.length ?? 0,
points: pts.map((p) => ({ x: Number(p.x.toFixed(1)), y: Number(p.y.toFixed(1)) })),
})
})
// Claude 가 읽는 본체 — debug/debug.log 에 [ROOF-FACE-DIAG] 라벨로 영속화.
debugCapture.log('ROOF-FACE-DIAG', { count: roofs.length, faces: records })
canvas.renderAll()
}
export const QPolygon = fabric.util.createClass(fabric.Polygon, {
type: 'QPolygon',

View File

@ -17,6 +17,7 @@ import { reattachDebugLabels } from '@/components/fabric/QPolygon'
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { findInteriorPoint } from '@/util/skeleton-utils'
import { logger } from '@/util/logger'
import { debugCapture } from '@/util/debugCapture'
import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical'
// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 시 出幅 변경분 surgical 반영 기능 토글.
@ -451,6 +452,7 @@ export function useEavesGableEdit(id) {
})
}
logger.log('[KERAB-VALLEY-DIAG] ' + JSON.stringify(valleyInfo))
debugCapture.log('KERAB-VALLEY-DIAG', { targetId: target.id, count: valleyInfo.length, valleys: valleyInfo })
}
// [KERAB-VALLEY-EXT 2026-05-27] 모델 교체: skeleton valley → 외곽 polygon concave corner.
// "확장" = roofLine 의 한쪽 끝점에서 self-extension (반대 끝점 → 그 끝점 방향) 으로 연장.
@ -1886,12 +1888,102 @@ export function useEavesGableEdit(id) {
trimCascadePts.push({ pt: m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } })
}
}
// [KERAB-VALLEY-EXT-SPLIT 2026-06-04] Option A: vExt(내부 roofLine)가 ridge/hip 와
// *내부* 교점에서 만나면 그 교점에서 vExt 자체를 분할한다.
// 이유: getSplitRoofsPoints(usePolygon.js)는 라인 *끝점*만 그래프 노드로 만든다
// (교차분할 없음). 절삭된 ridge(예: RG-2)가 vExt 의 끝점이 아닌 중간(internal)에
// 닿으면 공유 노드가 없어 dead-end → 지붕면 할당에서 고립 폐기되고 인접 면이 안
// 쪼개진다(예: F-2 비대). vExt 를 교점에서 분할해 끝점=노드를 만들어 ridge 가
// 할당 그래프에 연결되게 한다. 절삭 규칙/방향은 건드리지 않고 vExt 분할만 추가.
// revert: 분할 세그먼트도 lineName='kerabPatternValleyExt' + __targetId 이므로
// applyKerabRevertPattern 의 valleyExt 제거 스캔에서 자동 정리된다.
{
const SPLIT_TOL = 1.0
const interiorPts = []
for (const r of newTrimRecords) {
if (!r || r.hide || !r.newPt) continue
const ip = r.newPt
if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue
const dS = Math.hypot(ip.x - vStart.x, ip.y - vStart.y)
const dE = Math.hypot(ip.x - vEnd.x, ip.y - vEnd.y)
if (dS < SPLIT_TOL || dE < SPLIT_TOL) continue // 끝점은 이미 노드
if (interiorPts.some((p) => Math.hypot(p.x - ip.x, p.y - ip.y) < SPLIT_TOL)) continue
interiorPts.push({ x: ip.x, y: ip.y })
}
if (interiorPts.length) {
const vdx2 = vEnd.x - vStart.x
const vdy2 = vEnd.y - vStart.y
const tOf = (p) => (p.x - vStart.x) * vdx2 + (p.y - vStart.y) * vdy2
interiorPts.sort((a, b) => tOf(a) - tOf(b))
const bps = [vStart, ...interiorPts, vEnd]
// 첫 구간은 기존 vExt 재사용(끝점 단축), 나머지는 새 QLine 세그먼트.
vExt.set({ x2: bps[1].x, y2: bps[1].y })
if (typeof vExt.setCoords === 'function') vExt.setCoords()
const sz0 = calcLinePlaneSize({ x1: vExt.x1, y1: vExt.y1, x2: vExt.x2, y2: vExt.y2 })
vExt.attributes = { ...vExt.attributes, planeSize: sz0, actualSize: sz0 }
const newSegs = []
for (let bi = 1; bi < bps.length - 1; bi++) {
const a = bps[bi]
const b = bps[bi + 1]
const segSz = calcLinePlaneSize({ x1: a.x, y1: a.y, x2: b.x, y2: b.y })
const seg = new QLine([a.x, a.y, b.x, b.y], {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.VALLEY,
textMode: roof.textMode,
attributes: { ...(vExt.attributes || {}), planeSize: segSz, actualSize: segSz },
})
seg.lineName = 'kerabPatternValleyExt'
seg.__targetId = target.id
seg.__valleyExtSource = vExt.__valleyExtSource
if (vExt.parentLine) seg.parentLine = vExt.parentLine
if (vExt.direction) seg.direction = vExt.direction
canvas.add(seg)
seg.bringToFront()
roof.innerLines.push(seg)
newSegs.push(seg)
}
logger.log(
'[KERAB-VALLEY-EXT-SPLIT] vExt split ' +
JSON.stringify({
source: vr.source,
breaks: interiorPts.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })),
segs: newSegs.length + 1,
}),
)
debugCapture.log('KERAB-VALLEY-EXT-SPLIT', {
source: vr.source,
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
breaks: interiorPts.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })),
segCount: newSegs.length + 1,
})
}
}
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적.
// 기존 revert 로직(2566~) 이 hide/end/oldPt/originalAttrs 그대로 처리.
// 한 mousedown 에 여러 vExt 가 호출될 수 있어 concat.
if (newTrimRecords.length) {
target.__valleyExtTrims = (target.__valleyExtTrims || []).concat(newTrimRecords)
}
debugCapture.log('KERAB-VALLEY-EXT-TRIM', {
source: vr.source,
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
count: newTrimRecords.length,
trims: newTrimRecords.map((r) => ({
lineName: r.line?.lineName,
name: r.line?.name,
label: typeof labelOf === 'function' ? labelOf(r.line) : undefined,
hide: r.hide ?? false,
end: r.end,
oldPt: r.oldPt ? { x: Math.round(r.oldPt.x * 100) / 100, y: Math.round(r.oldPt.y * 100) / 100 } : null,
newPt: r.newPt ? { x: Math.round(r.newPt.x * 100) / 100, y: Math.round(r.newPt.y * 100) / 100 } : null,
now: r.line ? { x1: Math.round(r.line.x1 * 100) / 100, y1: Math.round(r.line.y1 * 100) / 100, x2: Math.round(r.line.x2 * 100) / 100, y2: Math.round(r.line.y2 * 100) / 100 } : null,
})),
})
} else {
logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source)
}

View File

@ -32,6 +32,7 @@ import { roofsState } from '@/store/roofAtom'
import { useText } from '@/hooks/useText'
import { fabric } from 'fabric'
import { QLine } from '@/components/fabric/QLine'
import { reattachRoofFaceDebugLabels } from '@/components/fabric/QPolygon'
import { calcLineActualSize2 } from '@/util/qpolygon-utils'
// [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert
import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning'
@ -678,10 +679,15 @@ export function useRoofAllocationSetting(id) {
const eq = (p, q) => Math.abs(p.x - q.x) < 0.5 && Math.abs(p.y - q.y) < 0.5
// [KERAB-VALLEY-OVERLAP-BAND-ID 2026-06-04] 밴드 sub-roof 식별.
// split 후 sub-roof 의 V1(vExt) edge 는 lineName 을 잃어 'L' 로 나오므로(BAND-PROBE 확인)
// vExt 기반 식별은 불가. 대신 밴드만 overlap-typed(kerabValleyOverlapLine) 변을 보유한다는
// 사실(인접 실제 면은 split 후 ov:0)을 이용 — overlap 변 2개 이상이면 밴드로 인정.
// 밴드는 항상 V2/V3/V4 3변이 overlap → ov>=3 이지만, 안전 여유로 2 로 둔다.
const rects = newSubRoofs.filter((sub) => {
if (!sub.lines || sub.lines.length < 3) return false
const cnt = sub.lines.filter((l) => l?.attributes?.type === 'kerabValleyOverlapLine').length
return cnt >= Math.max(3, sub.lines.length - 1)
const ovCnt = sub.lines.filter((l) => l?.attributes?.type === 'kerabValleyOverlapLine').length
return ovCnt >= 2
})
if (rects.length === 0) return
@ -736,165 +742,188 @@ export function useRoofAllocationSetting(id) {
`shares=[${[...adjacencyMap.values()].map((s) => s.length).join(',')}]`,
)
// [KERAB-VALLEY-OVERLAP-FOLD-DIAG 2026-06-04] fold 직전 — 띠(X) + 인접 면 전체 좌표 덤프 (대각선 원인 추적)
{
const rnd = (p) => ({ x: Math.round(p.x), y: Math.round(p.y) })
const adjDump = []
adjacencyMap.forEach((shares, sub) => {
adjDump.push({
subId: String(sub.id).slice(0, 8),
shares: shares.length,
shareEdges: shares.map((s) => ({ subEdgeStart: s.subEdgeStart, xEdgeIdx: s.xEdgeIdx })),
pts: (sub.points || []).map(rnd),
})
})
debugCapture.log('KERAB-VALLEY-OVERLAP-FOLD-DIAG', {
XId: String(X.id).slice(0, 8),
Xpts: Xpts.map(rnd),
XlineTypes: Xlines.map((l) => (l?.attributes?.type === 'kerabValleyOverlapLine' ? 'OV' : l?.lineName || 'L')),
adjacents: adjDump,
})
}
if (adjacencyMap.size === 0) {
canvas.remove(X)
return
}
// [KERAB-VALLEY-OVERLAP-FOLD 2026-06-04] 밴드를 "변 연장" 으로 한 면에 흡수 (대각선 제거).
// 할당 = 이어진 라인을 따라 면을 만드는 것. 밴드의 V1(내부 변)을 전부 가진 인접 면이
// V1 을 밴드 OV 외곽경로(V3→V2→V4, x 를 출폭만큼 바깥)로 연장해 밴드를 흡수한다.
// 부분만 닿은 면(이전 detour 우회 대상)은 흡수 안 함 — 그 우회가 대각선의 근본원인이었다.
const N = Xpts.length
const isOVedge = (i) => Xlines[i]?.attributes?.type === 'kerabValleyOverlapLine'
// 밴드의 V1(비 OV) 변 run — 밴드는 V1 이 한 덩어리이므로 연속 가정, 아니면 보존(흡수 skip).
let lStart = -1
let lLen = 0
for (let i = 0; i < N; i++) {
if (!isOVedge(i)) {
if (lStart === -1) lStart = i
lLen++
}
}
let lContig = lStart !== -1 && lLen < N
for (let k = 0; k < lLen && lContig; k++) {
if (isOVedge((lStart + k) % N)) lContig = false
}
if (!lContig) {
logger.log(`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 run 비연속 (lStart=${lStart} lLen=${lLen})`)
return
}
const vStartVtx = Xpts[lStart]
const vFinVtx = Xpts[(lStart + lLen) % N]
// OV 외곽 중간점들 — vFin 에서 vStart 로 도는 순서
const ovFromFin = []
for (let k = 1; k <= N - (lLen + 1); k++) {
ovFromFin.push(Xpts[(lStart + lLen + k) % N])
}
// 흡수 면 = V1 을 가장 많이(전부) 공유하는 인접 면
let absorber = null
let absorberShares = null
adjacencyMap.forEach((shares, sub) => {
// 다중 공유 → bowtie 위험, 머지 skip
if (shares.length !== 1) {
logger.log(
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — sharedEdges=${shares.length} (≠1, bowtie 위험)`,
)
return
if (!absorber || shares.length > absorberShares.length) {
absorber = sub
absorberShares = shares
}
const { subEdgeStart, xEdgeIdx } = shares[0]
const pts = (sub.points || []).map((p) => ({ x: p.x, y: p.y }))
const oldLines = [...(sub.lines || [])]
const N = Xpts.length
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] detour 방향은 sub.pts[subEdgeStart] 가
// X.xEdge.a (=Xpts[xEdgeIdx]) 인지 X.xEdge.b (=Xpts[xEdgeIdx+1]) 인지로 결정.
// sub 외곽선 진행 방향이 xEdge 의 a→b 와 같으면 detour 는 X 의 a 쪽 이웃부터 (역방향)
// 반대면 detour 는 X 의 b 쪽 이웃부터 (정방향).
const subA = pts[subEdgeStart]
const xA = Xpts[xEdgeIdx]
const sameDir = eq(subA, xA)
const detour = []
const detourEdgeIdx = []
if (sameDir) {
// a → Xpts[i-1] → Xpts[i-2] → ... → Xpts[i+2] → b
// detour vertices: N-2 개 (a, b 사이)
for (let k = 1; k <= N - 2; k++) {
detour.push(Xpts[(xEdgeIdx - k + N) % N])
}
// detour edges: N-1 개. a→Xpts[i-1] 는 X 의 (i-1)%N 변 (반대방향).
for (let k = 1; k <= N - 1; k++) {
detourEdgeIdx.push((xEdgeIdx - k + N) % N)
}
} else {
// b → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → a 의 a→b 표기:
// a → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → b (subA=b 인 관점에서 보면 반대)
// 정확히는 subA=X.xEdge.b → detour 시작 = Xpts[(i+2)%N] (b 의 X 내 이웃)
for (let k = 2; k <= N - 1; k++) {
detour.push(Xpts[(xEdgeIdx + k) % N])
}
for (let k = 1; k <= N - 1; k++) {
detourEdgeIdx.push((xEdgeIdx + k) % N)
}
}
const buildCand = (verts) => {
const c = [...pts]
c.splice(subEdgeStart + 1, 0, ...verts)
return c
}
const candA = buildCand(detour)
const oldSigned = signedArea(pts)
const aSigned = signedArea(candA)
const sameSign = (s) => oldSigned === 0 || Math.sign(s) === Math.sign(oldSigned)
const okA = sameSign(aSigned) && Math.abs(aSigned) > Math.abs(oldSigned) + 0.5
let finalPts = null
let finalEdges = null
if (okA) {
finalPts = candA
finalEdges = detourEdgeIdx
} else {
// fallback — 반대 방향 시도 (X 가 CW 로 들어온 경우 대비)
const detourRev = [...detour].reverse()
const detourEdgeRev = [...detourEdgeIdx].reverse()
const candB = buildCand(detourRev)
const bSigned = signedArea(candB)
const okB = sameSign(bSigned) && Math.abs(bSigned) > Math.abs(oldSigned) + 0.5
if (okB) {
finalPts = candB
finalEdges = detourEdgeRev
logger.log(
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} fallback reverse detour 사용 ` +
`oldSigned=${oldSigned.toFixed(0)}${bSigned.toFixed(0)}`,
)
} else {
logger.log(
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — detour 양방향 실패 ` +
`sameDir=${sameDir} oldSigned=${oldSigned.toFixed(0)} ` +
`aSigned=${aSigned.toFixed(0)} bSigned=${bSigned.toFixed(0)} ` +
`subA=(${subA.x.toFixed(1)},${subA.y.toFixed(1)}) ` +
`xA=(${xA.x.toFixed(1)},${xA.y.toFixed(1)}) ` +
`xB=(${Xpts[(xEdgeIdx + 1) % N].x.toFixed(1)},${Xpts[(xEdgeIdx + 1) % N].y.toFixed(1)})`,
)
return
}
}
// sub.lines 재구성 — 옛 공유 변 1개 → detour 변 N-1개
const newSubLines = []
for (let i = 0; i < oldLines.length; i++) {
if (i === subEdgeStart) {
for (let k = 0; k < finalEdges.length; k++) {
const xLine = Xlines[finalEdges[k]]
const p1 = finalPts[(subEdgeStart + k) % finalPts.length]
const p2 = finalPts[(subEdgeStart + k + 1) % finalPts.length]
const attrs = xLine?.attributes
? { ...xLine.attributes }
: { type: 'kerabValleyOverlapLine', offset: 0 }
const ln = new QLine([p1.x, p1.y, p2.x, p2.y], {
stroke: sub.stroke,
strokeWidth: sub.strokeWidth,
fontSize: sub.fontSize,
attributes: attrs,
textVisible: false,
parent: sub,
parentId: sub.id,
idx: newSubLines.length + 1,
})
ln.startPoint = p1
ln.endPoint = p2
newSubLines.push(ln)
}
} else {
const ln = oldLines[i]
if (ln) {
ln.idx = newSubLines.length + 1
newSubLines.push(ln)
}
}
}
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] fabric.Polygon 정식 points 갱신 패턴.
// src/util/canvas-util.js#anchorWrapper 패턴 그대로 적용 —
// _setPositionDimensions 가 width/height/pathOffset/left/top 을 재계산하므로
// 변경 전 앵커(pts[0]) 절대좌표를 캡쳐 → 변경 후 setPositionByOrigin 으로 복원.
// 그래야 polygon 의 path 가 새 bbox 기준으로 다시 그려지면서 시각 위치도 유지됨.
const anchorIdx = 0
const oldLocal = {
x: sub.points[anchorIdx].x - sub.pathOffset.x,
y: sub.points[anchorIdx].y - sub.pathOffset.y,
}
const absolutePoint = fabric.util.transformPoint(oldLocal, sub.calcTransformMatrix())
sub.points = finalPts
sub.lines = newSubLines
sub._setPositionDimensions({})
const strokeW = sub.strokeUniform ? sub.strokeWidth / sub.scaleX : sub.strokeWidth
const baseW = sub.width + strokeW
const baseH = sub.height + (sub.strokeUniform ? sub.strokeWidth / sub.scaleY : sub.strokeWidth)
const newX = (sub.points[anchorIdx].x - sub.pathOffset.x) / Math.max(baseW, 1e-9)
const newY = (sub.points[anchorIdx].y - sub.pathOffset.y) / Math.max(baseH, 1e-9)
sub.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5)
sub.setCoords?.()
sub.dirty = true
logger.log(
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} 머지 완료 ` +
`oldSigned=${oldSigned.toFixed(0)} → newSigned=${signedArea(finalPts).toFixed(0)} ` +
`pts=${finalPts.length} lines=${newSubLines.length}`,
)
})
if (!absorber || absorberShares.length < lLen) {
logger.log(
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 전체를 가진 흡수 면 없음 ` +
`(maxShares=${absorberShares?.length ?? 0}, lLen=${lLen})`,
)
return
}
const aPts = (absorber.points || []).map((p) => ({ x: p.x, y: p.y }))
const aLines = [...(absorber.lines || [])]
const aN = aPts.length
const absShared = absorberShares.map((s) => s.subEdgeStart).sort((a, b) => a - b)
let aContig = true
for (let i = 1; i < absShared.length; i++) {
if (absShared[i] !== absShared[i - 1] + 1) aContig = false
}
if (!aContig || absShared[absShared.length - 1] + 1 > aN - 1) {
logger.log(`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 흡수 면 공유 변 비연속/wrap [${absShared.join(',')}]`)
return
}
const runStart = absShared[0]
const runEnd = absShared[absShared.length - 1] + 1
// 삽입 방향: 흡수 면 run 시작점이 vStart 면 ovFromFin 역순, vFin 이면 그대로
const insert = eq(aPts[runStart], vStartVtx) ? [...ovFromFin].reverse() : [...ovFromFin]
const newPts = []
for (let i = 0; i <= runStart; i++) newPts.push(aPts[i])
for (const p of insert) newPts.push(p)
for (let i = runEnd; i < aN; i++) newPts.push(aPts[i])
// 면적 부호 보존 + 확장(절댓값 증가) 검증 — 실패 시 밴드 보존(흡수 skip)
const oldSigned = signedArea(aPts)
const newSigned = signedArea(newPts)
if (
(oldSigned !== 0 && Math.sign(newSigned) !== Math.sign(oldSigned)) ||
Math.abs(newSigned) <= Math.abs(oldSigned) + 0.5
) {
logger.log(
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 확장 검증 실패 ` +
`oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`,
)
return
}
// 새 라인 재구성 — 각 변을 기존 흡수 면 라인 또는 밴드 OV 라인에서 매핑, 없으면 생성
const findLine = (p1, p2) => {
for (const l of aLines) {
if (!l) continue
if (
(eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) ||
(eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1))
)
return { src: l, reuse: true }
}
for (const l of Xlines) {
if (!l) continue
if (
(eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) ||
(eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1))
)
return { src: l, reuse: false }
}
return null
}
const newLines = []
for (let i = 0; i < newPts.length; i++) {
const p1 = newPts[i]
const p2 = newPts[(i + 1) % newPts.length]
const found = findLine(p1, p2)
if (found && found.reuse) {
found.src.idx = newLines.length + 1
newLines.push(found.src)
} else {
const attrs = found?.src?.attributes ? { ...found.src.attributes } : { type: 'kerabValleyOverlapLine', offset: 0 }
const ln = new QLine([p1.x, p1.y, p2.x, p2.y], {
stroke: absorber.stroke,
strokeWidth: absorber.strokeWidth,
fontSize: absorber.fontSize,
attributes: attrs,
textVisible: false,
parent: absorber,
parentId: absorber.id,
idx: newLines.length + 1,
})
ln.startPoint = p1
ln.endPoint = p2
newLines.push(ln)
}
}
// [KERAB-VALLEY-OVERLAP-FOLD 2026-06-04] fabric.Polygon points 갱신 (canvas-util#anchorWrapper 패턴).
// _setPositionDimensions 가 bbox 재계산하므로 변경 전 앵커(pts[0]) 절대좌표 캡쳐 후 복원.
const anchorIdx = 0
const oldLocal = {
x: absorber.points[anchorIdx].x - absorber.pathOffset.x,
y: absorber.points[anchorIdx].y - absorber.pathOffset.y,
}
const absolutePoint = fabric.util.transformPoint(oldLocal, absorber.calcTransformMatrix())
absorber.points = newPts
absorber.lines = newLines
absorber._setPositionDimensions({})
const strokeW = absorber.strokeUniform ? absorber.strokeWidth / absorber.scaleX : absorber.strokeWidth
const baseW = absorber.width + strokeW
const baseH = absorber.height + (absorber.strokeUniform ? absorber.strokeWidth / absorber.scaleY : absorber.strokeWidth)
const newX = (absorber.points[anchorIdx].x - absorber.pathOffset.x) / Math.max(baseW, 1e-9)
const newY = (absorber.points[anchorIdx].y - absorber.pathOffset.y) / Math.max(baseH, 1e-9)
absorber.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5)
absorber.setCoords?.()
absorber.dirty = true
logger.log(
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} → absorber=${absorber.id} 흡수 완료 ` +
`pts=${newPts.length} lines=${newLines.length} oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`,
)
canvas.remove(X)
})
@ -1061,7 +1090,55 @@ export function useRoofAllocationSetting(id) {
const vExtsForOverlap = (roofBase.innerLines || []).filter(
(l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false,
)
for (const vExt of vExtsForOverlap) {
// [KERAB-VALLEY-OVERLAP-DEDUP 2026-06-04] buildOverlapLine(useEavesGableEdit)가 케라바 토글 시
// 생성한 밴드와 이 RECALC 가 이중 생성 → split 가 중복 면 → 머지 모호 → 대각선.
// 단일 소스로 통일: 재계산 전 기존 kerabValleyOverlapLine 을 roofBase/canvas 에서 제거.
// vExt 가 있는 roof 만 (= RECALC 가 다시 생성할 roof) 대상 — 아니면 기존 밴드 유실.
if (vExtsForOverlap.length > 0) {
const isBandLine = (l) =>
l && (l.lineName === 'kerabValleyOverlapLine' || l.attributes?.type === 'kerabValleyOverlapLine')
roofBase.lines = (roofBase.lines || []).filter((l) => !isBandLine(l))
roofBase.innerLines = (roofBase.innerLines || []).filter((l) => !isBandLine(l))
canvas
.getObjects()
.filter(
(o) =>
isBandLine(o) && (o.roofId === roofBase.id || o.parentId === roofBase.id || o.attributes?.roofId === roofBase.id),
)
.forEach((o) => canvas.remove(o))
}
// [KERAB-VALLEY-EXT-SPLIT 2026-06-04] vExt 가 split 되면 같은 V1 이 여러 collinear 세그먼트로 쪼개진다.
// overlap 밴드는 V1(벽) 당 한 번만 그려야 한다 — 세그먼트마다 make() 하면 분할점에서 중복 변이 2개 생긴다.
// 같은 wall(__targetId/wallLine/source)로 묶고, 세그먼트 양 끝의 최외곽 두 점을 vStart/vEnd 로 써서 한 번만 재계산.
const overlapGroups = new Map()
for (const seg of vExtsForOverlap) {
const gkey = seg.__targetId || seg.attributes?.wallLine || seg.__valleyExtSource || seg.parentLine?.id || seg.id
if (!overlapGroups.has(gkey)) overlapGroups.set(gkey, [])
overlapGroups.get(gkey).push(seg)
}
for (const segs of overlapGroups.values()) {
// 대표 세그먼트 — 속성/wallLine 해석용
const vExt = segs[0]
// 그룹 내 모든 끝점 중 가장 먼 두 점 = 합쳐진 V1 전체 span
let combStart = { x: vExt.x1, y: vExt.y1 }
let combEnd = { x: vExt.x2, y: vExt.y2 }
if (segs.length > 1) {
const pts = []
for (const s of segs) {
pts.push({ x: s.x1, y: s.y1 }, { x: s.x2, y: s.y2 })
}
let maxD = -1
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) {
const d = Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y)
if (d > maxD) {
maxD = d
combStart = pts[i]
combEnd = pts[j]
}
}
}
}
// [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] wallLineId 보장 fallback 체인.
// 1) vExt.attributes.wallLine (정상 케이스)
// 2) vExt.__targetId (useEavesGableEdit.js drawValleyExtensions 에서 백업)
@ -1072,7 +1149,7 @@ export function useRoofAllocationSetting(id) {
if (!wallLineId && vExt.parentLine) wallLineId = vExt.parentLine.attributes?.wallLine
if (!wallLineId) {
// vStart 가 끝점인 roof.lines 변에서 wallLine 추출
const vStartP = { x: vExt.x1, y: vExt.y1 }
const vStartP = { x: combStart.x, y: combStart.y }
for (const rl of roofBase.lines || []) {
if (!rl?.attributes?.wallLine) continue
const m1 = Math.hypot(rl.x1 - vStartP.x, rl.y1 - vStartP.y) < 1
@ -1096,8 +1173,8 @@ export function useRoofAllocationSetting(id) {
continue
}
}
const vStart = { x: vExt.x1, y: vExt.y1 }
const vEnd = { x: vExt.x2, y: vExt.y2 }
const vStart = { x: combStart.x, y: combStart.y }
const vEnd = { x: combEnd.x, y: combEnd.y }
const dxT = target.x2 - target.x1
const dyT = target.y2 - target.y1
const lenTSq = dxT * dxT + dyT * dyT
@ -1141,11 +1218,14 @@ export function useRoofAllocationSetting(id) {
return p
}
const vStartSnap = snapToCorner(vStart)
const wallLn = make(newWStart, wEndProj)
const bsLn = make(vStartSnap, newWStart)
const beLn = make(vEnd, wEndProj)
roofBase.lines.push(wallLn, bsLn, beLn)
roofBase.innerLines.push(wallLn, bsLn, beLn)
// [KERAB-VALLEY-OVERLAP-SINGLE-BAND 2026-06-04] 밴드(V1·V2·V3·V4 사각형)는 새 지붕면이 아니라
// "겹침" 영역 — 단일 면으로 두고 mergeValleyOverlapSubRoofs 가 인접 면(F3)에 접어 넣는다.
// 따라서 벽/connector 를 분할하지 않고 quad 4변만 만든다.
// V1(vExt)은 이미 innerLines 에 존재 → 나머지 3변(start connector, wall, end connector)만 추가.
// V1 의 RG-2 교차 노드(Option A split)는 RG-2 를 그래프에 연결하되 밴드는 한 면으로 유지.
const made = [make(vStartSnap, newWStart), make(newWStart, wEndProj), make(wEndProj, vEnd)]
roofBase.lines.push(...made)
roofBase.innerLines.push(...made)
}
if (roofBase.separatePolygon.length > 0) {
@ -1217,6 +1297,9 @@ export function useRoofAllocationSetting(id) {
drawDirectionArrow(roof)
})
// [ROOF-FACE-DIAG 2026-06-04] 할당된 지붕면별 면적/좌표 디버그 라벨+로그 (로컬 전용).
reattachRoofFaceDebugLabels(canvas)
setRoofMaterials(newRoofList)
setRoofsStore(newRoofList)
/** 외곽선 삭제 */

View File

@ -10,6 +10,7 @@ import { QLine } from '@/components/fabric/QLine'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import { useLine } from '@/hooks/useLine'
import { logger } from '@/util/logger'
import { debugCapture } from '@/util/debugCapture'
export const usePolygon = () => {
const canvas = useRecoilValue(canvasState)
@ -763,6 +764,23 @@ export const usePolygon = () => {
`(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`,
)
})
const __splitLineRec = (l) => ({
lineName: l.lineName ?? 'none',
name: l.name,
type: l.attributes?.type ?? 'none',
isStart: l.attributes?.isStart ?? false,
x1: Math.round(l.x1 * 100) / 100,
y1: Math.round(l.y1 * 100) / 100,
x2: Math.round(l.x2 * 100) / 100,
y2: Math.round(l.y2 * 100) / 100,
})
debugCapture.log('SPLIT-ALLOC-DIAG', {
polygonId: polygon.id?.slice(0, 8),
visibleInner: innerLines.length,
polygonLines: polygon.lines?.length ?? 0,
inner: innerLines.map(__splitLineRec),
outer: (polygon.lines || []).map(__splitLineRec),
})
/*// innerLine이 세팅이 안되어있는경우 찾아서 세팅한다.
if (!innerLines || innerLines.length === 0) {