[2173] 케라바 vExt — stop·V apex trim·overlap 머지·offset bbox

This commit is contained in:
ysCha 2026-05-29 16:02:51 +09:00
parent 23dafe3b18
commit e32d2dc3a5
4 changed files with 535 additions and 38 deletions

View File

@ -48,10 +48,21 @@ function __classifyLineForLabel(obj) {
return null return null
} }
export function reattachDebugLabels(canvas, parentId) {
__attachDebugLabels(canvas, parentId)
}
function __attachDebugLabels(canvas, parentId) { function __attachDebugLabels(canvas, parentId) {
if (!__isDebugLabelsEnabled()) return if (!__isDebugLabelsEnabled()) return
if (!canvas || !parentId) return if (!canvas || !parentId) return
// [KERAB-LABEL-REATTACH 2026-05-29] 케라바 토글 등으로 라인이 추가/변경된 후 재호출 가능하도록
// 같은 parentId 의 기존 __debugLabel 먼저 제거 → 카운트 reset 후 새로 부여.
canvas
.getObjects()
.filter((o) => o.name === DEBUG_LABEL_NAME && o.parentId === parentId)
.forEach((o) => canvas.remove(o))
const counters = {} const counters = {}
const objects = canvas.getObjects().filter((o) => o.parentId === parentId && o.name !== DEBUG_LABEL_NAME) const objects = canvas.getObjects().filter((o) => o.parentId === parentId && o.name !== DEBUG_LABEL_NAME)

View File

@ -13,6 +13,7 @@ import { getChonByDegree } from '@/util/canvas-util'
import { settingModalFirstOptionsState } from '@/store/settingAtom' import { settingModalFirstOptionsState } from '@/store/settingAtom'
import { fabric } from 'fabric' import { fabric } from 'fabric'
import { QLine } from '@/components/fabric/QLine' import { QLine } from '@/components/fabric/QLine'
import { reattachDebugLabels } from '@/components/fabric/QPolygon'
import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { findInteriorPoint } from '@/util/skeleton-utils' import { findInteriorPoint } from '@/util/skeleton-utils'
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
@ -151,6 +152,11 @@ export function useEavesGableEdit(id) {
} }
const mouseDownEvent = (e) => { const mouseDownEvent = (e) => {
// [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시
// discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호.
if (!e.target || e.target.name !== 'outerLine') {
return
}
logger.log( logger.log(
'[KERAB-MOUSEDOWN] fired ' + '[KERAB-MOUSEDOWN] fired ' +
JSON.stringify({ JSON.stringify({
@ -166,9 +172,6 @@ export function useEavesGableEdit(id) {
}), }),
) )
canvas.discardActiveObject() canvas.discardActiveObject()
if (!e.target || (e.target && e.target.name !== 'outerLine')) {
return
}
const target = e.target const target = e.target
@ -294,6 +297,15 @@ export function useEavesGableEdit(id) {
y2: Math.round(l.y2 * 10) / 10, y2: Math.round(l.y2 * 10) / 10,
})) }))
logger.log('[KERAB-INNERLINES-' + label + '] ' + JSON.stringify(rows)) logger.log('[KERAB-INNERLINES-' + label + '] ' + JSON.stringify(rows))
// [KERAB-LABEL-REATTACH 2026-05-29] AFTER 시점에서 라벨 재부착 (local 모드 한정).
// 케라바 토글로 추가/변경된 kerabPatternRidge/ExtRidge/Hip 등에도 H-/RG- 라벨 부여.
if (label === 'AFTER') {
try {
reattachDebugLabels(canvas, roof.id)
} catch (e) {
logger.warn('[KERAB-LABEL-REATTACH] failed', e)
}
}
} }
dumpInnerLineSnapshot('BEFORE') dumpInnerLineSnapshot('BEFORE')
// [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영. // [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영.
@ -646,12 +658,13 @@ export function useEavesGableEdit(id) {
} }
let ridgeStop = null let ridgeStop = null
let ridgeT = Infinity let ridgeT = Infinity
// [KERAB-VALLEY-EXT 2026-05-28] 사용자 지시: 일단 다 뻗기 — ridge-stop 비활성. 골짜기 케라바 룰은 별도 후속. // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 골짜기 확장라인이 "원래 있던" ridge(마루) 만나면 그 점에서 정지.
// eslint-disable-next-line no-constant-condition // 확장으로 생긴 ridge(kerabPatternRidge/kerabPatternExtRidge 등) 는 stop 대상 아님.
if (false) for (const il of roof.innerLines || []) { // 화이트리스트: lineName === 'ridge' 만 매칭.
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue if (!il || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.lineName && il.lineName.startsWith('kerabPattern')) continue if (il.lineName !== 'ridge') continue
const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (!ip) continue if (!ip) continue
if (!isPointOnSegment(ip, il.x1, il.y1, il.x2, il.y2)) continue if (!isPointOnSegment(ip, il.x1, il.y1, il.x2, il.y2)) continue
@ -1525,12 +1538,11 @@ export function useEavesGableEdit(id) {
} }
let ridgeStop = null let ridgeStop = null
let ridgeT = Infinity let ridgeT = Infinity
// [KERAB-VALLEY-EXT 2026-05-28] 사용자 지시: 일단 다 뻗기 — ridge-stop 비활성. 골짜기 케라바 룰은 별도 후속. // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (roof 측 post 경로).
// eslint-disable-next-line no-constant-condition for (const il of roof.innerLines || []) {
if (false) for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue if (!il || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.lineName && il.lineName.startsWith('kerabPattern')) continue if (il.lineName !== 'ridge') continue
const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (!ip) continue if (!ip) continue
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
@ -1653,12 +1665,11 @@ export function useEavesGableEdit(id) {
// ridge stop (roof 측과 동일 룰) // ridge stop (roof 측과 동일 룰)
let wRidgeT = Infinity let wRidgeT = Infinity
let wRidgeStop = null let wRidgeStop = null
// [KERAB-VALLEY-EXT 2026-05-28] 사용자 지시: 일단 다 뻗기 — ridge-stop 비활성. 골짜기 케라바 룰은 별도 후속. // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (wallBase 측 ray).
// eslint-disable-next-line no-constant-condition for (const il of roof.innerLines || []) {
if (false) for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue if (!il || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.lineName && il.lineName.startsWith('kerabPattern')) continue if (il.lineName !== 'ridge') continue
const ip = lineLineIntersection(wStart, wRayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) const ip = lineLineIntersection(wStart, wRayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (!ip) continue if (!ip) continue
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
@ -1672,31 +1683,196 @@ export function useEavesGableEdit(id) {
} }
const wEnd = wRidgeStop || wWallHit const wEnd = wRidgeStop || wWallHit
if (wEnd) { if (wEnd) {
const wallPts = [wStart.x, wStart.y, wEnd.x, wEnd.y] // [KERAB-VALLEY-OVERLAP 2026-05-28] roof 1개에 split 결과 sub-roof 가 나뉜다.
const wallSz = calcLinePlaneSize({ x1: wallPts[0], y1: wallPts[1], x2: wallPts[2], y2: wallPts[3] }) // 라인은 roof.id 로만 생성 → split 후 직사각형 sub-roof 가 분리되어 나옴.
const wallExt = new QLine(wallPts, { // apply() 후처리에서 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지하여 "겹침" 표현.
parentId: roof.id, const buildOverlapLine = (p1, p2, suffix) => {
fontSize: roof.fontSize, const lpts = [p1.x, p1.y, p2.x, p2.y]
stroke: '#1083E3', const lsz = calcLinePlaneSize({ x1: lpts[0], y1: lpts[1], x2: lpts[2], y2: lpts[3] })
strokeWidth: 3, const ln = new QLine(lpts, {
name: LINE_TYPE.SUBLINE.VALLEY, parentId: roof.id,
textMode: roof.textMode, fontSize: roof.fontSize,
attributes: { roofId: roof.id, planeSize: wallSz, actualSize: wallSz }, stroke: '#1083E3',
}) strokeWidth: 3,
wallExt.lineName = 'kerabPatternValleyExt' name: LINE_TYPE.SUBLINE.VALLEY,
wallExt.__targetId = target.id textMode: roof.textMode,
wallExt.__valleyExtSource = vr.source + '-wall' attributes: {
canvas.add(wallExt) roofId: roof.id,
wallExt.bringToFront() type: 'kerabValleyOverlapLine',
isStart: true,
pitch: roof.pitch,
planeSize: lsz,
actualSize: lsz,
},
})
ln.lineName = 'kerabValleyOverlapLine'
ln.roofId = roof.id
ln.__targetId = target.id
ln.__valleyExtSource = vr.source + suffix
canvas.add(ln)
ln.bringToFront()
return ln
}
// [KERAB-VALLEY-OVERLAP 2026-05-28] 평행 사각형으로 b 외곽 닫기 + (선택) 그 너머 ray 연장.
// 사용자 룰: "vStart 에서 wLine 까지 평행선 → 그 교점에서부터 wallExt 확장".
// 출발: vStart 의 wallLine(target) 위 수선의 발 = newWStart (90° 보조선). 원래 wStart(target corner) 는 사용 안 함.
// 도착: vEnd 의 동일 offset 평행이동 = wEndProj (90° 보조선, 이전과 동일).
// 사각형 4변: vStart-newWStart, newWStart-wEndProj(wallExt 본체), wEndProj-vEnd, vEnd-vStart(=vExt 이미 그려짐).
// wEnd(원 ray hit) 가 wEndProj 보다 더 멀면 wEndProj→wEnd 확장 라인 추가.
const vStart = { x: pts[0], y: pts[1] }
const vEnd = { x: pts[2], y: pts[3] }
const dxT = target.x2 - target.x1
const dyT = target.y2 - target.y1
const lenTSq = dxT * dxT + dyT * dyT
const tFoot = ((vStart.x - target.x1) * dxT + (vStart.y - target.y1) * dyT) / Math.max(lenTSq, 1e-9)
const newWStart = { x: target.x1 + tFoot * dxT, y: target.y1 + tFoot * dyT }
const offX = newWStart.x - vStart.x
const offY = newWStart.y - vStart.y
const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY }
// wEnd(원 ray hit) 가 wEndProj 보다 ray 방향으로 더 멀리 있는지 — newWStart 기준 투영 비교.
const tProj = (wEndProj.x - newWStart.x) * wUx + (wEndProj.y - newWStart.y) * wUy
const tEndR = (wEnd.x - newWStart.x) * wUx + (wEnd.y - newWStart.y) * wUy
buildOverlapLine(newWStart, wEndProj, '-wall')
buildOverlapLine(vStart, newWStart, '-bridge-start')
buildOverlapLine(vEnd, wEndProj, '-bridge-end')
if (tEndR > tProj + 0.5) {
buildOverlapLine(wEndProj, wEnd, '-extend')
}
logger.log( logger.log(
'[KERAB-VALLEY-EXT-WALL] drawn ' + '[KERAB-VALLEY-OVERLAP] drawn ' +
JSON.stringify({ JSON.stringify({
src: vr.source, src: vr.source,
stop: wRidgeStop ? 'ridge' : 'wall', stop: wRidgeStop ? 'ridge' : 'wall',
from: { x: Math.round(wStart.x * 100) / 100, y: Math.round(wStart.y * 100) / 100 }, newWStart: { x: Math.round(newWStart.x * 100) / 100, y: Math.round(newWStart.y * 100) / 100 },
to: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 }, wEndProj: { x: Math.round(wEndProj.x * 100) / 100, y: Math.round(wEndProj.y * 100) / 100 },
wEnd: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 },
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 },
extended: tEndR > tProj + 0.5,
}), }),
) )
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] 룰 2: vExt 가 ridge 와 교차하면
// wallExt(`:`) 측(=vExt 와 가까운 영역) ridge 부분 절삭.
// ridge 의 V apex 는 vExt 와 먼 끝점 (Y 출발점). 살아남는 쪽 = V apex 쪽.
// 방향 판정: 절삭 방향 = wallExt 중점 - vExt 중점 (wallExt 측). dot 양수면 절삭 대상.
const veMid = { x: (vStart.x + vEnd.x) / 2, y: (vStart.y + vEnd.y) / 2 }
const wsMid = { x: (newWStart.x + wEndProj.x) / 2, y: (newWStart.y + wEndProj.y) / 2 }
const bDirX = wsMid.x - veMid.x
const bDirY = wsMid.y - veMid.y
const isBSide = (px, py) => (px - veMid.x) * bDirX + (py - veMid.y) * bDirY > 0
const trimCascadePts = []
const newTrimRecords = []
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] trim 대상 = 원래 ridge/hip + 마루 확장(ExtRidge) + kLine(kerabPatternRidge).
// stop 룰(룰 1) 은 ridge 만, trim 룰(룰 2) 은 hip 도 포함 — vExt 는 hip 통과 후 절삭.
// 절삭 방향은 V apex 우선 룰 (아래) 로 결정.
const isRidge =
il.name === LINE_TYPE.SUBLINE.RIDGE &&
(il.lineName === 'ridge' || il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge')
const isHip =
il.name === LINE_TYPE.SUBLINE.HIP &&
(il.lineName === 'hip' || il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip')
if (!isRidge && !isHip) continue
const ip = lineLineIntersection(vStart, vEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (!ip) continue
if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰.
// V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점.
// 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정.
// 1) 한 끝만 V apex → 그 V apex 측 살림, 반대 끝 절삭.
// 2) 양 끝 모두 V apex → vExt 와 먼 끝 살림 (가까운 끝 절삭).
// 3) 둘 다 V apex 아님 → 기존 fallback (B 측 = wallExt 측 절삭).
const isVApex = (px, py) => {
let cnt = 0
for (const other of roof.innerLines || []) {
if (!other || other === il) continue
if (other.visible === false) continue
if (other.name !== LINE_TYPE.SUBLINE.HIP) continue
if (other.lineName !== 'kerabPatternHip' && other.lineName !== 'kerabPatternExtHip') continue
if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) cnt++
else if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) cnt++
if (cnt >= 2) return true
}
return false
}
const e1V = isVApex(il.x1, il.y1)
const e2V = isVApex(il.x2, il.y2)
const e1B = isBSide(il.x1, il.y1)
const e2B = isBSide(il.x2, il.y2)
let trimEnd = 0
if (e1V && !e2V) trimEnd = 2 // e1 V apex 살림, e2 절삭
else if (!e1V && e2V) trimEnd = 1
else if (e1V && e2V) {
// 양 끝 모두 V apex → vExt 와 가까운 끝 절삭
const d1 = Math.hypot(il.x1 - veMid.x, il.y1 - veMid.y)
const d2 = Math.hypot(il.x2 - veMid.x, il.y2 - veMid.y)
trimEnd = d1 < d2 ? 1 : 2
} else {
// 둘 다 V apex 아님 → 기존 B 측 fallback
if (e1B && !e2B) trimEnd = 1
else if (!e1B && e2B) trimEnd = 2
else continue
}
const oldPt = trimEnd === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
const originalAttrs = { ...(il.attributes || {}) }
if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y })
else il.set({ x2: ip.x, y2: ip.y })
if (typeof il.setCoords === 'function') il.setCoords()
const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz }
trimCascadePts.push(oldPt)
newTrimRecords.push({
line: il,
end: trimEnd,
oldPt,
newPt: { x: ip.x, y: ip.y },
originalAttrs,
})
logger.log(
'[KERAB-VALLEY-EXT-TRIM] ridge trim ' +
JSON.stringify({ lineName: il.lineName, trimEnd, oldPt, ip: { x: Math.round(ip.x * 100) / 100, y: Math.round(ip.y * 100) / 100 } }),
)
}
// cascade — 절삭된 끝점에 붙은 ridge 확장(kerabPatternExtRidge) 만 BFS hide
const cascadeHidden = new Set()
let cascadeGuard = 0
while (trimCascadePts.length && cascadeGuard++ < 200) {
const pt = trimCascadePts.shift()
if (!pt) continue
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (cascadeHidden.has(il)) continue
if (il.lineName !== 'kerabPatternExtRidge') continue
const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0
const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0
if (!m1 && !m2) continue
cascadeHidden.add(il)
const originalVisible = il.visible !== false
const originalAttrs = { ...(il.attributes || {}) }
il.visible = false
if (typeof il.setCoords === 'function') il.setCoords()
newTrimRecords.push({
line: il,
hide: true,
originalVisible,
originalAttrs,
})
logger.log(
'[KERAB-VALLEY-EXT-TRIM] cascade hide ' +
JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }),
)
trimCascadePts.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 })
}
}
// [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)
}
} else { } else {
logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source) logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source)
} }
@ -2443,8 +2619,11 @@ export function useEavesGableEdit(id) {
// [KERAB-VALLEY-EXT 2026-05-27] forward 에서 추가됐던 처마확장(kerabPatternValleyExt) 제거. // [KERAB-VALLEY-EXT 2026-05-27] forward 에서 추가됐던 처마확장(kerabPatternValleyExt) 제거.
// __targetId === target.id 매칭으로 해당 target 의 처마확장만 정확히 정리. 모든 패턴 분기 공통. // __targetId === target.id 매칭으로 해당 target 의 처마확장만 정확히 정리. 모든 패턴 분기 공통.
// roofBase 확장은 roof.innerLines 에 있고 wallBase 확장은 canvas 에만 있으므로 canvas 전체 스캔. // roofBase 확장은 roof.innerLines 에 있고 wallBase 확장은 canvas 에만 있으므로 canvas 전체 스캔.
// [KERAB-VALLEY-OVERLAP 2026-05-28] wallBase 측은 lineName='kerabValleyOverlapLine' (본체 + 90도 보조선 2개).
// b polygon 의 lines[] 에는 apply() 단계에서 들어가므로, 여기서는 canvas 객체와 roof.innerLines 만 정리.
// b 의 lines[] 에서 제거하는 책임은 apply() 다음 사이클에서 자동 (canvas 에서 사라지므로).
const valleyExtsToRemove = (canvas.getObjects() || []).filter( const valleyExtsToRemove = (canvas.getObjects() || []).filter(
(il) => il && il.lineName === 'kerabPatternValleyExt' && il.__targetId === target.id, (il) => il && (il.lineName === 'kerabPatternValleyExt' || il.lineName === 'kerabValleyOverlapLine') && il.__targetId === target.id,
) )
for (const v of valleyExtsToRemove) { for (const v of valleyExtsToRemove) {
removeLine(v) removeLine(v)

View File

@ -30,6 +30,7 @@ import { QcastContext } from '@/app/QcastProvider'
import { usePlan } from '@/hooks/usePlan' import { usePlan } from '@/hooks/usePlan'
import { roofsState } from '@/store/roofAtom' import { roofsState } from '@/store/roofAtom'
import { useText } from '@/hooks/useText' import { useText } from '@/hooks/useText'
import { fabric } from 'fabric'
import { QLine } from '@/components/fabric/QLine' import { QLine } from '@/components/fabric/QLine'
import { calcLineActualSize2 } from '@/util/qpolygon-utils' import { calcLineActualSize2 } from '@/util/qpolygon-utils'
// [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert // [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert
@ -656,6 +657,251 @@ export function useRoofAllocationSetting(id) {
) )
} }
/**
* [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] 골짜기 케라바 출폭 직사각형 sub-roof 인접 sub-roof 들에 union 머지.
*
* 배경: 케라바 토글 지붕면이 出幅만큼 서로 물려 겹친다는 표시. useEavesGableEdit.js wallExt 단계에서
* 평행 사각형 4 라인(kerabValleyOverlapLine) 으로 출폭 띠를 닫고 split 단계로 진입.
* split 사각형은 별도 sub-roof X . 의도는 X 영역이 인접 sub-roof 모두에 속해 겹치는 .
* 따라서 X 공유 변을 가진 인접 sub-roof 들에 X 나머지 (detour) 끼워넣어 X 영역을 흡수.
* 흡수 완료 X 자체는 캔버스에서 제거.
*
* 식별:
* - 직사각형 X: sub.lines attributes.type === 'kerabValleyOverlapLine' N-1 이상 (4 3~4)
* - 인접 sub-roof: X 변과 같은 끝점을 공유하는 다른 sub-roof
*
* 머지: 사각형 X 공유 1개를 detour N-1개로 대체. 방향은 면적 증가로 검증.
*/
const mergeValleyOverlapSubRoofs = () => {
const newSubRoofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed)
if (newSubRoofs.length === 0) return
const eq = (p, q) => Math.abs(p.x - q.x) < 0.5 && Math.abs(p.y - q.y) < 0.5
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)
})
if (rects.length === 0) return
logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] 직사각형 sub-roof 후보=${rects.length}`)
const signedArea = (pts) => {
let s = 0
for (let i = 0; i < pts.length; i++) {
const a = pts[i]
const b = pts[(i + 1) % pts.length]
s += a.x * b.y - b.x * a.y
}
return s / 2
}
rects.forEach((X) => {
const Xpts = (X.points || []).map((p) => ({ x: p.x, y: p.y }))
const Xlines = X.lines || []
if (Xpts.length < 3 || Xlines.length < Xpts.length) {
logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] X.id=${X.id} skip — pts=${Xpts.length} lines=${Xlines.length}`)
return
}
const Xedges = []
for (let i = 0; i < Xpts.length; i++) {
Xedges.push({ a: Xpts[i], b: Xpts[(i + 1) % Xpts.length], idx: i })
}
// 인접 sub-roof — X 와 공유 변 모두 수집 (다중 공유 검출용)
const adjacencyMap = new Map() // sub → [{ subEdgeStart, xEdgeIdx }, ...]
newSubRoofs.forEach((sub) => {
if (sub === X) return
const pts = sub.points || []
if (pts.length < 3) return
const shares = []
for (let i = 0; i < pts.length; i++) {
const p1 = pts[i]
const p2 = pts[(i + 1) % pts.length]
for (const e of Xedges) {
if ((eq(p1, e.a) && eq(p2, e.b)) || (eq(p1, e.b) && eq(p2, e.a))) {
shares.push({ subEdgeStart: i, xEdgeIdx: e.idx })
break
}
}
}
if (shares.length > 0) adjacencyMap.set(sub, shares)
})
logger.log(
`[KERAB-VALLEY-OVERLAP-MERGE] X.id=${X.id} pts=${Xpts.length} adjacents=${adjacencyMap.size} ` +
`shares=[${[...adjacencyMap.values()].map((s) => s.length).join(',')}]`,
)
if (adjacencyMap.size === 0) {
canvas.remove(X)
return
}
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
}
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}`,
)
})
canvas.remove(X)
})
canvas.renderAll?.()
}
/** /**
* 지붕면 할당 * 지붕면 할당
*/ */
@ -666,8 +912,10 @@ export function useRoofAllocationSetting(id) {
roofBases.forEach((roofBase) => { roofBases.forEach((roofBase) => {
try { try {
// 지붕 할당 로직에 extensionLine 추가 // 지붕 할당 로직에 extensionLine 추가
// [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 출폭 띠 외곽 라인(kerabValleyOverlapLine) 도 할당 대상.
// b polygon 의 lines[] 에 추가되어 b 의 면이 출폭 띠 영역까지 확장 → a 의 sub-면과 겹침 표현.
const roofEaveHelpLines = canvas.getObjects().filter((obj) => const roofEaveHelpLines = canvas.getObjects().filter((obj) =>
(obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine') && (obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine' || obj.lineName === 'kerabValleyOverlapLine') &&
obj.roofId === roofBase.id obj.roofId === roofBase.id
) )
// logger.log('roofBase.id:', roofBase.id) // logger.log('roofBase.id:', roofBase.id)
@ -683,6 +931,8 @@ export function useRoofAllocationSetting(id) {
// extensionLine과 일반 eaveHelpLine 분리 // extensionLine과 일반 eaveHelpLine 분리
const extensionLines = newEaveLines.filter(line => line.lineName === 'extensionLine') const extensionLines = newEaveLines.filter(line => line.lineName === 'extensionLine')
const normalEaveLines = newEaveLines.filter(line => line.lineName === 'eaveHelpLine') const normalEaveLines = newEaveLines.filter(line => line.lineName === 'eaveHelpLine')
// [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 겹침 라인 — overlap 판단 제외, 단순 결합
const overlapLines = newEaveLines.filter(line => line.lineName === 'kerabValleyOverlapLine')
// logger.log('extensionLines count:', extensionLines.length) // logger.log('extensionLines count:', extensionLines.length)
// logger.log('normalEaveLines count:', normalEaveLines.length) // logger.log('normalEaveLines count:', normalEaveLines.length)
@ -737,7 +987,7 @@ export function useRoofAllocationSetting(id) {
return !shouldRemove; return !shouldRemove;
}); });
// Combine remaining lines with newEaveLines // Combine remaining lines with newEaveLines
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines]; roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines, ...overlapLines];
} else { } else {
roofBase.lines = [...roofEaveHelpLines] roofBase.lines = [...roofEaveHelpLines]
} }
@ -807,6 +1057,12 @@ export function useRoofAllocationSetting(id) {
const __newRoofs = canvas.getObjects().filter((o) => o.name === 'roof' && !o.isFixed) const __newRoofs = canvas.getObjects().filter((o) => o.name === 'roof' && !o.isFixed)
logger.log(`[KERAB-PATTERN-DIAG] 분할 후 새 sub-roof=${__newRoofs.length}`) logger.log(`[KERAB-PATTERN-DIAG] 분할 후 새 sub-roof=${__newRoofs.length}`)
} }
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지.
// 골짜기 출폭 띠 영역이 두 면 모두에 속하도록 (겹침 표현).
// 직사각형 식별: 외곽 라인의 attributes.type === 'kerabValleyOverlapLine' 가 다수인 sub-roof.
// 인접 두 sub-roof = 직사각형의 변 좌표를 공유하는 다른 sub-roof.
mergeValleyOverlapSubRoofs()
} catch (e) { } catch (e) {
logger.log(e) logger.log(e)
canvas.discardActiveObject() canvas.discardActiveObject()
@ -818,6 +1074,18 @@ export function useRoofAllocationSetting(id) {
canvas.remove(line) canvas.remove(line)
}) })
// [KERAB-VALLEY-OVERLAP 2026-05-28] roofBase.lines 로 추가된 보조 라인(kerabValleyOverlapLine) 및
// wallBase 변형 kerabPatternValleyExt(innerLines 미포함) 는 split 이후 더 이상 필요 없으므로 정리.
// 미정리 시 canvas 잔류 → sub-roof 위에 솔리드/점선으로 남는다.
const overlapLeftovers = canvas
.getObjects()
.filter(
(obj) =>
(obj.lineName === 'kerabValleyOverlapLine' || obj.lineName === 'kerabPatternValleyExt') &&
(obj.roofId === roofBase.id || obj?.attributes?.roofId === roofBase.id),
)
overlapLeftovers.forEach((line) => canvas.remove(line))
canvas.remove(roofBase) canvas.remove(roofBase)
}) })

View File

@ -5,6 +5,7 @@
// 고객 회귀 검증을 위해 본 로직 전체를 useEavesGableEdit.js 에서 분리. // 고객 회귀 검증을 위해 본 로직 전체를 useEavesGableEdit.js 에서 분리.
// 호출 ON/OFF 는 useEavesGableEdit.js 상단 `ENABLE_KERAB_OFFSET_SURGICAL` 상수로 토글한다. // 호출 ON/OFF 는 useEavesGableEdit.js 상단 `ENABLE_KERAB_OFFSET_SURGICAL` 상수로 토글한다.
import { fabric } from 'fabric'
import { POLYGON_TYPE } from '@/common/common' import { POLYGON_TYPE } from '@/common/common'
import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
@ -123,16 +124,52 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
} }
// points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작. // points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작.
// [KERAB-OFFSET-SURGICAL 2026-05-29] 출폭 증가 시 새 corner 가 polygon bbox 밖에 있으면
// 외곽선이 안 그려짐. _setPositionDimensions 로 width/height/pathOffset 재계산 + 앵커
// 절대좌표 보존(setPositionByOrigin) 으로 polygon path 가 새 영역까지 다시 그려지게 강제.
const newPoints = roof.points.map((p, i) => { const newPoints = roof.points.map((p, i) => {
if (i === idx) return { x: newCorner1.x, y: newCorner1.y } if (i === idx) return { x: newCorner1.x, y: newCorner1.y }
if (i === (idx + 1) % N) return { x: newCorner2.x, y: newCorner2.y } if (i === (idx + 1) % N) return { x: newCorner2.x, y: newCorner2.y }
return { x: p.x, y: p.y } return { x: p.x, y: p.y }
}) })
let absolutePoint = null
let anchorIdx = 0
// 변경 대상이 아닌 첫 인덱스를 앵커로 — pathOffset 갱신 후 그 점 절대 좌표 보존.
for (let i = 0; i < roof.points.length; i++) {
if (i !== idx && i !== (idx + 1) % N) {
anchorIdx = i
break
}
}
try {
if (typeof roof.calcTransformMatrix === 'function' && roof.pathOffset) {
const oldLocal = {
x: roof.points[anchorIdx].x - roof.pathOffset.x,
y: roof.points[anchorIdx].y - roof.pathOffset.y,
}
absolutePoint = fabric.util.transformPoint(oldLocal, roof.calcTransformMatrix())
}
} catch (e) {
absolutePoint = null
}
roof.points = newPoints roof.points = newPoints
roof.set({ points: newPoints, dirty: true }) roof.set({ points: newPoints, dirty: true })
if (typeof roof._setPositionDimensions === 'function') roof._setPositionDimensions({})
if (absolutePoint && typeof roof.setPositionByOrigin === 'function') {
const strokeW = roof.strokeUniform ? roof.strokeWidth / Math.max(roof.scaleX || 1, 1e-9) : roof.strokeWidth
const baseW = (roof.width || 0) + strokeW
const baseH = (roof.height || 0) + (roof.strokeUniform ? roof.strokeWidth / Math.max(roof.scaleY || 1, 1e-9) : roof.strokeWidth)
const newX = (roof.points[anchorIdx].x - roof.pathOffset.x) / Math.max(baseW, 1e-9)
const newY = (roof.points[anchorIdx].y - roof.pathOffset.y) / Math.max(baseH, 1e-9)
roof.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5)
}
if (typeof roof.setCoords === 'function') roof.setCoords() if (typeof roof.setCoords === 'function') roof.setCoords()
// canvas 에 add 된 동일 wallLine 매칭의 eaves(외곽 roofLine) fabric 객체도 같이 갱신. // canvas 에 add 된 동일 wallLine 매칭의 eaves(외곽 roofLine) fabric 객체도 같이 갱신.
// [KERAB-OFFSET-SURGICAL 2026-05-29] outerLine 은 wall 좌표(출폭 0 기준) 유지가 원칙.
// corner 좌표(=wall + offset*normal) 로 set 하면 출폭 증가 시 외곽 처마라인이 통째로 이동해
// 화면상 roofLine 이 안 그려진 듯 보임. outerLine 은 attributes 만 갱신, 좌표는 보존.
const canvasEdgeObjs = canvas const canvasEdgeObjs = canvas
.getObjects() .getObjects()
.filter( .filter(
@ -142,7 +179,9 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
o.attributes?.wallLine === target.id, o.attributes?.wallLine === target.id,
) )
for (const eo of canvasEdgeObjs) { for (const eo of canvasEdgeObjs) {
eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true }) if (eo.name !== 'outerLine') {
eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true })
}
eo.attributes = { ...eo.attributes, offset: newOffset, planeSize: newSize, actualSize: newSize } eo.attributes = { ...eo.attributes, offset: newOffset, planeSize: newSize, actualSize: newSize }
if (typeof eo.setCoords === 'function') eo.setCoords() if (typeof eo.setCoords === 'function') eo.setCoords()
} }